Прочтение

This commit is contained in:
Халимов Рустам
2026-04-14 15:09:44 +03:00
parent 8165b74e43
commit 118f8b8971
24 changed files with 422 additions and 64 deletions

View File

@@ -117,6 +117,12 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Room
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")

View File

@@ -15,6 +15,16 @@ import core.presentation.theme.ForkMessengerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Request notifications permission for Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
101
)
}
setContent {
ForkMessengerTheme {
Surface(

View File

@@ -23,6 +23,12 @@ class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
init {
if (repository.isAuthenticated()) {
updatePushToken()
}
}
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow()

View File

@@ -16,6 +16,12 @@ import javax.inject.Singleton
import kotlinx.coroutines.delay
data class ReadMessagesRequest(
val chatId: String,
val lastReadMessageId: String,
val lastReadSequenceId: Int
)
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton
@@ -38,7 +44,7 @@ class ChatHubClient @Inject constructor() {
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken))
.build()
@@ -87,8 +93,13 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String ->
conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String ->
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji))
}, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String ->
// Using ReactionUpdated with empty emoji to signal removal or just a specific removal event
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, ""))
}, String::class.java, String::class.java, String::class.java, String::class.java)
// WebRTC Signaling Handlers
@@ -114,4 +125,10 @@ class ChatHubClient @Inject constructor() {
hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
}
}
}

View File

