Compare commits
2
Commits
945134f029
...
46c22300e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c22300e9 | ||
|
|
d9a20e40b0 |
+10
@@ -87,3 +87,13 @@ postgres_data/
|
||||
tmp/
|
||||
|
||||
*.txt
|
||||
|
||||
# Android / Kotlin Mobile
|
||||
client-mobile/.gradle/
|
||||
client-mobile/.idea/
|
||||
client-mobile/.run/
|
||||
client-mobile/build/
|
||||
client-mobile/.cxx/
|
||||
client-mobile/local.properties
|
||||
client-mobile/*.iml
|
||||
client-mobile/.kotlin/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Tue Apr 14 00:27:15 MSK 2026
|
||||
gradle.version=8.5
|
||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Tue Apr 14 00:13:58 MSK 2026
|
||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||
@@ -61,6 +61,10 @@ android {
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
// Включаем оптимизации компилятора Compose
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
||||
android:name="core.notifications.data.KnotFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import navigation.AppNavigation
|
||||
import core.presentation.theme.ForkMessengerTheme
|
||||
import core.presentation.theme.KnotTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -38,7 +38,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
setContent {
|
||||
ForkMessengerTheme {
|
||||
KnotTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Цветная иконка для notification -->
|
||||
<path
|
||||
android:fillColor="#6C5CE7"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||
</vector>
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">ForkMessenger</string>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="register">Register</string>
|
||||
<string name="username">Username</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">ForkMessenger</string>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Войти</string>
|
||||
<string name="register">Регистрация</string>
|
||||
<string name="username">Имя пользователя</string>
|
||||
|
||||
@@ -23,7 +23,6 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
private val api: ChatApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val messageDao: core.database.data.MessageDao,
|
||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
) : ChatRepository {
|
||||
private val gson = com.google.gson.Gson()
|
||||
@@ -35,10 +34,8 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
||||
entities.map { it.toDomain(baseUrl, gson) }
|
||||
}
|
||||
// Кэш отключён - всегда возвращаем пустой поток
|
||||
return kotlinx.coroutines.flow.flowOf(emptyList())
|
||||
}
|
||||
|
||||
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
||||
@@ -93,21 +90,19 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||
try {
|
||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
|
||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
|
||||
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||
}
|
||||
// Обновляем локальную БД
|
||||
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||
}
|
||||
|
||||
override suspend fun saveMessage(message: Message) {
|
||||
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
|
||||
// Кэш отключён
|
||||
}
|
||||
|
||||
override suspend fun deleteLocalMessage(messageId: String) {
|
||||
messageDao.deleteMessage(messageId)
|
||||
// Локальное удаление не поддерживается без кэша
|
||||
}
|
||||
|
||||
override suspend fun uploadMedia(file: java.io.File): String {
|
||||
@@ -145,7 +140,6 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||
api.deleteMessage(messageId, forEveryone)
|
||||
messageDao.deleteMessage(messageId)
|
||||
}
|
||||
|
||||
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||
|
||||
@@ -23,24 +23,6 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
||||
)
|
||||
}
|
||||
|
||||
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||
return core.database.data.MessageEntity(
|
||||
id = id,
|
||||
chatId = chatId,
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatar = senderAvatar,
|
||||
content = content,
|
||||
sequenceId = sequenceId,
|
||||
createdAt = createdAt,
|
||||
mediaType = mediaType.name.lowercase(),
|
||||
mediaJson = gson.toJson(media),
|
||||
reactionsJson = gson.toJson(reactions),
|
||||
isRead = isRead,
|
||||
replyToId = replyTo?.id
|
||||
)
|
||||
}
|
||||
|
||||
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
||||
val domainMediaType = when (type) {
|
||||
"gif" -> MediaType.GIF
|
||||
@@ -86,79 +68,6 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
||||
)
|
||||
}
|
||||
|
||||
fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||
return core.database.data.MessageEntity(
|
||||
id = id,
|
||||
chatId = chatId ?: "",
|
||||
senderId = senderId ?: "",
|
||||
senderName = sender?.displayName ?: "Unknown",
|
||||
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||
content = content,
|
||||
sequenceId = sequenceId ?: 0,
|
||||
createdAt = createdAt ?: "",
|
||||
mediaType = type ?: "text",
|
||||
mediaJson = gson.toJson(media),
|
||||
reactionsJson = gson.toJson(reactions),
|
||||
isRead = senderId == currentUserId,
|
||||
replyToId = replyTo?.id
|
||||
)
|
||||
}
|
||||
|
||||
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
|
||||
val mediaTypeEnum = when (mediaType) {
|
||||
"gif" -> MediaType.GIF
|
||||
"image", "photo" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio", "voice" -> MediaType.AUDIO
|
||||
"file" -> MediaType.FILE
|
||||
else -> MediaType.TEXT
|
||||
}
|
||||
|
||||
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
|
||||
gson.fromJson(mediaJson, mediaTypeToken)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
} ?: emptyList()
|
||||
|
||||
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
|
||||
gson.fromJson(reactionsJson, reactionsTypeToken)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
|
||||
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
|
||||
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
|
||||
} catch (innerE: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
return Message(
|
||||
id = id,
|
||||
chatId = chatId,
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatar = senderAvatar,
|
||||
content = content,
|
||||
sequenceId = sequenceId,
|
||||
createdAt = createdAt,
|
||||
media = mediaDtos.map {
|
||||
chats.domain.model.Media(
|
||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
||||
type = it.type ?: "unknown",
|
||||
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||
filename = it.filename,
|
||||
size = it.size,
|
||||
duration = it.duration
|
||||
)
|
||||
},
|
||||
mediaType = mediaTypeEnum,
|
||||
reactions = reactionDtos.associate { it.emoji to it.count },
|
||||
isRead = isRead
|
||||
)
|
||||
}
|
||||
|
||||
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
||||
return if (this.startsWith("http")) {
|
||||
this
|
||||
|
||||
@@ -4,7 +4,6 @@ import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.ChatRepositoryImpl
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.database.data.MessageDao
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
@@ -30,10 +29,9 @@ object ChatModule {
|
||||
api: ChatApi,
|
||||
tokenManager: TokenManager,
|
||||
serverConfig: ServerConfig,
|
||||
messageDao: MessageDao,
|
||||
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
): ChatRepository {
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package chats.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class Chat(
|
||||
val id: String,
|
||||
val type: String,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package chats.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class Message(
|
||||
val id: String,
|
||||
val chatId: String,
|
||||
@@ -19,6 +22,7 @@ data class Message(
|
||||
val replyTo: Message? = null
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Media(
|
||||
val id: String,
|
||||
val type: String,
|
||||
|
||||
@@ -152,39 +152,46 @@ fun ChatDetailScreen(
|
||||
val items = mutableListOf<MessageListItem>()
|
||||
if (state.messages.isEmpty()) return@remember items
|
||||
|
||||
// Группируем от новых к старым (Index 0 = bottom)
|
||||
// Группируем от старых к новым (для normal layout)
|
||||
val reversedMessages = state.messages.reversed()
|
||||
var i = 0
|
||||
while (i < state.messages.size) {
|
||||
val isoDate = state.messages[i].createdAt
|
||||
// Получаем дату в локальном часовом поясе (например "2024-04-15")
|
||||
while (i < reversedMessages.size) {
|
||||
val isoDate = reversedMessages[i].createdAt
|
||||
val localDate = viewModel.getLocalDateString(isoDate)
|
||||
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
||||
|
||||
// Собираем все сообщения за этот локальный день
|
||||
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) {
|
||||
dayMessages.add(state.messages[i])
|
||||
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
|
||||
dayMessages.add(reversedMessages[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Добавляем сообщения в инвертированном порядке
|
||||
// Добавляем заголовок даты
|
||||
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
||||
|
||||
// Добавляем сообщения
|
||||
dayMessages.forEach { msg ->
|
||||
items.add(MessageListItem.MessageItem(msg))
|
||||
}
|
||||
|
||||
// Добавляем заголовок (над группой сообщений)
|
||||
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// Выносим allChatMedia наружу - до LazyColumn
|
||||
val allChatMedia = remember(state.messages) {
|
||||
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
|
||||
}
|
||||
|
||||
// Отслеживаем текущую дату для плавающего заголовка
|
||||
val floatingDate by remember {
|
||||
derivedStateOf {
|
||||
val firstIndex = listState.firstVisibleItemIndex
|
||||
// Не показываем плашку на самом первом элементе (там и так дата)
|
||||
// И показываем только если мы прокрутили хотя бы немного
|
||||
if (firstIndex > 0 && firstIndex < listItems.size) {
|
||||
val item = listItems[firstIndex]
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@derivedStateOf null
|
||||
|
||||
// Находим первый видимый элемент
|
||||
val firstVisibleItemIndex = visibleItems.first().index
|
||||
if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) {
|
||||
val item = listItems[firstVisibleItemIndex]
|
||||
if (item is MessageListItem.MessageItem) {
|
||||
viewModel.formatDateHeader(item.message.createdAt)
|
||||
} else if (item is MessageListItem.DateHeader) {
|
||||
@@ -195,67 +202,77 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// Показывать ли кнопку "вниз"
|
||||
// Она должна появляться если мы не в самом низу
|
||||
// Проверка видимости кнопки скролла вниз
|
||||
val showScrollToBottom by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 5
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val totalCount = layoutInfo.totalItemsCount
|
||||
if (totalCount == 0) return@derivedStateOf false
|
||||
|
||||
// Проверяем, видим ли мы последний элемент
|
||||
val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0
|
||||
lastVisibleIndex < totalCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Авто-скролл к новым сообщениям
|
||||
val firstMessageId = state.messages.firstOrNull()?.id
|
||||
LaunchedEffect(firstMessageId) {
|
||||
if (firstMessageId != null) {
|
||||
// Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз
|
||||
val isAtBottom = listState.firstVisibleItemIndex <= 1
|
||||
val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId()
|
||||
// Авто-скролл к новым сообщениям - только если мы уже внизу
|
||||
val lastMessageId = state.messages.lastOrNull()?.id
|
||||
LaunchedEffect(lastMessageId) {
|
||||
if (lastMessageId != null && listItems.isNotEmpty()) {
|
||||
val isAtBottom = listState.layoutInfo.run {
|
||||
val visibleItems = visibleItemsInfo
|
||||
val totalCount = totalItemsCount
|
||||
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
|
||||
visibleItems.last().index >= totalCount - 2
|
||||
}
|
||||
|
||||
if (isAtBottom || isMyMessage) {
|
||||
listState.animateScrollToItem(0)
|
||||
// Если мы внизу - помечаем сразу как прочитанное
|
||||
if (isAtBottom && !isMyMessage) {
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
if (isAtBottom) {
|
||||
listState.animateScrollToItem(listItems.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях
|
||||
// Автоматическая прокрутка при инициализации
|
||||
LaunchedEffect(listItems.size) {
|
||||
if (listItems.isNotEmpty()) {
|
||||
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) {
|
||||
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0
|
||||
listState.scrollToItem(0)
|
||||
viewModel.onInitialScrollDone()
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
listState.scrollToItem(listItems.size - 1)
|
||||
viewModel.onInitialScrollDone()
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
// Подгрузка истории при прокрутке вверх
|
||||
LaunchedEffect(listState, state.messages.size) {
|
||||
snapshotFlow {
|
||||
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
val totalCount = listState.layoutInfo.totalItemsCount
|
||||
lastIndex to totalCount
|
||||
}
|
||||
.collect { (lastVisibleIndex, totalCount) ->
|
||||
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) {
|
||||
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount")
|
||||
viewModel.loadMoreMessages()
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.collect { layoutInfo ->
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val totalCount = layoutInfo.totalItemsCount
|
||||
if (totalCount == 0 || visibleItems.isEmpty()) return@collect
|
||||
|
||||
val firstVisibleIndex = visibleItems.first().index
|
||||
if (firstVisibleIndex < 5 && !state.isLoadingMore) {
|
||||
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex")
|
||||
viewModel.loadMoreMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val unreadCount = remember(state.messages) {
|
||||
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
|
||||
}
|
||||
|
||||
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0)
|
||||
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) {
|
||||
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) {
|
||||
viewModel.markAsRead()
|
||||
// Авто-прочитка при нахождении внизу списка
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (!listState.isScrollInProgress) {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val totalCount = layoutInfo.totalItemsCount
|
||||
if (totalCount > 0 && visibleItems.isNotEmpty()) {
|
||||
val lastVisibleIndex = visibleItems.last().index
|
||||
if (lastVisibleIndex >= totalCount - 2) {
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,21 +355,26 @@ fun ChatDetailScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val allChatMedia = state.messages.flatMap { msg ->
|
||||
msg.media.map { it to msg.id }
|
||||
}.reversed()
|
||||
|
||||
items(listItems, key = {
|
||||
when(it) {
|
||||
is MessageListItem.MessageItem -> it.message.id
|
||||
is MessageListItem.DateHeader -> "date_${it.date}"
|
||||
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||
items(
|
||||
items = listItems,
|
||||
key = { item ->
|
||||
when(item) {
|
||||
is MessageListItem.MessageItem -> "msg_${item.message.id}"
|
||||
is MessageListItem.DateHeader -> "date_${item.date}"
|
||||
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||
}
|
||||
},
|
||||
contentType = { item ->
|
||||
when(item) {
|
||||
is MessageListItem.MessageItem -> "message"
|
||||
is MessageListItem.DateHeader -> "date_header"
|
||||
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||
}
|
||||
}
|
||||
}) { item ->
|
||||
) { item ->
|
||||
when(item) {
|
||||
is MessageListItem.MessageItem -> {
|
||||
SwipeableMessageItem(
|
||||
@@ -487,7 +509,7 @@ fun ChatDetailScreen(
|
||||
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
|
||||
.clickable {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
listState.animateScrollToItem(listItems.size - 1)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class MessageListItem {
|
||||
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
|
||||
data class DateHeader(val date: String) : MessageListItem()
|
||||
|
||||
@@ -200,7 +200,9 @@ private fun EmojiContent(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(8),
|
||||
state = scrollState,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
emojiCategories.forEach { category ->
|
||||
// Заголовок категории
|
||||
@@ -214,7 +216,7 @@ private fun EmojiContent(
|
||||
)
|
||||
}
|
||||
|
||||
items(category.emojis) { emoji ->
|
||||
items(category.emojis, key = { emoji -> emoji }) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
@@ -362,9 +364,11 @@ private fun GifContent(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.weight(1f).padding(4.dp),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(displayGifs) { gif ->
|
||||
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
|
||||
val url = gif.files?.get("hd")?.get("gif")?.url
|
||||
?: gif.files?.get("sd")?.get("gif")?.url
|
||||
?: gif.file?.get("hd")?.get("gif")?.url
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
@@ -30,10 +29,10 @@ fun SwipeableMessageItem(
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||
val maxDrag = with(density) { 90.dp.toPx() }
|
||||
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var isTriggered by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
@@ -42,27 +41,18 @@ fun SwipeableMessageItem(
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
scope.launch {
|
||||
// We only allow swiping to the left (negative delta)
|
||||
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f)
|
||||
offsetX.snapTo(newOffset)
|
||||
// We only allow swiping to the left (negative delta)
|
||||
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
|
||||
offsetX = newOffset
|
||||
|
||||
if (newOffset <= -replyThreshold && !isTriggered) {
|
||||
isTriggered = true
|
||||
// Trigger haptic feedback if available?
|
||||
} else if (newOffset > -replyThreshold) {
|
||||
isTriggered = false
|
||||
}
|
||||
}
|
||||
isTriggered = newOffset <= -replyThreshold
|
||||
},
|
||||
onDragStopped = {
|
||||
if (offsetX.value <= -replyThreshold) {
|
||||
if (offsetX <= -replyThreshold) {
|
||||
onReply()
|
||||
}
|
||||
scope.launch {
|
||||
offsetX.animateTo(0f)
|
||||
isTriggered = false
|
||||
}
|
||||
offsetX = 0f
|
||||
isTriggered = false
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -72,7 +62,7 @@ fun SwipeableMessageItem(
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.size(36.dp)
|
||||
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f))
|
||||
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
|
||||
.clip(CircleShape)
|
||||
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -88,7 +78,7 @@ fun SwipeableMessageItem(
|
||||
// Message Content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package core.database.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val chatId: String,
|
||||
val senderId: String,
|
||||
val senderName: String,
|
||||
val senderAvatar: String?,
|
||||
val content: String?,
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
val mediaType: String,
|
||||
val mediaJson: String, // Simplified for now
|
||||
val reactionsJson: String,
|
||||
val isRead: Boolean,
|
||||
val replyToId: String? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
||||
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
||||
suspend fun clearChat(chatId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :messageId")
|
||||
suspend fun deleteMessage(messageId: String)
|
||||
|
||||
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
|
||||
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
|
||||
}
|
||||
|
||||
@Database(entities = [MessageEntity::class], version = 1)
|
||||
abstract class ChatDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package core.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import core.database.data.ChatDatabase
|
||||
import core.database.data.MessageDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): ChatDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
ChatDatabase::class.java,
|
||||
"chat_database"
|
||||
).fallbackToDestructiveMigration().build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideMessageDao(database: ChatDatabase): MessageDao {
|
||||
return database.messageDao()
|
||||
}
|
||||
}
|
||||
+1
-8
@@ -1,12 +1,5 @@
|
||||
package core.notifications.data
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import auth.data.remote.api.AuthApi
|
||||
@@ -18,7 +11,7 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
||||
class KnotFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
@Inject
|
||||
lateinit var authApi: AuthApi
|
||||
@@ -7,9 +7,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import ru.knot.messager.R
|
||||
|
||||
object NotificationHelper {
|
||||
private const val CHANNEL_ID = "fork_notifications_channel"
|
||||
private const val CHANNEL_ID = "knot_notifications_channel"
|
||||
|
||||
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -19,7 +20,7 @@ object NotificationHelper {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Fork Messenger Notifications",
|
||||
"Knot Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Chat and system notifications"
|
||||
@@ -42,7 +43,7 @@ object NotificationHelper {
|
||||
)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -28,7 +28,7 @@ private val DarkColors = darkColorScheme(
|
||||
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
|
||||
|
||||
@Composable
|
||||
fun ForkMessengerTheme(content: @Composable () -> Unit) {
|
||||
fun KnotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColors,
|
||||
shapes = MaterialTheme.shapes.copy(
|
||||
|
||||
@@ -284,8 +284,8 @@ fun LazyListScope.renderFiles() {
|
||||
fun LazyListScope.renderLinks() {
|
||||
items(2) {
|
||||
ListItem(
|
||||
headlineContent = { Text("https://github.com/forkmessager") },
|
||||
supportingContent = { Text("GitHub - ForkMessager Project") },
|
||||
headlineContent = { Text("https://github.com/knot-messenger") },
|
||||
supportingContent = { Text("GitHub - Knot Project") },
|
||||
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user