2 Commits
57 changed files with 161 additions and 295 deletions
+10
View File
@@ -87,3 +87,13 @@ postgres_data/
tmp/ tmp/
*.txt *.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.
@@ -1,2 +0,0 @@
#Tue Apr 14 00:27:15 MSK 2026
gradle.version=8.5
-2
View File
@@ -1,2 +0,0 @@
#Tue Apr 14 00:13:58 MSK 2026
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
+4
View File
@@ -61,6 +61,10 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.8" kotlinCompilerExtensionVersion = "1.5.8"
} }
// Включаем оптимизации компилятора Compose
kotlinOptions {
jvmTarget = "17"
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -31,7 +31,7 @@
</activity> </activity>
<service <service
android:name="core.notifications.data.ForkFirebaseMessagingService" android:name="core.notifications.data.KnotFirebaseMessagingService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
@@ -9,7 +9,7 @@ import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import navigation.AppNavigation import navigation.AppNavigation
import core.presentation.theme.ForkMessengerTheme import core.presentation.theme.KnotTheme
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -38,7 +38,7 @@ class MainActivity : ComponentActivity() {
} }
setContent { setContent {
ForkMessengerTheme { KnotTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background 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> <resources>
<string name="app_name">ForkMessenger</string> <string name="app_name">Knot</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="register">Register</string> <string name="register">Register</string>
<string name="username">Username</string> <string name="username">Username</string>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">ForkMessenger</string> <string name="app_name">Knot</string>
<string name="login">Войти</string> <string name="login">Войти</string>
<string name="register">Регистрация</string> <string name="register">Регистрация</string>
<string name="username">Имя пользователя</string> <string name="username">Имя пользователя</string>
@@ -23,7 +23,6 @@ class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi, private val api: ChatApi,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val serverConfig: ServerConfig, private val serverConfig: ServerConfig,
private val messageDao: core.database.data.MessageDao,
private val hubClient: chats.data.remote.signalr.ChatHubClient private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository { ) : ChatRepository {
private val gson = com.google.gson.Gson() 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>> { 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> -> return kotlinx.coroutines.flow.flowOf(emptyList())
entities.map { it.toDomain(baseUrl, gson) }
}
} }
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> { 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) { override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try { 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)) hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ChatRepo", "Error marking messages as read", e) android.util.Log.e("ChatRepo", "Error marking messages as read", e)
} }
// Обновляем локальную БД
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
} }
override suspend fun saveMessage(message: Message) { override suspend fun saveMessage(message: Message) {
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}") // Кэш отключён
} }
override suspend fun deleteLocalMessage(messageId: String) { override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessage(messageId) // Локальное удаление не поддерживается без кэша
} }
override suspend fun uploadMedia(file: java.io.File): String { 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) { override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone) api.deleteMessage(messageId, forEveryone)
messageDao.deleteMessage(messageId)
} }
override suspend fun editMessage(messageId: String, content: String): Message { 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 { fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
val domainMediaType = when (type) { val domainMediaType = when (type) {
"gif" -> MediaType.GIF "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 { fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) { return if (this.startsWith("http")) {
this this
+1 -3
View File
@@ -4,7 +4,6 @@ import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.database.data.MessageDao
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import dagger.Module import dagger.Module
@@ -30,10 +29,9 @@ object ChatModule {
api: ChatApi, api: ChatApi,
tokenManager: TokenManager, tokenManager: TokenManager,
serverConfig: ServerConfig, serverConfig: ServerConfig,
messageDao: MessageDao,
hubClient: chats.data.remote.signalr.ChatHubClient hubClient: chats.data.remote.signalr.ChatHubClient
): ChatRepository { ): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient) return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
} }
@Provides @Provides
@@ -1,5 +1,8 @@
package chats.domain.model package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Chat( data class Chat(
val id: String, val id: String,
val type: String, val type: String,
@@ -1,5 +1,8 @@
package chats.domain.model package chats.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Message( data class Message(
val id: String, val id: String,
val chatId: String, val chatId: String,
@@ -19,6 +22,7 @@ data class Message(
val replyTo: Message? = null val replyTo: Message? = null
) )
@Immutable
data class Media( data class Media(
val id: String, val id: String,
val type: String, val type: String,
@@ -152,39 +152,46 @@ fun ChatDetailScreen(
val items = mutableListOf<MessageListItem>() val items = mutableListOf<MessageListItem>()
if (state.messages.isEmpty()) return@remember items if (state.messages.isEmpty()) return@remember items
// Группируем от новых к старым (Index 0 = bottom) // Группируем от старых к новым (для normal layout)
val reversedMessages = state.messages.reversed()
var i = 0 var i = 0
while (i < state.messages.size) { while (i < reversedMessages.size) {
val isoDate = state.messages[i].createdAt val isoDate = reversedMessages[i].createdAt
// Получаем дату в локальном часовом поясе (например "2024-04-15")
val localDate = viewModel.getLocalDateString(isoDate) val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>() val dayMessages = mutableListOf<chats.domain.model.Message>()
// Собираем все сообщения за этот локальный день while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) { dayMessages.add(reversedMessages[i])
dayMessages.add(state.messages[i])
i++ i++
} }
// Добавляем сообщения в инвертированном порядке // Добавляем заголовок даты
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
// Добавляем сообщения
dayMessages.forEach { msg -> dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg)) items.add(MessageListItem.MessageItem(msg))
} }
// Добавляем заголовок (над группой сообщений)
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
} }
items items
} }
// Выносим allChatMedia наружу - до LazyColumn
val allChatMedia = remember(state.messages) {
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
}
// Отслеживаем текущую дату для плавающего заголовка // Отслеживаем текущую дату для плавающего заголовка
val floatingDate by remember { val floatingDate by remember {
derivedStateOf { derivedStateOf {
val firstIndex = listState.firstVisibleItemIndex val layoutInfo = listState.layoutInfo
// Не показываем плашку на самом первом элементе (там и так дата) val visibleItems = layoutInfo.visibleItemsInfo
// И показываем только если мы прокрутили хотя бы немного if (visibleItems.isEmpty()) return@derivedStateOf null
if (firstIndex > 0 && firstIndex < listItems.size) {
val item = listItems[firstIndex] // Находим первый видимый элемент
val firstVisibleItemIndex = visibleItems.first().index
if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) {
val item = listItems[firstVisibleItemIndex]
if (item is MessageListItem.MessageItem) { if (item is MessageListItem.MessageItem) {
viewModel.formatDateHeader(item.message.createdAt) viewModel.formatDateHeader(item.message.createdAt)
} else if (item is MessageListItem.DateHeader) { } else if (item is MessageListItem.DateHeader) {
@@ -195,67 +202,77 @@ fun ChatDetailScreen(
} }
// Показывать ли кнопку "вниз" // Показывать ли кнопку "вниз"
// Она должна появляться если мы не в самом низу
// Проверка видимости кнопки скролла вниз
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { 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 val lastMessageId = state.messages.lastOrNull()?.id
LaunchedEffect(firstMessageId) { LaunchedEffect(lastMessageId) {
if (firstMessageId != null) { if (lastMessageId != null && listItems.isNotEmpty()) {
// Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз val isAtBottom = listState.layoutInfo.run {
val isAtBottom = listState.firstVisibleItemIndex <= 1 val visibleItems = visibleItemsInfo
val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId() val totalCount = totalItemsCount
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
visibleItems.last().index >= totalCount - 2
}
if (isAtBottom || isMyMessage) { if (isAtBottom) {
listState.animateScrollToItem(0) listState.animateScrollToItem(listItems.size - 1)
// Если мы внизу - помечаем сразу как прочитанное
if (isAtBottom && !isMyMessage) {
viewModel.markAsRead()
}
} }
} }
} }
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях // Автоматическая прокрутка при инициализации
LaunchedEffect(listItems.size) { LaunchedEffect(listItems.size) {
if (listItems.isNotEmpty()) { if (listItems.isNotEmpty()) {
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) { listState.scrollToItem(listItems.size - 1)
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0 viewModel.onInitialScrollDone()
listState.scrollToItem(0) viewModel.markAsRead()
viewModel.onInitialScrollDone()
viewModel.markAsRead()
}
} }
} }
// Подгрузка истории при прокрутке вверх // Подгрузка истории при прокрутке вверх
LaunchedEffect(listState, state.messages.size) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow { listState.layoutInfo }
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 .collect { layoutInfo ->
val totalCount = listState.layoutInfo.totalItemsCount val visibleItems = layoutInfo.visibleItemsInfo
lastIndex to totalCount val totalCount = layoutInfo.totalItemsCount
} if (totalCount == 0 || visibleItems.isEmpty()) return@collect
.collect { (lastVisibleIndex, totalCount) ->
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) { val firstVisibleIndex = visibleItems.first().index
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount") if (firstVisibleIndex < 5 && !state.isLoadingMore) {
viewModel.loadMoreMessages() android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex")
viewModel.loadMoreMessages()
}
} }
}
} }
val unreadCount = remember(state.messages) { val unreadCount = remember(state.messages) {
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
} }
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0) // Авто-прочитка при нахождении внизу списка
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) { LaunchedEffect(listState.isScrollInProgress) {
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) { if (!listState.isScrollInProgress) {
viewModel.markAsRead() 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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
val allChatMedia = state.messages.flatMap { msg -> items(
msg.media.map { it to msg.id } items = listItems,
}.reversed() key = { item ->
when(item) {
items(listItems, key = { is MessageListItem.MessageItem -> "msg_${item.message.id}"
when(it) { is MessageListItem.DateHeader -> "date_${item.date}"
is MessageListItem.MessageItem -> it.message.id MessageListItem.UnreadSeparator -> "unread_separator"
is MessageListItem.DateHeader -> "date_${it.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) { when(item) {
is MessageListItem.MessageItem -> { is MessageListItem.MessageItem -> {
SwipeableMessageItem( SwipeableMessageItem(
@@ -487,7 +509,7 @@ fun ChatDetailScreen(
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
.clickable { .clickable {
scope.launch { scope.launch {
listState.animateScrollToItem(0) listState.animateScrollToItem(listItems.size - 1)
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog(
) )
} }
@Immutable
sealed class MessageListItem { sealed class MessageListItem {
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
data class DateHeader(val date: String) : MessageListItem() data class DateHeader(val date: String) : MessageListItem()
@@ -200,7 +200,9 @@ private fun EmojiContent(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(8), columns = GridCells.Fixed(8),
state = scrollState, 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 -> emojiCategories.forEach { category ->
// Заголовок категории // Заголовок категории
@@ -214,7 +216,7 @@ private fun EmojiContent(
) )
} }
items(category.emojis) { emoji -> items(category.emojis, key = { emoji -> emoji }) { emoji ->
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -362,9 +364,11 @@ private fun GifContent(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f).padding(4.dp), 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 val url = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url ?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url ?: gif.file?.get("hd")?.get("gif")?.url
@@ -1,6 +1,5 @@
package chats.presentation.components package chats.presentation.components
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
@@ -30,10 +29,10 @@ fun SwipeableMessageItem(
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
val replyThreshold = with(density) { 60.dp.toPx() } val replyThreshold = with(density) { 60.dp.toPx() }
val maxDrag = with(density) { 90.dp.toPx() } val maxDrag = with(density) { 90.dp.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var isTriggered by remember { mutableStateOf(false) } var isTriggered by remember { mutableStateOf(false) }
Box( Box(
@@ -42,27 +41,18 @@ fun SwipeableMessageItem(
.draggable( .draggable(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
state = rememberDraggableState { delta -> state = rememberDraggableState { delta ->
scope.launch { // We only allow swiping to the left (negative delta)
// We only allow swiping to the left (negative delta) val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f) offsetX = newOffset
offsetX.snapTo(newOffset)
isTriggered = newOffset <= -replyThreshold
if (newOffset <= -replyThreshold && !isTriggered) {
isTriggered = true
// Trigger haptic feedback if available?
} else if (newOffset > -replyThreshold) {
isTriggered = false
}
}
}, },
onDragStopped = { onDragStopped = {
if (offsetX.value <= -replyThreshold) { if (offsetX <= -replyThreshold) {
onReply() onReply()
} }
scope.launch { offsetX = 0f
offsetX.animateTo(0f) isTriggered = false
isTriggered = false
}
} }
) )
) { ) {
@@ -72,7 +62,7 @@ fun SwipeableMessageItem(
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(end = 16.dp) .padding(end = 16.dp)
.size(36.dp) .size(36.dp)
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f)) .alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
.clip(CircleShape) .clip(CircleShape)
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)), .background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -88,7 +78,7 @@ fun SwipeableMessageItem(
// Message Content // Message Content
Box( Box(
modifier = Modifier modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) } .offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth() .fillMaxWidth()
) { ) {
content() 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
}
-32
View File
@@ -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,12 +1,5 @@
package core.notifications.data 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.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import auth.data.remote.api.AuthApi import auth.data.remote.api.AuthApi
@@ -18,7 +11,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ForkFirebaseMessagingService : FirebaseMessagingService() { class KnotFirebaseMessagingService : FirebaseMessagingService() {
@Inject @Inject
lateinit var authApi: AuthApi lateinit var authApi: AuthApi
@@ -7,9 +7,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import ru.knot.messager.R
object NotificationHelper { 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) { 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 val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -19,7 +20,7 @@ object NotificationHelper {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Fork Messenger Notifications", "Knot Notifications",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Chat and system notifications" description = "Chat and system notifications"
@@ -42,7 +43,7 @@ object NotificationHelper {
) )
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setAutoCancel(true) .setAutoCancel(true)
@@ -28,7 +28,7 @@ private val DarkColors = darkColorScheme(
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
@Composable @Composable
fun ForkMessengerTheme(content: @Composable () -> Unit) { fun KnotTheme(content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colorScheme = DarkColors, colorScheme = DarkColors,
shapes = MaterialTheme.shapes.copy( shapes = MaterialTheme.shapes.copy(
@@ -284,8 +284,8 @@ fun LazyListScope.renderFiles() {
fun LazyListScope.renderLinks() { fun LazyListScope.renderLinks() {
items(2) { items(2) {
ListItem( ListItem(
headlineContent = { Text("https://github.com/forkmessager") }, headlineContent = { Text("https://github.com/knot-messenger") },
supportingContent = { Text("GitHub - ForkMessager Project") }, supportingContent = { Text("GitHub - Knot Project") },
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) } leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
) )
} }