@@ -4,6 +4,8 @@ import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.data.remote.dto.MediaItemDto
import chats.data.remote.dto.ReactionDto
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
@@ -14,12 +16,16 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
import kotlinx.coroutines.flow.map
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
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
) : ChatRepository {
private val gson = com.google.gson.Gson()
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
@@ -27,9 +33,24 @@ class ChatRepositoryImpl @Inject constructor(
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
}
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) }
}
}
override suspend fun getMessages(chatId: String): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getMessages(chatId).map { it.toDomain(baseUrl) }
return try {
val messages = api.getMessages(chatId)
// Save to DB
messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) })
messages.map { it.toDomain(baseUrl) }
} catch (e: Exception) {
// If network fails, caller should ideally use the Flow from DB
emptyList()
}
}
override suspend fun sendMessage(
@@ -55,8 +76,21 @@ class ChatRepositoryImpl @Inject constructor(
api.sendTypingStatus(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
api.markMessagesAsRead(chatId, lastMessageId)
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
val request = chats.data.remote.signalr.ReadMessagesRequest(
chatId = chatId,
lastReadMessageId = lastMessageId,
lastReadSequenceId = lastReadSequenceId
)
hubClient.readMessages(request)
}
override suspend fun saveMessage(message: Message) {
messageDao.insertMessages(listOf(message.toEntity(gson)))
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessage(messageId)
}
override suspend fun uploadMedia(file: java.io.File): String {
@@ -98,6 +132,24 @@ 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(baseUrl: String): Message {
val domainMediaType = when (type) {
"gif" -> MediaType.GIF
@@ -135,10 +187,70 @@ fun MessageDto.toDomain(baseUrl: String): Message {
},
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
isRead = true, // Network messages are usually considered read when fetched or handled by server
replyTo = replyTo?.toDomain(baseUrl)
)
}
fun MessageDto.toEntity(baseUrl: 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 = true,
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> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = gson.fromJson(reactionsJson, reactionsTypeToken) ?: 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,
type = it.type,
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

View File

@@ -4,6 +4,7 @@ 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
@@ -25,8 +26,14 @@ object ChatModule {
@Provides
@Singleton
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig)
fun provideChatRepository(
api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
messageDao: MessageDao,
hubClient: chats.data.remote.signalr.ChatHubClient
): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
}
@Provides

View File

@@ -5,6 +5,7 @@ import chats.domain.model.Message
interface ChatRepository {
suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
suspend fun getMessages(chatId: String): List<Message>
suspend fun sendMessage(
chatId: String,
@@ -14,7 +15,9 @@ interface ChatRepository {
): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
suspend fun saveMessage(message: Message)
suspend fun deleteLocalMessage(messageId: String)
suspend fun uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>

View File

@@ -16,7 +16,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble
@@ -28,7 +31,7 @@ import java.io.File
import ru.knot.messager.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun ChatDetailScreen(
chatId: String,
@@ -45,6 +48,9 @@ fun ChatDetailScreen(
var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
@@ -89,10 +95,27 @@ fun ChatDetailScreen(
viewModel.setChatId(chatId)
}
// Автопрокрутка к последнему сообщению
// Автопрокрутка к первому непрочитанному или к самому низу
LaunchedEffect(state.initialScrollIndex) {
state.initialScrollIndex?.let { index ->
if (index < state.messages.size) {
listState.scrollToItem(index)
}
}
}
// Автопрокрутка к новому сообщению, если мы внизу
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
if (isAtBottom || state.initialScrollIndex == null) {
listState.animateScrollToItem(state.messages.size - 1)
}
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
if (isAtBottom) {
viewModel.markAsRead()
}
}
}
@@ -141,6 +164,7 @@ fun ChatDetailScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
) {
// Список сообщений
Box(
@@ -193,7 +217,15 @@ fun ChatDetailScreen(
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) {
IconButton(
onClick = {
isEmojiPickerVisible = !isEmojiPickerVisible
if (isEmojiPickerVisible) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji),
@@ -207,7 +239,13 @@ fun ChatDetailScreen(
TextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.onFocusChanged {
if (it.isFocused) {
isEmojiPickerVisible = false
}
},
placeholder = { Text(stringResource(R.string.message_placeholder)) },
maxLines = 4,
colors = TextFieldDefaults.colors(

View File

@@ -33,7 +33,8 @@ data class ChatDetailState(
val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false
val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null
)
@HiltViewModel
@@ -69,13 +70,39 @@ class ChatDetailViewModel @Inject constructor(
// Ensure SignalR is connected
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
signalrClient.connect(baseUrl, token)
}
loadChatInfo(chatId)
loadMessages(chatId)
observeMessages(chatId)
observeSignalREvents()
// Initial sync from network
refreshMessages(chatId)
}
private fun observeMessages(chatId: String) {
repository.getMessagesFlow(chatId)
.onEach { messages ->
_state.update { it.copy(messages = messages) }
// Calculate scroll index if not set
if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) {
val firstUnreadIndex = messages.indexOfFirst { !it.isRead && it.senderId != getCurrentUserId() }
val targetIndex = if (firstUnreadIndex != -1) firstUnreadIndex else messages.size - 1
_state.update { it.copy(initialScrollIndex = targetIndex) }
// Automark as read
markAsRead()
}
}
.launchIn(viewModelScope)
}
fun refreshMessages(chatId: String) {
viewModelScope.launch {
repository.getMessages(chatId)
}
}
private fun loadChatInfo(chatId: String) {
@@ -92,17 +119,16 @@ class ChatDetailViewModel @Inject constructor(
}
}
fun loadMessages(chatId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
// Бэкенд обычно возвращает сообщения от новых к старым.
// Для чата нам нужно наоборот: старые вверху, новые внизу.
val messages = repository.getMessages(chatId).reversed()
_state.update { it.copy(messages = messages, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
@@ -119,13 +145,10 @@ class ChatDetailViewModel @Inject constructor(
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
// Avoid adding duplicates if already loaded
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain(baseUrl)
if (s.messages.none { it.id == domainMsg.id }) {
s.copy(messages = s.messages + domainMsg)
} else s
val domainMsg = event.message.toDomain(baseUrl)
viewModelScope.launch {
repository.saveMessage(domainMsg)
}
}
is ChatEvent.ReactionUpdated -> {
@@ -142,39 +165,53 @@ class ChatDetailViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
// Logic to update reactions map.
// Note: Simplified logic, usually we need to know if it was added or removed.
// If we assume reaction_updated is a toggle:
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
// This is a placeholder logic as the exact behavior depends on server implementation.
// For now, let's just increment/decrement based on some convention or just refresh.
currentReactions[emoji] = count + 1
msg.copy(reactions = currentReactions)
} else msg
fun markAsRead() {
val chatId = currentChatId ?: return
val messages = _state.value.messages
if (messages.isEmpty()) return
val lastMessage = messages.last()
viewModelScope.launch {
try {
repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId)
} catch (e: Exception) {
// Ignore
}
s.copy(messages = updatedMessages)
}
}
fun sendMessage(text: String) {
val chatId = currentChatId ?: return
if (text.isBlank()) return
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = text,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.TEXT,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
val sentMessage = repository.sendMessage(chatId, text)
// Optimistic update if not already there
_state.update { s ->
if (s.messages.none { it.id == sentMessage.id }) {
s.copy(messages = s.messages + sentMessage)
} else s
}
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
}
}
@@ -253,6 +290,28 @@ class ChatDetailViewModel @Inject constructor(
fun sendGif(url: String) {
val chatId = currentChatId ?: return
val tempId = "temp_gif_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = url,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
val attachment = chats.data.remote.api.AttachmentRequest(
@@ -263,12 +322,8 @@ class ChatDetailViewModel @Inject constructor(
)
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
// Optimistic update
_state.update { s ->
if (s.messages.none { it.id == sentMessage.id }) {
s.copy(messages = s.messages + sentMessage)
} else s
}
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
@@ -287,6 +342,7 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
}
}

View File

@@ -43,6 +43,10 @@ fun ChatListScreen(
val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.loadChats()
}
Scaffold(
topBar = {
TopAppBar(

View File

@@ -83,6 +83,18 @@ class ChatListViewModel @Inject constructor(
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
}
is ChatEvent.MessagesRead -> {
if (event.userId == getCurrentUserId()) {
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.chatId) {
chat.copy(unreadCount = 0)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}
else -> Unit
}
}
@@ -92,12 +104,15 @@ class ChatListViewModel @Inject constructor(
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.message.chatId) {
val isMyMessage = event.message.senderId == currentUserId
chat.copy(
lastMessage = event.message.toDomain(baseUrl),
unreadCount = chat.unreadCount + 1
unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1
)
} else chat
}

View File

@@ -0,0 +1,41 @@
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)
}
@Database(entities = [MessageEntity::class], version = 1)
abstract class ChatDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}

View File

@@ -0,0 +1,32 @@
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()
}
}

View File

@@ -61,12 +61,23 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.createNotificationChannel(channel)
}
// Create intent to open MainActivity
val intent = Intent(this, com.knot.messenger.MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("type", type)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
}