Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118f8b8971 | ||
|
|
8165b74e43 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,11 +95,28 @@ 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()) {
|
||||||
|
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2
|
||||||
|
if (isAtBottom || state.initialScrollIndex == null) {
|
||||||
listState.animateScrollToItem(state.messages.size - 1)
|
listState.animateScrollToItem(state.messages.size - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
|
||||||
|
if (isAtBottom) {
|
||||||
|
viewModel.markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -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)
|
||||||
if (s.messages.none { it.id == domainMsg.id }) {
|
viewModelScope.launch {
|
||||||
s.copy(messages = s.messages + domainMsg)
|
repository.saveMessage(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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user