2 Commits
Author SHA1 Message Date
Халимов Рустам 118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам 8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
28 changed files with 530 additions and 70 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -117,6 +117,12 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx") implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-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 // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -15,6 +15,16 @@ import core.presentation.theme.ForkMessengerTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 { setContent {
ForkMessengerTheme { ForkMessengerTheme {
Surface( Surface(
@@ -75,4 +75,12 @@ class AuthRepositoryImpl @Inject constructor(
Result.failure(e) Result.failure(e)
} }
} }
override suspend fun updatePushToken(token: String) {
try {
api.updatePushToken(token)
} catch (e: Exception) {
// Silent fail
}
}
} }
@@ -8,4 +8,5 @@ interface AuthRepository {
suspend fun logout() suspend fun logout()
suspend fun fetchConfig(): Result<Unit> suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean fun isAuthenticated(): Boolean
suspend fun updatePushToken(token: String)
} }
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.google.firebase.messaging.FirebaseMessaging
import javax.inject.Inject import javax.inject.Inject
data class AuthState( data class AuthState(
@@ -22,6 +23,12 @@ class AuthViewModel @Inject constructor(
private val repository: AuthRepository private val repository: AuthRepository
) : ViewModel() { ) : ViewModel() {
init {
if (repository.isAuthenticated()) {
updatePushToken()
}
}
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated())) private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow() val state: StateFlow<AuthState> = _state.asStateFlow()
@@ -35,6 +42,7 @@ class AuthViewModel @Inject constructor(
repository.login(userName, password) repository.login(userName, password)
.onSuccess { .onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) } _state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
} }
.onFailure { e -> .onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _state.update { it.copy(isLoading = false, error = e.message) }
@@ -48,10 +56,22 @@ class AuthViewModel @Inject constructor(
repository.register(userName, password) repository.register(userName, password)
.onSuccess { .onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) } _state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
} }
.onFailure { e -> .onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _state.update { it.copy(isLoading = false, error = e.message) }
} }
} }
} }
private fun updatePushToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
repository.updatePushToken(token)
}
}
}
}
} }
@@ -7,42 +7,66 @@ import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 @Singleton
class ChatHubClient @Inject constructor() { class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64) private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow() val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) { fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub") lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken)) .withAccessTokenProvider(Single.just(accessToken))
.build() .build()
setupHandlers() setupHandlers()
hubConnection?.onClosed { exception -> hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed", exception) Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
// Optional: Reconnect logic _status.value = ConnectionStatus.DISCONNECTED
scope.launch {
delay(5000)
connect(baseUrl, accessToken)
}
} }
scope.launch { scope.launch {
try { try {
hubConnection?.start()?.blockingAwait() hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected") Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e) Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
} }
} }
} }
@@ -69,8 +93,13 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.NewChat(chat)) _events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java) }, 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)) _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) }, String::class.java, String::class.java, String::class.java, String::class.java)
// WebRTC Signaling Handlers // WebRTC Signaling Handlers
@@ -94,5 +123,12 @@ class ChatHubClient @Inject constructor() {
fun disconnect() { fun disconnect() {
hubConnection?.stop() hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
}
} }
} }
@@ -4,6 +4,8 @@ import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto 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.Chat
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
@@ -14,12 +16,16 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.map
class ChatRepositoryImpl @Inject constructor( 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
) : ChatRepository { ) : ChatRepository {
private val gson = com.google.gson.Gson()
override suspend fun getChats(): List<Chat> { override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: "" val currentUserId = tokenManager.getUserId() ?: ""
@@ -27,13 +33,37 @@ class ChatRepositoryImpl @Inject constructor(
return api.getChats().map { it.toDomain(currentUserId, baseUrl) } return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
} }
override suspend fun getMessages(chatId: String): List<Message> { override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getMessages(chatId).map { it.toDomain(baseUrl) } return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
entities.map { it.toDomain(baseUrl, gson) }
}
} }
override suspend fun sendMessage(chatId: String, content: String): Message { override suspend fun getMessages(chatId: String): List<Message> {
val request = SendMessageRequest(content = content, type = "text") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
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(
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.sendMessage(chatId, request).toDomain(baseUrl) return api.sendMessage(chatId, request).toDomain(baseUrl)
} }
@@ -46,8 +76,21 @@ class ChatRepositoryImpl @Inject constructor(
api.sendTypingStatus(chatId) api.sendTypingStatus(chatId)
} }
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) { override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
api.markMessagesAsRead(chatId, lastMessageId) 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 { override suspend fun uploadMedia(file: java.io.File): String {
@@ -89,8 +132,27 @@ 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 { fun MessageDto.toDomain(baseUrl: String): Message {
val domainMediaType = when (type) { val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE "image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO "video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO "audio", "voice" -> MediaType.AUDIO
@@ -125,10 +187,70 @@ fun MessageDto.toDomain(baseUrl: String): Message {
}, },
mediaType = domainMediaType, mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), 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) 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 { fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) { return if (this.startsWith("http")) {
this this
+9 -2
View File
@@ -4,6 +4,7 @@ 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
@@ -25,8 +26,14 @@ object ChatModule {
@Provides @Provides
@Singleton @Singleton
fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository { fun provideChatRepository(
return ChatRepositoryImpl(api, tokenManager, serverConfig) api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
messageDao: MessageDao,
hubClient: chats.data.remote.signalr.ChatHubClient
): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
} }
@Provides @Provides
+1 -1
View File
@@ -26,5 +26,5 @@ data class Media(
) )
enum class MediaType { enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
} }
@@ -5,11 +5,19 @@ import chats.domain.model.Message
interface ChatRepository { interface ChatRepository {
suspend fun getChats(): List<Chat> suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
suspend fun getMessages(chatId: String): List<Message> suspend fun getMessages(chatId: String): List<Message>
suspend fun sendMessage(chatId: String, content: String): Message suspend fun sendMessage(
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
): Message
suspend fun addReaction(messageId: String, emoji: String) suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: 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 uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto> 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> suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
@@ -16,7 +16,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.res.stringResource
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chats.presentation.components.MediaPicker import chats.presentation.components.MediaPicker
import chats.presentation.components.MessageBubble import chats.presentation.components.MessageBubble
@@ -28,7 +31,7 @@ import java.io.File
import ru.knot.messager.R import ru.knot.messager.R
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatDetailScreen( fun ChatDetailScreen(
chatId: String, chatId: String,
@@ -45,6 +48,9 @@ fun ChatDetailScreen(
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording 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 autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) } var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
@@ -89,10 +95,27 @@ fun ChatDetailScreen(
viewModel.setChatId(chatId) viewModel.setChatId(chatId)
} }
// Автопрокрутка к последнему сообщению // Автопрокрутка к первому непрочитанному или к самому низу
LaunchedEffect(state.initialScrollIndex) {
state.initialScrollIndex?.let { index ->
if (index < state.messages.size) {
listState.scrollToItem(index)
}
}
}
// Автопрокрутка к новому сообщению, если мы внизу
LaunchedEffect(state.messages.size) { LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) { 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.imePadding()
) { ) {
// Список сообщений // Список сообщений
Box( Box(
@@ -193,7 +217,15 @@ fun ChatDetailScreen(
.padding(8.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) { IconButton(
onClick = {
isEmojiPickerVisible = !isEmojiPickerVisible
if (isEmojiPickerVisible) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
) {
Icon( Icon(
Icons.Default.EmojiEmotions, Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.emoji), contentDescription = stringResource(R.string.emoji),
@@ -207,7 +239,13 @@ fun ChatDetailScreen(
TextField( TextField(
value = textInput, value = textInput,
onValueChange = { textInput = it }, onValueChange = { textInput = it },
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.onFocusChanged {
if (it.isFocused) {
isEmojiPickerVisible = false
}
},
placeholder = { Text(stringResource(R.string.message_placeholder)) }, placeholder = { Text(stringResource(R.string.message_placeholder)) },
maxLines = 4, maxLines = 4,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
@@ -33,7 +33,8 @@ data class ChatDetailState(
val searchedGifs: List<KlipyGifDto> = emptyList(), val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(), val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(), val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null
) )
@HiltViewModel @HiltViewModel
@@ -65,9 +66,43 @@ class ChatDetailViewModel @Inject constructor(
fun setChatId(chatId: String) { fun setChatId(chatId: String) {
currentChatId = chatId currentChatId = chatId
// Ensure SignalR is connected
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
signalrClient.connect(baseUrl, token)
}
loadChatInfo(chatId) loadChatInfo(chatId)
loadMessages(chatId) observeMessages(chatId)
observeSignalREvents() 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) { private fun loadChatInfo(chatId: String) {
@@ -84,17 +119,16 @@ class ChatDetailViewModel @Inject constructor(
} }
} }
fun loadMessages(chatId: String) { private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
viewModelScope.launch { _state.update { s ->
_state.update { it.copy(isLoading = true) } val updatedMessages = s.messages.map { msg ->
try { if (msg.id == messageId) {
// Бэкенд обычно возвращает сообщения от новых к старым. val currentReactions = msg.reactions.toMutableMap()
// Для чата нам нужно наоборот: старые вверху, новые внизу. currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
val messages = repository.getMessages(chatId).reversed() msg.copy(reactions = currentReactions)
_state.update { it.copy(messages = messages, isLoading = false) } } else msg
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
} }
s.copy(messages = updatedMessages)
} }
} }
@@ -111,13 +145,10 @@ class ChatDetailViewModel @Inject constructor(
.onEach { event -> .onEach { event ->
when (event) { when (event) {
is ChatEvent.NewMessage -> { is ChatEvent.NewMessage -> {
// Avoid adding duplicates if already loaded
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s -> val domainMsg = event.message.toDomain(baseUrl)
val domainMsg = event.message.toDomain(baseUrl) viewModelScope.launch {
if (s.messages.none { it.id == domainMsg.id }) { repository.saveMessage(domainMsg)
s.copy(messages = s.messages + domainMsg)
} else s
} }
} }
is ChatEvent.ReactionUpdated -> { is ChatEvent.ReactionUpdated -> {
@@ -134,31 +165,53 @@ class ChatDetailViewModel @Inject constructor(
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) { fun markAsRead() {
_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
}
s.copy(messages = updatedMessages)
}
}
fun sendMessage(text: String) {
val chatId = currentChatId ?: return val chatId = currentChatId ?: return
val messages = _state.value.messages
if (messages.isEmpty()) return
val lastMessage = messages.last()
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.sendMessage(chatId, text) repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId)
} catch (e: Exception) { } catch (e: Exception) {
// Ignore
}
}
}
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)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) } _state.update { it.copy(error = e.localizedMessage) }
} }
} }
@@ -237,9 +290,40 @@ class ChatDetailViewModel @Inject constructor(
fun sendGif(url: String) { fun sendGif(url: String) {
val chatId = currentChatId ?: return 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 { viewModelScope.launch {
try { try {
repository.sendMessage(chatId, url) val attachment = chats.data.remote.api.AttachmentRequest(
type = "image",
url = url,
fileName = "gif.gif",
fileSize = 0
)
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Add to recent // Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
@@ -258,6 +342,7 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(recentGifs = newList) } _state.update { it.copy(recentGifs = newList) }
} }
} catch (e: Exception) { } catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) } _state.update { it.copy(error = e.localizedMessage) }
} }
} }
@@ -43,6 +43,10 @@ fun ChatListScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState() val storyState by storyViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.loadChats()
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -83,6 +83,18 @@ class ChatListViewModel @Inject constructor(
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) } _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 else -> Unit
} }
} }
@@ -92,12 +104,15 @@ class ChatListViewModel @Inject constructor(
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) { private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
_state.update { currentState -> _state.update { currentState ->
val updatedChats = currentState.chats.map { chat -> val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.message.chatId) { if (chat.id == event.message.chatId) {
val isMyMessage = event.message.senderId == currentUserId
chat.copy( chat.copy(
lastMessage = event.message.toDomain(baseUrl), lastMessage = event.message.toDomain(baseUrl),
unreadCount = chat.unreadCount + 1 unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1
) )
} else chat } else chat
} }
@@ -142,13 +142,29 @@ fun MessageBubble(
} }
} }
// Media Content // GIF Content (Klipy)
if (message.media.isNotEmpty()) { if (message.mediaType == MediaType.GIF) {
AsyncImage(
model = message.content,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { /* Handle click */ },
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(4.dp))
}
// Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size val mediaCount = message.media.size
if (mediaCount == 1) { if (mediaCount == 1) {
val mediaItem = message.media[0] val mediaItem = message.media[0]
when { when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE || mediaItem.type.startsWith("image") -> { mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage( AsyncImage(
model = mediaItem.url, model = mediaItem.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context), imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
@@ -218,7 +234,7 @@ fun MessageBubble(
} }
// Text Content // Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage) { if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -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
}
+32
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()
}
}
@@ -61,12 +61,23 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.createNotificationChannel(channel) 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) val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setAutoCancel(true) .setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build()) notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
} }