2 Commits
57 changed files with 161 additions and 295 deletions
+10
View File
@@ -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.
@@ -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 {
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
+1 -3
View File
@@ -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)
if (newOffset <= -replyThreshold && !isTriggered) {
isTriggered = true
// Trigger haptic feedback if available?
} else if (newOffset > -replyThreshold) {
isTriggered = false
}
}
// We only allow swiping to the left (negative delta)
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
offsetX = newOffset
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
}
-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
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) }
)
}