diff --git a/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt b/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt index 509fd9420..13b7cb201 100644 --- a/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt +++ b/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt @@ -3,17 +3,23 @@ package org.monogram.data.service import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import org.koin.android.ext.android.inject +import org.monogram.data.di.TdNotificationManager import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.push.PushSyncTrigger import org.monogram.domain.repository.AppPreferencesProvider class FcmPushService : FirebaseMessagingService() { private val gateway: TelegramGateway by inject() private val appPreferences: AppPreferencesProvider by inject() + private val notificationManager: TdNotificationManager by inject() + private val pushSyncTrigger: PushSyncTrigger by inject() private val delegate by lazy { BaseFcmPushService( context = this, gateway = gateway, - appPreferences = appPreferences + appPreferences = appPreferences, + notificationManager = notificationManager, + pushSyncTrigger = pushSyncTrigger ) } diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt index 9bcf908a4..c4c5dc39b 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -562,7 +562,14 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { TdApi.ChatPosition(TdApi.ChatListArchive(), order, pinned, null) } - "f" -> null + "f" -> { + if (parts.size < 4) return@mapNotNull null + val folderId = parts[1].toIntOrNull() ?: return@mapNotNull null + val order = parts[2].toLongOrNull() ?: return@mapNotNull null + if (order == 0L) return@mapNotNull null + val pinned = parts[3] == "1" + TdApi.ChatPosition(TdApi.ChatListFolder(folderId), order, pinned, null) + } else -> null } diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index a0480aec5..984a378c4 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -83,6 +83,7 @@ class ChatModelFactory( var personalAvatarPath: String? = null var signMessages = false var joinToSendMessages = false + var joinByRequest = false val isArchived = chat.positions.any { it.list is TdApi.ChatListArchive } @@ -143,6 +144,7 @@ class ChatModelFactory( hasAutomaticTranslation = it.hasAutomaticTranslation signMessages = it.signMessages joinToSendMessages = it.joinToSendMessages + joinByRequest = it.joinByRequest } ?: if (allowRemoteLookups && !cache.isSupergroupTemporarilyMissing(type.supergroupId)) lazyLoad( cache.pendingSupergroups, @@ -383,7 +385,9 @@ class ChatModelFactory( hasAutomaticTranslation = hasAutomaticTranslation, personalAvatarPath = personalAvatarPath, signMessages = signMessages, - joinToSendMessages = joinToSendMessages + joinToSendMessages = joinToSendMessages, + pendingJoinRequestCount = chat.pendingJoinRequests?.totalCount ?: 0, + joinByRequest = joinByRequest ) } diff --git a/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt index 2ee093385..de5080764 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt @@ -24,7 +24,6 @@ class ChatPersistenceManager( ) { private val lastSavedEntities = ConcurrentHashMap() private val pendingSaveJobs = ConcurrentHashMap() - private val mainChatList = TdApi.ChatListMain() fun rememberSavedEntity(entity: ChatEntity) { lastSavedEntities[entity.id] = entity @@ -38,7 +37,7 @@ class ChatPersistenceManager( try { delay(SINGLE_CHAT_SAVE_DEBOUNCE_MS) val activeChatList = activeChatListProvider() - val position = resolvePersistPosition(chat, activeChatList) + val position = resolvePersistPosition(chat, activeChatList, listManager) val model = modelFactory.mapChatToModel( chat = chat, order = position?.order ?: 0L, @@ -149,7 +148,7 @@ class ChatPersistenceManager( return chatMapper.mapToEntity(model) } - val persistPosition = resolvePersistPosition(chat, activeChatList) + val persistPosition = resolvePersistPosition(chat, activeChatList, listManager) val mapped = chatMapper.mapToEntity(chat, model) return if (persistPosition != null && (persistPosition.order != mapped.order || persistPosition.isPinned != mapped.isPinned) @@ -160,16 +159,6 @@ class ChatPersistenceManager( } } - private fun resolvePersistPosition(chat: TdApi.Chat, activeChatList: TdApi.ChatList): TdApi.ChatPosition? { - return chat.positions.find { pos -> - pos.order != 0L && listManager.isSameChatList(pos.list, mainChatList) - } - ?: chat.positions.find { pos -> - pos.order != 0L && listManager.isSameChatList(pos.list, activeChatList) - } - ?: chat.positions.firstOrNull { it.order != 0L } - } - private fun isEntityChanged(old: ChatEntity, new: ChatEntity): Boolean { return old.withoutCreatedAt() != new.withoutCreatedAt() } @@ -181,4 +170,18 @@ class ChatPersistenceManager( private const val SINGLE_CHAT_SAVE_DEBOUNCE_MS = 2000L private const val SNAPSHOT_PERSIST_LIMIT = 1000 } -} \ No newline at end of file +} + +internal fun resolvePersistPosition( + chat: TdApi.Chat, + activeChatList: TdApi.ChatList, + listManager: ChatListManager +): TdApi.ChatPosition? { + return chat.positions.find { pos -> + pos.order != 0L && listManager.isSameChatList(pos.list, activeChatList) + } + ?: chat.positions.find { pos -> + pos.order != 0L && listManager.isSameChatList(pos.list, TdApi.ChatListMain()) + } + ?: chat.positions.firstOrNull { it.order != 0L } +} diff --git a/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt index 6ac37aa48..bed0292a5 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt @@ -164,6 +164,9 @@ class ChatUpdateHandler( is TdApi.UpdateFile -> { if (fileManager.handleFileUpdate(update.file)) { val chatId = fileManager.getChatIdByPhotoId(update.file.id) + if (chatId != null) { + onSaveChat(chatId) + } onScheduleUpdate(chatId) onRefreshForumTopics() } @@ -192,6 +195,7 @@ class ChatUpdateHandler( cache.putUser(update.user) val privateChatId = cache.userIdToChatId[update.user.id] if (privateChatId != null) { + onSaveChat(privateChatId) onTriggerUpdate(privateChatId) } onRefreshForumTopics() diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt index 448d96efb..fa0cf807d 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt @@ -20,7 +20,10 @@ interface ChatRemoteSource { suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) + suspend fun setChatHasHiddenMembers(chatId: Long, hasHiddenMembers: Boolean) + suspend fun setChatHasAggressiveAntiSpamEnabled(chatId: Long, enabled: Boolean) suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) + suspend fun setChatJoinByRequest(chatId: Long, joinByRequest: Boolean) suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 686811a4c..ea406ef94 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -93,6 +93,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { photoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -103,6 +104,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { videoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -147,6 +149,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { gifPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -157,6 +160,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { paths: List, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -289,6 +293,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { limit: Int, threadId: Long? = null ): List + suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? suspend fun getRemoteMessagesAround( chatId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt index 6f8ced671..be0b27bd2 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt @@ -8,6 +8,7 @@ interface SettingsRemoteDataSource { suspend fun getActiveSessions(): TdApi.Sessions? suspend fun getInstalledBackgrounds(forDarkTheme: Boolean): TdApi.Backgrounds? suspend fun getStorageStatistics(chatLimit: Int): TdApi.StorageStatistics? + suspend fun getStorageStatisticsFast(): TdApi.StorageStatisticsFast? suspend fun getNetworkStatistics(): TdApi.NetworkStatistics? suspend fun getOption(name: String): TdApi.OptionValue? suspend fun getChatNotificationSettingsExceptions( @@ -44,7 +45,7 @@ interface SettingsRemoteDataSource { chatIds: LongArray?, returnDeletedFileStatistics: Boolean, chatLimit: Int - ): Boolean + ): TdApi.StorageStatistics? suspend fun resetNetworkStatistics(): Boolean // Files diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt index 08acb3be2..e784abc2b 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt @@ -9,8 +9,8 @@ import org.monogram.data.compat.buildSearchPublicChats import org.monogram.data.compat.buildTdChatPermissions import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway -import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.repository.TelegramLinkRepository class TdChatRemoteSource( private val gateway: TelegramGateway, @@ -118,6 +118,27 @@ class TdChatRemoteSource( } } + override suspend fun setChatHasHiddenMembers(chatId: Long, hasHiddenMembers: Boolean) { + coRunCatching { + val chat = gateway.execute(TdApi.GetChat(chatId)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return + gateway.execute(TdApi.ToggleSupergroupHasHiddenMembers(supergroupId, hasHiddenMembers)) + } + } + + override suspend fun setChatHasAggressiveAntiSpamEnabled(chatId: Long, enabled: Boolean) { + coRunCatching { + val chat = gateway.execute(TdApi.GetChat(chatId)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return + gateway.execute( + TdApi.ToggleSupergroupHasAggressiveAntiSpamEnabled( + supergroupId, + enabled + ) + ) + } + } + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) { coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) @@ -131,6 +152,22 @@ class TdChatRemoteSource( } } + override suspend fun setChatJoinByRequest(chatId: Long, joinByRequest: Boolean) { + coRunCatching { + val chat = gateway.execute(TdApi.GetChat(chatId)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return + val fullInfo = gateway.execute(TdApi.GetSupergroupFullInfo(supergroupId)) + gateway.execute( + TdApi.ToggleSupergroupJoinByRequest( + supergroupId, + joinByRequest, + fullInfo.guardBotUserId, + true + ) + ) + } + } + override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) { coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index 68e5fd478..ba791d649 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -781,6 +781,32 @@ class TdMessageRemoteDataSource( } } + override suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? { + val message = + safeExecute(TdApi.GetChatMessageByDate(chatId, dateEpochSeconds)) ?: return null + cache.putMessage(message) + + val chat = getChat(chatId) + val lastReadInbox = chat?.lastReadInboxMessageId ?: 0L + val lastReadOutbox = chat?.lastReadOutboxMessageId ?: 0L + + return try { + withTimeout(5000) { + messageMapper.mapMessageToModelSync( + message, + lastReadInbox, + lastReadOutbox, + isChatOpen = true + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("TdMessageRemote", "Error mapping dated message ${message.id}", e) + createFallbackMessage(message) + } + } + private fun createFallbackMessage(msg: TdApi.Message): MessageModel = MessageModel( id = msg.id, date = msg.date, @@ -815,14 +841,18 @@ class TdMessageRemoteDataSource( } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null val topicId = resolveTopicId(chatId, threadId) - val req = TdApi.SendMessage().apply { - this.chatId = chatId - this.topicId = topicId - this.replyTo = replyTo - this.inputMessageContent = content - this.options = sendOptions.toTdMessageSendOptions() + var lastMessage: TdApi.Message? = null + explodeTextContent(content, MAX_TEXT_MESSAGE_CODE_POINTS).forEach { messageContent -> + val req = TdApi.SendMessage().apply { + this.chatId = chatId + this.topicId = topicId + this.replyTo = replyTo + this.inputMessageContent = messageContent + this.options = sendOptions.toTdMessageSendOptions() + } + lastMessage = safeExecute(req) } - return safeExecute(req) + return lastMessage } override suspend fun sendRichMessage( @@ -863,6 +893,7 @@ class TdMessageRemoteDataSource( photoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -870,7 +901,7 @@ class TdMessageRemoteDataSource( val content = TdApi.InputMessagePhoto( buildInputPhoto(TdApi.InputFileLocal(photoPath)), TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)), - false, + showCaptionAboveMedia, null, false ) @@ -899,6 +930,7 @@ class TdMessageRemoteDataSource( videoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -906,7 +938,7 @@ class TdMessageRemoteDataSource( val content = TdApi.InputMessageVideo( buildInputVideo(TdApi.InputFileLocal(videoPath)), TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)), - false, + showCaptionAboveMedia, null, false ) @@ -1068,6 +1100,7 @@ class TdMessageRemoteDataSource( gifPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -1075,7 +1108,7 @@ class TdMessageRemoteDataSource( val content = TdApi.InputMessageAnimation( buildInputAnimation(TdApi.InputFileLocal(gifPath)), TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)), - false, + showCaptionAboveMedia, false ) val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null @@ -1101,6 +1134,7 @@ class TdMessageRemoteDataSource( paths: List, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -1120,14 +1154,14 @@ class TdMessageRemoteDataSource( if (isVideo) TdApi.InputMessageVideo( buildInputVideo(TdApi.InputFileLocal(path)), cap, - false, + index == 0 && showCaptionAboveMedia, null, false ) else TdApi.InputMessagePhoto( buildInputPhoto(TdApi.InputFileLocal(path)), cap, - false, + index == 0 && showCaptionAboveMedia, null, false ) @@ -1285,6 +1319,7 @@ class TdMessageRemoteDataSource( } override suspend fun editMessageCaption(chatId: Long, messageId: Long, caption: String, entities: List): TdApi.Message? { + val showCaptionAboveMedia = resolveShowCaptionAboveMedia(chatId, messageId) val req = TdApi.EditMessageCaption().apply { this.chatId = chatId this.messageId = messageId @@ -1293,7 +1328,7 @@ class TdMessageRemoteDataSource( caption, entities.toTdTextEntities(caption) ) - this.showCaptionAboveMedia = false + this.showCaptionAboveMedia = showCaptionAboveMedia } return safeExecute(req) } @@ -1447,6 +1482,181 @@ class TdMessageRemoteDataSource( return TdApi.TextEntity(start, safeLength, tdType) } + private fun explodeTextContent( + content: TdApi.InputMessageText, + maxCodePointCount: Int + ): List { + val formattedText = content.text ?: return listOf(content) + if (formattedText.text.codePointCount(0, formattedText.text.length) <= maxCodePointCount) { + return listOf(content) + } + + val chunks = mutableListOf() + val text = formattedText.text + val textLength = text.length + var start = 0 + var end = 0 + var currentCodePointCount = 0 + + while (start < textLength) { + val codePoint = text.codePointAt(end) + currentCodePointCount++ + end += Character.charCount(codePoint) + if (currentCodePointCount == maxCodePointCount || end == textLength) { + var chunkEnd = end + if (chunkEnd < textLength) { + chunkEnd = findChunkBoundary(text, start, chunkEnd) + if (chunkEnd <= start) { + chunkEnd = end + } + } + val chunkText = formattedText.substring(start, chunkEnd) + val isFirstChunk = chunks.isEmpty() + chunks += TdApi.InputMessageText().apply { + this.text = chunkText + this.linkPreviewOptions = content.linkPreviewOptions + this.clearDraft = isFirstChunk && content.clearDraft + } + start = chunkEnd + end = chunkEnd + currentCodePointCount = 0 + } + } + + return chunks + } + + private fun TdApi.FormattedText.substring(start: Int, end: Int): TdApi.FormattedText { + val chunkText = text.substring(start, end) + val chunkEntities = entities.orEmpty() + .mapNotNull { entity -> + val entityStart = maxOf(entity.offset, start) + val entityEnd = minOf(entity.offset + entity.length, end) + if (entityEnd <= entityStart) return@mapNotNull null + TdApi.TextEntity( + entityStart - start, + entityEnd - entityStart, + entity.type + ) + } + .sortedWith(compareBy { it.offset }.thenByDescending { it.length }) + .toTypedArray() + return TdApi.FormattedText(chunkText, chunkEntities) + } + + private fun findChunkBoundary(text: String, start: Int, candidateEnd: Int): Int { + val searchStart = candidateEnd - (candidateEnd - start) / 3 + var lastWhitespace = -1 + var lastSplitter = -1 + for (index in (candidateEnd - 1) downTo searchStart) { + val char = text[index] + if (char == '\n' && index > start) return index + if (lastWhitespace == -1 && char.isWhitespace()) { + lastWhitespace = index + } + if (lastSplitter == -1 && isChunkSplitter(char)) { + lastSplitter = index + } + } + return when { + lastWhitespace > start -> lastWhitespace + lastSplitter > start -> lastSplitter + else -> candidateEnd + } + } + + private fun isChunkSplitter(char: Char): Boolean { + return char in charArrayOf('.', ',', '!', '?', ';', ':', ')', ']', '}', '/', '\\', '-', '>') + } + + private fun resolveShowCaptionAboveMedia(chatId: Long, messageId: Long): Boolean { + val content = cache.getMessage(chatId, messageId)?.content ?: return false + return when (content) { + is TdApi.MessagePhoto -> content.showCaptionAboveMedia + is TdApi.MessageVideo -> content.showCaptionAboveMedia + is TdApi.MessageAnimation -> content.showCaptionAboveMedia + else -> false + } + } + + private fun applyReactionDelta( + existing: TdApi.MessageReactions?, + actorId: TdApi.MessageSender?, + oldReactionTypes: Array, + newReactionTypes: Array + ): TdApi.MessageReactions { + val mutableReactions = existing?.reactions.orEmpty() + .map { reaction -> + TdApi.MessageReaction().apply { + type = reaction.type + totalCount = reaction.totalCount + isChosen = reaction.isChosen + usedSenderId = reaction.usedSenderId + recentSenderIds = reaction.recentSenderIds?.clone() ?: emptyArray() + } + } + .associateByTo(linkedMapOf(), ::reactionKey) + + oldReactionTypes.forEach { reactionType -> + val key = reactionKey(reactionType) + val current = mutableReactions[key] ?: return@forEach + current.totalCount = (current.totalCount - 1).coerceAtLeast(0) + if (actorId != null) { + current.recentSenderIds = current.recentSenderIds.orEmpty() + .filterNot { sameSender(it, actorId) } + .toTypedArray() + } + if (current.totalCount == 0) { + mutableReactions.remove(key) + } + } + + newReactionTypes.forEach { reactionType -> + val key = reactionKey(reactionType) + val current = mutableReactions.getOrPut(key) { + TdApi.MessageReaction().apply { + type = reactionType + totalCount = 0 + isChosen = false + usedSenderId = null + recentSenderIds = emptyArray() + } + } + current.totalCount += 1 + if (actorId != null) { + current.recentSenderIds = ( + listOf(actorId) + current.recentSenderIds.orEmpty() + .filterNot { sameSender(it, actorId) } + .take(3) + ).toTypedArray() + } + } + + return TdApi.MessageReactions().apply { + reactions = mutableReactions.values.toTypedArray() + areTags = existing?.areTags ?: false + paidReactors = existing?.paidReactors ?: emptyArray() + canGetAddedReactions = existing?.canGetAddedReactions ?: false + } + } + + private fun reactionKey(reaction: TdApi.MessageReaction): String = reactionKey(reaction.type) + + private fun reactionKey(reaction: TdApi.ReactionType): String = when (reaction) { + is TdApi.ReactionTypeEmoji -> "emoji:${reaction.emoji}" + is TdApi.ReactionTypeCustomEmoji -> "custom:${reaction.customEmojiId}" + is TdApi.ReactionTypePaid -> "paid" + else -> reaction.javaClass.simpleName + } + + private fun sameSender(first: TdApi.MessageSender, second: TdApi.MessageSender): Boolean { + return when { + first is TdApi.MessageSenderUser && second is TdApi.MessageSenderUser -> first.userId == second.userId + first is TdApi.MessageSenderChat && second is TdApi.MessageSenderChat -> first.chatId == second.chatId + else -> false + } + } + private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { if (threadId == null || threadId == 0L) return null val chat = cache.getChat(chatId) ?: getChat(chatId) @@ -1836,10 +2046,31 @@ class TdMessageRemoteDataSource( refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageReaction -> { - // Bots receive delta-only reaction updates, so avoid rereads here. + cache.updateMessage(update.chatId, update.messageId) { message -> + val interactionInfo = message.interactionInfo ?: TdApi.MessageInteractionInfo() + interactionInfo.reactions = applyReactionDelta( + existing = interactionInfo.reactions, + actorId = update.actorId, + oldReactionTypes = update.oldReactionTypes.orEmpty(), + newReactionTypes = update.newReactionTypes.orEmpty() + ) + message.interactionInfo = interactionInfo + } + refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageReactions -> { - // Bots receive reaction payloads that don't justify a full message reread. + cache.updateMessage(update.chatId, update.messageId) { message -> + val interactionInfo = message.interactionInfo ?: TdApi.MessageInteractionInfo() + val existingReactions = interactionInfo.reactions + interactionInfo.reactions = TdApi.MessageReactions().apply { + reactions = update.reactions + areTags = existingReactions?.areTags ?: false + paidReactors = existingReactions?.paidReactors ?: emptyArray() + canGetAddedReactions = existingReactions?.canGetAddedReactions ?: false + } + message.interactionInfo = interactionInfo + } + refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageMentionRead -> { cache.updateMessage(update.chatId, update.messageId) { message -> @@ -2229,5 +2460,6 @@ class TdMessageRemoteDataSource( private companion object { private val MISSING_MESSAGE_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(2) private const val DRAFT_LINK_PREVIEW_TAG = "DraftLinkPreview" + private const val MAX_TEXT_MESSAGE_CODE_POINTS = 4096 } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt index 7b1267bce..3b143e201 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt @@ -50,6 +50,9 @@ class TdSettingsRemoteDataSource( override suspend fun getStorageStatistics(chatLimit: Int): TdApi.StorageStatistics? = coRunCatching { gateway.execute(TdApi.GetStorageStatistics(chatLimit)) }.getOrNull() + override suspend fun getStorageStatisticsFast(): TdApi.StorageStatisticsFast? = + coRunCatching { gateway.execute(TdApi.GetStorageStatisticsFast()) }.getOrNull() + override suspend fun getNetworkStatistics(): TdApi.NetworkStatistics? = coRunCatching { Log.d("NetworkStats", "Fetching network statistics...") @@ -128,7 +131,7 @@ class TdSettingsRemoteDataSource( chatIds: LongArray?, returnDeletedFileStatistics: Boolean, chatLimit: Int - ): Boolean = + ): TdApi.StorageStatistics? = coRunCatching { gateway.execute( TdApi.OptimizeStorage( @@ -143,8 +146,7 @@ class TdSettingsRemoteDataSource( chatLimit ) ) - true - }.getOrDefault(false) + }.getOrNull() override suspend fun resetNetworkStatistics(): Boolean = coRunCatching { diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt index dc21c5e35..62aabe621 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt @@ -88,6 +88,10 @@ class TdUserRemoteDataSource( override suspend fun searchPublicChat(username: String): TdApi.Chat? = coRunCatching { gateway.execute(TdApi.SearchPublicChat(username)) }.getOrNull() + override suspend fun getSimilarChatIds(chatId: Long): LongArray = + coRunCatching { gateway.execute(TdApi.GetChatSimilarChats(chatId)).chatIds } + .getOrDefault(longArrayOf()) + override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? { var requestedSupergroupId = 0L return coRunCatching { diff --git a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt index 51202f8a3..a7262c559 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt @@ -20,6 +20,7 @@ interface UserRemoteDataSource { suspend fun setCloseFriendIds(userIds: LongArray) suspend fun sharePhoneNumber(userId: Long) suspend fun searchPublicChat(username: String): TdApi.Chat? + suspend fun getSimilarChatIds(chatId: Long): LongArray suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? suspend fun getSupergroupMembers( supergroupId: Long, diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index 9e1d3921e..c24aa1d86 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -33,17 +33,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.NotificationSettingDao import org.monogram.data.db.model.NotificationSettingEntity import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.notifications.NotificationMuteDecision import org.monogram.data.notifications.NotificationMuteResolver import org.monogram.data.notifications.NotificationScopeState +import org.monogram.data.notifications.TdlibNotificationStateStore import org.monogram.data.push.FcmRuntime import org.monogram.data.push.UnifiedPushManager import org.monogram.data.service.NotificationDismissReceiver @@ -56,11 +61,14 @@ import org.monogram.domain.repository.PushProvider import org.monogram.domain.repository.StringProvider import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume import kotlin.math.min class TdNotificationManager( private val context: Context, private val gateway: TelegramGateway, + private val updates: UpdateDispatcher, private val appPreferences: AppPreferencesProvider, private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingDao: NotificationSettingDao, @@ -72,11 +80,14 @@ class TdNotificationManager( ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) + private val notificationStateVersion = MutableStateFlow(0L) + private val notificationStateCounter = AtomicLong(0L) private val userCache = ConcurrentHashMap() private val chatCache = ConcurrentHashMap() private val messagesHistory = ConcurrentHashMap>() private val lastMessageIds = ConcurrentHashMap() private val activeNotifications = ConcurrentHashMap>() + private val nativeNotificationStateStore = TdlibNotificationStateStore() private val bitmapCache = object : LruCache(5 * 1024 * 1024) { override fun sizeOf(key: Int, value: Bitmap): Int { return value.byteCount @@ -131,6 +142,8 @@ class TdNotificationManager( scope.launch { gateway.isAuthenticated.collect { authenticated -> if (authenticated) { + nativeNotificationStateStore.reset() + clearAllRenderedNotifications() loadedScopeSettings.clear() scopeNotificationsEnabled.clear() refreshMyUserId() @@ -142,65 +155,11 @@ class TdNotificationManager( } scope.launch { - gateway.updates.collect { update -> - when (update) { - is TdApi.UpdateNewMessage -> { - val senderDebug = senderIdToDebug(update.message.senderId) - Log.d( - TAG, - "UpdateNewMessage chatId=${update.message.chatId} messageId=${update.message.id} " + - "outgoing=${update.message.isOutgoing} sender=$senderDebug" - ) - handleNewMessage(update.message) - } - is TdApi.UpdateUser -> userCache[update.user.id] = update.user - is TdApi.UpdateFile -> { - val file = update.file - val local = file.local - val localPath = local?.path - if (local?.isDownloadingCompleted == true && !localPath.isNullOrEmpty()) { - val callbacks = synchronized(activeDownloads) { - activeDownloads.remove(file.id) - } - if (callbacks != null) { - scope.launch(Dispatchers.IO) { - val bitmap = try { - BitmapFactory.decodeFile(localPath) - } catch (e: Exception) { - null - } - if (bitmap != null) { - bitmapCache.put(file.id, bitmap) - } - callbacks.forEach { it(bitmap) } - } - } - } - } - is TdApi.UpdateChatNotificationSettings -> { - updateChatNotificationSettings(update.chatId, update.notificationSettings) - chatCache[update.chatId]?.let { chat -> - chatCache[update.chatId] = chat.apply { - notificationSettings = update.notificationSettings - } - } - } - is TdApi.UpdateChatReadInbox -> { - clearHistory(update.chatId) - updateSummary() - } - is TdApi.UpdateOption -> { - if (update.name == "is_authenticated" && (update.value as? TdApi.OptionValueBoolean)?.value == true) { - refreshMyUserId() - updatePushRegistration() - } else if (update.name == "my_id") { - val id = (update.value as? TdApi.OptionValueInteger)?.value ?: 0L - if (id > 0L) { - myUserId = id - } - } - } - else -> {} + updates.all.collect { update -> + runCatching { + handleCoreUpdate(update) + }.onFailure { + Log.e(TAG, "Failed to handle update ${update.javaClass.simpleName}", it) } } } @@ -242,6 +201,42 @@ class TdNotificationManager( } } + private suspend fun handleCoreUpdate(update: TdApi.Update) { + when (update) { + is TdApi.UpdateActiveNotifications -> handleActiveNotifications(update) + is TdApi.UpdateNotificationGroup -> handleNotificationGroupUpdate(update) + is TdApi.UpdateNotification -> handleNotificationUpdate(update) + is TdApi.UpdateUser -> userCache[update.user.id] = update.user + is TdApi.UpdateFile -> handleFileUpdate(update.file) + is TdApi.UpdateChatNotificationSettings -> { + updateChatNotificationSettings(update.chatId, update.notificationSettings) + chatCache[update.chatId]?.let { chat -> + chatCache[update.chatId] = chat.apply { + notificationSettings = update.notificationSettings + } + } + } + + is TdApi.UpdateChatReadInbox -> clearHistory(update.chatId) + is TdApi.UpdateOption -> handleOptionUpdate(update) + } + } + + private suspend fun handleOptionUpdate(update: TdApi.UpdateOption) { + if (update.name == "is_authenticated" && (update.value as? TdApi.OptionValueBoolean)?.value == true) { + refreshMyUserId() + updatePushRegistration() + return + } + + if (update.name == "my_id") { + val id = (update.value as? TdApi.OptionValueInteger)?.value ?: 0L + if (id > 0L) { + myUserId = id + } + } + } + private fun updateChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) { val entity = NotificationSettingEntity( chatId = chatId, @@ -382,6 +377,14 @@ class TdNotificationManager( return resolveMuteDecision(chat).isMuted } + fun currentNotificationStateVersion(): Long = notificationStateVersion.value + + suspend fun awaitNotificationStateChange(afterVersion: Long, timeoutMs: Long): Boolean { + return withTimeoutOrNull(timeoutMs) { + notificationStateVersion.first { it > afterVersion } + } != null + } + private fun resolveMuteDecision(chat: TdApi.Chat): NotificationMuteDecision { return muteResolver.resolve( chat = chat, @@ -394,6 +397,11 @@ class TdNotificationManager( } fun clearHistory(chatId: Long) { + nativeNotificationStateStore.clearChat(chatId) + clearRenderedHistory(chatId) + } + + private fun clearRenderedHistory(chatId: Long) { messagesHistory.remove(chatId) lastMessageIds.remove(chatId) activeNotifications.remove(chatId)?.forEach { notificationId -> @@ -403,7 +411,19 @@ class TdNotificationManager( updateSummary() } + private fun clearAllRenderedNotifications() { + messagesHistory.keys.toList().forEach { chatId -> + clearRenderedHistory(chatId) + } + notificationManager.cancel(SUMMARY_ID) + } + fun removeNotification(chatId: Long, notificationId: Int) { + nativeNotificationStateStore.removeNotification(chatId, notificationId) + removeRenderedNotification(chatId, notificationId) + } + + private fun removeRenderedNotification(chatId: Long, notificationId: Int) { activeNotifications[chatId]?.remove(notificationId) notificationManager.cancel(notificationId) @@ -423,133 +443,102 @@ class TdNotificationManager( updateSummary() } - private fun handleNewMessage(message: TdApi.Message) { - Log.d( - TAG, - "handleNewMessage enter chatId=${message.chatId} messageId=${message.id} outgoing=${message.isOutgoing} " + - "content=${message.content?.javaClass?.simpleName ?: "null"}" - ) - - if (message.isOutgoing) { - Log.d( - TAG, - "Skip notification: outgoing message, chatId=${message.chatId}, messageId=${message.id}" - ) - return + private suspend fun handleActiveNotifications(update: TdApi.UpdateActiveNotifications) { + markNotificationStateChanged() + nativeNotificationStateStore.replaceAll(update).forEach { chatId -> + renderNativeNotifications(chatId) } + } - val messageContent = message.content - if (messageContent == null) { - Log.w(TAG, "Skipping notification for message ${message.id}: content is null") - return + private suspend fun handleNotificationGroupUpdate(update: TdApi.UpdateNotificationGroup) { + markNotificationStateChanged() + nativeNotificationStateStore.apply(update).forEach { chatId -> + renderNativeNotifications(chatId) } + } - val senderId = message.senderId - if (senderId == null) { - Log.w(TAG, "Skipping notification for message ${message.id}: senderId is null") - return + private suspend fun handleNotificationUpdate(update: TdApi.UpdateNotification) { + markNotificationStateChanged() + nativeNotificationStateStore.apply(update).forEach { chatId -> + renderNativeNotifications(chatId) } + } - if (senderId is TdApi.MessageSenderUser && senderId.userId != 0L && senderId.userId == myUserId) { - Log.d( - TAG, - "Skip notification: sender is self, chatId=${message.chatId}, messageId=${message.id}" - ) - return - } + private fun markNotificationStateChanged() { + notificationStateVersion.value = notificationStateCounter.incrementAndGet() + } - val lastId = lastMessageIds[message.chatId] - if (lastId != null && message.id <= lastId) { - Log.d( - TAG, - "Skip notification: stale/duplicate message, chatId=${message.chatId}, messageId=${message.id}, lastId=$lastId" - ) + private fun handleFileUpdate(file: TdApi.File) { + val local = file.local + val localPath = local?.path + if (local?.isDownloadingCompleted != true || localPath.isNullOrEmpty()) { return } - lastMessageIds[message.chatId] = message.id - - scope.launch { - val chat = getChatSuspend(message.chatId) - if (chat == null) { - Log.d( - TAG, - "Skip notification: chat unavailable, chatId=${message.chatId}, messageId=${message.id}" - ) - return@launch - } - val chatType = chat.type - if (chatType == null) { - Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") - return@launch - } + val callbacks = synchronized(activeDownloads) { + activeDownloads.remove(file.id) + } ?: return - val isMember = withTimeoutOrNull(1_500L) { checkMembership(chat) } ?: true - if (!isMember) { - Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") - return@launch + scope.launch(Dispatchers.IO) { + val bitmap = try { + BitmapFactory.decodeFile(localPath) + } catch (_: Exception) { + null } - - val muteDecision = resolveMuteDecision(chat) - if (muteDecision.isMuted) { - Log.d( - TAG, - "Skip notification: muted reason=${muteDecision.reason} scope=${muteDecision.scope} " + - "muteFor=${muteDecision.muteFor} useDefault=${muteDecision.useDefault} " + - "chatId=${chat.id} messageId=${message.id}" - ) - return@launch + if (bitmap != null) { + bitmapCache.put(file.id, bitmap) } + callbacks.forEach { it(bitmap) } + } + } - val contentText = - if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText( - messageContent - ) + private suspend fun renderNativeNotifications(chatId: Long) { + val notifications = nativeNotificationStateStore.getChatNotifications(chatId) + if (notifications.isEmpty()) { + clearRenderedHistory(chatId) + return + } - if (contentText.isBlank()) { - Log.d( - TAG, - "Skip notification: empty content text, chatId=${chat.id}, messageId=${message.id}" - ) - return@launch - } + val chat = getChatSuspend(chatId) + if (chat == null) { + Log.d(TAG, "Skip native notification render: chat unavailable, chatId=$chatId") + clearRenderedHistory(chatId) + return + } - val timestamp = message.date.toLong() * 1000 - val shouldPreloadAvatar = - !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value + val chatType = chat.type + if (chatType == null) { + Log.w(TAG, "Skip native notification render: chat type unavailable, chatId=$chatId") + clearRenderedHistory(chatId) + return + } - resolveSender(senderId, chat, true) { senderName, senderBitmap -> - Log.d( - TAG, - "Resolved sender for notification chatId=${chat.id} messageId=${message.id} " + - "senderName=$senderName hasBitmap=${senderBitmap != null}" - ) + if (resolveMuteDecision(chat).isMuted) { + clearRenderedHistory(chatId) + return + } - Log.d( - TAG, - "Append notification chatId=${chat.id} messageId=${message.id} " + - "chatType=${chatType.javaClass.simpleName} text=${ - previewText( - contentText - ) - }" - ) - appendMessageToNotification( - chatId = chat.id, - messageId = message.id, - chatType = chatType, - senderName = senderName, - senderBitmap = senderBitmap, - chatIcon = senderBitmap, - text = contentText, - timestamp = timestamp - ) + val isMember = withTimeoutOrNull(1_500L) { checkMembership(chat) } ?: true + if (!isMember) { + Log.d(TAG, "Skip native notification render: user is not a member, chatId=$chatId") + clearRenderedHistory(chatId) + return + } - if (shouldPreloadAvatar && senderBitmap == null) { - preloadNotificationAssets(senderId, chat) - } - } + val resolvedEntries = notifications.mapNotNull { notification -> + resolveNotificationHistoryEntry(chat, notification) } + if (resolvedEntries.isEmpty()) { + clearRenderedHistory(chatId) + return + } + + replaceNotificationHistory( + chatId = chatId, + chatType = chatType, + historyEntries = resolvedEntries, + chatIcon = resolvedEntries.lastOrNull()?.senderBitmap + ) } private suspend fun checkMembership(chat: TdApi.Chat): Boolean { @@ -564,7 +553,8 @@ class TdNotificationManager( val result = gateway.execute(TdApi.GetBasicGroup(chatType.basicGroupId)) result.status is TdApi.ChatMemberStatusMember || result.status is TdApi.ChatMemberStatusCreator || - result.status is TdApi.ChatMemberStatusAdministrator + result.status is TdApi.ChatMemberStatusAdministrator || + (result.status as? TdApi.ChatMemberStatusRestricted)?.isMember == true }.getOrDefault(true) } is TdApi.ChatTypeSupergroup -> { @@ -575,7 +565,8 @@ class TdNotificationManager( val result = gateway.execute(TdApi.GetSupergroup(chatType.supergroupId)) result.status is TdApi.ChatMemberStatusMember || result.status is TdApi.ChatMemberStatusCreator || - result.status is TdApi.ChatMemberStatusAdministrator + result.status is TdApi.ChatMemberStatusAdministrator || + (result.status as? TdApi.ChatMemberStatusRestricted)?.isMember == true }.getOrDefault(true) } @@ -583,29 +574,43 @@ class TdNotificationManager( } } - fun appendMessageToNotification( + private fun replaceNotificationHistory( chatId: Long, - messageId: Long, chatType: TdApi.ChatType, - senderName: String, - senderBitmap: Bitmap?, - chatIcon: Bitmap?, - text: String, - timestamp: Long + historyEntries: List, + chatIcon: Bitmap? ) { - if (text.isBlank()) { - Log.d( - TAG, - "Skip appendMessageToNotification: blank text, chatId=$chatId, messageId=$messageId" - ) + val trimmedHistory = historyEntries.takeLast(10) + if (trimmedHistory.isEmpty()) { + clearRenderedHistory(chatId) return } - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "Skip appendMessageToNotification: missing POST_NOTIFICATIONS permission") - return + messagesHistory[chatId] = CopyOnWriteArrayList(trimmedHistory) + lastMessageIds[chatId] = trimmedHistory.maxOf { it.messageId } + activeNotifications[chatId] = ConcurrentHashMap.newKeySet().apply { + add(notificationIdForChat(chatId)) } + postHistoryNotification( + chatId = chatId, + chatType = chatType, + historySnapshot = trimmedHistory, + chatIcon = chatIcon + ) + } + + private fun postHistoryNotification( + chatId: Long, + chatType: TdApi.ChatType, + historySnapshot: List, + chatIcon: Bitmap? + ) { + val latestEntry = historySnapshot.lastOrNull() ?: run { + clearRenderedHistory(chatId) + return + } + val notificationId = notificationIdForChat(chatId) val channelId = when (chatType) { is TdApi.ChatTypePrivate -> CHANNEL_PRIVATE is TdApi.ChatTypeBasicGroup -> CHANNEL_GROUPS @@ -613,24 +618,9 @@ class TdNotificationManager( else -> CHANNEL_OTHER } - val history = messagesHistory.getOrPut(chatId) { CopyOnWriteArrayList() } - history.add( - NotificationHistoryEntry( - messageId = messageId, - senderName = senderName, - text = text, - timestamp = timestamp, - senderBitmap = senderBitmap - ) - ) - if (history.size > 10) { - history.removeAt(0) - } - - val notificationId = notificationIdForChat(chatId) Log.d( TAG, - "Notification history updated chatId=$chatId size=${history.size} notificationId=$notificationId" + "Notification history updated chatId=$chatId size=${historySnapshot.size} notificationId=$notificationId" ) activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId) @@ -642,7 +632,6 @@ class TdNotificationManager( val myself = Person.Builder().setName(stringProvider.getString("notification_person_me")).build() val messagingStyle = NotificationCompat.MessagingStyle(myself) - val historySnapshot = history.toList() historySnapshot.forEach { entry -> val personBuilder = Person.Builder() .setName(entry.senderName) @@ -668,11 +657,12 @@ class TdNotificationManager( } messagingStyle.isGroupConversation = isGroup - val chatTitle = chatCache[chatId]?.title ?: senderName + val chatTitle = chatCache[chatId]?.title ?: latestEntry.senderName if (isGroup) { messagingStyle.conversationTitle = chatTitle } + val latestText = latestEntry.text val priority = when (appPreferences.notificationPriority.value) { 0 -> NotificationCompat.PRIORITY_LOW 2 -> NotificationCompat.PRIORITY_HIGH @@ -696,7 +686,7 @@ class TdNotificationManager( readAction?.let { builder.addAction(it) } builder.setContentTitle(chatTitle) - builder.setContentText(text) + builder.setContentText(latestText) if (appPreferences.inAppSounds.value) { builder.setDefaults(NotificationCompat.DEFAULT_SOUND) @@ -733,7 +723,7 @@ class TdNotificationManager( chatId = chatId, chatType = chatType, title = chatTitle, - text = text, + text = latestText, channelId = channelId, notificationId = notificationId, pendingIntent = pendingIntent, @@ -884,18 +874,6 @@ class TdNotificationManager( return if (hash == SUMMARY_ID) SUMMARY_ID + 1 else hash } - private fun senderIdToDebug(senderId: TdApi.MessageSender?): String = when (senderId) { - null -> "null" - is TdApi.MessageSenderUser -> "user:${senderId.userId}" - is TdApi.MessageSenderChat -> "chat:${senderId.chatId}" - else -> senderId.javaClass.simpleName - } - - private fun previewText(text: String, max: Int = 80): String { - val normalized = text.replace('\n', ' ').trim() - return if (normalized.length <= max) normalized else normalized.take(max) + "..." - } - private fun getCircularBitmap(bitmap: Bitmap): Bitmap { val size = min(bitmap.width, bitmap.height) @@ -1019,6 +997,59 @@ class TdNotificationManager( } } + private suspend fun resolveNotificationHistoryEntry( + chat: TdApi.Chat, + notification: TdApi.Notification + ): NotificationHistoryEntry? { + return when (val notificationType = notification.type) { + is TdApi.NotificationTypeNewMessage -> { + val message = notificationType.message ?: return null + val text = if (appPreferences.showSenderOnly.value) { + stringProvider.getString("notification_new_message") + } else { + getMessageText(message.content) + } + if (text.isBlank()) { + return null + } + + val (senderName, senderBitmap) = resolveSenderSuspend( + senderId = message.senderId, + chat = chat, + onlyIfLocal = true + ) + NotificationHistoryEntry( + messageId = message.id, + senderName = senderName, + text = text, + timestamp = notification.date.toLong() * 1000L, + senderBitmap = senderBitmap + ) + } + + is TdApi.NotificationTypeNewPushMessage -> { + val senderName = notificationType.senderName + ?.takeIf { it.isNotBlank() } + ?: chat.title?.takeIf { it.isNotBlank() } + ?: stringProvider.getString("unknown_user") + NotificationHistoryEntry( + messageId = notification.id.toLong(), + senderName = senderName, + text = getPushMessageText(notificationType.content), + timestamp = notification.date.toLong() * 1000L + ) + } + + else -> NotificationHistoryEntry( + messageId = notification.id.toLong(), + senderName = chat.title?.takeIf { it.isNotBlank() } + ?: stringProvider.getString("unknown_user"), + text = stringProvider.getString("notification_new_message"), + timestamp = notification.date.toLong() * 1000L + ) + } + } + private fun getMessageText(content: TdApi.MessageContent?): String { fun withDetails(base: String, details: String?): String { val cleanDetails = details?.trim().orEmpty() @@ -1058,6 +1089,93 @@ class TdNotificationManager( } } + private fun getPushMessageText(content: TdApi.PushMessageContent?): String { + return when (content) { + is TdApi.PushMessageContentText -> sanitizeSpoilers(content.text?.let { + TdApi.FormattedText( + it, + null + ) + }) + + is TdApi.PushMessageContentPhoto -> { + val caption = content.caption?.trim().orEmpty() + if (caption.isBlank()) { + stringProvider.getString("logs_media_photo") + } else { + "📷 ${stringProvider.getString("logs_media_photo")} $caption" + } + } + + is TdApi.PushMessageContentVideo -> { + val caption = content.caption?.trim().orEmpty() + if (caption.isBlank()) { + stringProvider.getString("logs_media_video") + } else { + "📹 ${stringProvider.getString("logs_media_video")} $caption" + } + } + + is TdApi.PushMessageContentVoiceNote -> "🎤 ${stringProvider.getString("logs_media_voice")}" + is TdApi.PushMessageContentAudio -> { + val title = content.audio?.title?.trim().orEmpty() + if (title.isBlank()) { + stringProvider.getString("logs_media_audio") + } else { + "🎵 ${stringProvider.getString("logs_media_audio")} $title" + } + } + + is TdApi.PushMessageContentDocument -> { + val fileName = content.document?.fileName?.trim().orEmpty() + if (fileName.isBlank()) { + stringProvider.getString("logs_media_document") + } else { + "📄 ${stringProvider.getString("logs_media_document")} $fileName" + } + } + + is TdApi.PushMessageContentContact -> content.name.ifBlank { + stringProvider.getString("logs_media_contact") + } + + is TdApi.PushMessageContentSticker -> stringProvider.getString("reply_content_sticker") + is TdApi.PushMessageContentPoll -> content.question.ifBlank { + stringProvider.getString("logs_media_poll") + } + + is TdApi.PushMessageContentChatChangeTitle -> content.title.ifBlank { + stringProvider.getString("notification_new_message") + } + + is TdApi.PushMessageContentChatAddMembers -> content.memberName.ifBlank { + stringProvider.getString("notification_new_message") + } + + is TdApi.PushMessageContentBasicGroupChatCreate, + is TdApi.PushMessageContentVideoChatStarted, + is TdApi.PushMessageContentVideoChatEnded, + is TdApi.PushMessageContentInviteVideoChatParticipants, + is TdApi.PushMessageContentChatChangePhoto, + is TdApi.PushMessageContentChatSetBackground, + is TdApi.PushMessageContentChatSetTheme, + is TdApi.PushMessageContentChatDeleteMember, + is TdApi.PushMessageContentChatJoinByLink, + is TdApi.PushMessageContentChatJoinByRequest, + is TdApi.PushMessageContentRecurringPayment, + is TdApi.PushMessageContentSuggestProfilePhoto, + is TdApi.PushMessageContentSuggestBirthdate, + is TdApi.PushMessageContentProximityAlertTriggered, + is TdApi.PushMessageContentChecklistTasksAdded, + is TdApi.PushMessageContentChecklistTasksDone, + is TdApi.PushMessageContentPollOptionAdded, + is TdApi.PushMessageContentMessageForwards, + is TdApi.PushMessageContentMediaAlbum -> stringProvider.getString("notification_new_message") + + else -> stringProvider.getString("notification_new_message") + } + } + private suspend fun refreshMyUserId() { myUserId = coRunCatching { gateway.execute(TdApi.GetMe()).id @@ -1211,6 +1329,18 @@ class TdNotificationManager( } } + private suspend fun resolveSenderSuspend( + senderId: TdApi.MessageSender?, + chat: TdApi.Chat, + onlyIfLocal: Boolean = false + ): Pair = suspendCancellableCoroutine { continuation -> + resolveSender(senderId, chat, onlyIfLocal) { senderName, senderBitmap -> + if (continuation.isActive) { + continuation.resume(senderName to senderBitmap) + } + } + } + private fun preloadNotificationAssets(senderId: TdApi.MessageSender?, chat: TdApi.Chat) { // Avoid network-triggered preloads for notifications; use local cache only. resolveSender(senderId, chat, true) { _, _ -> } diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 218b53f14..91c66ee08 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -643,7 +643,10 @@ val dataModule = module { chatsRemote = get(), dispatchers = get(), storageMapper = get(), - stringProvider = get() + stringProvider = get(), + chatLocalDataSource = get(), + userLocalDataSource = get(), + stickerLocalDataSource = get() ) } @@ -954,6 +957,7 @@ val dataModule = module { get(), get(), get(), + get(), get() ) } diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt index 0ec7feede..cfe5a6c83 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt @@ -11,6 +11,9 @@ interface UpdateDispatcher { // Messages val newMessage: Flow + val activeNotifications: Flow + val notificationGroup: Flow + val notification: Flow val messageEdited: Flow val messageContent: Flow val messageSendSucceeded: Flow @@ -43,6 +46,7 @@ interface UpdateDispatcher { val file: Flow // Connection + val option: Flow val connectionState: Flow val installedStickerSets: Flow val newChat: Flow diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt index a5227f650..c152dc863 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt @@ -19,6 +19,9 @@ class UpdateDispatcherImpl( override val authorizationState = flow() override val newMessage = flow() + override val activeNotifications = flow() + override val notificationGroup = flow() + override val notification = flow() override val messageEdited = flow() override val messageContent = flow() override val messageSendSucceeded = flow() @@ -47,6 +50,7 @@ class UpdateDispatcherImpl( override val file = flow() + override val option = flow() override val connectionState = flow() override val installedStickerSets = flow() override val newChat = flow() diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index 8777fe3a1..8dd8a37c1 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -73,6 +73,8 @@ class ConnectionManager( private var failureStreak = 0 private var lastEffectiveNetworkType: ProxyNetworkType? = null private var lastObservedNetworkSnapshot = networkSnapshotProvider.snapshot.value + private var lastTypedConnectionState: TdApi.ConnectionState? = null + private var lastTypedConnectionStateAtMs: Long = 0L private val reconnectRequests = Channel( capacity = 32, @@ -122,6 +124,8 @@ class ConnectionManager( private fun observeConnectionState() { scope.launch(dispatchers.default) { updates.connectionState.collect { update -> + lastTypedConnectionState = update.state + lastTypedConnectionStateAtMs = System.currentTimeMillis() handleConnectionState(update.state, "update") } } @@ -355,10 +359,34 @@ class ConnectionManager( return null } + val typedState = lastTypedConnectionState + if (typedState != null) { + if (!sameConnectionStateKind(typedState, state)) { + Log.w( + tag, + "Connection probe mismatch typed=${typedState.javaClass.simpleName} probe=${state.javaClass.simpleName} reason=$reason" + ) + } + + val typedAgeMs = System.currentTimeMillis() - lastTypedConnectionStateAtMs + if (typedAgeMs in 0..TYPED_CONNECTION_PROBE_GRACE_MS) { + Log.d( + tag, + "Ignoring probed connection state ${state.javaClass.simpleName} because recent typed state ${typedState.javaClass.simpleName} is authoritative ($typedAgeMs ms, reason=$reason)" + ) + return typedState + } + } + handleConnectionState(state, "probe:$reason") return state } + private fun sameConnectionStateKind( + first: TdApi.ConnectionState, + second: TdApi.ConnectionState + ): Boolean = first.constructor == second.constructor + private suspend fun maybeAdjustProxyOnFailures(reason: String) { val isAutoBestEnabled = appPreferences.isAutoBestProxyEnabled.value if (!isAutoBestEnabled) return @@ -724,6 +752,7 @@ class ConnectionManager( companion object { private const val MAX_RETRY_DELAY_MS = 60_000L private const val RECONNECT_COALESCE_WINDOW_MS = 350L + private const val TYPED_CONNECTION_PROBE_GRACE_MS = 15_000L private const val FAILURE_THRESHOLD_FOR_PROXY_REAPPLY = 3 } } diff --git a/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt b/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt index fb3485d1d..23976bcd6 100644 --- a/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt +++ b/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt @@ -27,6 +27,7 @@ class TdLibParametersProvider( useMessageDatabase = true useFileDatabase = true useChatInfoDatabase = true + useSecretChats = true } } diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index bd59604de..1b360479c 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -72,7 +72,9 @@ class ChatMapper( hasAutomaticTranslation: Boolean = false, personalAvatarPath: String? = null, signMessages: Boolean = false, - joinToSendMessages: Boolean = false + joinToSendMessages: Boolean = false, + pendingJoinRequestCount: Int = 0, + joinByRequest: Boolean = false ): ChatModel { val permissions = chat.permissions.toDomainChatPermissions() val isChannel = chat.type.isChannelType() @@ -124,6 +126,7 @@ class ChatMapper( lastMessageSenderName = lastMessageSenderName, lastMessagePreviewPath = lastMessagePreviewPath, lastMessagePreviewPaths = lastMessagePreviewPaths, + joinByRequest = joinByRequest, isMuted = isMuted, isMarkedAsUnread = chat.isMarkedAsUnread, hasProtectedContent = chat.hasProtectedContent, @@ -131,6 +134,7 @@ class ChatMapper( hasAutomaticTranslation = hasAutomaticTranslation, signMessages = signMessages, joinToSendMessages = joinToSendMessages, + pendingJoinRequestCount = pendingJoinRequestCount, availableReactions = availableReactions, messageAutoDeleteTime = chat.messageAutoDeleteTime, canBeDeletedOnlyForSelf = chat.canBeDeletedOnlyForSelf, diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt index b79262d1b..c98c72ee2 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt @@ -8,10 +8,11 @@ internal fun encodeChatPositions(positions: Array): String? val encoded = positions.mapNotNull { position -> if (position.order == 0L) return@mapNotNull null val pinned = if (position.isPinned) 1 else 0 - when (position.list) { + val list = position.list + when (list) { is TdApi.ChatListMain -> "m:${position.order}:$pinned" is TdApi.ChatListArchive -> "a:${position.order}:$pinned" - is TdApi.ChatListFolder -> null + is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${position.order}:$pinned" else -> null } } diff --git a/data/src/main/java/org/monogram/data/mapper/StorageMapper.kt b/data/src/main/java/org/monogram/data/mapper/StorageMapper.kt index 47cecab68..08ba003ed 100644 --- a/data/src/main/java/org/monogram/data/mapper/StorageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StorageMapper.kt @@ -7,18 +7,20 @@ import org.monogram.domain.models.StorageUsageModel import org.monogram.domain.repository.StringProvider class StorageMapper(private val stringProvider: StringProvider) { - fun mapToDomain(_statistics: TdApi.StorageStatistics, chatStats: List): StorageUsageModel { + fun mapToDomain( + statistics: TdApi.StorageStatistics, + chatStats: List + ): StorageUsageModel { val filteredChats = chatStats.filter { it.size > 0L } return StorageUsageModel( - totalSize = filteredChats.sumOf { it.size }, - fileCount = filteredChats.sumOf { it.fileCount }, + totalSize = statistics.size, + fileCount = statistics.count, chatStats = filteredChats ) } fun mapChatStatsToDomain(stats: TdApi.StorageStatisticsByChat, chatTitle: String): ChatStorageUsageModel { - val removableStats = stats.byFileType.filterNot { isNonRemovableFileType(it.fileType) } - val mappedFileTypes = removableStats.map { mapFileTypeStatsToDomain(it) } + val mappedFileTypes = stats.byFileType.map { mapFileTypeStatsToDomain(it) } return ChatStorageUsageModel( chatId = stats.chatId, chatTitle = chatTitle, @@ -36,17 +38,6 @@ class StorageMapper(private val stringProvider: StringProvider) { ) } - private fun isNonRemovableFileType(fileType: TdApi.FileType): Boolean { - return when (fileType) { - is TdApi.FileTypeSticker, - is TdApi.FileTypeThumbnail, - is TdApi.FileTypeProfilePhoto, - is TdApi.FileTypeWallpaper -> true - - else -> false - } - } - fun mapFileTypeToDomain(fileType: TdApi.FileType): String { return when (fileType) { is TdApi.FileTypePhoto -> stringProvider.getString("storage_photos") diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt index 964bacda1..5da2b4e6f 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt @@ -148,6 +148,7 @@ internal class MessageContentMapper( messageId = context.messageId, networkAutoDownload = context.networkAutoDownload ), + showCaptionAboveMedia = content.showCaptionAboveMedia, isUploading = context.isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false), uploadProgress = photoFile?.let(fileHelper::computeUploadProgress) ?: 0f, isDownloading = isDownloading || isQueued, @@ -209,6 +210,7 @@ internal class MessageContentMapper( messageId = context.messageId, networkAutoDownload = context.networkAutoDownload ), + showCaptionAboveMedia = content.showCaptionAboveMedia, isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive, uploadProgress = fileHelper.computeUploadProgress(videoFile), isDownloading = isDownloading || isQueued, @@ -393,6 +395,7 @@ internal class MessageContentMapper( messageId = context.messageId, networkAutoDownload = context.networkAutoDownload ), + showCaptionAboveMedia = content.showCaptionAboveMedia, isUploading = context.isActuallyUploading && animationFile.remote.isUploadingActive, uploadProgress = fileHelper.computeUploadProgress(animationFile), isDownloading = isDownloading || isQueued, diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt index c324f4410..6485f5371 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt @@ -221,7 +221,11 @@ internal class MessagePersistenceMapper( CachedMessageContent( "photo", content.caption.text, - encodeMeta(best?.width ?: 0, best?.height ?: 0), + encodeMeta( + best?.width ?: 0, + best?.height ?: 0, + if (content.showCaptionAboveMedia) 1 else 0 + ), fileId = 0, path = null, thumbnailPath = null, @@ -237,7 +241,8 @@ internal class MessagePersistenceMapper( content.video.width, content.video.height, content.video.duration, - if (content.video.supportsStreaming) 1 else 0 + if (content.video.supportsStreaming) 1 else 0, + if (content.showCaptionAboveMedia) 1 else 0 ), fileId = 0, path = null, @@ -314,7 +319,8 @@ internal class MessagePersistenceMapper( encodeMeta( content.animation.width, content.animation.height, - content.animation.duration + content.animation.duration, + if (content.showCaptionAboveMedia) 1 else 0 ), fileId = 0, path = null @@ -525,6 +531,7 @@ internal class MessagePersistenceMapper( height = meta.getOrNull(1)?.toIntOrNull() ?: 0, caption = entity.content, entities = decodeEntities(entity.entities), + showCaptionAboveMedia = (meta.getOrNull(2)?.toIntOrNull() ?: 0) == 1, fileId = fileId, originalFileId = originalFileId, minithumbnail = entity.minithumbnail @@ -543,6 +550,7 @@ internal class MessagePersistenceMapper( duration = meta.getOrNull(2)?.toIntOrNull() ?: 0, caption = entity.content, entities = decodeEntities(entity.entities), + showCaptionAboveMedia = (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1, fileId = fileId, supportsStreaming = supportsStreaming, minithumbnail = entity.minithumbnail @@ -625,6 +633,7 @@ internal class MessagePersistenceMapper( height = meta.getOrNull(1)?.toIntOrNull() ?: 0, caption = entity.content, entities = decodeEntities(entity.entities), + showCaptionAboveMedia = (meta.getOrNull(3)?.toIntOrNull() ?: 0) == 1, fileId = fileId ) } diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt index 675c909f8..fdfca3506 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt @@ -6,7 +6,25 @@ import org.monogram.data.mapper.isChannelType import org.monogram.data.mapper.isGroupType import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.toDomainChatType -import org.monogram.domain.models.* +import org.monogram.domain.models.AffiliateProgramInfoModel +import org.monogram.domain.models.BirthdateModel +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotInfoModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.BotVerificationModel +import org.monogram.domain.models.BotVerificationParametersModel +import org.monogram.domain.models.BusinessInfoModel +import org.monogram.domain.models.BusinessLocationModel +import org.monogram.domain.models.BusinessOpeningHoursIntervalModel +import org.monogram.domain.models.BusinessOpeningHoursModel +import org.monogram.domain.models.BusinessStartPageModel +import org.monogram.domain.models.ChatAdministratorRightsModel +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ProfileAudioModel +import org.monogram.domain.models.ProfileTabType +import org.monogram.domain.models.SupergroupBotCommandsModel +import org.monogram.domain.models.UserRatingModel fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel { val birthdate = birthdate?.let { date -> @@ -114,7 +132,8 @@ fun TdApi.Chat.toDomain(): ChatModel { isChannel = isChannel, isGroup = type.isGroupType(), type = type.toDomainChatType(), - lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" + lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "", + pendingJoinRequestCount = pendingJoinRequests?.totalCount ?: 0 ) } diff --git a/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt b/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt new file mode 100644 index 000000000..2763130bf --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt @@ -0,0 +1,237 @@ +package org.monogram.data.notifications + +import org.drinkless.tdlib.TdApi + +class TdlibNotificationStateStore { + private data class GroupState( + val id: Int, + val chatId: Long, + val notifications: LinkedHashMap + ) + + private val groups = LinkedHashMap() + private val dismissedUpToByChat = LinkedHashMap() + private val dismissedNotificationIdsByChat = LinkedHashMap>() + private var hasNativeSync = false + + @Synchronized + fun hasNativeSync(): Boolean = hasNativeSync + + @Synchronized + fun reset() { + groups.clear() + dismissedUpToByChat.clear() + dismissedNotificationIdsByChat.clear() + hasNativeSync = false + } + + @Synchronized + fun replaceAll(update: TdApi.UpdateActiveNotifications): Set { + val previousChatIds = groups.values.mapTo(linkedSetOf()) { it.chatId } + groups.clear() + hasNativeSync = true + + update.groups.orEmpty().forEach { group -> + val notifications = linkedMapOf() + group.notifications.orEmpty().forEach { notification -> + upsertNotification( + chatId = group.chatId, + notifications = notifications, + notification = notification + ) + } + if (group.totalCount != 0 || notifications.isNotEmpty()) { + groups[group.id] = GroupState( + id = group.id, + chatId = group.chatId, + notifications = notifications + ) + } + } + + val currentChatIds = groups.values.mapTo(linkedSetOf()) { it.chatId } + dismissedUpToByChat.keys.retainAll(currentChatIds) + dismissedNotificationIdsByChat.keys.retainAll(currentChatIds) + return previousChatIds + currentChatIds + } + + @Synchronized + fun apply(update: TdApi.UpdateNotificationGroup): Set { + hasNativeSync = true + val affectedChatIds = linkedSetOf() + val existing = groups[update.notificationGroupId] + if (existing != null) { + affectedChatIds += existing.chatId + } + + val notifications = LinkedHashMap(existing?.notifications ?: linkedMapOf()) + val removedNotificationIds = update.removedNotificationIds ?: intArrayOf() + removedNotificationIds.forEach { removedId -> + notifications.remove(removedId) + dismissedNotificationIdsByChat[update.chatId]?.remove(removedId) + } + update.addedNotifications.orEmpty().forEach { notification -> + upsertNotification( + chatId = update.chatId, + notifications = notifications, + notification = notification + ) + } + + if (update.totalCount == 0 && notifications.isEmpty()) { + groups.remove(update.notificationGroupId) + } else { + groups[update.notificationGroupId] = GroupState( + id = update.notificationGroupId, + chatId = update.chatId, + notifications = notifications + ) + affectedChatIds += update.chatId + } + + return affectedChatIds + } + + @Synchronized + fun apply(update: TdApi.UpdateNotification): Set { + hasNativeSync = true + val group = groups[update.notificationGroupId] ?: return emptySet() + upsertNotification( + chatId = group.chatId, + notifications = group.notifications, + notification = update.notification + ) + return setOf(group.chatId) + } + + @Synchronized + fun getChatNotifications(chatId: Long): List { + val dismissedUpTo = dismissedUpToByChat[chatId] ?: Int.MIN_VALUE + val dismissedIds = dismissedNotificationIdsByChat[chatId].orEmpty() + return groups.values + .asSequence() + .filter { it.chatId == chatId } + .flatMap { it.notifications.values.asSequence() } + .filter { it.id > dismissedUpTo && it.id !in dismissedIds } + .sortedBy { it.date } + .toList() + } + + @Synchronized + fun clearChat(chatId: Long) { + dismissChatUpToCurrentMax(chatId) + } + + @Synchronized + fun removeNotification(chatId: Long, notificationId: Int) { + if (containsRawNotification(chatId, notificationId)) { + dismissedNotificationIdsByChat.getOrPut(chatId) { linkedSetOf() } += notificationId + return + } + + dismissChatUpToCurrentMax(chatId) + } + + private fun upsertNotification( + chatId: Long, + notifications: LinkedHashMap, + notification: TdApi.Notification + ) { + when (val type = notification.type) { + is TdApi.NotificationTypeNewMessage -> { + removePushNotificationsByMessageId(chatId, type.message?.id ?: 0L) + removePushNotificationsByMessageId(notifications, type.message?.id ?: 0L) + notifications[notification.id] = notification + } + + is TdApi.NotificationTypeNewPushMessage -> { + val messageId = type.messageId + if (containsRealMessageWithId(chatId, messageId) || containsRealMessageWithId( + notifications, + messageId + ) + ) { + return + } + notifications[notification.id] = notification + } + + else -> notifications[notification.id] = notification + } + } + + private fun containsRawNotification(chatId: Long, notificationId: Int): Boolean { + return groups.values.any { group -> + group.chatId == chatId && group.notifications.containsKey(notificationId) + } + } + + private fun dismissChatUpToCurrentMax(chatId: Long) { + val maxId = groups.values + .asSequence() + .filter { it.chatId == chatId } + .flatMap { it.notifications.values.asSequence() } + .maxOfOrNull { it.id } + ?: return + + val current = dismissedUpToByChat[chatId] ?: Int.MIN_VALUE + if (maxId > current) { + dismissedUpToByChat[chatId] = maxId + } + dismissedNotificationIdsByChat.remove(chatId) + } + + private fun removePushNotificationsByMessageId( + chatId: Long, + messageId: Long + ) { + if (messageId == 0L) return + groups.values + .asSequence() + .filter { it.chatId == chatId } + .forEach { group -> + removePushNotificationsByMessageId(group.notifications, messageId) + } + } + + private fun removePushNotificationsByMessageId( + notifications: LinkedHashMap, + messageId: Long + ) { + if (messageId == 0L) return + val idsToRemove = notifications.values + .filter { notification -> + val type = notification.type as? TdApi.NotificationTypeNewPushMessage + type?.messageId == messageId + } + .map { it.id } + + idsToRemove.forEach(notifications::remove) + } + + private fun containsRealMessageWithId( + chatId: Long, + messageId: Long + ): Boolean { + if (messageId == 0L) return false + return groups.values + .asSequence() + .filter { it.chatId == chatId } + .flatMap { it.notifications.values.asSequence() } + .any { notification -> + val type = notification.type as? TdApi.NotificationTypeNewMessage + type?.message?.id == messageId + } + } + + private fun containsRealMessageWithId( + notifications: LinkedHashMap, + messageId: Long + ): Boolean { + if (messageId == 0L) return false + return notifications.values.any { notification -> + val type = notification.type as? TdApi.NotificationTypeNewMessage + type?.message?.id == messageId + } + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt index d453145a9..47fb11699 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt @@ -104,6 +104,13 @@ class ChatInfoRepositoryImpl( return chat.toDomain() } + override suspend fun getSimilarChatIds(chatId: Long): List { + return remote.getSimilarChatIds(chatId) + .filter { it != 0L && it != chatId } + .distinct() + .toList() + } + override suspend fun getChatMembers( chatId: Long, offset: Int, diff --git a/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt b/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt new file mode 100644 index 000000000..c2eff2060 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt @@ -0,0 +1,74 @@ +package org.monogram.data.repository + +internal enum class ChatOpenOwnershipTransition { + AcquiredFirstOwner, + AddedOwner, + DuplicateOwner, + ReleasedOwner, + ReleasedLastOwner, + MissingOwner +} + +internal data class ChatOpenOwnershipChange( + val transition: ChatOpenOwnershipTransition, + val ownerCount: Int, + val owners: Set +) { + val shouldOpen: Boolean + get() = transition == ChatOpenOwnershipTransition.AcquiredFirstOwner + + val shouldClose: Boolean + get() = transition == ChatOpenOwnershipTransition.ReleasedLastOwner +} + +internal class ChatOpenOwnershipRegistry { + private val ownersByChatId = mutableMapOf>() + + fun acquire(chatId: Long, ownerTag: String): ChatOpenOwnershipChange { + val owners = ownersByChatId.getOrPut(chatId) { linkedSetOf() } + val added = owners.add(ownerTag) + val transition = when { + !added -> ChatOpenOwnershipTransition.DuplicateOwner + owners.size == 1 -> ChatOpenOwnershipTransition.AcquiredFirstOwner + else -> ChatOpenOwnershipTransition.AddedOwner + } + return ChatOpenOwnershipChange( + transition = transition, + ownerCount = owners.size, + owners = owners.toSet() + ) + } + + fun release(chatId: Long, ownerTag: String): ChatOpenOwnershipChange { + val owners = ownersByChatId[chatId] + ?: return ChatOpenOwnershipChange( + transition = ChatOpenOwnershipTransition.MissingOwner, + ownerCount = 0, + owners = emptySet() + ) + + val removed = owners.remove(ownerTag) + if (!removed) { + return ChatOpenOwnershipChange( + transition = ChatOpenOwnershipTransition.MissingOwner, + ownerCount = owners.size, + owners = owners.toSet() + ) + } + + if (owners.isEmpty()) { + ownersByChatId.remove(chatId) + return ChatOpenOwnershipChange( + transition = ChatOpenOwnershipTransition.ReleasedLastOwner, + ownerCount = 0, + owners = emptySet() + ) + } + + return ChatOpenOwnershipChange( + transition = ChatOpenOwnershipTransition.ReleasedOwner, + ownerCount = owners.size, + owners = owners.toSet() + ) + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index ec663b3d5..2ec968daf 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -344,18 +344,17 @@ class ChatsListRepositoryImpl( return@coRunCatching } - if (!shouldEmitList(folderId, newList)) { - return@coRunCatching - } - if (newList.isEmpty() && cache.activeListPositions.isNotEmpty()) { Log.w( TAG, "Rebuild produced empty list with non-empty active positions: folder=$folderId list=${activeChatList.debugName()} positions=${cache.activeListPositions.size} chats=${cache.allChats.size} limit=$limit" ) + scheduleRecoveryIfNeeded("rebuild_empty_with_positions:$folderId") } - emitListUpdate(folderId, newList) + if (shouldEmitList(folderId, newList)) { + emitListUpdate(folderId, newList) + } persistenceManager.persistChatModels(newList, activeChatList) }.onFailure { error -> Log.e(TAG, "Error rebuilding chat list", error) @@ -1003,10 +1002,22 @@ class ChatsListRepositoryImpl( chatRemoteSource.setChatSignMessages(chatId, signMessages) } + override suspend fun setChatHasHiddenMembers(chatId: Long, hasHiddenMembers: Boolean) { + chatRemoteSource.setChatHasHiddenMembers(chatId, hasHiddenMembers) + } + + override suspend fun setChatHasAggressiveAntiSpamEnabled(chatId: Long, enabled: Boolean) { + chatRemoteSource.setChatHasAggressiveAntiSpamEnabled(chatId, enabled) + } + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) { chatRemoteSource.setChatJoinToSendMessages(chatId, joinToSendMessages) } + override suspend fun setChatJoinByRequest(chatId: Long, joinByRequest: Boolean) { + chatRemoteSource.setChatJoinByRequest(chatId, joinByRequest) + } + override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) { chatRemoteSource.setChatAvailableReactions(chatId, availableReactions) } diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index 9cc1a9561..260f11993 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi import org.json.JSONArray @@ -112,6 +114,8 @@ internal class MessageRepositoryImpl( ) private val fullRichMessageCache = ConcurrentHashMap() + private val chatOpenOwnershipMutex = Mutex() + private val chatOpenOwnershipRegistry = ChatOpenOwnershipRegistry() private val _textCompositionStyles = MutableStateFlow>(emptyList()) private val hardResetFlagKey = "cache_hard_reset_v2" @@ -353,29 +357,88 @@ internal class MessageRepositoryImpl( return true } - override suspend fun openChat(chatId: Long) { - messageRemoteDataSource.setChatOpened(chatId) - try { - gateway.execute(TdApi.OpenChat(chatId)) - } catch (e: Exception) { - Log.e("MessageRepository", "Error opening chat $chatId", e) - if (chatId > 0) { + override suspend fun openChat(chatId: Long, ownerTag: String) { + val normalizedOwnerTag = ownerTag.ifBlank { "unknown" } + val ownershipChange = chatOpenOwnershipMutex.withLock { + chatOpenOwnershipRegistry.acquire(chatId, normalizedOwnerTag) + } + + when (ownershipChange.transition) { + ChatOpenOwnershipTransition.AcquiredFirstOwner -> { + messageRemoteDataSource.setChatOpened(chatId) try { - gateway.execute(TdApi.CreatePrivateChat(chatId, false)) gateway.execute(TdApi.OpenChat(chatId)) - } catch (e2: Exception) { - Log.e("MessageRepository", "Failed to create and open private chat $chatId", e2) + } catch (e: Exception) { + Log.e( + "MessageRepository", + "Error opening chat $chatId owner=$normalizedOwnerTag owners=${ownershipChange.owners}", + e + ) + if (chatId > 0) { + try { + gateway.execute(TdApi.CreatePrivateChat(chatId, false)) + gateway.execute(TdApi.OpenChat(chatId)) + } catch (e2: Exception) { + Log.e( + "MessageRepository", + "Failed to create and open private chat $chatId owner=$normalizedOwnerTag owners=${ownershipChange.owners}", + e2 + ) + } + } } } + + ChatOpenOwnershipTransition.AddedOwner, + ChatOpenOwnershipTransition.DuplicateOwner -> { + Log.d( + "MessageRepository", + "Chat $chatId already open owner=$normalizedOwnerTag transition=${ownershipChange.transition} owners=${ownershipChange.owners}" + ) + } + + ChatOpenOwnershipTransition.ReleasedOwner, + ChatOpenOwnershipTransition.ReleasedLastOwner, + ChatOpenOwnershipTransition.MissingOwner -> Unit } } - override suspend fun closeChat(chatId: Long) { - messageRemoteDataSource.setChatClosed(chatId) - try { - gateway.execute(TdApi.CloseChat(chatId)) - } catch (e: Exception) { - Log.e("MessageRepository", "Error closing chat $chatId", e) + override suspend fun closeChat(chatId: Long, ownerTag: String) { + val normalizedOwnerTag = ownerTag.ifBlank { "unknown" } + val ownershipChange = chatOpenOwnershipMutex.withLock { + chatOpenOwnershipRegistry.release(chatId, normalizedOwnerTag) + } + + when (ownershipChange.transition) { + ChatOpenOwnershipTransition.ReleasedLastOwner -> { + messageRemoteDataSource.setChatClosed(chatId) + try { + gateway.execute(TdApi.CloseChat(chatId)) + } catch (e: Exception) { + Log.e( + "MessageRepository", + "Error closing chat $chatId owner=$normalizedOwnerTag", + e + ) + } + } + + ChatOpenOwnershipTransition.ReleasedOwner, + ChatOpenOwnershipTransition.DuplicateOwner, + ChatOpenOwnershipTransition.AddedOwner, + ChatOpenOwnershipTransition.AcquiredFirstOwner -> { + Log.d( + "MessageRepository", + "Chat $chatId remains open after close owner=$normalizedOwnerTag transition=${ownershipChange.transition} owners=${ownershipChange.owners}" + ) + } + + ChatOpenOwnershipTransition.MissingOwner -> { + Log.w( + "MessageRepository", + "Ignoring closeChat for chat $chatId owner=$normalizedOwnerTag because owner was not registered" + ) + } } } @@ -421,6 +484,7 @@ internal class MessageRepositoryImpl( photoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -430,6 +494,7 @@ internal class MessageRepositoryImpl( photoPath = photoPath, caption = caption, captionEntities = captionEntities, + showCaptionAboveMedia = showCaptionAboveMedia, replyToMsgId = replyToMsgId, threadId = threadId, sendOptions = sendOptions @@ -441,6 +506,7 @@ internal class MessageRepositoryImpl( videoPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -450,6 +516,7 @@ internal class MessageRepositoryImpl( videoPath = videoPath, caption = caption, captionEntities = captionEntities, + showCaptionAboveMedia = showCaptionAboveMedia, replyToMsgId = replyToMsgId, threadId = threadId, sendOptions = sendOptions @@ -523,6 +590,7 @@ internal class MessageRepositoryImpl( gifPath: String, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -532,6 +600,7 @@ internal class MessageRepositoryImpl( gifPath = gifPath, caption = caption, captionEntities = captionEntities, + showCaptionAboveMedia = showCaptionAboveMedia, replyToMsgId = replyToMsgId, threadId = threadId, sendOptions = sendOptions @@ -543,6 +612,7 @@ internal class MessageRepositoryImpl( paths: List, caption: String, captionEntities: List, + showCaptionAboveMedia: Boolean, replyToMsgId: Long?, threadId: Long?, sendOptions: MessageSendOptions @@ -552,6 +622,7 @@ internal class MessageRepositoryImpl( paths = paths, caption = caption, captionEntities = captionEntities, + showCaptionAboveMedia = showCaptionAboveMedia, replyToMsgId = replyToMsgId, threadId = threadId, sendOptions = sendOptions @@ -1235,6 +1306,12 @@ internal class MessageRepositoryImpl( ) } + override suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? { + return withContext(dispatcherProvider.io) { + messageRemoteDataSource.getChatMessageByDate(chatId, dateEpochSeconds) + } + } + override fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) { messageRemoteDataSource.updateVisibleRange(chatId, visibleMessageIds, nearbyMessageIds) } diff --git a/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt index e5ff683d2..5867b74d6 100644 --- a/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt @@ -5,10 +5,15 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.cache.ChatLocalDataSource import org.monogram.data.datasource.cache.SettingsCacheDataSource +import org.monogram.data.datasource.cache.StickerLocalDataSource +import org.monogram.data.datasource.cache.UserLocalDataSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.mapper.StorageMapper +import org.monogram.domain.models.StorageCleanupResultModel +import org.monogram.domain.models.StorageUsageBreakdownModel import org.monogram.domain.models.StorageUsageModel import org.monogram.domain.repository.StorageRepository import org.monogram.domain.repository.StringProvider @@ -19,7 +24,10 @@ class StorageRepositoryImpl( private val chatsRemote: ChatsRemoteDataSource, private val dispatchers: DispatcherProvider, private val storageMapper: StorageMapper, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val chatLocalDataSource: ChatLocalDataSource, + private val userLocalDataSource: UserLocalDataSource, + private val stickerLocalDataSource: StickerLocalDataSource ) : StorageRepository { private val manuallyClearableFileTypes = arrayOf( TdApi.FileTypeAnimation(), @@ -64,17 +72,35 @@ class StorageRepositoryImpl( storageMapper.mapToDomain(stats, processedChats) } - override suspend fun clearStorage(chatId: Long?): Boolean { - return remote.optimizeStorage( + override suspend fun getStorageUsageBreakdown(): StorageUsageBreakdownModel? { + val stats = remote.getStorageStatisticsFast() ?: return null + return StorageUsageBreakdownModel( + tdlibMediaSize = stats.filesSize, + tdlibDatabaseSize = stats.databaseSize, + tdlibLogsSize = stats.logSize, + languagePackDatabaseSize = stats.languagePackDatabaseSize + ) + } + + override suspend fun clearStorage(chatId: Long?): StorageCleanupResultModel { + val deletedStats = remote.optimizeStorage( size = 0, ttl = 0, count = 0, immunityDelay = 0, fileTypes = manuallyClearableFileTypes, chatIds = chatId?.let { longArrayOf(it) }, - returnDeletedFileStatistics = false, + returnDeletedFileStatistics = true, chatLimit = 20 ) + if (deletedStats != null) { + clearSessionLocalStorageReferences() + } + return StorageCleanupResultModel( + tdlibFreedSize = deletedStats?.size ?: 0L, + tdlibFreedFileCount = deletedStats?.count ?: 0, + tdlibCleanupSucceeded = deletedStats != null + ) } override suspend fun setDatabaseMaintenanceSettings( @@ -90,7 +116,7 @@ class StorageRepositoryImpl( chatIds = null, returnDeletedFileStatistics = true, chatLimit = 0 - ) + ) != null } override suspend fun getStorageOptimizerEnabled(): Boolean { @@ -105,4 +131,11 @@ class StorageRepositoryImpl( override suspend fun setStorageOptimizerEnabled(enabled: Boolean) { remote.setOption("use_storage_optimizer", TdApi.OptionValueBoolean(enabled)) } -} \ No newline at end of file + + private suspend fun clearSessionLocalStorageReferences() { + chatLocalDataSource.clearCachedMediaPaths() + chatLocalDataSource.clearCachedChatAvatarPaths() + userLocalDataSource.clearCachedAvatarPaths() + stickerLocalDataSource.clearPaths() + } +} diff --git a/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt b/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt index ba8e8ac15..bbdc6f99c 100644 --- a/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt +++ b/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt @@ -11,14 +11,19 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.json.JSONObject +import org.monogram.data.di.TdNotificationManager +import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.push.PushSyncTrigger import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider class BaseFcmPushService( private val context: Context, private val gateway: TelegramGateway, - private val appPreferences: AppPreferencesProvider + private val appPreferences: AppPreferencesProvider, + private val notificationManager: TdNotificationManager, + private val pushSyncTrigger: PushSyncTrigger ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -51,16 +56,31 @@ class BaseFcmPushService( val jsonPayload = json.toString() if (jsonPayload.isBlank()) return - wakeLock.acquire(10_000L) + wakeLock.acquire(PUSH_WAKE_LOCK_MS) scope.launch { + val notificationStateVersion = notificationManager.currentNotificationStateVersion() try { - withTimeout(8_000L) { + withTimeout(PROCESS_PUSH_TIMEOUT_MS) { gateway.execute(TdApi.ProcessPushNotification(jsonPayload)) } Log.d(TAG, "ProcessPushNotification success") + val updated = notificationManager.awaitNotificationStateChange( + afterVersion = notificationStateVersion, + timeoutMs = NOTIFICATION_SETTLE_TIMEOUT_MS + ) + if (!updated) { + Log.w( + TAG, + "No notification-state update after push, requesting sync fallback" + ) + pushSyncTrigger.requestSync("fcm_push_settle_timeout") + } } catch (e: Exception) { if (e is CancellationException) throw e Log.e(TAG, "Error processing push", e) + if (!e.isAuthRelatedPushError()) { + pushSyncTrigger.requestSync("fcm_push_process_error") + } } finally { if (wakeLock.isHeld) { wakeLock.release() @@ -96,7 +116,19 @@ class BaseFcmPushService( } } + private fun Throwable.isAuthRelatedPushError(): Boolean { + val tdError = (this as? TdLibException)?.error ?: return false + val message = tdError.message.orEmpty().lowercase() + return tdError.code == 401 || + message.contains("unauthorized") || + message.contains("authorization") || + message.contains("not logged in") + } + private companion object { const val TAG = "FcmPushService" + const val PUSH_WAKE_LOCK_MS = 15_000L + const val PROCESS_PUSH_TIMEOUT_MS = 8_000L + const val NOTIFICATION_SETTLE_TIMEOUT_MS = 2_500L } } diff --git a/data/src/test/java/org/monogram/data/chats/ChatCacheTest.kt b/data/src/test/java/org/monogram/data/chats/ChatCacheTest.kt index 69f812bf2..05883efb4 100644 --- a/data/src/test/java/org/monogram/data/chats/ChatCacheTest.kt +++ b/data/src/test/java/org/monogram/data/chats/ChatCacheTest.kt @@ -1,8 +1,10 @@ package org.monogram.data.chats import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test +import org.drinkless.tdlib.TdApi import org.monogram.data.db.model.ChatEntity class ChatCacheTest { @@ -64,4 +66,37 @@ class ChatCacheTest { assertEquals(99L, chat?.lastMessage?.id) assertEquals(1710000000, chat?.lastMessage?.date) } + + @Test + fun `putChatFromEntity restores folder positions from cache`() { + val cache = ChatCache() + val entity = ChatEntity( + id = 3L, + title = "Folder chat", + unreadCount = 0, + avatarPath = null, + lastMessageText = "", + lastMessageTime = "0", + order = 12L, + isPinned = false, + isMuted = false, + isChannel = false, + isGroup = false, + type = "PRIVATE", + positionsCache = "m:12:0|f:42:7:1|a:3:0", + isArchived = false, + memberCount = 0, + onlineCount = 0 + ) + + cache.putChatFromEntity(entity) + + val chat = cache.getChat(3L) + assertNotNull(chat) + val restoredChat = chat!! + val folderPosition = restoredChat.positions.firstOrNull { it.list is TdApi.ChatListFolder } + assertNotNull(folderPosition) + assertEquals(3, restoredChat.positions.size) + assertEquals(42, (folderPosition!!.list as TdApi.ChatListFolder).chatFolderId) + } } diff --git a/data/src/test/java/org/monogram/data/chats/ChatPersistenceManagerTest.kt b/data/src/test/java/org/monogram/data/chats/ChatPersistenceManagerTest.kt new file mode 100644 index 000000000..0dce69553 --- /dev/null +++ b/data/src/test/java/org/monogram/data/chats/ChatPersistenceManagerTest.kt @@ -0,0 +1,29 @@ +package org.monogram.data.chats + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class ChatPersistenceManagerTest { + + @Test + fun `resolvePersistPosition prefers active folder over main`() { + val chat = TdApi.Chat().apply { + id = 1L + positions = arrayOf( + TdApi.ChatPosition(TdApi.ChatListMain(), 10L, false, null), + TdApi.ChatPosition(TdApi.ChatListFolder(42), 7L, true, null), + TdApi.ChatPosition(TdApi.ChatListArchive(), 3L, false, null) + ) + } + + val position = resolvePersistPosition(chat, TdApi.ChatListFolder(42), ChatListManager(ChatCache()) {}) + + assertNotNull(position) + val resolved = position!! + assertEquals(42, (resolved.list as TdApi.ChatListFolder).chatFolderId) + assertEquals(7L, resolved.order) + assertEquals(true, resolved.isPinned) + } +} diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index 0163aedf3..d1b68e5df 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -206,6 +206,27 @@ class ConnectionManagerTest { assertEquals(ConnectionStatus.Connected, connectionManager.connectionStateFlow.value) } + @Test + fun `recent typed connection state stays authoritative over probe mismatch`() = runManagerTest { + authFlow.emit( + TdApi.UpdateAuthorizationState(TdApi.AuthorizationStateReady()) + ) + foregroundTracker.setForeground(true) + networkProvider.update( + NetworkSnapshot(true, true, ProxyNetworkType.WIFI, 1) + ) + scope.flush() + + connectionFlow.value = TdApi.UpdateConnectionState(TdApi.ConnectionStateReady()) + scope.flush() + + chatRemoteSource.currentConnectionState = TdApi.ConnectionStateWaitingForNetwork() + connectionManager.retryConnection() + scope.advanceAndFlush(500L) + + assertEquals(ConnectionStatus.Connected, connectionManager.connectionStateFlow.value) + } + @org.junit.Ignore("Broken by recent proxy refactor in develop") @Test fun `repeated failure threshold triggers proxy smart switch but single reconnect does not`() = @@ -400,9 +421,23 @@ class ConnectionManagerTest { ) = Unit override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) = Unit + override suspend fun setChatHasHiddenMembers( + chatId: Long, + hasHiddenMembers: Boolean + ) { + } + + override suspend fun setChatHasAggressiveAntiSpamEnabled( + chatId: Long, + enabled: Boolean + ) { + } + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) = Unit + override suspend fun setChatJoinByRequest(chatId: Long, joinByRequest: Boolean) = Unit + override suspend fun setChatAvailableReactions( chatId: Long, availableReactions: List @@ -504,6 +539,10 @@ class ConnectionManagerTest { ) : UpdateDispatcher { override val all: Flow = MutableSharedFlow() override val newMessage: Flow = MutableSharedFlow() + override val activeNotifications: Flow = + MutableSharedFlow() + override val notificationGroup: Flow = MutableSharedFlow() + override val notification: Flow = MutableSharedFlow() override val messageEdited: Flow = MutableSharedFlow() override val messageContent: Flow = MutableSharedFlow() override val messageSendSucceeded: Flow = @@ -534,6 +573,7 @@ class ConnectionManagerTest { override val userPrivacySettingRules: Flow = MutableSharedFlow() override val file: Flow = MutableSharedFlow() + override val option: Flow = MutableSharedFlow() override val installedStickerSets: Flow = MutableSharedFlow() override val newChat: Flow = MutableSharedFlow() diff --git a/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt index 34c0e51ee..0fae39e74 100644 --- a/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt +++ b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt @@ -38,6 +38,17 @@ class ChatMapperTest { assertEquals(expected, time) } + @Test + fun `encodeChatPositions keeps folder ids`() { + val positions = arrayOf( + TdApi.ChatPosition(TdApi.ChatListMain(), 10L, true, null), + TdApi.ChatPosition(TdApi.ChatListFolder(42), 7L, false, null), + TdApi.ChatPosition(TdApi.ChatListArchive(), 3L, false, null) + ) + + assertEquals("m:10:1|f:42:7:0|a:3:0", encodeChatPositions(positions)) + } + private fun createTextMessage(timestampSeconds: Int): TdApi.Message { return TdApi.Message().apply { date = timestampSeconds diff --git a/data/src/test/java/org/monogram/data/mapper/StorageMapperTest.kt b/data/src/test/java/org/monogram/data/mapper/StorageMapperTest.kt new file mode 100644 index 000000000..2245a0789 --- /dev/null +++ b/data/src/test/java/org/monogram/data/mapper/StorageMapperTest.kt @@ -0,0 +1,79 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ChatStorageUsageModel +import org.monogram.domain.repository.StringProvider + +class StorageMapperTest { + + private val mapper = StorageMapper(FakeStringProvider()) + + @Test + fun `mapToDomain keeps tdlib totals as source of truth`() { + val statistics = TdApi.StorageStatistics().apply { + size = 4_096L + count = 12 + } + val chatStats = listOf( + ChatStorageUsageModel( + chatId = 1L, + chatTitle = "Chat", + size = 1_024L, + fileCount = 3, + byFileType = emptyList() + ) + ) + + val result = mapper.mapToDomain(statistics, chatStats) + + assertEquals(4_096L, result.totalSize) + assertEquals(12, result.fileCount) + assertEquals(chatStats, result.chatStats) + } + + @Test + fun `mapChatStatsToDomain keeps manually clearable categories visible`() { + val statistics = TdApi.StorageStatisticsByChat().apply { + chatId = 42L + byFileType = arrayOf( + fileTypeStat(TdApi.FileTypeSticker(), 100L, 1), + fileTypeStat(TdApi.FileTypeThumbnail(), 200L, 2), + fileTypeStat(TdApi.FileTypeProfilePhoto(), 300L, 3), + fileTypeStat(TdApi.FileTypeWallpaper(), 400L, 4) + ) + } + + val result = mapper.mapChatStatsToDomain(statistics, "Saved Messages") + + assertEquals(1_000L, result.size) + assertEquals(10, result.fileCount) + assertEquals(4, result.byFileType.size) + assertTrue(result.byFileType.any { it.fileType == "storage_stickers" && it.size == 100L }) + assertEquals(3, result.byFileType.count { it.fileType == "storage_other_files" }) + } + + private fun fileTypeStat( + fileType: TdApi.FileType, + size: Long, + count: Int + ) = TdApi.StorageStatisticsByFileType().apply { + this.fileType = fileType + this.size = size + this.count = count + } + + private class FakeStringProvider : StringProvider { + override fun getString(resName: String): String = resName + + override fun getString(resName: String, vararg formatArgs: Any): String = resName + + override fun getQuantityString( + resName: String, + quantity: Int, + vararg formatArgs: Any + ): String = resName + } +} diff --git a/data/src/test/java/org/monogram/data/notifications/TdlibNotificationStateStoreTest.kt b/data/src/test/java/org/monogram/data/notifications/TdlibNotificationStateStoreTest.kt new file mode 100644 index 000000000..cdaf6453d --- /dev/null +++ b/data/src/test/java/org/monogram/data/notifications/TdlibNotificationStateStoreTest.kt @@ -0,0 +1,199 @@ +package org.monogram.data.notifications + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TdlibNotificationStateStoreTest { + + private val store = TdlibNotificationStateStore() + + @Test + fun `replaceAll stores notifications and enables native sync`() { + val update = TdApi.UpdateActiveNotifications().apply { + groups = arrayOf( + notificationGroup( + groupId = 1, + chatId = 100L, + notifications = arrayOf(pushNotification(10, 1), pushNotification(11, 2)) + ) + ) + } + + val affectedChats = store.replaceAll(update) + + assertTrue(store.hasNativeSync()) + assertEquals(setOf(100L), affectedChats) + assertEquals(listOf(10, 11), store.getChatNotifications(100L).map { it.id }) + } + + @Test + fun `group update removes and adds notifications`() { + store.replaceAll( + TdApi.UpdateActiveNotifications().apply { + groups = arrayOf( + notificationGroup( + groupId = 1, + chatId = 100L, + notifications = arrayOf(pushNotification(10, 1), pushNotification(11, 2)) + ) + ) + } + ) + + val affectedChats = store.apply( + TdApi.UpdateNotificationGroup().apply { + notificationGroupId = 1 + chatId = 100L + totalCount = 2 + addedNotifications = arrayOf(pushNotification(12, 3)) + removedNotificationIds = intArrayOf(10) + } + ) + + assertEquals(setOf(100L), affectedChats) + assertEquals(listOf(11, 12), store.getChatNotifications(100L).map { it.id }) + } + + @Test + fun `reset clears notifications and disables native sync`() { + store.replaceAll( + TdApi.UpdateActiveNotifications().apply { + groups = arrayOf( + notificationGroup( + groupId = 1, + chatId = 100L, + notifications = arrayOf(pushNotification(10, 1)) + ) + ) + } + ) + + store.reset() + + assertFalse(store.hasNativeSync()) + assertTrue(store.getChatNotifications(100L).isEmpty()) + } + + @Test + fun `new message replaces push notification with same message id`() { + store.replaceAll( + TdApi.UpdateActiveNotifications().apply { + groups = arrayOf( + notificationGroup( + groupId = 1, + chatId = 100L, + notifications = arrayOf( + pushNotification( + id = 10, + date = 1, + messageId = 500L + ) + ) + ) + ) + } + ) + + store.apply( + TdApi.UpdateNotificationGroup().apply { + notificationGroupId = 1 + chatId = 100L + totalCount = 1 + addedNotifications = + arrayOf(messageNotification(id = 11, date = 2, messageId = 500L)) + removedNotificationIds = intArrayOf() + } + ) + + val notifications = store.getChatNotifications(100L) + + assertEquals(listOf(11), notifications.map { it.id }) + assertTrue(notifications.single().type is TdApi.NotificationTypeNewMessage) + } + + @Test + fun `clear chat hides current notifications until newer ones arrive`() { + store.replaceAll( + TdApi.UpdateActiveNotifications().apply { + groups = arrayOf( + notificationGroup( + groupId = 1, + chatId = 100L, + notifications = arrayOf( + pushNotification(id = 10, date = 1, messageId = 500L), + pushNotification(id = 11, date = 2, messageId = 501L) + ) + ) + ) + } + ) + + store.clearChat(100L) + + assertTrue(store.getChatNotifications(100L).isEmpty()) + + store.apply( + TdApi.UpdateNotificationGroup().apply { + notificationGroupId = 1 + chatId = 100L + totalCount = 3 + addedNotifications = arrayOf(pushNotification(id = 12, date = 3, messageId = 502L)) + removedNotificationIds = intArrayOf() + } + ) + + assertEquals(listOf(12), store.getChatNotifications(100L).map { it.id }) + } + + private fun notificationGroup( + groupId: Int, + chatId: Long, + notifications: Array + ) = TdApi.NotificationGroup().apply { + id = groupId + this.chatId = chatId + totalCount = notifications.size + this.notifications = notifications + type = TdApi.NotificationGroupTypeMessages() + } + + private fun pushNotification( + id: Int, + date: Int, + messageId: Long = id.toLong() + ) = TdApi.Notification().apply { + this.id = id + this.date = date + isSilent = false + type = TdApi.NotificationTypeNewPushMessage().apply { + this.messageId = messageId + senderId = TdApi.MessageSenderUser(1L) + senderName = "Push sender" + content = TdApi.PushMessageContentText("push-$messageId", false) + } + } + + private fun messageNotification( + id: Int, + date: Int, + messageId: Long + ) = TdApi.Notification().apply { + this.id = id + this.date = date + isSilent = false + type = TdApi.NotificationTypeNewMessage().apply { + message = TdApi.Message().apply { + this.id = messageId + chatId = 100L + senderId = TdApi.MessageSenderUser(1L) + this.date = date + content = TdApi.MessageText().apply { + text = TdApi.FormattedText("message-$messageId", emptyArray()) + } + } + } + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt b/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt new file mode 100644 index 000000000..a1d14247e --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt @@ -0,0 +1,60 @@ +package org.monogram.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatOpenOwnershipRegistryTest { + private val registry = ChatOpenOwnershipRegistry() + + @Test + fun `first owner opens and last owner closes`() { + val acquired = registry.acquire(chatId = 10L, ownerTag = "cmp1") + val released = registry.release(chatId = 10L, ownerTag = "cmp1") + + assertEquals(ChatOpenOwnershipTransition.AcquiredFirstOwner, acquired.transition) + assertTrue(acquired.shouldOpen) + assertFalse(acquired.shouldClose) + + assertEquals(ChatOpenOwnershipTransition.ReleasedLastOwner, released.transition) + assertFalse(released.shouldOpen) + assertTrue(released.shouldClose) + } + + @Test + fun `second owner keeps chat open until both close`() { + val first = registry.acquire(chatId = 10L, ownerTag = "cmp1") + val second = registry.acquire(chatId = 10L, ownerTag = "cmp2") + val releaseFirst = registry.release(chatId = 10L, ownerTag = "cmp1") + val releaseSecond = registry.release(chatId = 10L, ownerTag = "cmp2") + + assertEquals(ChatOpenOwnershipTransition.AcquiredFirstOwner, first.transition) + assertEquals(ChatOpenOwnershipTransition.AddedOwner, second.transition) + assertFalse(second.shouldOpen) + assertEquals(setOf("cmp1", "cmp2"), second.owners) + + assertEquals(ChatOpenOwnershipTransition.ReleasedOwner, releaseFirst.transition) + assertFalse(releaseFirst.shouldClose) + assertEquals(setOf("cmp2"), releaseFirst.owners) + + assertEquals(ChatOpenOwnershipTransition.ReleasedLastOwner, releaseSecond.transition) + assertTrue(releaseSecond.shouldClose) + } + + @Test + fun `duplicate owner and missing owner are ignored`() { + registry.acquire(chatId = 10L, ownerTag = "cmp1") + + val duplicate = registry.acquire(chatId = 10L, ownerTag = "cmp1") + val missing = registry.release(chatId = 10L, ownerTag = "cmp2") + + assertEquals(ChatOpenOwnershipTransition.DuplicateOwner, duplicate.transition) + assertEquals(1, duplicate.ownerCount) + assertFalse(duplicate.shouldOpen) + + assertEquals(ChatOpenOwnershipTransition.MissingOwner, missing.transition) + assertEquals(setOf("cmp1"), missing.owners) + assertFalse(missing.shouldClose) + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt index 236d26d80..d0d3ef4df 100644 --- a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt +++ b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt @@ -143,6 +143,7 @@ private class FakeUserRemoteDataSource( override suspend fun sharePhoneNumber(userId: Long) = Unit override suspend fun searchPublicChat(username: String): TdApi.Chat? = null + override suspend fun getSimilarChatIds(chatId: Long): LongArray = longArrayOf() override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? = null override suspend fun getSupergroupMembers( supergroupId: Long, diff --git a/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt index 0847cd1af..b767b72e8 100644 --- a/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt +++ b/data/src/test/java/org/monogram/data/repository/TelegramLinkRepositoryImplTest.kt @@ -1,11 +1,11 @@ package org.monogram.data.repository import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -142,6 +142,9 @@ private class FakeUpdateDispatcher : UpdateDispatcher { override val all: Flow = MutableSharedFlow() override val authorizationState: Flow = MutableSharedFlow() override val newMessage: Flow = MutableSharedFlow() + override val activeNotifications: Flow = MutableSharedFlow() + override val notificationGroup: Flow = MutableSharedFlow() + override val notification: Flow = MutableSharedFlow() override val messageEdited: Flow = MutableSharedFlow() override val messageContent: Flow = MutableSharedFlow() override val messageSendSucceeded: Flow = MutableSharedFlow() @@ -171,6 +174,7 @@ private class FakeUpdateDispatcher : UpdateDispatcher { override val userPrivacySettingRules: Flow = MutableSharedFlow() override val file: Flow = MutableSharedFlow() + override val option: Flow = MutableSharedFlow() override val connectionState: Flow = MutableSharedFlow() override val installedStickerSets: Flow = MutableSharedFlow() override val newChat: Flow = MutableSharedFlow() diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index d01a2be2e..3b90759cc 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -35,6 +35,7 @@ data class ChatModel( val hasAutomaticTranslation: Boolean = false, val signMessages: Boolean = false, val joinToSendMessages: Boolean = false, + val pendingJoinRequestCount: Int = 0, val availableReactions: ChatAvailableReactionsModel = ChatAvailableReactionsModel.All, val messageAutoDeleteTime: Int = 0, val canBeDeletedOnlyForSelf: Boolean = false, @@ -82,6 +83,7 @@ data class ChatModel( val lastMessageSenderName: String = "", val lastMessagePreviewPath: String? = null, val lastMessagePreviewPaths: List = emptyList(), + val joinByRequest: Boolean = false, ) @Serializable diff --git a/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt index 573a78d0f..5bf47f065 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt @@ -5,6 +5,11 @@ import kotlinx.serialization.Serializable @Serializable data class ChatViewportCacheEntry( val anchorMessageId: Long? = null, + val anchorAliasIds: List = emptyList(), val anchorOffsetPx: Int = 0, - val atBottom: Boolean = true + val atBottom: Boolean = true, + val readFully: Boolean = atBottom, + val topEndMessageId: Long? = null, + val returnToMessageIds: List = emptyList(), + val anchorChatId: Long? = null ) diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index 530beab7d..684eb489d 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -134,6 +134,7 @@ sealed interface MessageContent { val height: Int, val caption: String = "", val entities: List = emptyList(), + val showCaptionAboveMedia: Boolean = false, val isUploading: Boolean = false, val uploadProgress: Float = 0f, val isDownloading: Boolean = false, @@ -160,6 +161,7 @@ sealed interface MessageContent { if (fileId != other.fileId) return false if (originalFileId != other.originalFileId) return false if (hasSpoiler != other.hasSpoiler) return false + if (showCaptionAboveMedia != other.showCaptionAboveMedia) return false if (path != other.path) return false if (thumbnailPath != other.thumbnailPath) return false if (caption != other.caption) return false @@ -179,6 +181,7 @@ sealed interface MessageContent { result = 31 * result + downloadError.hashCode() result = 31 * result + fileId result = 31 * result + hasSpoiler.hashCode() + result = 31 * result + showCaptionAboveMedia.hashCode() result = 31 * result + (path?.hashCode() ?: 0) result = 31 * result + (thumbnailPath?.hashCode() ?: 0) result = 31 * result + caption.hashCode() @@ -196,6 +199,7 @@ sealed interface MessageContent { val duration: Int, val caption: String = "", val entities: List = emptyList(), + val showCaptionAboveMedia: Boolean = false, val isUploading: Boolean = false, val uploadProgress: Float = 0f, val isDownloading: Boolean = false, @@ -223,6 +227,7 @@ sealed interface MessageContent { if (fileId != other.fileId) return false if (supportsStreaming != other.supportsStreaming) return false if (hasSpoiler != other.hasSpoiler) return false + if (showCaptionAboveMedia != other.showCaptionAboveMedia) return false if (path != other.path) return false if (thumbnailPath != other.thumbnailPath) return false if (caption != other.caption) return false @@ -244,6 +249,7 @@ sealed interface MessageContent { result = 31 * result + fileId result = 31 * result + supportsStreaming.hashCode() result = 31 * result + hasSpoiler.hashCode() + result = 31 * result + showCaptionAboveMedia.hashCode() result = 31 * result + (path?.hashCode() ?: 0) result = 31 * result + (thumbnailPath?.hashCode() ?: 0) result = 31 * result + caption.hashCode() @@ -363,6 +369,7 @@ sealed interface MessageContent { val height: Int, val caption: String = "", val entities: List = emptyList(), + val showCaptionAboveMedia: Boolean = false, val isUploading: Boolean = false, val uploadProgress: Float = 0f, val isDownloading: Boolean = false, @@ -387,6 +394,7 @@ sealed interface MessageContent { if (downloadError != other.downloadError) return false if (fileId != other.fileId) return false if (hasSpoiler != other.hasSpoiler) return false + if (showCaptionAboveMedia != other.showCaptionAboveMedia) return false if (path != other.path) return false if (caption != other.caption) return false if (entities != other.entities) return false @@ -405,6 +413,7 @@ sealed interface MessageContent { result = 31 * result + downloadError.hashCode() result = 31 * result + fileId result = 31 * result + hasSpoiler.hashCode() + result = 31 * result + showCaptionAboveMedia.hashCode() result = 31 * result + (path?.hashCode() ?: 0) result = 31 * result + caption.hashCode() result = 31 * result + entities.hashCode() diff --git a/domain/src/main/java/org/monogram/domain/models/StorageUsageModel.kt b/domain/src/main/java/org/monogram/domain/models/StorageUsageModel.kt index 008da11bf..c276ec975 100644 --- a/domain/src/main/java/org/monogram/domain/models/StorageUsageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/StorageUsageModel.kt @@ -6,6 +6,19 @@ data class StorageUsageModel( val chatStats: List ) +data class StorageUsageBreakdownModel( + val tdlibMediaSize: Long, + val tdlibDatabaseSize: Long, + val tdlibLogsSize: Long, + val languagePackDatabaseSize: Long +) + +data class StorageCleanupResultModel( + val tdlibFreedSize: Long, + val tdlibFreedFileCount: Int, + val tdlibCleanupSucceeded: Boolean +) + data class ChatStorageUsageModel( val chatId: Long, val chatTitle: String = "", diff --git a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt index dc19ecc1e..e3c00fd9e 100644 --- a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt @@ -37,6 +37,9 @@ interface CacheProvider { fun saveChatViewport(chatId: Long, threadId: Long?, viewport: ChatViewportCacheEntry) fun getChatViewport(chatId: Long, threadId: Long?): ChatViewportCacheEntry? + fun saveChatListScrollPosition(folderId: Int, index: Int, offset: Int) + fun getChatListScrollPosition(folderId: Int): Pair? + fun getChatListScrollPositions(): Map> fun setSavedGifs(gifs: List) diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt index 3f5d869bf..fa1f371d3 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt @@ -8,6 +8,7 @@ import org.monogram.domain.models.GroupMemberModel interface ChatInfoRepository { suspend fun getChatFullInfo(chatId: Long): ChatFullInfoModel? suspend fun searchPublicChat(username: String): ChatModel? + suspend fun getSimilarChatIds(chatId: Long): List suspend fun getChatMembers( chatId: Long, offset: Int, diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt index aee0552d8..671f8f327 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt @@ -10,7 +10,10 @@ interface ChatSettingsRepository { suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) + suspend fun setChatHasHiddenMembers(chatId: Long, hasHiddenMembers: Boolean) + suspend fun setChatHasAggressiveAntiSpamEnabled(chatId: Long, enabled: Boolean) suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) + suspend fun setChatJoinByRequest(chatId: Long, joinByRequest: Boolean) suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index 2c89f9f24..66856c011 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -104,8 +104,8 @@ interface MessageRepository : fromMessageId: Long, limit: Int ): List - suspend fun openChat(chatId: Long) - suspend fun closeChat(chatId: Long) + suspend fun openChat(chatId: Long, ownerTag: String = "unknown") + suspend fun closeChat(chatId: Long, ownerTag: String = "unknown") suspend fun sendVideoNote( chatId: Long, @@ -160,6 +160,7 @@ interface MessageRepository : ): List suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long? = null): List + suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? suspend fun getMessageThreadContext(chatId: Long, messageId: Long): MessageThreadContext? @@ -192,6 +193,7 @@ interface MessageRepository : photoPath: String, caption: String = "", captionEntities: List = emptyList(), + showCaptionAboveMedia: Boolean = false, replyToMsgId: Long? = null, threadId: Long? = null, sendOptions: MessageSendOptions = MessageSendOptions() @@ -202,6 +204,7 @@ interface MessageRepository : videoPath: String, caption: String = "", captionEntities: List = emptyList(), + showCaptionAboveMedia: Boolean = false, replyToMsgId: Long? = null, threadId: Long? = null, sendOptions: MessageSendOptions = MessageSendOptions() @@ -246,6 +249,7 @@ interface MessageRepository : gifPath: String, caption: String = "", captionEntities: List = emptyList(), + showCaptionAboveMedia: Boolean = false, replyToMsgId: Long? = null, threadId: Long? = null, sendOptions: MessageSendOptions = MessageSendOptions() @@ -305,6 +309,7 @@ interface MessageRepository : paths: List, caption: String = "", captionEntities: List = emptyList(), + showCaptionAboveMedia: Boolean = false, replyToMsgId: Long? = null, threadId: Long? = null, sendOptions: MessageSendOptions = MessageSendOptions() diff --git a/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt index 2e44609f7..a21ea2641 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt @@ -1,10 +1,13 @@ package org.monogram.domain.repository +import org.monogram.domain.models.StorageCleanupResultModel +import org.monogram.domain.models.StorageUsageBreakdownModel import org.monogram.domain.models.StorageUsageModel interface StorageRepository { suspend fun getStorageUsage(): StorageUsageModel? - suspend fun clearStorage(chatId: Long? = null): Boolean + suspend fun getStorageUsageBreakdown(): StorageUsageBreakdownModel? + suspend fun clearStorage(chatId: Long? = null): StorageCleanupResultModel suspend fun setDatabaseMaintenanceSettings(maxDatabaseSize: Long, maxTimeFromLastAccess: Int): Boolean suspend fun getStorageOptimizerEnabled(): Boolean diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ce7128cd..d4f6bf4c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Plugins -agp = "9.3.0-rc01" +agp = "9.3.0" exifinterface = "1.4.2" kotlin = "2.3.21" google-services = "4.5.0" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt index aab50344a..dd8007c73 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.monogram.domain.models.AttachMenuBotModel import org.monogram.domain.models.ChatViewportCacheEntry @@ -128,12 +129,47 @@ class CachePreferences(private val context: Context) : CacheProvider { val legacy = prefs.getLong("chat_scroll_$chatId", 0L) return if (legacy == 0L) { - ChatViewportCacheEntry(atBottom = true) + ChatViewportCacheEntry(atBottom = true, readFully = true) } else { - ChatViewportCacheEntry(anchorMessageId = legacy, anchorOffsetPx = 0, atBottom = false) + ChatViewportCacheEntry( + anchorMessageId = legacy, + anchorOffsetPx = 0, + atBottom = false, + readFully = false + ) } } + override fun saveChatListScrollPosition(folderId: Int, index: Int, offset: Int) { + prefs.edit() + .putString( + chatListScrollKey(folderId), + Json.encodeToString(ChatListScrollPosition(index, offset)) + ) + .apply() + } + + override fun getChatListScrollPosition(folderId: Int): Pair? { + val raw = prefs.getString(chatListScrollKey(folderId), null) ?: return null + val decoded = runCatching { + Json.decodeFromString(raw) + }.getOrNull() ?: return null + return decoded.index to decoded.offset + } + + override fun getChatListScrollPositions(): Map> { + return prefs.all.entries.mapNotNull { (key, value) -> + if (!key.startsWith(CHAT_LIST_SCROLL_PREFIX)) return@mapNotNull null + val folderId = + key.removePrefix(CHAT_LIST_SCROLL_PREFIX).toIntOrNull() ?: return@mapNotNull null + val raw = value as? String ?: return@mapNotNull null + val decoded = runCatching { + Json.decodeFromString(raw) + }.getOrNull() ?: return@mapNotNull null + folderId to (decoded.index to decoded.offset) + }.toMap() + } + override fun setSavedGifs(gifs: List) { _savedGifs.value = gifs } @@ -166,9 +202,18 @@ class CachePreferences(private val context: Context) : CacheProvider { companion object { private const val KEY_SAVED_GIFS = "saved_gifs" + private const val CHAT_LIST_SCROLL_PREFIX = "chat_list_scroll_" private fun chatViewportKey(chatId: Long, threadId: Long?): String { return "chat_viewport_${chatId}_${threadId ?: 0L}" } + + private fun chatListScrollKey(folderId: Int): String = "$CHAT_LIST_SCROLL_PREFIX$folderId" } } + +@Serializable +private data class ChatListScrollPosition( + val index: Int, + val offset: Int +) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index 44caa3338..33e2f15b1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -140,6 +140,10 @@ interface ChatComponent { fun onViewportSettled() fun onMessageViewportChanged(visibleMessageIds: Set, nearbyMessageIds: Set) fun onScrollToBottom() + fun onScrollToNextUnreadMention() + fun onClearUnreadMentions() + fun onScrollToNextUnreadReaction() + fun onClearUnreadReactions() fun onDownloadFile(fileId: Int, userInitiated: Boolean = false) fun onDownloadHighRes(messageId: Long) fun onCancelDownloadFile(fileId: Int) @@ -301,6 +305,7 @@ interface ChatComponent { val editingMessage: MessageModel? = null, val checklistMessage: MessageModel? = null, val checklistDraft: ChecklistDraft? = null, + val pendingEditedMessageIds: Set = emptySet(), val editRequestTime: Long = 0L, val draftText: String = "", val draftLinkTargets: List = emptyList(), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt index 45b21f16e..2364439b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -39,11 +40,14 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.window.core.layout.WindowWidthSizeClass import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled @@ -66,6 +70,7 @@ import org.monogram.presentation.features.chats.conversation.ui.content.remember import org.monogram.presentation.features.chats.conversation.ui.message.LinkPreviewAction import org.monogram.presentation.features.chats.conversation.ui.message.LocalLinkHandler import org.monogram.presentation.features.chats.conversation.ui.message.LocalMessageRenderDependencies +import org.monogram.presentation.features.chats.conversation.ui.message.LocalPendingEditedMessageIds import org.monogram.presentation.features.chats.conversation.ui.message.PreviewImageViewerRequest import org.monogram.presentation.features.chats.conversation.ui.message.PreviewVideoViewerRequest import org.monogram.presentation.features.chats.conversation.ui.message.rememberChatMessageRenderDependencies @@ -308,6 +313,26 @@ fun ChatContent( } } + val lifecycleOwner = LocalLifecycleOwner.current + val latestState by rememberUpdatedState(state) + DisposableEffect(lifecycleOwner, conversationKey, renderMode) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP && + renderMode == ChatRenderMode.Active && + latestState.highlightRequest != null + ) { + component.onHighlightConsumed() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (renderMode == ChatRenderMode.Active && latestState.highlightRequest != null) { + component.onHighlightConsumed() + } + } + } + LaunchedEffect(renderMode, state.viewportPhase, conversationKey) { val previousVisible = isVisible isVisible = updateChatContentVisibilityLatch( @@ -375,6 +400,10 @@ fun ChatContent( component.onDismissPinnedMessages() } } + val showUnreadShortcutButtons = + !state.viewAsTopics && + state.currentTopicId == null && + state.rootMessage == null val bottomContentPadding = if (state.rootMessage != null && (chromeState.showInputBar || chromeState.showJoinButton)) { 120.dp @@ -385,6 +414,7 @@ fun ChatContent( CompositionLocalProvider( LocalLinkHandler provides { component.onLinkClick(it) }, LocalMessageRenderDependencies provides messageRenderDependencies, + LocalPendingEditedMessageIds provides state.pendingEditedMessageIds, LocalVoicePlaybackController provides voicePlaybackController ) { val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(this).toDp() } @@ -518,6 +548,10 @@ fun ChatContent( isVisible = isVisible, showInitialLoading = showInitialLoading, showScrollToBottomButton = showScrollToBottomButton, + showUnreadShortcutButtons = showUnreadShortcutButtons, + unreadMentionCount = state.unreadMentionCount, + unreadReactionCount = state.unreadReactionCount, + showReactions = state.showReactions, isRecordingVideo = isRecordingVideo, isAnyViewerOpen = isAnyViewerOpen, showAllSearchResults = showAllSearchResults, @@ -648,6 +682,7 @@ fun ChatContent( else if (state.webViewUrl != null) component.onDismissWebView() else if (state.isSearchActive) component.onSearchToggle() else if (state.currentTopicId != null) component.onTopicClick(0) + else component.onBackClicked() } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt index ed976bc99..cd98cfdc7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt @@ -17,10 +17,10 @@ internal data class ConversationLoadSession( internal data class ChatInitialLoadKey( val chatId: Long, val effectiveThreadId: Long?, - val initialMessageId: Long?, - val savedViewportAnchorMessageId: Long?, - val firstUnreadMessageId: Long?, - val rootMessageId: Long? + val rootMessageId: Long?, + val initialTarget: String, + val initialAnchorMessageId: Long?, + val backfillNewerAfterInitialLoad: Boolean ) internal object ChatConversationLog { @@ -95,6 +95,12 @@ internal object ChatConversationLog { append(" oldestLoaded=").append(state.isOldestLoaded) append(" scrollToMessageId=").append(state.scrollToMessageId ?: 0L) append(" savedAnchor=").append(state.lastSavedViewport?.anchorMessageId ?: 0L) + append(" savedAliases=").append(state.lastSavedViewport?.anchorAliasIds?.size ?: 0) + append(" savedReadFully=").append(state.lastSavedViewport?.readFully ?: false) + append(" savedTopEnd=").append(state.lastSavedViewport?.topEndMessageId ?: 0L) + append(" savedReturnStack=").append( + state.lastSavedViewport?.returnToMessageIds?.size ?: 0 + ) append(" currentTopicId=").append(state.currentTopicId ?: 0L) append(" rootMessageId=").append(state.rootMessage?.id ?: 0L) if (!extra.isNullOrBlank()) { @@ -188,7 +194,8 @@ internal object ChatConversationLog { componentInstanceId = component.componentInstanceId, source = source ?: session?.source, target = target ?: session?.target, - anchorId = anchorId ?: state.lastSavedViewport?.anchorMessageId, + anchorId = anchorId ?: state.lastSavedViewport?.anchorMessageId + ?: state.lastSavedViewport?.anchorAliasIds?.firstOrNull(), messagesBefore = messagesBefore ?: state.messages.size, messagesAfter = messagesAfter ?: state.messages.size, unreadCount = state.unreadSeparatorCount, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt index 3367149f8..ad5b6c271 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt @@ -7,8 +7,11 @@ sealed interface ChatScrollCommand { @Immutable data class RestoreViewport( val anchorMessageId: Long?, + val anchorAliasIds: List = emptyList(), val anchorOffsetPx: Int, - val atBottom: Boolean + val atBottom: Boolean, + val readFully: Boolean = atBottom, + val topEndMessageId: Long? = null ) : ChatScrollCommand @Immutable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt index 2a6af9833..ed3451852 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt @@ -111,6 +111,10 @@ interface ChatStore : Store component.scrollToBottomInternal() + is Intent.ScrollToNextUnreadMention -> component.handleScrollToNextUnreadMention() + is Intent.ClearUnreadMentions -> component.handleClearUnreadMentions() + is Intent.ScrollToNextUnreadReaction -> component.handleScrollToNextUnreadReaction() + is Intent.ClearUnreadReactions -> component.handleClearUnreadReactions() is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId, intent.userInitiated) is Intent.DownloadHighRes -> component.handleDownloadHighRes(intent.messageId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index 3696cac34..56ffc82c5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -80,8 +80,11 @@ import org.monogram.presentation.features.chats.conversation.logic.loadWallpaper import org.monogram.presentation.features.chats.conversation.logic.observePreferences import org.monogram.presentation.features.chats.conversation.logic.observeSponsoredMessagePolicy import org.monogram.presentation.features.chats.conversation.logic.observeUserUpdates +import org.monogram.presentation.features.chats.conversation.logic.perfTargetName +import org.monogram.presentation.features.chats.conversation.logic.pushViewportReturnTarget import org.monogram.presentation.features.chats.conversation.logic.refreshDraftLinkPreviewOnPhotoDownloadIfNeeded import org.monogram.presentation.features.chats.conversation.logic.refreshSponsoredMessageAfterMediaDownload +import org.monogram.presentation.features.chats.conversation.logic.resolveInitialChatScrollTarget import org.monogram.presentation.features.chats.conversation.logic.setupMessageCollectors import org.monogram.presentation.features.chats.conversation.logic.setupPinnedMessageCollector import org.monogram.presentation.features.chats.conversation.logic.shouldStartInitialLoad @@ -96,6 +99,7 @@ import kotlin.math.abs private const val VIEWPORT_PERSIST_DEBOUNCE_MS = 750L private const val VIEWPORT_OFFSET_DELTA_THRESHOLD_PX = 8 +private const val MAX_RETURN_TO_MESSAGE_IDS = 16 private const val RICH_PREFETCH_PARALLELISM = 3 internal fun shouldRepairUnexpectedChatReset( @@ -449,7 +453,7 @@ class DefaultChatComponent( state = _state.value, componentInstanceId = componentInstanceId ) - repositoryMessage.closeChat(chatId) + repositoryMessage.closeChat(chatId, ownerTag = componentInstanceId) } } @@ -586,7 +590,7 @@ class DefaultChatComponent( } } } - repositoryMessage.openChat(chatId) + repositoryMessage.openChat(chatId, ownerTag = componentInstanceId) ChatConversationLog.logViewport( chatId = chatId, threadId = _state.value.currentMessageThreadId ?: _state.value.currentTopicId, @@ -607,13 +611,9 @@ class DefaultChatComponent( val currentState = _state.value if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return - if (!currentState.viewAsTopics) { - if (initialMessageId != null) { - scrollToMessage(initialMessageId) - } else if (currentState.messages.isEmpty()) { - requestInitialLoad(source = "lifecycle_resume") - } - } else if (currentState.messages.isEmpty() && currentState.currentTopicId == null) { + if (currentState.messages.isEmpty() && + (!currentState.viewAsTopics || currentState.currentTopicId == null) + ) { requestInitialLoad(source = "lifecycle_resume") } } @@ -623,14 +623,25 @@ class DefaultChatComponent( if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return val savedViewport = cacheProvider.getChatViewport(chatId, currentState.effectiveThreadId()) + val predictedTarget = resolveInitialChatScrollTarget( + chatId = chatId, + explicitMessageId = initialMessageId, + savedViewport = savedViewport, + firstUnreadMessageId = currentState.unreadSeparatorLastReadInboxMessageId.takeIf { + currentState.unreadSeparatorCount > 0 + }, + unreadCount = currentState.unreadSeparatorCount, + isComments = currentState.rootMessage != null + ) val loadKey = buildChatInitialLoadKey( chatId = chatId, effectiveThreadId = currentState.effectiveThreadId(), - initialMessageId = initialMessageId, + explicitMessageId = initialMessageId, savedViewport = savedViewport, firstUnreadMessageId = currentState.unreadSeparatorLastReadInboxMessageId.takeIf { currentState.unreadSeparatorCount > 0 }, + unreadCount = currentState.unreadSeparatorCount, rootMessageId = currentState.rootMessage?.id ) if (!shouldStartInitialLoad(lastStartedLoadKey, loadKey, hasStartedInitialLoadForContext)) { @@ -642,13 +653,14 @@ class DefaultChatComponent( event = "request_initial_load", state = currentState, componentInstanceId = componentInstanceId, - extra = "source=$source savedViewportAnchor=${savedViewport?.anchorMessageId ?: 0L}" + extra = "source=$source target=${predictedTarget.perfTargetName()} targetAnchor=${predictedTarget.anchorMessageId ?: 0L} savedViewportAnchor=${savedViewport?.anchorMessageId ?: 0L}" ) ChatConversationLog.logPerf( component = this, phase = "initial_load_trigger", source = source, - anchorId = savedViewport?.anchorMessageId + target = predictedTarget.perfTargetName(), + anchorId = predictedTarget.anchorMessageId ) loadMessages(loadSource = source) } @@ -875,6 +887,15 @@ class DefaultChatComponent( } override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) + override fun onScrollToNextUnreadMention() = + store.accept(ChatStore.Intent.ScrollToNextUnreadMention) + + override fun onClearUnreadMentions() = store.accept(ChatStore.Intent.ClearUnreadMentions) + + override fun onScrollToNextUnreadReaction() = + store.accept(ChatStore.Intent.ScrollToNextUnreadReaction) + + override fun onClearUnreadReactions() = store.accept(ChatStore.Intent.ClearUnreadReactions) override fun onDownloadFile(fileId: Int, userInitiated: Boolean) { AutoDownloadSuppression.clear(fileId) @@ -893,7 +914,8 @@ class DefaultChatComponent( ChatViewportCacheEntry( anchorMessageId = messageId, anchorOffsetPx = 0, - atBottom = messageId == 0L + atBottom = messageId == 0L, + readFully = messageId == 0L ) ) } @@ -1054,7 +1076,10 @@ class DefaultChatComponent( override fun onCopyLink(localClipboard: Clipboard) = store.accept(ChatStore.Intent.CopyLink(localClipboard)) - override fun scrollToMessage(messageId: Long) = store.accept(ChatStore.Intent.ScrollToMessage(messageId)) + override fun scrollToMessage(messageId: Long) { + rememberReturnTargetBeforeJump(messageId) + store.accept(ChatStore.Intent.ScrollToMessage(messageId)) + } override fun onBotCommandClick(command: String) = store.accept(ChatStore.Intent.BotCommandClick(command)) override fun onShowBotCommands() = store.accept(ChatStore.Intent.ShowBotCommands) override fun onDismissBotCommands() = store.accept(ChatStore.Intent.DismissBotCommands) @@ -1239,7 +1264,27 @@ class DefaultChatComponent( ): Boolean { previous ?: return true return previous.anchorMessageId != next.anchorMessageId || + previous.anchorAliasIds != next.anchorAliasIds || previous.atBottom != next.atBottom || + previous.readFully != next.readFully || + previous.topEndMessageId != next.topEndMessageId || + previous.returnToMessageIds != next.returnToMessageIds || + previous.anchorChatId != next.anchorChatId || abs(previous.anchorOffsetPx - next.anchorOffsetPx) > VIEWPORT_OFFSET_DELTA_THRESHOLD_PX } + + private fun rememberReturnTargetBeforeJump(targetMessageId: Long) { + val currentViewport = _state.value.lastSavedViewport ?: return + val returnTarget = currentViewport.anchorMessageId + ?: currentViewport.anchorAliasIds.firstOrNull() + ?: return + if (returnTarget == targetMessageId) return + + val updatedViewport = pushViewportReturnTarget( + viewport = currentViewport, + returnTargetMessageId = returnTarget, + maxSize = MAX_RETURN_TO_MESSAGE_IDS + ) ?: return + updateViewport(updatedViewport) + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt index e46be4abc..c19eabf42 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt @@ -6,18 +6,29 @@ import org.monogram.presentation.features.chats.conversation.ChatInitialLoadKey internal fun buildChatInitialLoadKey( chatId: Long, effectiveThreadId: Long?, - initialMessageId: Long?, + explicitMessageId: Long?, savedViewport: ChatViewportCacheEntry?, firstUnreadMessageId: Long?, + unreadCount: Int = 0, rootMessageId: Long? ): ChatInitialLoadKey { + val target = resolveInitialChatScrollTarget( + chatId = chatId, + explicitMessageId = explicitMessageId, + savedViewport = savedViewport, + firstUnreadMessageId = firstUnreadMessageId, + unreadCount = unreadCount, + isComments = rootMessageId != null + ) + return ChatInitialLoadKey( chatId = chatId, effectiveThreadId = effectiveThreadId, - initialMessageId = initialMessageId, - savedViewportAnchorMessageId = savedViewport?.anchorMessageId, - firstUnreadMessageId = firstUnreadMessageId, - rootMessageId = rootMessageId + rootMessageId = rootMessageId, + initialTarget = target.perfTargetName(), + initialAnchorMessageId = target.anchorMessageId, + backfillNewerAfterInitialLoad = + (target as? InitialChatScrollTarget.AroundMessage)?.backfillNewerAfterInitialLoad == true ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt index 46357e93a..261c703f1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt @@ -1,28 +1,70 @@ package org.monogram.presentation.features.chats.conversation.logic import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.presentation.features.chats.conversation.ChatConversationLog import org.monogram.presentation.features.chats.conversation.ChatScrollCommand import org.monogram.presentation.features.chats.conversation.ScrollAlign internal sealed interface InitialChatScrollTarget { + val command: ChatScrollCommand + val origin: InitialChatScrollTargetOrigin + val anchorMessageId: Long? + data class AroundMessage( val messageId: Long, - val command: ChatScrollCommand, + override val command: ChatScrollCommand, + override val origin: InitialChatScrollTargetOrigin, val highlight: Boolean, val backfillNewerAfterInitialLoad: Boolean = false - ) : InitialChatScrollTarget + ) : InitialChatScrollTarget { + override val anchorMessageId: Long = messageId + } + + data class Bottom( + override val command: ChatScrollCommand, + override val origin: InitialChatScrollTargetOrigin + ) : InitialChatScrollTarget { + override val anchorMessageId: Long? = + (command as? ChatScrollCommand.RestoreViewport)?.anchorMessageId + } + + data class Comments( + override val command: ChatScrollCommand, + override val origin: InitialChatScrollTargetOrigin + ) : InitialChatScrollTarget { + override val anchorMessageId: Long? = + (command as? ChatScrollCommand.RestoreViewport)?.anchorMessageId + } +} + +internal enum class InitialChatScrollTargetOrigin(private val suffix: String) { + ExplicitMessage("explicit"), + SavedViewport("saved_viewport"), + FirstUnread("first_unread"), + CommentsSavedViewport("comments_saved"), + CommentsStart("comments_start"), + BottomSavedViewport("bottom_saved"), + BottomFallback("bottom_fallback"); - data class Bottom(val command: ChatScrollCommand) : InitialChatScrollTarget - data class Comments(val command: ChatScrollCommand) : InitialChatScrollTarget + fun perfName(prefix: String): String = "$prefix.$suffix" } internal fun InitialChatScrollTarget.perfTargetName(): String = when (this) { - is InitialChatScrollTarget.AroundMessage -> "around" - is InitialChatScrollTarget.Bottom -> "bottom" - is InitialChatScrollTarget.Comments -> "comments" + is InitialChatScrollTarget.AroundMessage -> origin.perfName("around") + is InitialChatScrollTarget.Bottom -> origin.perfName("bottom") + is InitialChatScrollTarget.Comments -> origin.perfName("comments") +} + +private fun ChatViewportCacheEntry.primaryAnchorMessageId(): Long? { + return anchorMessageId ?: anchorAliasIds.firstOrNull() +} + +private fun ChatViewportCacheEntry.isCompatibleWith(chatId: Long): Boolean { + return anchorChatId == null || anchorChatId == chatId } internal fun resolveInitialChatScrollTarget( + chatId: Long, explicitMessageId: Long?, savedViewport: ChatViewportCacheEntry?, firstUnreadMessageId: Long?, @@ -30,9 +72,22 @@ internal fun resolveInitialChatScrollTarget( backfillUnreadThreshold: Int = 50, isComments: Boolean ): InitialChatScrollTarget { + val normalizedSavedViewport = savedViewport?.takeIf { it.isCompatibleWith(chatId) } + if (savedViewport != null && normalizedSavedViewport == null) { + runCatching { + ChatConversationLog.logViewport( + chatId = chatId, + threadId = null, + event = "saved_viewport_ignored_chat_mismatch", + extra = "savedChatId=${savedViewport.anchorChatId ?: 0L} savedAnchor=${savedViewport.primaryAnchorMessageId() ?: 0L}" + ) + } + } + if (explicitMessageId != null) { return InitialChatScrollTarget.AroundMessage( messageId = explicitMessageId, + origin = InitialChatScrollTargetOrigin.ExplicitMessage, highlight = true, command = ChatScrollCommand.JumpToMessage( messageId = explicitMessageId, @@ -43,26 +98,42 @@ internal fun resolveInitialChatScrollTarget( ) } - val savedAnchorId = savedViewport?.anchorMessageId - if (savedViewport != null && !savedViewport.atBottom && savedAnchorId != null) { + val savedAnchorId = normalizedSavedViewport?.primaryAnchorMessageId() + if (normalizedSavedViewport != null && + !normalizedSavedViewport.atBottom && + !normalizedSavedViewport.readFully && + savedAnchorId != null + ) { return InitialChatScrollTarget.AroundMessage( messageId = savedAnchorId, + origin = InitialChatScrollTargetOrigin.SavedViewport, highlight = false, command = ChatScrollCommand.RestoreViewport( anchorMessageId = savedAnchorId, - anchorOffsetPx = savedViewport.anchorOffsetPx, - atBottom = false + anchorAliasIds = normalizedSavedViewport.anchorAliasIds, + anchorOffsetPx = normalizedSavedViewport.anchorOffsetPx, + atBottom = false, + readFully = normalizedSavedViewport.readFully, + topEndMessageId = normalizedSavedViewport.topEndMessageId ) ) } if (isComments) { return InitialChatScrollTarget.Comments( - command = savedViewport?.let { + origin = if (normalizedSavedViewport != null) { + InitialChatScrollTargetOrigin.CommentsSavedViewport + } else { + InitialChatScrollTargetOrigin.CommentsStart + }, + command = normalizedSavedViewport?.let { ChatScrollCommand.RestoreViewport( - anchorMessageId = it.anchorMessageId, + anchorMessageId = it.primaryAnchorMessageId(), + anchorAliasIds = it.anchorAliasIds, anchorOffsetPx = it.anchorOffsetPx, - atBottom = it.atBottom + atBottom = it.atBottom, + readFully = it.readFully, + topEndMessageId = it.topEndMessageId ) } ?: ChatScrollCommand.ScrollToStart(animated = false) ) @@ -71,6 +142,7 @@ internal fun resolveInitialChatScrollTarget( if (firstUnreadMessageId != null) { return InitialChatScrollTarget.AroundMessage( messageId = firstUnreadMessageId, + origin = InitialChatScrollTargetOrigin.FirstUnread, highlight = false, backfillNewerAfterInitialLoad = !isComments && unreadCount > backfillUnreadThreshold, command = ChatScrollCommand.JumpToMessage( @@ -82,22 +154,34 @@ internal fun resolveInitialChatScrollTarget( ) } - if (savedViewport != null && savedViewport.atBottom) { + if (normalizedSavedViewport != null && normalizedSavedViewport.atBottom) { return InitialChatScrollTarget.Bottom( + origin = InitialChatScrollTargetOrigin.BottomSavedViewport, command = ChatScrollCommand.RestoreViewport( - anchorMessageId = savedViewport.anchorMessageId, - anchorOffsetPx = savedViewport.anchorOffsetPx, - atBottom = true + anchorMessageId = normalizedSavedViewport.primaryAnchorMessageId(), + anchorAliasIds = normalizedSavedViewport.anchorAliasIds, + anchorOffsetPx = normalizedSavedViewport.anchorOffsetPx, + atBottom = true, + readFully = normalizedSavedViewport.readFully, + topEndMessageId = normalizedSavedViewport.topEndMessageId ) ) } return InitialChatScrollTarget.Bottom( - command = savedViewport?.let { + origin = if (normalizedSavedViewport != null) { + InitialChatScrollTargetOrigin.BottomSavedViewport + } else { + InitialChatScrollTargetOrigin.BottomFallback + }, + command = normalizedSavedViewport?.let { ChatScrollCommand.RestoreViewport( - anchorMessageId = it.anchorMessageId, + anchorMessageId = it.primaryAnchorMessageId(), + anchorAliasIds = it.anchorAliasIds, anchorOffsetPx = it.anchorOffsetPx, - atBottom = it.atBottom + atBottom = it.atBottom, + readFully = it.readFully, + topEndMessageId = it.topEndMessageId ) } ?: ChatScrollCommand.ScrollToBottom(animated = false) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStack.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStack.kt new file mode 100644 index 000000000..041e35e1d --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStack.kt @@ -0,0 +1,36 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.monogram.domain.models.ChatViewportCacheEntry + +internal data class ChatViewportReturnPopResult( + val targetMessageId: Long?, + val viewport: ChatViewportCacheEntry? +) + +internal fun pushViewportReturnTarget( + viewport: ChatViewportCacheEntry?, + returnTargetMessageId: Long, + maxSize: Int +): ChatViewportCacheEntry? { + viewport ?: return null + val updatedReturnTargets = viewport.returnToMessageIds + .filterNot { it == returnTargetMessageId } + returnTargetMessageId + return viewport.copy( + returnToMessageIds = updatedReturnTargets.takeLast(maxSize) + ) +} + +internal fun popViewportReturnTarget( + viewport: ChatViewportCacheEntry? +): ChatViewportReturnPopResult { + viewport ?: return ChatViewportReturnPopResult(targetMessageId = null, viewport = null) + val targetMessageId = viewport.returnToMessageIds.lastOrNull() + if (targetMessageId == null) { + return ChatViewportReturnPopResult(targetMessageId = null, viewport = viewport) + } + + return ChatViewportReturnPopResult( + targetMessageId = targetMessageId, + viewport = viewport.copy(returnToMessageIds = viewport.returnToMessageIds.dropLast(1)) + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/OptimisticMessageEdits.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/OptimisticMessageEdits.kt new file mode 100644 index 000000000..ac55f5306 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/OptimisticMessageEdits.kt @@ -0,0 +1,130 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ChatComponent + +internal fun MessageModel.withOptimisticEdit( + text: String, + entities: List +): MessageModel { + val updatedContent = when (val currentContent = content) { + is MessageContent.Text -> currentContent.copy( + text = text, + entities = entities + ) + + is MessageContent.Photo -> currentContent.copy( + caption = text, + entities = entities + ) + + is MessageContent.Video -> currentContent.copy( + caption = text, + entities = entities + ) + + is MessageContent.Document -> currentContent.copy( + caption = text, + entities = entities + ) + + is MessageContent.Audio -> currentContent.copy( + caption = text, + entities = entities + ) + + is MessageContent.Gif -> currentContent.copy( + caption = text, + entities = entities + ) + + is MessageContent.RichMessage -> currentContent.copy(markdownSource = text) + else -> return this + } + + return copy(content = updatedContent) +} + +internal fun ChatComponent.State.withUpdatedMessage( + messageId: Long, + transform: (MessageModel) -> MessageModel +): ChatComponent.State { + var changed = false + + fun updateMessage(message: MessageModel?): MessageModel? { + if (message == null || message.id != messageId) return message + val updated = transform(message) + if (updated != message) { + changed = true + } + return updated + } + + val updatedMessages = messages.map { message -> + if (message.id != messageId) { + message + } else { + val updated = transform(message) + if (updated != message) { + changed = true + } + updated + } + } + val updatedRootMessage = updateMessage(rootMessage) + val updatedChecklistMessage = updateMessage(checklistMessage) + val updatedEditingMessage = updateMessage(editingMessage) + + return if (!changed) { + this + } else { + copy( + messages = updatedMessages, + rootMessage = updatedRootMessage, + checklistMessage = updatedChecklistMessage, + editingMessage = updatedEditingMessage + ) + } +} + +internal fun ChatComponent.State.withPendingEditedMessage( + message: MessageModel +): ChatComponent.State { + val updatedState = withUpdatedMessage(message.id) { current -> + current.copy(content = message.content) + } + return updatedState.copy( + editingMessage = null, + pendingEditedMessageIds = updatedState.pendingEditedMessageIds + message.id, + editRequestTime = 0L + ) +} + +internal fun ChatComponent.State.withRevertedPendingEditedMessage( + originalMessage: MessageModel +): ChatComponent.State { + val updatedState = withUpdatedMessage(originalMessage.id) { current -> + current.copy(content = originalMessage.content) + } + return updatedState.copy( + pendingEditedMessageIds = updatedState.pendingEditedMessageIds - originalMessage.id + ) +} + +internal fun ChatComponent.State.clearPendingEditedMessage( + messageId: Long +): ChatComponent.State = copy( + pendingEditedMessageIds = pendingEditedMessageIds - messageId +) + +internal fun ChatComponent.State.preservePendingEditedContent( + incomingMessage: MessageModel, + previousMessage: MessageModel? +): MessageModel { + if (previousMessage == null || incomingMessage.id !in pendingEditedMessageIds) { + return incomingMessage + } + return incomingMessage.copy(content = previousMessage.content) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicy.kt new file mode 100644 index 000000000..b1a98f227 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicy.kt @@ -0,0 +1,64 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ChatComponent + +internal enum class UnreadShortcutType { + Mention, + Reaction +} + +internal sealed interface UnreadShortcutAction { + data class ScrollToMessage(val messageId: Long) : UnreadShortcutAction + data object LoadOlder : UnreadShortcutAction + data object LoadNewer : UnreadShortcutAction + data object None : UnreadShortcutAction +} + +internal fun resolveUnreadShortcutAction( + messages: List, + targetChatId: Long, + isLatestLoaded: Boolean, + isOldestLoaded: Boolean, + type: UnreadShortcutType +): UnreadShortcutAction { + val targetMessageId = messages + .asSequence() + .filter { message -> + message.chatId == targetChatId && when (type) { + UnreadShortcutType.Mention -> message.hasUnreadMention + UnreadShortcutType.Reaction -> message.hasUnreadReactions + } + } + .maxOfOrNull(MessageModel::id) + + return when { + targetMessageId != null -> UnreadShortcutAction.ScrollToMessage(targetMessageId) + !isLatestLoaded -> UnreadShortcutAction.LoadNewer + !isOldestLoaded -> UnreadShortcutAction.LoadOlder + else -> UnreadShortcutAction.None + } +} + +internal fun ChatComponent.State.clearUnreadShortcut( + targetChatId: Long, + type: UnreadShortcutType +): ChatComponent.State { + fun MessageModel.clearedIfNeeded(): MessageModel { + if (chatId != targetChatId) return this + return when (type) { + UnreadShortcutType.Mention -> + if (hasUnreadMention) copy(hasUnreadMention = false) else this + + UnreadShortcutType.Reaction -> + if (hasUnreadReactions) copy(hasUnreadReactions = false) else this + } + } + + return copy( + unreadMentionCount = if (type == UnreadShortcutType.Mention) 0 else unreadMentionCount, + unreadReactionCount = if (type == UnreadShortcutType.Reaction) 0 else unreadReactionCount, + messages = messages.map(MessageModel::clearedIfNeeded), + rootMessage = rootMessage?.clearedIfNeeded() + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt index 6d56f8204..b9bb71282 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt @@ -52,6 +52,62 @@ internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { } } +internal fun DefaultChatComponent.handleScrollToNextUnreadMention() { + handleUnreadShortcut(UnreadShortcutType.Mention) +} + +internal fun DefaultChatComponent.handleClearUnreadMentions() { + clearUnreadShortcut(UnreadShortcutType.Mention) +} + +internal fun DefaultChatComponent.handleScrollToNextUnreadReaction() { + handleUnreadShortcut(UnreadShortcutType.Reaction) +} + +internal fun DefaultChatComponent.handleClearUnreadReactions() { + clearUnreadShortcut(UnreadShortcutType.Reaction) +} + +private fun DefaultChatComponent.handleUnreadShortcut(type: UnreadShortcutType) { + val currentState = _state.value + when ( + val action = resolveUnreadShortcutAction( + messages = currentState.messages, + targetChatId = activeThreadChatId(), + isLatestLoaded = currentState.isLatestLoaded, + isOldestLoaded = currentState.isOldestLoaded, + type = type + ) + ) { + is UnreadShortcutAction.ScrollToMessage -> scrollToMessageInternal(action.messageId) + UnreadShortcutAction.LoadNewer -> scrollToBottomInternal() + UnreadShortcutAction.LoadOlder -> loadMoreMessages() + UnreadShortcutAction.None -> Unit + } +} + +private fun DefaultChatComponent.clearUnreadShortcut(type: UnreadShortcutType) { + val targetChatId = activeThreadChatId() + val hasUnread = when (type) { + UnreadShortcutType.Mention -> _state.value.unreadMentionCount > 0 + UnreadShortcutType.Reaction -> _state.value.unreadReactionCount > 0 + } + if (!hasUnread) return + + _state.update { currentState -> + currentState.clearUnreadShortcut( + targetChatId = targetChatId, + type = type + ) + } + scope.launch { + when (type) { + UnreadShortcutType.Mention -> repositoryMessage.markAllMentionsAsRead(targetChatId) + UnreadShortcutType.Reaction -> repositoryMessage.markAllReactionsAsRead(targetChatId) + } + } +} + internal fun DefaultChatComponent.handleDeleteMessage(message: MessageModel, revoke: Boolean = false) { val messageIdsToDelete = if (message.mediaAlbumId != 0L) { _state.value.messages @@ -77,24 +133,40 @@ internal fun DefaultChatComponent.handleSaveEditedMessage( parseMode: RichTextParseMode? ) { val editingMsg = _state.value.editingMessage ?: return + val targetChatId = editingMsg.chatId + val optimisticMessage = editingMsg.withOptimisticEdit(text, entities) + _state.update { state -> + state.withPendingEditedMessage(optimisticMessage) + } + scope.launch { - when (editingMsg.content) { - is MessageContent.RichMessage -> repositoryMessage.editRichMessage( - chatId = chatId, - messageId = editingMsg.id, - markdown = text, - parseMode = parseMode ?: RichTextParseMode.Markdown - ) + runCatching { + when (editingMsg.content) { + is MessageContent.RichMessage -> repositoryMessage.editRichMessage( + chatId = targetChatId, + messageId = editingMsg.id, + markdown = text, + parseMode = parseMode ?: RichTextParseMode.Markdown + ) - is MessageContent.Photo, - is MessageContent.Video, - is MessageContent.Document, - is MessageContent.Audio, - is MessageContent.Gif -> repositoryMessage.editMessageCaption(chatId, editingMsg.id, text, entities) + is MessageContent.Photo, + is MessageContent.Video, + is MessageContent.Document, + is MessageContent.Audio, + is MessageContent.Gif -> repositoryMessage.editMessageCaption( + targetChatId, + editingMsg.id, + text, + entities + ) - else -> repositoryMessage.editMessage(chatId, editingMsg.id, text, entities) + else -> repositoryMessage.editMessage(targetChatId, editingMsg.id, text, entities) + } + }.onFailure { + _state.update { state -> + state.withRevertedPendingEditedMessage(editingMsg) + } } - onCancelEdit() } } @@ -107,7 +179,11 @@ internal fun DefaultChatComponent.handleSaveChecklistDraft(draft: ChecklistDraft if (checklistMessage != null) { _state.update { state -> state.withUpdatedChecklistDraft(checklistMessage.id, draft) - .copy(checklistMessage = null, checklistDraft = null) + .copy( + checklistMessage = null, + checklistDraft = null, + pendingEditedMessageIds = state.pendingEditedMessageIds + checklistMessage.id + ) } } else { _state.update { state -> state.copy(checklistMessage = null, checklistDraft = null) } @@ -136,6 +212,11 @@ internal fun DefaultChatComponent.handleSaveChecklistDraft(draft: ChecklistDraft }.onSuccess { Log.d("ChecklistFlow", "save_draft_success messageId=${checklistMessage?.id}") }.onFailure { error -> + if (checklistMessage != null) { + _state.update { state -> + state.withRevertedPendingEditedMessage(checklistMessage) + } + } Log.e("ChecklistFlow", "save_draft_failed messageId=${checklistMessage?.id}", error) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index 3470753e2..f4b89a23b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -210,8 +210,12 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( } else { mergedMessage } - val old = messageMap.put(msg.id, restoredMessage) - if (old != restoredMessage) { + val contentSafeMessage = state.preservePendingEditedContent( + incomingMessage = restoredMessage, + previousMessage = previous + ) + val old = messageMap.put(msg.id, contentSafeMessage) + if (old != contentSafeMessage) { hasChanges = true } } @@ -358,6 +362,7 @@ internal fun DefaultChatComponent.loadMessages( } val target = resolveInitialChatScrollTarget( + chatId = targetChatId, explicitMessageId = currentState.scrollToMessageId, savedViewport = savedViewport, firstUnreadMessageId = firstUnreadId, @@ -369,7 +374,7 @@ internal fun DefaultChatComponent.loadMessages( event = "load_messages_target_resolved", state = _state.value, componentInstanceId = componentInstanceId, - extra = "source=$loadSource target=${target.perfTargetName()} targetChatId=$targetChatId savedAnchor=${savedViewport?.anchorMessageId ?: 0L} firstUnreadId=${firstUnreadId ?: 0L}" + extra = "source=$loadSource target=${target.perfTargetName()} targetChatId=$targetChatId targetAnchor=${target.anchorMessageId ?: 0L} savedAnchor=${savedViewport?.anchorMessageId ?: savedViewport?.anchorAliasIds?.firstOrNull() ?: 0L} firstUnreadId=${firstUnreadId ?: 0L}" ) ChatOpenPerfBridge.updateTarget(chatId, threadId, target.perfTargetName()) activeLoadSession = ConversationLoadSession( @@ -382,7 +387,7 @@ internal fun DefaultChatComponent.loadMessages( phase = "chat_open_total_start", source = loadSource, target = target.perfTargetName(), - anchorId = savedViewport?.anchorMessageId + anchorId = target.anchorMessageId ?: savedViewport?.anchorMessageId ) when (target) { @@ -776,6 +781,19 @@ internal fun DefaultChatComponent.requestMessageHighlight(messageId: Long) { } } +internal enum class ScrollToBottomHandling { + Runtime, + ReloadLatestWindow +} + +internal fun ChatComponent.State.resolveScrollToBottomHandling(): ScrollToBottomHandling { + return if (messages.isNotEmpty() && isLatestLoaded) { + ScrollToBottomHandling.Runtime + } else { + ScrollToBottomHandling.ReloadLatestWindow + } +} + internal fun ChatComponent.State.enqueueRuntimeScrollToBottom( animated: Boolean = true ): ChatComponent.State { @@ -1059,7 +1077,7 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { componentInstanceId = componentInstanceId, extra = "isComments=$isComments" ) - if (currentState.messages.isNotEmpty() && currentState.isLatestLoaded) { + if (currentState.resolveScrollToBottomHandling() == ScrollToBottomHandling.Runtime) { _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } ChatConversationLog.logViewportState( event = "scroll_to_bottom_runtime_enqueued", @@ -1068,63 +1086,6 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { ) return } - if (currentState.messages.isNotEmpty() && !currentState.isLatestLoaded) { - _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } - ChatConversationLog.logViewportState( - event = "scroll_to_bottom_partial_runtime_enqueued", - state = _state.value, - componentInstanceId = componentInstanceId - ) - if (loadNewerJob?.isActive == true || currentState.isLoadingNewer) return - loadNewerJob = scope.launch { - _state.update { it.copy(isLoadingNewer = true) } - ChatConversationLog.logViewportState( - event = "scroll_to_bottom_load_newer_start", - state = _state.value, - componentInstanceId = componentInstanceId - ) - try { - var reachedLatest = false - repeat(20) { - if (reachedLatest || _state.value.isLatestLoaded) { - reachedLatest = true - return@repeat - } - reachedLatest = loadNewerMessagesPage() - } - if (reachedLatest || _state.value.isLatestLoaded) { - _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } - ChatConversationLog.logViewportState( - event = "scroll_to_bottom_load_newer_enqueued", - state = _state.value, - componentInstanceId = componentInstanceId - ) - } - } catch (e: Exception) { - ChatConversationLog.logViewportState( - event = "scroll_to_bottom_load_newer_failed", - state = _state.value, - componentInstanceId = componentInstanceId, - extra = "error=${e.javaClass.simpleName}" - ) - Log.e( - "DefaultChatComponent", - "Failed to load newer messages before scroll to bottom", - e - ) - lastLoadedNewerId = 0L - } finally { - inFlightNewerAnchorId = 0L - _state.update { it.copy(isLoadingNewer = false) } - ChatConversationLog.logViewportState( - event = "scroll_to_bottom_load_newer_finish", - state = _state.value, - componentInstanceId = componentInstanceId - ) - } - } - return - } if (currentState.isLoading) return cancelAllLoadingJobs() messageLoadingJob = scope.launch { @@ -1615,7 +1576,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { val currentMessages = currentState.messages.toMutableList() val removed = currentMessages.removeAll { messageIds.contains(it.id) } if (removed) { - currentState.copy(messages = currentMessages) + currentState.copy( + messages = currentMessages, + pendingEditedMessageIds = currentState.pendingEditedMessageIds - messageIds.toSet() + ) } else { currentState } @@ -1627,59 +1591,64 @@ internal fun DefaultChatComponent.setupMessageCollectors() { repositoryMessage.messageEditedFlow .onEach { message -> if (_state.value.isMessageInActiveThread(chatId, message) || message.chatId == chatId) { - val now = System.currentTimeMillis() - val suppressUntil = reactionUpdateSuppressedUntil[message.id] - val suppressReactionUpdate = suppressUntil != null && now < suppressUntil + messageMutex.withLock { + val targetMessageId = resolveRemappedMessageId(message.id) + _state.update { currentState -> + val now = System.currentTimeMillis() + val suppressUntil = reactionUpdateSuppressedUntil[targetMessageId] + val suppressReactionUpdate = suppressUntil != null && now < suppressUntil + + if (!suppressReactionUpdate && suppressUntil != null) { + reactionUpdateSuppressedUntil.remove(targetMessageId, suppressUntil) + } - if (!suppressReactionUpdate && suppressUntil != null) { - reactionUpdateSuppressedUntil.remove(message.id, suppressUntil) - } + currentState.withUpdatedMessage(targetMessageId) { current -> + val mediaSafeMessage = when { + current.content is MessageContent.Photo && message.content is MessageContent.Photo -> { + val currentPhoto = current.content as MessageContent.Photo + val incomingPhoto = message.content as MessageContent.Photo + if (currentPhoto.fileId == incomingPhoto.fileId) { + message.copy( + content = incomingPhoto.copy( + path = incomingPhoto.path ?: currentPhoto.path, + thumbnailPath = incomingPhoto.thumbnailPath + ?: currentPhoto.thumbnailPath + ) + ) + } else { + message + } + } - updateMessageContent(message.id) { current -> - val mediaSafeMessage = when { - current.content is MessageContent.Photo && message.content is MessageContent.Photo -> { - val currentPhoto = current.content as MessageContent.Photo - val incomingPhoto = message.content as MessageContent.Photo - if (currentPhoto.fileId == incomingPhoto.fileId) { - val resolvedPath = incomingPhoto.path ?: currentPhoto.path - message.copy( - content = incomingPhoto.copy( - path = resolvedPath, - thumbnailPath = incomingPhoto.thumbnailPath ?: currentPhoto.thumbnailPath - ) - ) - } else { - message - } - } + current.content is MessageContent.Video && message.content is MessageContent.Video -> { + val currentVideo = current.content as MessageContent.Video + val incomingVideo = message.content as MessageContent.Video + if (currentVideo.fileId == incomingVideo.fileId) { + message.copy( + content = incomingVideo.copy( + path = incomingVideo.path ?: currentVideo.path, + thumbnailPath = incomingVideo.thumbnailPath + ?: currentVideo.thumbnailPath + ) + ) + } else { + message + } + } - current.content is MessageContent.Video && message.content is MessageContent.Video -> { - val currentVideo = current.content as MessageContent.Video - val incomingVideo = message.content as MessageContent.Video - if (currentVideo.fileId == incomingVideo.fileId) { - val resolvedPath = incomingVideo.path ?: currentVideo.path - message.copy( - content = incomingVideo.copy( - path = resolvedPath, - thumbnailPath = incomingVideo.thumbnailPath ?: currentVideo.thumbnailPath - ) - ) - } else { - message + else -> message } - } - - else -> message - } - when { - suppressReactionUpdate -> mediaSafeMessage.copy(reactions = current.reactions) - reactionsSemanticEqual( - current.reactions, - mediaSafeMessage.reactions - ) -> mediaSafeMessage.copy(reactions = current.reactions) + when { + suppressReactionUpdate -> mediaSafeMessage.copy(reactions = current.reactions) + reactionsSemanticEqual( + current.reactions, + mediaSafeMessage.reactions + ) -> mediaSafeMessage.copy(reactions = current.reactions) - else -> mediaSafeMessage + else -> mediaSafeMessage + } + }.clearPendingEditedMessage(targetMessageId) } } handleEditedRichMessage(message) @@ -1824,26 +1793,13 @@ private fun DefaultChatComponent.updateFullScreenImagePath(messageId: Long, newP private inline fun DefaultChatComponent.updateMessageContent( messageId: Long, - crossinline transform: (MessageModel) -> MessageModel + noinline transform: (MessageModel) -> MessageModel ) { scope.launch { messageMutex.withLock { val targetMessageId = resolveRemappedMessageId(messageId) _state.update { currentState -> - val currentMessages = currentState.messages.toMutableList() - val index = currentMessages.indexOfFirst { it.id == targetMessageId } - if (index != -1) { - val currentMessage = currentMessages[index] - val updatedMessage = transform(currentMessage) - if (updatedMessage != currentMessage) { - currentMessages[index] = updatedMessage - currentState.copy(messages = currentMessages) - } else { - currentState - } - } else { - currentState - } + currentState.withUpdatedMessage(targetMessageId, transform) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchDateJumpPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchDateJumpPolicy.kt new file mode 100644 index 000000000..2d9d3a391 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchDateJumpPolicy.kt @@ -0,0 +1,32 @@ +package org.monogram.presentation.features.chats.conversation.logic + +private const val SINGLE_DAY_RANGE_MAX_SECONDS = 86_400 + +internal data class SearchDateJumpRequest( + val fromEpochSeconds: Int, + val toEpochSeconds: Int +) { + val targetEpochSeconds: Int + get() = toEpochSeconds +} + +internal fun resolveSearchDateJumpRequest( + query: String, + senderId: Long?, + fromEpochSeconds: Int?, + toEpochSeconds: Int?, + isThreadScoped: Boolean +): SearchDateJumpRequest? { + if (isThreadScoped) return null + if (query.isNotBlank()) return null + if (senderId != null) return null + if (fromEpochSeconds == null || toEpochSeconds == null) return null + + val rangeSeconds = toEpochSeconds - fromEpochSeconds + if (rangeSeconds < 0 || rangeSeconds >= SINGLE_DAY_RANGE_MAX_SECONDS) return null + + return SearchDateJumpRequest( + fromEpochSeconds = fromEpochSeconds, + toEpochSeconds = toEpochSeconds + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt index 4ff68b0bc..533e35fe1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt @@ -330,6 +330,18 @@ private fun DefaultChatComponent.startSearch( toEpochSeconds: Int? = _state.value.searchDateToEpochSeconds, withDebounce: Boolean ) { + val jumpRequest = resolveSearchDateJumpRequest( + query = query.trim(), + senderId = sender?.id, + fromEpochSeconds = fromEpochSeconds, + toEpochSeconds = toEpochSeconds, + isThreadScoped = activeThreadId() != null + ) + if (jumpRequest != null) { + jumpToDate(jumpRequest) + return + } + val targetChatId = activeThreadChatId() val targetThreadId = activeThreadId() searchJob = scope.launch { @@ -423,6 +435,79 @@ private fun DefaultChatComponent.startSearch( } } +private fun DefaultChatComponent.jumpToDate(request: SearchDateJumpRequest) { + val targetChatId = activeThreadChatId() + searchJob = scope.launch { + try { + _state.update { + it.copy( + isSearchingMessages = true, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L + ) + } + + val message = repositoryMessage.getChatMessageByDate( + chatId = targetChatId, + dateEpochSeconds = request.targetEpochSeconds + ) + val stillRelevant = _state.value.isSearchActive && + _state.value.searchQuery.trim().isEmpty() && + _state.value.searchSender == null && + _state.value.searchDateFromEpochSeconds == request.fromEpochSeconds && + _state.value.searchDateToEpochSeconds == request.toEpochSeconds && + activeThreadChatId() == targetChatId && + activeThreadId() == null + if (!stillRelevant) return@launch + + val resolvedResults = if (message != null && + message.date in request.fromEpochSeconds..request.toEpochSeconds + ) { + listOf(message) + } else { + emptyList() + } + + _state.update { + it.copy( + isSearchingMessages = false, + searchResults = resolvedResults, + searchResultsTotalCount = resolvedResults.size, + selectedSearchResultIndex = if (resolvedResults.isNotEmpty()) 0 else -1, + searchNextFromMessageId = 0L + ) + } + + if (resolvedResults.isNotEmpty()) { + scrollToSearchResult(0, resolvedResults) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _state.update { + if ( + it.searchQuery.isBlank() && + it.searchSender == null && + it.searchDateFromEpochSeconds == request.fromEpochSeconds && + it.searchDateToEpochSeconds == request.toEpochSeconds + ) { + it.copy( + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L + ) + } else { + it + } + } + } + } +} + private fun List.filterByDateRange( fromEpochSeconds: Int?, toEpochSeconds: Int? diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt index e1ef0a403..091cd7ec1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt @@ -293,7 +293,7 @@ fun NativeCircularCameraContent( } catch (e: Exception) { e.printStackTrace() } coroutineScope.launch(Dispatchers.IO) { - val finalFile = File(context.filesDir, "CIRCLE_FULL_${System.currentTimeMillis()}.mp4") + val finalFile = File(context.cacheDir, "CIRCLE_FULL_${System.currentTimeMillis()}.mp4") try { val segmentsToProcess = ArrayList(recordedSegments) val transcoder = CircularTranscoder(segmentsToProcess, sessionAudioFile, finalFile) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt index 5cd110531..f11c900f5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt @@ -190,6 +190,14 @@ fun ChannelAlbumMessageBubble( else -> emptyList() } } + val showCaptionAboveMedia = remember(captionMsg) { + when (val content = captionMsg?.content) { + is MessageContent.Photo -> content.showCaptionAboveMedia + is MessageContent.Video -> content.showCaptionAboveMedia + is MessageContent.Gif -> content.showCaptionAboveMedia + else -> false + } + } val dateFormatManager: DateFormatManager = koinInject() val timeFormat = dateFormatManager.getHourMinuteFormat() @@ -225,6 +233,62 @@ fun ChannelAlbumMessageBubble( } } + if (caption.isNotEmpty() && showCaptionAboveMedia) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + val inlineContent = rememberMessageInlineContent(entities, fontSize) + val finalAnnotatedString = buildAnnotatedMessageTextWithEmoji( + text = caption, + entities = entities, + isOutgoing = false, + revealedSpoilers = revealedSpoilers + ) + + MessageText( + text = finalAnnotatedString, + rawText = caption, + inlineContent = inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + lineHeight = (fontSize * 1.375f).sp + ), + color = MaterialTheme.colorScheme.onSurface, + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(bubblePosition + offset) }, + onLongClick = { offset -> + val clickPosition = bubblePosition + offset + if (onMessageLongPress != null) { + onMessageLongPress(lastMsg, clickPosition) + } else { + onLongClick(clickPosition) + } + } + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + contentAlignment = Alignment.CenterEnd + ) { + MessageMetadata( + msg = lastMsg, + isOutgoing = lastMsg.isOutgoing, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + } + } + CompactMediaMosaic( messages = uniqueMessages, autoplayGifs = autoplayGifs, @@ -256,7 +320,7 @@ fun ChannelAlbumMessageBubble( isAnyViewerOpen = isAnyViewerOpen ) - if (caption.isNotEmpty()) { + if (caption.isNotEmpty() && !showCaptionAboveMedia) { Column( modifier = Modifier .fillMaxWidth() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt index 1096add44..fa1734bea 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt @@ -1,35 +1,22 @@ package org.monogram.presentation.features.chats.conversation.ui.channel -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -58,22 +45,20 @@ import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade -import org.koin.compose.koinInject import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel -import org.monogram.domain.models.MessageSendingState import org.monogram.presentation.R -import org.monogram.presentation.core.util.DateFormatManager +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression -import org.monogram.presentation.core.media.VideoStickerPlayer -import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground +import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView import org.monogram.presentation.features.chats.conversation.ui.message.MessageText import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent @@ -122,15 +107,13 @@ fun ChannelGifMessageBubble( var gifPosition by remember { mutableStateOf(Offset.Zero) } val revealedSpoilers = remember { mutableStateListOf() } - val dateFormatManager: DateFormatManager = koinInject() - val timeFormat = dateFormatManager.getHourMinuteFormat() - var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { namespacedCacheKey("channel_gif:${content.fileId}", stablePath) } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } + val showCaptionAboveMedia = content.caption.isNotEmpty() && content.showCaptionAboveMedia LaunchedEffect(content.path) { if (!content.path.isNullOrBlank()) { @@ -194,6 +177,70 @@ fun ChannelGifMessageBubble( } } + @Composable + fun CaptionSection() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 6.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = false, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f, + modifier = Modifier.padding(bottom = 2.dp) + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.375f).sp + ), + modifier = Modifier.padding(bottom = 2.dp), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(gifPosition + offset) }, + onLongClick = { offset -> onLongClick(gifPosition + offset) } + ) + } + if (showMetadata) { + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata( + msg = msg, + isOutgoing = msg.isOutgoing, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f + ) + ) + } + } + } + } + + if (showCaptionAboveMedia) { + CaptionSection() + } + val mediaRatio = if (content.width > 0 && content.height > 0) (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) else 1f @@ -333,164 +380,14 @@ fun ChannelGifMessageBubble( ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - msg.views?.let { viewsCount -> - if (viewsCount > 0) { - Icon( - imageVector = Icons.Outlined.Visibility, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color.White - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = formatViews(context, viewsCount), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = Color.White - ) - Spacer(modifier = Modifier.width(8.dp)) - } - } - Text( - text = formatTime(msg.date, timeFormat), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = Color.White - ) - if (msg.isOutgoing) { - Spacer(modifier = Modifier.width(4.dp)) - AnimatedContent( - targetState = msg.sendingState to msg.isRead, - transitionSpec = { - fadeIn(animationSpec = tween(300)) togetherWith fadeOut( - animationSpec = tween( - 300 - ) - ) - }, - label = "SendingState" - ) { (sendingState, isRead) -> - val statusIcon = when (sendingState) { - is MessageSendingState.Pending -> Icons.Default.Schedule - is MessageSendingState.Failed -> Icons.Default.Error - null -> if (isRead) Icons.Default.DoneAll else Icons.Default.Check - } - Icon( - imageVector = statusIcon, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = if (sendingState is MessageSendingState.Failed) Color.Red else Color.White - ) - } - } - } + MessageMetadata(msg, msg.isOutgoing, Color.White) } } } } - if (content.caption.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 6.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = false, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f, - modifier = Modifier.padding(bottom = 2.dp) - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.375f).sp - ), - modifier = Modifier.padding(bottom = 2.dp), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClick(gifPosition + offset) }, - onLongClick = { offset -> onLongClick(gifPosition + offset) } - ) - } - if (showMetadata) { - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - msg.views?.let { viewsCount -> - if (viewsCount > 0) { - Icon( - imageVector = Icons.Outlined.Visibility, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = formatViews(context, viewsCount), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) - ) - Spacer(modifier = Modifier.width(8.dp)) - } - } - Text( - text = formatTime(msg.date, timeFormat), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) - ) - if (msg.isOutgoing) { - Spacer(modifier = Modifier.width(4.dp)) - AnimatedContent( - targetState = msg.sendingState to msg.isRead, - transitionSpec = { - fadeIn(animationSpec = tween(300)) togetherWith fadeOut( - animationSpec = tween( - 300 - ) - ) - }, - label = "SendingState" - ) { (sendingState, isRead) -> - val statusIcon = when (sendingState) { - is MessageSendingState.Pending -> Icons.Default.Schedule - is MessageSendingState.Failed -> Icons.Default.Error - null -> if (isRead) Icons.Default.DoneAll else Icons.Default.Check - } - Icon( - imageVector = statusIcon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = if (sendingState is MessageSendingState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( - 0.6f - ) - ) - } - } - } - } - } + if (content.caption.isNotEmpty() && !showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt index 4a9903329..b3e054edc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt @@ -120,6 +120,7 @@ fun ChannelPhotoMessageBubble( } val hasCaption = content.caption.isNotEmpty() + val showCaptionAboveMedia = hasCaption && content.showCaptionAboveMedia LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { if (content.path.isNullOrBlank() && !content.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( @@ -168,6 +169,74 @@ fun ChannelPhotoMessageBubble( } } + @Composable + fun CaptionSection() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 4.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = false, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.35f).sp + ), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(imagePosition + offset) }, + onLongClick = { offset -> onLongClick(imagePosition + offset) } + ) + } + + if (showMetadata) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + contentAlignment = Alignment.CenterEnd + ) { + MessageMetadata( + msg = msg, + isOutgoing = msg.isOutgoing, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ) + ) + } + } + } + } + + if (showCaptionAboveMedia) { + CaptionSection() + } + val mediaRatio = if (content.width > 0 && content.height > 0) { (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) } else 1f @@ -180,10 +249,18 @@ fun ChannelPhotoMessageBubble( .fillMaxWidth() .height(mediaHeight) .clip( - if (hasCaption) RoundedCornerShape( - topStart = topStart, - topEnd = topEnd - ) else bubbleShape + when { + !hasCaption -> bubbleShape + showCaptionAboveMedia -> RoundedCornerShape( + bottomStart = bottomStart, + bottomEnd = if (showComments && msg.canGetMessageThread) 4.dp else bottomEnd + ) + + else -> RoundedCornerShape( + topStart = topStart, + topEnd = topEnd + ) + } ) .clipToBounds() .onGloballyPositioned { imagePosition = it.positionInWindow() } @@ -280,65 +357,8 @@ fun ChannelPhotoMessageBubble( } // Caption Section - if (hasCaption) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 4.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = false, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.35f).sp - ), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClick(imagePosition + offset) }, - onLongClick = { offset -> onLongClick(imagePosition + offset) } - ) - } - - if (showMetadata) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp), - contentAlignment = Alignment.CenterEnd - ) { - MessageMetadata( - msg = msg, - isOutgoing = msg.isOutgoing, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - } - } + if (hasCaption && !showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt index e5be6047b..50f2a7185 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt @@ -145,6 +145,7 @@ internal fun ChannelTextMessageBubble( val footerRow: @Composable (Modifier) -> Unit = { footerModifier -> MessageFooterRow( + messageId = msg.id, timeText = timeText, color = footerColor, isEdited = msg.editDate > 0, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index 1e69351d2..b755bd742 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -138,6 +138,7 @@ fun ChannelVideoMessageBubble( } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } val hasCaption = content.caption.isNotEmpty() + val showCaptionAboveMedia = hasCaption && content.showCaptionAboveMedia LaunchedEffect(content.path) { if (!content.path.isNullOrBlank()) { @@ -198,6 +199,74 @@ fun ChannelVideoMessageBubble( } } + @Composable + fun CaptionSection() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 4.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = false, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.35f).sp + ), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(videoPosition + offset) }, + onLongClick = { offset -> onLongClick(videoPosition + offset) } + ) + } + + if (showMetadata) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + contentAlignment = Alignment.CenterEnd + ) { + MessageMetadata( + msg = msg, + isOutgoing = msg.isOutgoing, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ) + ) + } + } + } + } + + if (showCaptionAboveMedia) { + CaptionSection() + } + val mediaRatio = if (content.width > 0 && content.height > 0) (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) else 1f @@ -210,10 +279,18 @@ fun ChannelVideoMessageBubble( .fillMaxWidth() .height(mediaHeight) .clip( - if (hasCaption) RoundedCornerShape( - topStart = topStart, - topEnd = topEnd - ) else bubbleShape + when { + !hasCaption -> bubbleShape + showCaptionAboveMedia -> RoundedCornerShape( + bottomStart = bottomStart, + bottomEnd = if (showComments && msg.canGetMessageThread) 4.dp else bottomEnd + ) + + else -> RoundedCornerShape( + topStart = topStart, + topEnd = topEnd + ) + } ) .clipToBounds() .onGloballyPositioned { videoPosition = it.positionInWindow() } @@ -425,65 +502,8 @@ fun ChannelVideoMessageBubble( } // Caption Section - if (hasCaption) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 4.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = false, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.35f).sp - ), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClick(videoPosition + offset) }, - onLongClick = { offset -> onLongClick(videoPosition + offset) } - ) - } - - if (showMetadata) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp), - contentAlignment = Alignment.CenterEnd - ) { - MessageMetadata( - msg = msg, - isOutgoing = msg.isOutgoing, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - } - } + if (hasCaption && !showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt index 68eff5745..c58ec0fd6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt @@ -15,7 +15,10 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,11 +32,11 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.rounded.AddReaction +import androidx.compose.material.icons.rounded.AlternateEmail import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -43,6 +46,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color @@ -53,6 +57,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize @@ -215,6 +221,10 @@ internal fun ChatContentBody( isVisible: Boolean, showInitialLoading: Boolean, showScrollToBottomButton: Boolean, + showUnreadShortcutButtons: Boolean, + unreadMentionCount: Int, + unreadReactionCount: Int, + showReactions: Boolean, isRecordingVideo: Boolean, isAnyViewerOpen: Boolean, showAllSearchResults: Boolean, @@ -402,6 +412,10 @@ internal fun ChatContentBody( val onForwardOriginClick: (ForwardInfo) -> Unit = remember(component) { { forwardInfo -> component.onForwardOriginClick(forwardInfo) } } + val showMentionButton = showUnreadShortcutButtons && unreadMentionCount > 0 + val showReactionButton = showUnreadShortcutButtons && showReactions && unreadReactionCount > 0 + val showBottomActionStack = + !state.isSearchActive && (showScrollToBottomButton || showMentionButton || showReactionButton) Box( modifier = Modifier @@ -513,72 +527,56 @@ internal fun ChatContentBody( } AnimatedVisibility( - visible = showScrollToBottomButton && !state.isSearchActive, + visible = showBottomActionStack, enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp) ) { - Box { - FloatingActionButton( - onClick = component::onScrollToBottom, - containerColor = MaterialTheme.colorScheme.primaryContainer, - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp - ), - shape = CircleShape, - modifier = Modifier.size(48.dp) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.End + ) { + UnreadShortcutButton( + visible = showReactionButton, + count = unreadReactionCount, + contentDescription = stringResource(R.string.cd_reactions), + onClick = component::onScrollToNextUnreadReaction, + onLongClick = component::onClearUnreadReactions ) { Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.cd_scroll_to_bottom), - modifier = Modifier.size(24.dp) + imageVector = Icons.Rounded.AddReaction, + contentDescription = null, + modifier = Modifier.size(20.dp) ) } - AnimatedVisibility( - visible = state.unreadCount > 0, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = (-8).dp) + UnreadShortcutButton( + visible = showMentionButton, + count = unreadMentionCount, + contentDescription = stringResource(R.string.cd_mentions), + onClick = component::onScrollToNextUnreadMention, + onLongClick = component::onClearUnreadMentions ) { - Surface( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - shadowElevation = 4.dp - ) { - AnimatedContent( - targetState = state.unreadCount, - transitionSpec = { - if (targetState > initialState) { - (slideInVertically { height -> height } + fadeIn()).togetherWith( - slideOutVertically { height -> -height } + fadeOut() - ) - } else { - (slideInVertically { height -> -height } + fadeIn()).togetherWith( - slideOutVertically { height -> height } + fadeOut() - ) - }.using(SizeTransform(clip = false)) - }, - label = "UnreadCountAnimation" - ) { count -> - Text( - text = if (count > 999) "999+" else count.toString(), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - fontSize = 10.sp - ), - color = MaterialTheme.colorScheme.onPrimary - ) - } - } + Icon( + imageVector = Icons.Rounded.AlternateEmail, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + + UnreadShortcutButton( + visible = showScrollToBottomButton, + count = state.unreadCount, + contentDescription = stringResource(R.string.cd_scroll_to_bottom), + onClick = component::onScrollToBottom + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) } } } @@ -618,3 +616,88 @@ internal fun ChatContentBody( } } } + +@Composable +private fun UnreadShortcutButton( + visible: Boolean, + count: Int, + contentDescription: String, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + icon: @Composable () -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut() + ) { + Box { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + shadowElevation = 0.dp, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .semantics { this.contentDescription = contentDescription } + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + AnimatedVisibility( + visible = count > 0, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-8).dp) + ) { + UnreadShortcutBadge(count) + } + } + } +} + +@Composable +private fun UnreadShortcutBadge(count: Int) { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + shadowElevation = 4.dp + ) { + AnimatedContent( + targetState = count, + transitionSpec = { + if (targetState > initialState) { + (slideInVertically { height -> height } + fadeIn()).togetherWith( + slideOutVertically { height -> -height } + fadeOut() + ) + } else { + (slideInVertically { height -> -height } + fadeIn()).togetherWith( + slideOutVertically { height -> height } + fadeOut() + ) + }.using(SizeTransform(clip = false)) + }, + label = "UnreadCountAnimation" + ) { value -> + Text( + text = if (value > 999) "999+" else value.toString(), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ), + color = MaterialTheme.colorScheme.onPrimary + ) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt index cf940767a..61f0871bf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt @@ -67,6 +67,42 @@ internal data class ChatContentPreviewState( val messageSource: List, ) +internal fun shouldEnableCustomBackHandling( + editingPhotoPath: String?, + editingVideoPath: String?, + selectedMessageId: Long?, + selectedMessageIds: Set, + currentTopicId: Long?, + showBotCommands: Boolean, + restrictUserId: Long?, + showPinnedMessagesList: Boolean, + fullScreenImages: List?, + fullScreenVideoPath: String?, + fullScreenVideoMessageId: Long?, + miniAppUrl: String?, + webViewUrl: String?, + instantViewUrl: String?, + youtubeUrl: String?, + isSearchActive: Boolean +): Boolean { + return editingPhotoPath != null || + editingVideoPath != null || + selectedMessageId != null || + selectedMessageIds.isNotEmpty() || + currentTopicId != null || + showBotCommands || + restrictUserId != null || + showPinnedMessagesList || + fullScreenImages != null || + fullScreenVideoPath != null || + fullScreenVideoMessageId != null || + miniAppUrl != null || + webViewUrl != null || + instantViewUrl != null || + youtubeUrl != null || + isSearchActive +} + @Composable internal fun rememberChatContentPreviewState( state: ChatComponent.State, @@ -564,22 +600,24 @@ internal fun rememberChatChromeState( state.isSearchActive ) { derivedStateOf { - editingPhotoPath != null || - editingVideoPath != null || - selectedMessageId != null || - state.selectedMessageIds.isNotEmpty() || - state.currentTopicId != null || - state.showBotCommands || - state.restrictUserId != null || - state.showPinnedMessagesList || - state.fullScreenImages != null || - state.fullScreenVideoPath != null || - state.fullScreenVideoMessageId != null || - state.miniAppUrl != null || - state.webViewUrl != null || - state.instantViewUrl != null || - state.youtubeUrl != null || - state.isSearchActive + shouldEnableCustomBackHandling( + editingPhotoPath = editingPhotoPath, + editingVideoPath = editingVideoPath, + selectedMessageId = selectedMessageId, + selectedMessageIds = state.selectedMessageIds, + currentTopicId = state.currentTopicId, + showBotCommands = state.showBotCommands, + restrictUserId = state.restrictUserId, + showPinnedMessagesList = state.showPinnedMessagesList, + fullScreenImages = state.fullScreenImages, + fullScreenVideoPath = state.fullScreenVideoPath, + fullScreenVideoMessageId = state.fullScreenVideoMessageId, + miniAppUrl = state.miniAppUrl, + webViewUrl = state.webViewUrl, + instantViewUrl = state.instantViewUrl, + youtubeUrl = state.youtubeUrl, + isSearchActive = state.isSearchActive + ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt index 9fd1ca6f0..705a20fbb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt @@ -300,9 +300,12 @@ internal fun ChatContentEffects( animated = false ) } else { - val groupedIndex = groupedMessageIndexById[command.anchorMessageId] + val anchorCandidateIds = + listOf(command.anchorMessageId) + command.anchorAliasIds + val groupedIndex = + anchorCandidateIds.firstNotNullOfOrNull(groupedMessageIndexById::get) ?: awaitGroupedIndex( - messageId = command.anchorMessageId, + messageIds = anchorCandidateIds, groupedMessageIndexByIdProvider = { groupedMessageIndexById } ) ?: -1 @@ -314,6 +317,11 @@ internal fun ChatContentEffects( targetIndex = targetIndex, anchorOffsetPx = command.anchorOffsetPx ) + } else if ( + command.topEndMessageId != null && + groupedMessages.firstOrNull()?.firstMessageId == command.topEndMessageId + ) { + scrollState.scrollToChatStartStaged(animated = false) } else { scrollState.scrollToChatBottomStaged( isComments = isComments, @@ -494,6 +502,7 @@ internal fun ChatContentEffects( conversationItems = conversationItems, isComments = isComments, isLatestLoaded = state.isLatestLoaded, + isOldestLoaded = state.isOldestLoaded, isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, @@ -531,6 +540,7 @@ internal fun ChatContentEffects( conversationItems = conversationItems, isComments = isComments, isLatestLoaded = state.isLatestLoaded, + isOldestLoaded = state.isOldestLoaded, isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt index 7b34ade56..119ba977b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt @@ -1034,6 +1034,7 @@ private fun MessageRowItem( val highlightBackground = remember { androidx.compose.animation.Animatable(Color.Transparent) } val highlightBorderAlpha = remember { Animatable(0f) } val highlightScale = remember { Animatable(1f) } + var handledHighlightToken by remember(mainMsg.id) { mutableLongStateOf(Long.MIN_VALUE) } LaunchedEffect(mainMsg.id, canReportVisibleMessagesAsRead) { if (!canReportVisibleMessagesAsRead) return@LaunchedEffect @@ -1057,9 +1058,13 @@ private fun MessageRowItem( } } - LaunchedEffect(highlightRequest, isTargetVisibleInViewport) { - highlightRequest ?: return@LaunchedEffect - if (!isTargetVisibleInViewport) return@LaunchedEffect + LaunchedEffect(highlightRequest?.token, isTargetVisibleInViewport) { + val request = highlightRequest ?: return@LaunchedEffect + if (!shouldRunMessageHighlight(request, isTargetVisibleInViewport, handledHighlightToken)) { + return@LaunchedEffect + } + + handledHighlightToken = request.token highlightBackground.stop() highlightBorderAlpha.stop() @@ -1073,15 +1078,24 @@ private fun MessageRowItem( highlightAccent.copy(alpha = if (isDarkTheme) 0.20f else 0.16f), animationSpec = tween(220) ) - launch { highlightBorderAlpha.animateTo(1f, animationSpec = tween(180)) } - launch { highlightScale.animateTo(1.012f, animationSpec = tween(220)) } - component.onHighlightConsumed() + val borderInJob = launch { highlightBorderAlpha.animateTo(1f, animationSpec = tween(180)) } + val scaleInJob = launch { highlightScale.animateTo(1.012f, animationSpec = tween(220)) } + borderInJob.join() + scaleInJob.join() kotlinx.coroutines.delay(950) - launch { highlightScale.animateTo(1f, animationSpec = tween(260)) } - launch { highlightBorderAlpha.animateTo(0f, animationSpec = tween(1800)) } - highlightBackground.animateTo(Color.Transparent, animationSpec = tween(2200)) + val scaleOutJob = launch { highlightScale.animateTo(1f, animationSpec = tween(260)) } + val borderOutJob = + launch { highlightBorderAlpha.animateTo(0f, animationSpec = tween(1800)) } + val backgroundOutJob = launch { + highlightBackground.animateTo(Color.Transparent, animationSpec = tween(2200)) + } + scaleOutJob.join() + borderOutJob.join() + backgroundOutJob.join() + + component.onHighlightConsumed() } val backgroundColor by animateColorAsState( @@ -1705,6 +1719,15 @@ private fun highlightRequestForItem( return request.takeIf { isMatch } } +internal fun shouldRunMessageHighlight( + highlightRequest: MessageHighlightRequest?, + isTargetVisibleInViewport: Boolean, + handledHighlightToken: Long +): Boolean { + val request = highlightRequest ?: return false + return isTargetVisibleInViewport && request.token != handledHighlightToken +} + private fun ChatMessageListUiState.toAppearanceConfig(): MessageAppearanceConfig = MessageAppearanceConfig( fontSize = fontSize, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt index ee4207a85..fb2cc6e2e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt @@ -319,6 +319,22 @@ internal suspend fun awaitGroupedIndex( } } +internal suspend fun awaitGroupedIndex( + messageIds: List, + groupedMessageIndexByIdProvider: () -> Map, + timeoutMs: Long = 1200L +): Int? { + if (messageIds.isEmpty()) return null + return withTimeoutOrNull(timeoutMs) { + snapshotFlow { + val indexById = groupedMessageIndexByIdProvider() + messageIds.firstNotNullOfOrNull(indexById::get) + } + .filterNotNull() + .first() + } +} + internal suspend fun LazyListState.restoreViewportAtIndex( targetIndex: Int, anchorOffsetPx: Int @@ -399,13 +415,14 @@ internal fun buildViewportSnapshot( conversationItems: List, isComments: Boolean, isLatestLoaded: Boolean, + isOldestLoaded: Boolean, isLoadingOlder: Boolean, isLoadingNewer: Boolean, isAtBottom: Boolean, showNavPadding: Boolean ): ChatViewportCacheEntry? { if (groupedMessages.isEmpty()) { - return ChatViewportCacheEntry(atBottom = true) + return ChatViewportCacheEntry(atBottom = true, readFully = true) } val atBottomNow = scrollState.isAtBottom( @@ -413,7 +430,11 @@ internal fun buildViewportSnapshot( isLatestLoaded = isLatestLoaded ) if (atBottomNow) { - return ChatViewportCacheEntry(atBottom = true) + return ChatViewportCacheEntry( + atBottom = true, + readFully = true, + topEndMessageId = groupedMessages.firstOrNull()?.firstMessageId?.takeIf { isOldestLoaded } + ) } val leadingItems = chatContentLeadingItemsCount( @@ -436,19 +457,34 @@ internal fun buildViewportSnapshot( } } ?: return null - val anchorMessageId = when ( + val anchorEntry = when ( val item = conversationItems.getOrNull( lazyIndexToGroupedIndex(anchorItem.index, leadingItems) ) ) { - is ConversationListItem.Grouped -> item.groupedMessageItem.firstMessageId + is ConversationListItem.Grouped -> item.groupedMessageItem else -> null } ?: return null + val anchorMessageId = anchorEntry.firstMessageId + val anchorAliasIds = when (anchorEntry) { + is GroupedMessageItem.Single -> emptyList() + is GroupedMessageItem.Album -> anchorEntry.messages.map { it.id } + .filterNot { it == anchorMessageId } + } + val anchorChatId = when (anchorEntry) { + is GroupedMessageItem.Single -> anchorEntry.message.chatId + is GroupedMessageItem.Album -> anchorEntry.messages.firstOrNull()?.chatId + } + return ChatViewportCacheEntry( anchorMessageId = anchorMessageId, + anchorAliasIds = anchorAliasIds, anchorOffsetPx = anchorItem.offset - info.viewportStartOffset, - atBottom = false + atBottom = false, + readFully = false, + topEndMessageId = groupedMessages.firstOrNull()?.firstMessageId?.takeIf { isOldestLoaded }, + anchorChatId = anchorChatId ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt index 97b1b5ed1..a968dbf7f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt @@ -172,6 +172,14 @@ fun ChatAlbumMessageBubble( else -> emptyList() } } + val showCaptionAboveMedia = remember(captionMsg) { + when (val content = captionMsg?.content) { + is MessageContent.Photo -> content.showCaptionAboveMedia + is MessageContent.Video -> content.showCaptionAboveMedia + is MessageContent.Gif -> content.showCaptionAboveMedia + else -> false + } + } val lastMsg = uniqueMessages.last() val firstMsg = uniqueMessages.first() @@ -211,6 +219,62 @@ fun ChatAlbumMessageBubble( } } + if (caption.isNotEmpty() && showCaptionAboveMedia) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + val inlineContent = rememberMessageInlineContent(entities, fontSize) + val finalAnnotatedString = buildAnnotatedMessageTextWithEmoji( + text = caption, + entities = entities, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers + ) + + MessageText( + text = finalAnnotatedString, + rawText = caption, + inlineContent = inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.375f).sp + ), + color = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(bubblePosition + offset) }, + onLongClick = { offset -> + val clickPosition = bubblePosition + offset + if (onMessageLongPress != null) { + onMessageLongPress(lastMsg, clickPosition) + } else { + onLongClick(clickPosition) + } + } + ) + + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata( + msg = lastMsg, + isOutgoing = isOutgoing, + contentColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + ) + } + } + } + CompactMediaMosaic( messages = uniqueMessages, autoplayGifs = autoplayGifs, @@ -242,7 +306,7 @@ fun ChatAlbumMessageBubble( isAnyViewerOpen = isAnyViewerOpen ) - if (caption.isNotEmpty()) { + if (caption.isNotEmpty() && !showCaptionAboveMedia) { Column( modifier = Modifier .fillMaxWidth() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt index 428b99c36..80ff0182c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt @@ -9,8 +9,6 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -54,14 +51,12 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade -import org.koin.compose.koinInject import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.media.VideoStickerPlayer import org.monogram.presentation.core.media.VideoType -import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression @@ -99,9 +94,6 @@ fun GifMessageBubble( val smallCorner = 4.dp val tailCorner = 2.dp - val dateFormatManager: DateFormatManager = koinInject() - val timeFormat = dateFormatManager.getHourMinuteFormat() - var stablePath by remember(msg.id) { mutableStateOf(content.path) } !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { @@ -197,6 +189,70 @@ fun GifMessageBubble( } } + @Composable + fun CaptionSection() { + val timeColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f, + modifier = Modifier.padding(bottom = 2.dp) + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.375f).sp + ), + modifier = Modifier.padding(bottom = 2.dp), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(gifPosition + offset) }, + onLongClick = { offset -> onLongClick(gifPosition + offset) } + ) + } + if (showMetadata) { + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata(msg, isOutgoing, timeColor) + } + } + } + } + + if (content.caption.isNotEmpty() && content.showCaptionAboveMedia) { + CaptionSection() + } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val mediaHeight = resolveMediaBubbleHeight( containerWidth = bubbleWidth, @@ -361,117 +417,14 @@ fun GifMessageBubble( ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (msg.editDate > 0) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.info_edited), - modifier = Modifier.size(12.dp), - tint = Color.White - ) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = formatTime(msg.date, timeFormat), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = Color.White - ) - if (isOutgoing) { - Spacer(modifier = Modifier.width(4.dp)) - MessageSendingStatusIcon( - sendingState = msg.sendingState, - isRead = msg.isRead, - baseColor = Color.White, - size = 12.dp, - usePrimaryForRead = false - ) - } - } + MessageMetadata(msg, isOutgoing, Color.White) } } } } - if (content.caption.isNotEmpty()) { - val timeColor = - if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy( - 0.7f - ) - Column( - modifier = Modifier - .fillMaxWidth() - .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = isOutgoing, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f, - modifier = Modifier.padding(bottom = 2.dp) - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.375f).sp - ), - modifier = Modifier.padding(bottom = 2.dp), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClick(gifPosition + offset) }, - onLongClick = { offset -> onLongClick(gifPosition + offset) } - ) - } - if (showMetadata) { - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - if (msg.editDate > 0) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.info_edited), - modifier = Modifier.size(14.dp), - tint = timeColor - ) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = formatTime(msg.date, timeFormat), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), - color = timeColor - ) - if (isOutgoing) { - Spacer(modifier = Modifier.width(4.dp)) - MessageSendingStatusIcon( - sendingState = msg.sendingState, - isRead = msg.isRead, - baseColor = timeColor, - size = 14.dp - ) - } - } - } - } + if (content.caption.isNotEmpty() && !content.showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageFooterRow.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageFooterRow.kt index 6b7e09653..aeb47c9d4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageFooterRow.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageFooterRow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,6 +23,7 @@ import org.monogram.presentation.R @Composable internal fun MessageFooterRow( + messageId: Long, timeText: String, color: Color, isEdited: Boolean, @@ -31,6 +33,7 @@ internal fun MessageFooterRow( modifier: Modifier = Modifier, viewsText: String? = null ) { + val isEditPending = messageId in LocalPendingEditedMessageIds.current Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically @@ -51,7 +54,15 @@ internal fun MessageFooterRow( Spacer(modifier = Modifier.width(8.dp)) } - if (isEdited) { + if (isEditPending) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = color + ) + Spacer(modifier = Modifier.width(4.dp)) + } else if (isEdited) { Icon( imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.info_edited), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt index bb37625cd..136230283 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt @@ -64,6 +64,7 @@ import kotlin.math.pow val LocalLinkHandler = staticCompositionLocalOf<(String) -> Unit> { { _ -> } } +val LocalPendingEditedMessageIds = staticCompositionLocalOf> { emptySet() } fun formatTime(ts: Int, timeFormat: String): String = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(ts.toLong() * 1000)) @@ -267,6 +268,8 @@ fun MessageMetadata( contentColor: Color ) { val context = LocalContext.current + val pendingEditedMessageIds = LocalPendingEditedMessageIds.current + val isEditPending = msg.id in pendingEditedMessageIds Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -286,7 +289,14 @@ fun MessageMetadata( ) } - if (msg.editDate > 0) { + if (isEditPending) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = contentColor + ) + } else if (msg.editDate > 0) { Icon( imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.info_edited), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt index 41eeb6a9e..94b061ee7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt @@ -69,7 +69,7 @@ fun PaidMediaMessageBubble( } msg.replyToMsg?.let { ReplyContent(it, isOutgoing, onClick = { onLongClick() }) } - if (content.caption.isNotBlank()) { + if (content.caption.isNotBlank() && content.showCaptionAboveMedia) { MessageText( text = buildAnnotatedMessageTextWithEmoji(content.caption, content.entities), rawText = content.caption, @@ -98,6 +98,16 @@ fun PaidMediaMessageBubble( ) } } + + if (content.caption.isNotBlank() && !content.showCaptionAboveMedia) { + MessageText( + text = buildAnnotatedMessageTextWithEmoji(content.caption, content.entities), + rawText = content.caption, + inlineContent = rememberMessageInlineContent(content.entities, 16f), + style = MaterialTheme.typography.bodyMedium, + entities = content.entities + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt index 912ddd784..ac06d29c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt @@ -184,6 +184,70 @@ fun PhotoMessageBubble( } } + @Composable + fun CaptionSection() { + val timeColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f, + modifier = Modifier.padding(bottom = 4.dp) + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.375f).sp + ), + modifier = Modifier.padding(bottom = 4.dp), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClick(imagePosition + offset) }, + onLongClick = { offset -> onLongClick(imagePosition + offset) } + ) + } + if (showMetadata) { + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata(msg, isOutgoing, timeColor) + } + } + } + } + + if (content.caption.isNotEmpty() && content.showCaptionAboveMedia) { + CaptionSection() + } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val mediaHeight = resolveMediaBubbleHeight(bubbleWidth, mediaRatio) @@ -308,62 +372,8 @@ fun PhotoMessageBubble( } } - if (content.caption.isNotEmpty()) { - val timeColor = if (isOutgoing) - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = isOutgoing, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f, - modifier = Modifier.padding(bottom = 4.dp) - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.375f).sp - ), - modifier = Modifier.padding(bottom = 4.dp), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClick(imagePosition + offset) }, - onLongClick = { offset -> onLongClick(imagePosition + offset) } - ) - } - if (showMetadata) { - Box(modifier = Modifier.align(Alignment.End)) { - MessageMetadata(msg, isOutgoing, timeColor) - } - } - } + if (content.caption.isNotEmpty() && !content.showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt index 446326f28..d9e0e7f74 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt @@ -210,6 +210,7 @@ internal fun RichMessageBubble( } MessageFooterRow( + messageId = msg.id, timeText = timeText, color = timeColor, isEdited = msg.editDate > 0, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt index 03db480aa..6388b551b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt @@ -143,6 +143,7 @@ internal fun TextMessageBubble( val footerRow: @Composable (Modifier) -> Unit = { footerModifier -> MessageFooterRow( + messageId = msg.id, timeText = timeText, color = timeColor, isEdited = msg.editDate > 0, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index 1d6169fc2..0670c064c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -216,6 +216,70 @@ fun VideoMessageBubble( } } + @Composable + fun CaptionSection() { + val timeColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) + .zIndex(1f) + ) { + val renderData = rememberMessageTextRenderData( + text = content.caption, + entities = content.entities, + allowBigEmoji = false, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers, + fontSize = fontSize + ) + + if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { + BigEmojiContent( + items = renderData.bigEmojiItems, + sizeDp = fontSize * 5f, + modifier = Modifier.padding(bottom = 4.dp) + ) + } else { + MessageText( + text = renderData.annotatedText, + rawText = content.caption, + inlineContent = renderData.inlineContent, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = fontSize.sp, + letterSpacing = letterSpacing.sp, + lineHeight = (fontSize * 1.375f).sp + ), + modifier = Modifier.padding(bottom = 4.dp), + onSpoilerClick = { index -> + if (revealedSpoilers.contains(index)) { + revealedSpoilers.remove(index) + } else { + revealedSpoilers.add(index) + } + }, + onClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) }, + onLongClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) } + ) + } + if (showMetadata) { + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata(msg, isOutgoing, timeColor) + } + } + } + } + + if (content.caption.isNotEmpty() && content.showCaptionAboveMedia) { + CaptionSection() + } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val mediaHeight = resolveMediaBubbleHeight( containerWidth = bubbleWidth, @@ -393,62 +457,8 @@ fun VideoMessageBubble( } } - if (content.caption.isNotEmpty()) { - val timeColor = if (isOutgoing) - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp) - .zIndex(1f) - ) { - val renderData = rememberMessageTextRenderData( - text = content.caption, - entities = content.entities, - allowBigEmoji = false, - isOutgoing = isOutgoing, - revealedSpoilers = revealedSpoilers, - fontSize = fontSize - ) - - if (renderData.isBigEmoji && renderData.bigEmojiItems.isNotEmpty()) { - BigEmojiContent( - items = renderData.bigEmojiItems, - sizeDp = fontSize * 5f, - modifier = Modifier.padding(bottom = 4.dp) - ) - } else { - MessageText( - text = renderData.annotatedText, - rawText = content.caption, - inlineContent = renderData.inlineContent, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = fontSize.sp, - letterSpacing = letterSpacing.sp, - lineHeight = (fontSize * 1.375f).sp - ), - modifier = Modifier.padding(bottom = 4.dp), - onSpoilerClick = { index -> - if (revealedSpoilers.contains(index)) { - revealedSpoilers.remove(index) - } else { - revealedSpoilers.add(index) - } - }, - onClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) }, - onLongClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) } - ) - } - if (showMetadata) { - Box(modifier = Modifier.align(Alignment.End)) { - MessageMetadata(msg, isOutgoing, timeColor) - } - } - } + if (content.caption.isNotEmpty() && !content.showCaptionAboveMedia) { + CaptionSection() } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 25c0762ef..26ad1515e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -94,6 +94,7 @@ class DefaultChatListComponent( private val attachMenuBotRepository: AttachMenuBotRepository = container.repositories.attachMenuBotRepository private val updateRepository: UpdateRepository = container.repositories.updateRepository private val storyRepository: StoryRepository = container.repositories.storyRepository + private val cacheProvider = container.cacheProvider override val appPreferences: AppPreferences = container.preferences.appPreferences private val messageDisplayer = container.utils.messageDisplayer() @@ -102,7 +103,8 @@ class DefaultChatListComponent( isForwarding = isForwarding, isShareTargetMode = isShareTargetMode, projectChannelSubscriptionState = appPreferences.projectChannelSubscriptionState(), - isLoadingByFolder = mapOf(-1 to true) + isLoadingByFolder = mapOf(-1 to true), + scrollPositions = cacheProvider.getChatListScrollPositions() ) ) private val _uiState = MutableStateFlow(_state.value.toUiState()) @@ -1432,6 +1434,7 @@ class DefaultChatListComponent( store.accept(ChatListStore.Intent.UpdateScrollPosition(folderId, index, offset)) internal fun handleUpdateScrollPosition(folderId: Int, index: Int, offset: Int) { + cacheProvider.saveChatListScrollPosition(folderId, index, offset) _state.update { val newPositions = it.scrollPositions.toMutableMap() newPositions[folderId] = index to offset diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt index 01ed007a5..763f6baca 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt @@ -352,7 +352,11 @@ fun AccountMenu( iconBackgroundColor = menuIconColor, title = stringResource(R.string.menu_settings), subtitle = stringResource(R.string.menu_settings_subtitle), - position = ItemPosition.MIDDLE, + position = if (updateState is UpdateState.UpdateAvailable || updateState is UpdateState.ReadyToInstall || updateState is UpdateState.Downloading) { + ItemPosition.MIDDLE + } else { + ItemPosition.BOTTOM + }, onClick = { onSettingsClick() animateDismiss() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 5d9a25099..9912acc2b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.monogram.domain.models.BotMenuButtonModel import org.monogram.domain.models.ChatInteractionType +import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.ChatRevenueStatisticsModel import org.monogram.domain.models.ChatStatisticsModel @@ -98,6 +99,7 @@ class DefaultProfileComponent( private var hasStartedProfilePhotoListPreload = false private val loadingTabs = mutableSetOf() private var profileStoriesJob: Job? = null + private var memberSearchJob: Job? = null init { loadData() @@ -146,10 +148,36 @@ class DefaultProfileComponent( val linkedChat = linkedChatId?.let { coRunCatching { chatListRepository.getChatById(it) }.getOrNull() } + val currentSimilarChats = _state.value.similarChats val isGroupOrChannel = chat?.let { it.isGroup || it.isChannel } ?: (chatId < 0) val preferredTabKey = fullInfo?.mainProfileTab.toProfileTabKeyOrNull() - val visibleTabs = buildProfileTabSpecs( + val resolvedMemberCount = (chat?.memberCount ?: 0).takeIf { it > 0 } + ?: fullInfo?.memberCount + ?: 0 + val supportedTabs = buildProfileTabSpecs( isGroupOrChannel = isGroupOrChannel, + preferredTabKey = preferredTabKey, + showMembers = shouldShowMembersTab( + chat = chat, + fullInfo = fullInfo, + resolvedMemberCount = resolvedMemberCount + ), + showAdministrators = shouldShowAdminsTab( + chat = chat, + fullInfo = fullInfo + ), + showRestricted = shouldShowModerationTab( + chat = chat, + memberCount = fullInfo?.restrictedCount ?: 0 + ), + showBanned = shouldShowModerationTab( + chat = chat, + memberCount = fullInfo?.bannedCount ?: 0 + ), + showSimilarChats = currentSimilarChats.isNotEmpty() + ) + val visibleTabs = buildInitialVisibleProfileTabSpecs( + supportedTabs = supportedTabs, preferredTabKey = preferredTabKey ) val currentSelectedTabKey = _state.value.selectedTabKey @@ -183,6 +211,8 @@ class DefaultProfileComponent( refreshProfileStories() ensureTabLoaded(selectedTabKey) + probeHiddenMediaTabs() + probeSimilarChatsTab() preloadProfilePhotoList( resolvedChatId = chat?.id ?: chatId, @@ -385,13 +415,18 @@ class DefaultProfileComponent( private fun ensureTabLoaded(tabKey: ProfileTabKey) { when (tabKey) { ProfileTabKey.STORIES -> Unit - ProfileTabKey.MEMBERS -> { - if (!_state.value.membersTab.hasLoaded) { - loadMembersNextPage() + ProfileTabKey.SIMILAR -> { + if (!_state.value.hasLoadedSimilarChats) { + loadSimilarChats() } } - else -> { + if (tabKey.isMemberTab()) { + if (!_state.value.memberTabState(tabKey).hasLoaded) { + loadMembersNextPage(tabKey) + } + return + } if (!_state.value.messageTabState(tabKey).hasLoaded) { loadMessageTabNextPage(tabKey) } @@ -402,16 +437,27 @@ class DefaultProfileComponent( private fun loadNextPage(tabKey: ProfileTabKey) { when (tabKey) { ProfileTabKey.STORIES -> Unit - ProfileTabKey.MEMBERS -> loadMembersNextPage() - else -> loadMessageTabNextPage(tabKey) + ProfileTabKey.SIMILAR -> Unit + else -> if (tabKey.isMemberTab()) { + loadMembersNextPage(tabKey) + } else { + loadMessageTabNextPage(tabKey) + } } } - private fun loadMembersNextPage() { - val currentTab = _state.value.membersTab - if (!loadingTabs.add(ProfileTabKey.MEMBERS)) return - if (currentTab.isLoadingInitial || currentTab.isLoadingNext || (currentTab.hasLoaded && !currentTab.canLoadMore)) { - loadingTabs.remove(ProfileTabKey.MEMBERS) + private fun loadMembersNextPage(tabKey: ProfileTabKey) { + val filter = tabKey.toChatMembersFilterOrNull() ?: return + val currentTab = _state.value.memberTabState(tabKey) + if (!loadingTabs.add(tabKey)) return + if ( + currentTab.isLoadingInitial || + currentTab.isLoadingNext || + currentTab.isSearchResultsVisible || + currentTab.isSearching || + (currentTab.hasLoaded && !currentTab.canLoadMore) + ) { + loadingTabs.remove(tabKey) return } @@ -419,9 +465,11 @@ class DefaultProfileComponent( val isInitialLoad = !currentTab.hasLoaded _state.update { it.copy( - membersTab = it.membersTab.copy( - isLoadingInitial = isInitialLoad, - isLoadingNext = !isInitialLoad + memberTabs = it.memberTabs + ( + tabKey to it.memberTabState(tabKey).copy( + isLoadingInitial = isInitialLoad, + isLoadingNext = !isInitialLoad + ) ) ) } @@ -432,20 +480,22 @@ class DefaultProfileComponent( chatId, currentTab.nextOffset, limit, - ChatMembersFilter.Recent + filter ) val canLoadMore = newMembers.size >= limit _state.update { - val latestTab = it.membersTab + val latestTab = it.memberTabState(tabKey) val mergedMembers = (latestTab.items + newMembers).distinctBy { member -> member.user.id } it.copy( - membersTab = latestTab.copy( - items = mergedMembers, - canLoadMore = canLoadMore, - nextOffset = latestTab.nextOffset + newMembers.size, - hasLoaded = true + memberTabs = it.memberTabs + ( + tabKey to latestTab.copy( + items = mergedMembers, + canLoadMore = canLoadMore, + nextOffset = latestTab.nextOffset + newMembers.size, + hasLoaded = true + ) ) ) } @@ -454,12 +504,14 @@ class DefaultProfileComponent( } catch (e: Exception) { e.printStackTrace() } finally { - loadingTabs.remove(ProfileTabKey.MEMBERS) + loadingTabs.remove(tabKey) _state.update { it.copy( - membersTab = it.membersTab.copy( - isLoadingInitial = false, - isLoadingNext = false + memberTabs = it.memberTabs + ( + tabKey to it.memberTabState(tabKey).copy( + isLoadingInitial = false, + isLoadingNext = false + ) ) ) } @@ -509,6 +561,7 @@ class DefaultProfileComponent( hasLoaded = true ) } + nextState = nextState.updateMediaTabVisibility(tabKey) if (tabKey == ProfileTabKey.MEDIA && nextState.fullScreenImages != null && !nextState.isViewingProfilePhotos) { nextState = refreshViewerMedia(nextState) @@ -552,6 +605,145 @@ class DefaultProfileComponent( return copy(messageTabs = messageTabs + (key to transform(currentTab))) } + private fun ProfileComponent.State.updateMemberTab( + key: ProfileTabKey, + transform: (ProfileComponent.MembersTabState) -> ProfileComponent.MembersTabState + ): ProfileComponent.State { + val currentTab = memberTabState(key) + return copy(memberTabs = memberTabs + (key to transform(currentTab))) + } + + private fun ProfileComponent.State.supportedTabSpecs(): List { + val chat = chat + val fullInfo = fullInfo + val isGroupOrChannel = chat?.let { it.isGroup || it.isChannel } ?: (chatId < 0) + val resolvedMemberCount = (chat?.memberCount ?: 0).takeIf { it > 0 } + ?: fullInfo?.memberCount + ?: 0 + return buildProfileTabSpecs( + isGroupOrChannel = isGroupOrChannel, + preferredTabKey = fullInfo?.mainProfileTab.toProfileTabKeyOrNull(), + showMembers = shouldShowMembersTab( + chat = chat, + fullInfo = fullInfo, + resolvedMemberCount = resolvedMemberCount + ), + showAdministrators = shouldShowAdminsTab( + chat = chat, + fullInfo = fullInfo + ), + showRestricted = shouldShowModerationTab( + chat = chat, + memberCount = fullInfo?.restrictedCount ?: 0 + ), + showBanned = shouldShowModerationTab( + chat = chat, + memberCount = fullInfo?.bannedCount ?: 0 + ), + showSimilarChats = similarChats.isNotEmpty() + ) + } + + private fun ProfileComponent.State.ensureVisibleTab(tabKey: ProfileTabKey): ProfileComponent.State { + if (visibleTabs.any { it.key == tabKey }) return this + val visibleKeys = visibleTabs.map(ProfileTabSpec::key).toMutableSet().apply { + add(tabKey) + } + return copy(visibleTabs = supportedTabSpecs().filter { it.key in visibleKeys }) + } + + private fun ProfileComponent.State.removeVisibleTab(tabKey: ProfileTabKey): ProfileComponent.State { + if (selectedTabKey == tabKey || visibleTabs.none { it.key == tabKey }) return this + val visibleKeys = visibleTabs + .map(ProfileTabSpec::key) + .filterNot { it == tabKey } + .toSet() + return copy(visibleTabs = supportedTabSpecs().filter { it.key in visibleKeys }) + } + + private fun ProfileComponent.State.updateMediaTabVisibility(tabKey: ProfileTabKey): ProfileComponent.State { + if (!tabKey.isMediaTab()) return this + val hasContent = messageTabState(tabKey).items.isNotEmpty() + if (hasContent) { + var nextState = ensureVisibleTab(tabKey) + val mediaTabState = nextState.messageTabState(ProfileTabKey.MEDIA) + if ( + tabKey != ProfileTabKey.MEDIA && + nextState.selectedTabKey != ProfileTabKey.MEDIA && + nextState.visibleTabs.any { it.key == ProfileTabKey.MEDIA } && + mediaTabState.hasLoaded && + mediaTabState.items.isEmpty() + ) { + nextState = nextState.removeVisibleTab(ProfileTabKey.MEDIA) + } + return nextState + } + if (selectedTabKey == tabKey) { + return this + } + val visibleMediaCount = visibleTabs.count { it.key.isMediaTab() } + if (tabKey == ProfileTabKey.MEDIA && visibleMediaCount <= 1) { + return this + } + return removeVisibleTab(tabKey) + } + + private fun probeHiddenMediaTabs() { + val snapshot = _state.value + val visibleKeys = snapshot.visibleTabs.map(ProfileTabSpec::key).toSet() + snapshot.supportedTabSpecs() + .asSequence() + .map(ProfileTabSpec::key) + .filter(ProfileTabKey::isMediaTab) + .filterNot { it in visibleKeys } + .forEach(::loadMessageTabNextPage) + } + + private fun probeSimilarChatsTab() { + val snapshot = _state.value + val chat = snapshot.chat ?: return + if (!chat.isChannel) return + if (snapshot.hasLoadedSimilarChats || snapshot.isSimilarChatsLoading) return + loadSimilarChats() + } + + private fun loadSimilarChats() { + if (!loadingTabs.add(ProfileTabKey.SIMILAR)) return + val snapshot = _state.value + if (snapshot.isSimilarChatsLoading || snapshot.hasLoadedSimilarChats) { + loadingTabs.remove(ProfileTabKey.SIMILAR) + return + } + + scope.launch { + _state.update { it.copy(isSimilarChatsLoading = true) } + try { + val similarChats = chatInfoRepository.getSimilarChatIds(chatId) + .mapNotNull { relatedChatId -> + coRunCatching { chatListRepository.getChatById(relatedChatId) }.getOrNull() + } + .distinctBy(ChatModel::id) + + _state.update { state -> + var nextState = state.copy( + similarChats = similarChats, + isSimilarChatsLoading = false, + hasLoadedSimilarChats = true + ) + nextState = if (similarChats.isNotEmpty()) { + nextState.ensureVisibleTab(ProfileTabKey.SIMILAR) + } else { + nextState.removeVisibleTab(ProfileTabKey.SIMILAR) + } + nextState + } + } finally { + loadingTabs.remove(ProfileTabKey.SIMILAR) + _state.update { it.copy(isSimilarChatsLoading = false) } + } + } + } + override fun onTabSelected(tabKey: ProfileTabKey) { if (_state.value.selectedTabKey == tabKey) return if (_state.value.visibleTabs.none { it.key == tabKey }) return @@ -560,6 +752,111 @@ class DefaultProfileComponent( ensureTabLoaded(tabKey) } + override fun onSearch() { + val tabKey = _state.value.selectedTabKey + if (!tabKey.isMemberTab()) return + + val isActive = _state.value.memberTabState(tabKey).isSearchActive + if (isActive) { + onSearchDismissed() + return + } + + _state.update { + it.updateMemberTab(tabKey) { tab -> + tab.copy(isSearchActive = true) + } + } + } + + override fun onSearchQueryChanged(query: String) { + val tabKey = _state.value.selectedTabKey + if (!tabKey.isMemberTab()) return + + memberSearchJob?.cancel() + _state.update { + it.updateMemberTab(tabKey) { tab -> + tab.copy( + searchQuery = query, + searchResults = if (query.isBlank()) emptyList() else tab.searchResults, + isSearching = query.isNotBlank() + ) + } + } + + if (query.isBlank()) return + + memberSearchJob = scope.launch { + delay(300) + try { + val results = chatInfoRepository.getChatMembers( + chatId = chatId, + offset = 0, + limit = 50, + filter = ChatMembersFilter.Search(query) + ) + val filteredResults = filterMemberSearchResults(tabKey, results) + _state.update { state -> + state.updateMemberTab(tabKey) { tab -> + if (tab.searchQuery != query) { + tab + } else { + tab.copy( + searchResults = filteredResults, + isSearching = false + ) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + _state.update { state -> + state.updateMemberTab(tabKey) { tab -> + if (tab.searchQuery != query) { + tab + } else { + tab.copy(isSearching = false) + } + } + } + } + } + } + + override fun onSearchDismissed() { + val tabKey = _state.value.selectedTabKey + if (!tabKey.isMemberTab()) return + + memberSearchJob?.cancel() + _state.update { + it.updateMemberTab(tabKey) { tab -> + tab.copy( + searchQuery = "", + searchResults = emptyList(), + isSearchActive = false, + isSearching = false + ) + } + } + } + + private fun filterMemberSearchResults( + tabKey: ProfileTabKey, + members: List + ): List = + when (tabKey) { + ProfileTabKey.ADMINS -> members.filter { + it.status is ChatMemberStatus.Administrator || it.status is ChatMemberStatus.Creator + } + + ProfileTabKey.RESTRICTED -> members.filter { it.status is ChatMemberStatus.Restricted } + ProfileTabKey.BANNED -> members.filter { it.status is ChatMemberStatus.Banned } + ProfileTabKey.MEMBERS -> members.filter { it.status is ChatMemberStatus.Member } + else -> members + } + private fun observeUserUpdates() { userRepository.getUserFlow(chatId) .onEach { user -> @@ -629,6 +926,11 @@ class DefaultProfileComponent( } override fun onBack() { + val selectedTabKey = _state.value.selectedTabKey + if (selectedTabKey.isMemberTab() && _state.value.memberTabState(selectedTabKey).isSearchActive) { + onSearchDismissed() + return + } onBackClicked() } @@ -958,6 +1260,79 @@ class DefaultProfileComponent( } } + override fun onToggleJoinToSendMessages(enabled: Boolean) { + val previousChat = _state.value.chat ?: return + if (previousChat.joinToSendMessages == enabled) return + + performProfileSettingChange( + optimisticUpdate = { state -> + state.copy(chat = state.chat?.copy(joinToSendMessages = enabled)) + }, + rollbackUpdate = { state -> + state.copy(chat = state.chat?.copy(joinToSendMessages = previousChat.joinToSendMessages)) + } + ) { + chatSettingsRepository.setChatJoinToSendMessages(chatId, enabled) + } + } + + override fun onToggleJoinByRequest(enabled: Boolean) { + val previousChat = _state.value.chat ?: return + if (!previousChat.isGroup || !previousChat.isSupergroup || previousChat.isChannel) return + if (previousChat.joinByRequest == enabled) return + + performProfileSettingChange( + optimisticUpdate = { state -> + state.copy(chat = state.chat?.copy(joinByRequest = enabled)) + }, + rollbackUpdate = { state -> + state.copy(chat = state.chat?.copy(joinByRequest = previousChat.joinByRequest)) + } + ) { + chatSettingsRepository.setChatJoinByRequest(chatId, enabled) + } + } + + override fun onToggleHiddenMembers(enabled: Boolean) { + val previousFullInfo = _state.value.fullInfo ?: return + if (!previousFullInfo.canHideMembers || previousFullInfo.hasHiddenMembers == enabled) return + + performProfileSettingChange( + optimisticUpdate = { state -> + state.copy(fullInfo = state.fullInfo?.copy(hasHiddenMembers = enabled)) + }, + rollbackUpdate = { state -> + state.copy(fullInfo = state.fullInfo?.copy(hasHiddenMembers = previousFullInfo.hasHiddenMembers)) + } + ) { + chatSettingsRepository.setChatHasHiddenMembers(chatId, enabled) + } + } + + override fun onToggleAggressiveAntiSpam(enabled: Boolean) { + val previousFullInfo = _state.value.fullInfo ?: return + if (!previousFullInfo.canToggleAggressiveAntiSpam || + previousFullInfo.hasAggressiveAntiSpamEnabled == enabled + ) { + return + } + + performProfileSettingChange( + optimisticUpdate = { state -> + state.copy(fullInfo = state.fullInfo?.copy(hasAggressiveAntiSpamEnabled = enabled)) + }, + rollbackUpdate = { state -> + state.copy( + fullInfo = state.fullInfo?.copy( + hasAggressiveAntiSpamEnabled = previousFullInfo.hasAggressiveAntiSpamEnabled + ) + ) + } + ) { + chatSettingsRepository.setChatHasAggressiveAntiSpamEnabled(chatId, enabled) + } + } + override fun onEdit() { onEditClicked() } @@ -983,6 +1358,10 @@ class DefaultProfileComponent( onSendMessageClicked(chatId) } + override fun onRelatedChatClick(chatId: Long) { + onSendMessageClicked(chatId) + } + override fun onToggleBlockUser() { val userId = _state.value.user?.id ?: return val shouldBlock = !_state.value.isBlocked @@ -1138,10 +1517,12 @@ class DefaultProfileComponent( override fun onUpdateMemberStatus(userId: Long, status: ChatMemberStatus) { scope.launch { chatInfoRepository.setChatMemberStatus(chatId, userId, status) - _state.update { it.copy(membersTab = ProfileComponent.MembersTabState()) } - if (_state.value.visibleTabs.any { it.key == ProfileTabKey.MEMBERS }) { - ensureTabLoaded(ProfileTabKey.MEMBERS) - } + _state.update { it.copy(memberTabs = defaultMemberTabStates()) } + _state.value.visibleTabs + .asSequence() + .map(ProfileTabSpec::key) + .filter(ProfileTabKey::isMemberTab) + .forEach(::ensureTabLoaded) } } @@ -1434,6 +1815,22 @@ class DefaultProfileComponent( } } + private fun performProfileSettingChange( + optimisticUpdate: (ProfileComponent.State) -> ProfileComponent.State, + rollbackUpdate: (ProfileComponent.State) -> ProfileComponent.State, + block: suspend () -> Unit + ) { + _state.update(optimisticUpdate) + scope.launch { + runCatching { block() } + .onFailure { error -> + _state.update(rollbackUpdate) + messageDisplayer.show(error.message ?: "Action failed") + } + loadData() + } + } + private fun runAction( action: ChatActionType, closeOnSuccess: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index 4c374b829..af2f415bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -23,6 +23,9 @@ interface ProfileComponent { fun onBack() fun onTabSelected(tabKey: ProfileTabKey) + fun onSearch() + fun onSearchQueryChanged(query: String) + fun onSearchDismissed() fun onMessageClick(message: MessageModel) fun onMessageLongClick(message: MessageModel) fun onAvatarClick() @@ -46,10 +49,15 @@ interface ProfileComponent { fun onDismissMiniApp() fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) fun onToggleMute() + fun onToggleJoinToSendMessages(enabled: Boolean) + fun onToggleJoinByRequest(enabled: Boolean) + fun onToggleHiddenMembers(enabled: Boolean) + fun onToggleAggressiveAntiSpam(enabled: Boolean) fun onEdit() fun onShowQRCode() fun onDismissQRCode() fun onSendMessage() + fun onRelatedChatClick(chatId: Long) fun onToggleBlockUser() fun onDeleteChat() fun onEditContact() @@ -107,7 +115,10 @@ interface ProfileComponent { val visibleTabs: List = emptyList(), val selectedTabKey: ProfileTabKey = ProfileTabKey.STORIES, val messageTabs: Map = defaultMessageTabStates(), - val membersTab: MembersTabState = MembersTabState(), + val memberTabs: Map = defaultMemberTabStates(), + val similarChats: List = emptyList(), + val isSimilarChatsLoading: Boolean = false, + val hasLoadedSimilarChats: Boolean = false, val profilePhotos: List = emptyList(), val personalAvatarPath: String? = null, @@ -190,6 +201,9 @@ interface ProfileComponent { fun messageTabState(key: ProfileTabKey): MessageTabState = messageTabs[key] ?: MessageTabState() + fun memberTabState(key: ProfileTabKey): MembersTabState = + memberTabs[key] ?: MembersTabState() + val mediaMessages: List get() = messageTabState(ProfileTabKey.MEDIA).items @@ -209,7 +223,10 @@ interface ProfileComponent { get() = messageTabState(ProfileTabKey.GIFS).items val members: List - get() = membersTab.items + get() = memberTabState(ProfileTabKey.MEMBERS).items + + val relatedChats: List + get() = similarChats } data class LocationData( @@ -233,8 +250,18 @@ interface ProfileComponent { val isLoadingNext: Boolean = false, val canLoadMore: Boolean = true, val nextOffset: Int = 0, - val hasLoaded: Boolean = false - ) + val hasLoaded: Boolean = false, + val searchQuery: String = "", + val searchResults: List = emptyList(), + val isSearchActive: Boolean = false, + val isSearching: Boolean = false + ) { + val visibleItems: List + get() = if (searchQuery.isBlank()) items else searchResults + + val isSearchResultsVisible: Boolean + get() = searchQuery.isNotBlank() + } } private fun defaultMessageTabStates(): Map = @@ -247,3 +274,11 @@ private fun defaultMessageTabStates(): Map = + listOf( + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED + ).associateWith { ProfileComponent.MembersTabState() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 117232228..01a5d04c1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum @@ -81,7 +82,6 @@ import org.monogram.presentation.features.profile.components.ProfileTOSDialog import org.monogram.presentation.features.profile.components.ProfileTopBar import org.monogram.presentation.features.profile.components.StatisticsViewer import org.monogram.presentation.features.profile.components.profileMediaSection -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -182,7 +182,6 @@ fun ProfileContent(component: ProfileComponent) { else if (state.selectedLocation != null) component.onDismissLocation() } - val searchNotImplemented = stringResource(R.string.search_not_implemented) val linkCopied = stringResource(R.string.link_copied) val userIdCopied = stringResource(R.string.logs_user_id_copied) @@ -234,6 +233,7 @@ fun ProfileContent(component: ProfileComponent) { val canLeaveTopBar = !isCurrentUserProfile && exitAction == ChatExitAction.Leave val canDeleteTopBar = !isCurrentUserProfile && exitAction == ChatExitAction.Delete val isActionPending = state.actionState is ChatActionState.Pending + val canSearchTopBar = state.selectedTabKey.isMemberTab() var showLeaveSheet by remember { mutableStateOf(false) } var showDeleteChatSheet by remember { mutableStateOf(false) } var showBlockSheet by remember { mutableStateOf(false) } @@ -252,7 +252,7 @@ fun ProfileContent(component: ProfileComponent) { isBot = isBot, isScam = isScam, isFake = isFake, - canSearch = false, + canSearch = canSearchTopBar, canShare = canShareTopBar, canEdit = canEditTopBar, canEditContact = canEditContactTopBar, @@ -262,7 +262,7 @@ fun ProfileContent(component: ProfileComponent) { isBlocked = state.isBlocked, canLeave = canLeaveTopBar, canDeleteChat = canDeleteTopBar, - onSearch = { Toast.makeText(context, searchNotImplemented, Toast.LENGTH_SHORT).show() }, + onSearch = component::onSearch, onShare = { when { shareUsername != null -> { @@ -402,6 +402,10 @@ fun ProfileContent(component: ProfileComponent) { onShowStatistics = component::onShowStatistics, onShowRevenueStatistics = component::onShowRevenueStatistics, onLinkedChatClick = component::onLinkedChatClick, + onToggleJoinToSendMessages = component::onToggleJoinToSendMessages, + onToggleJoinByRequest = component::onToggleJoinByRequest, + onToggleHiddenMembers = component::onToggleHiddenMembers, + onToggleAggressiveAntiSpam = component::onToggleAggressiveAntiSpam, onShowPermissions = component::onShowPermissions, onAcceptTOS = component::onAcceptTOS, onLocationClick = component::onLocationClick @@ -421,12 +425,22 @@ fun ProfileContent(component: ProfileComponent) { onLoadMore = component::onLoadMoreMedia, onMemberClick = component::onMemberClick, onMemberLongClick = component::onMemberLongClick, + onChatClick = component::onRelatedChatClick, onLoadMedia = { msg -> component.onDownloadMedia(msg) }, + onSearchQueryChanged = component::onSearchQueryChanged, + onSearchDismissed = component::onSearchDismissed, onOpenActiveStory = component::onOpenActiveStory, onOpenPostedStory = component::onOpenPostedStory, - onOpenArchive = component::onOpenStoryArchive + onOpenArchive = component::onOpenStoryArchive, + onShowLogs = component::onShowLogs, + onShowStatistics = component::onShowStatistics, + onShowRevenueStatistics = component::onShowRevenueStatistics, + onToggleJoinToSendMessages = component::onToggleJoinToSendMessages, + onToggleJoinByRequest = component::onToggleJoinByRequest, + onToggleHiddenMembers = component::onToggleHiddenMembers, + onToggleAggressiveAntiSpam = component::onToggleAggressiveAntiSpam ) item(span = { GridItemSpan(3) }) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt index bc1f0509b..f6d941a96 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt @@ -1,7 +1,10 @@ package org.monogram.presentation.features.profile import androidx.annotation.StringRes +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ProfileTabType +import org.monogram.domain.repository.ChatMembersFilter import org.monogram.domain.repository.ProfileMediaFilter import org.monogram.presentation.R @@ -9,6 +12,10 @@ enum class ProfileTabKey { STORIES, MEDIA, MEMBERS, + ADMINS, + RESTRICTED, + BANNED, + SIMILAR, FILES, MUSIC, VOICE, @@ -20,7 +27,8 @@ enum class ProfileTabContentType { STORIES_LIST, MEDIA_GRID, MESSAGE_LIST, - MEMBERS_LIST + MEMBERS_LIST, + CHAT_LIST } data class ProfileTabSpec( @@ -34,29 +42,60 @@ data class ProfileTabSpec( fun buildProfileTabSpecs( isGroupOrChannel: Boolean, - preferredTabKey: ProfileTabKey? + preferredTabKey: ProfileTabKey?, + showMembers: Boolean = isGroupOrChannel, + showAdministrators: Boolean = false, + showRestricted: Boolean = false, + showBanned: Boolean = false, + showSimilarChats: Boolean = false, + showMedia: Boolean = true, + showFiles: Boolean = true, + showMusic: Boolean = true, + showVoice: Boolean = true, + showLinks: Boolean = true, + showGifs: Boolean = true ): List { - val supportedKeys = if (isGroupOrChannel) { - listOf( - ProfileTabKey.STORIES, - ProfileTabKey.MEDIA, - ProfileTabKey.MEMBERS, - ProfileTabKey.FILES, - ProfileTabKey.MUSIC, - ProfileTabKey.VOICE, - ProfileTabKey.LINKS, - ProfileTabKey.GIFS - ) - } else { - listOf( - ProfileTabKey.STORIES, - ProfileTabKey.MEDIA, - ProfileTabKey.FILES, - ProfileTabKey.MUSIC, - ProfileTabKey.VOICE, - ProfileTabKey.LINKS, - ProfileTabKey.GIFS - ) + val supportedKeys = buildList { + add(ProfileTabKey.STORIES) + if (showMedia) { + add(ProfileTabKey.MEDIA) + } + if (showMembers) { + add(ProfileTabKey.MEMBERS) + } + if (showAdministrators) { + add(ProfileTabKey.ADMINS) + } + if (showRestricted) { + add(ProfileTabKey.RESTRICTED) + } + if (showBanned) { + add(ProfileTabKey.BANNED) + } + if (showSimilarChats) { + add(ProfileTabKey.SIMILAR) + } + if (showFiles) { + add(ProfileTabKey.FILES) + } + if (showMusic) { + add(ProfileTabKey.MUSIC) + } + if (showVoice) { + add(ProfileTabKey.VOICE) + } + if (showLinks) { + add(ProfileTabKey.LINKS) + } + if (showGifs) { + add(ProfileTabKey.GIFS) + } + }.let { keys -> + if (isGroupOrChannel) { + keys + } else { + keys.filterNot(ProfileTabKey::isMemberTab) + } } val initialKey = preferredTabKey?.takeIf { it in supportedKeys } ?: ProfileTabKey.STORIES @@ -71,6 +110,67 @@ fun buildProfileTabSpecs( } } +fun buildInitialVisibleProfileTabSpecs( + supportedTabs: List, + preferredTabKey: ProfileTabKey? +): List { + val visibleKeys = buildSet { + add(ProfileTabKey.STORIES) + if (supportedTabs.any { it.key == ProfileTabKey.MEDIA }) { + add(ProfileTabKey.MEDIA) + } + supportedTabs + .map(ProfileTabSpec::key) + .filter(ProfileTabKey::isMemberTab) + .forEach(::add) + if (supportedTabs.any { it.key == ProfileTabKey.SIMILAR }) { + add(ProfileTabKey.SIMILAR) + } + preferredTabKey + ?.takeIf { preferred -> supportedTabs.any { it.key == preferred } } + ?.let(::add) + } + + return supportedTabs.filter { it.key in visibleKeys } +} + +fun shouldShowMembersTab( + chat: ChatModel?, + fullInfo: ChatFullInfoModel?, + resolvedMemberCount: Int +): Boolean { + val chatModel = chat ?: return false + if (!chatModel.isGroup && !chatModel.isChannel) return false + + val hasMemberData = fullInfo?.canGetMembers == true || resolvedMemberCount > 0 + if (!hasMemberData) return false + + return when { + chatModel.isChannel -> chatModel.isAdmin + chatModel.isGroup -> fullInfo?.hasHiddenMembers != true || chatModel.isAdmin + else -> false + } +} + +fun shouldShowAdminsTab( + chat: ChatModel?, + fullInfo: ChatFullInfoModel? +): Boolean { + val chatModel = chat ?: return false + if (!chatModel.isGroup && !chatModel.isChannel) return false + return chatModel.isAdmin || (fullInfo?.administratorCount ?: 0) > 0 +} + +fun shouldShowModerationTab( + chat: ChatModel?, + memberCount: Int +): Boolean { + val chatModel = chat ?: return false + if (!chatModel.isGroup && !chatModel.isChannel) return false + if (!chatModel.isAdmin) return false + return memberCount > 0 +} + fun ProfileTabType?.toProfileTabKeyOrNull(): ProfileTabKey? = when (this) { ProfileTabType.MEDIA -> ProfileTabKey.MEDIA @@ -82,6 +182,37 @@ fun ProfileTabType?.toProfileTabKeyOrNull(): ProfileTabKey? = else -> null } +fun ProfileTabKey.isMemberTab(): Boolean = + when (this) { + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED -> true + + else -> false + } + +fun ProfileTabKey.isMediaTab(): Boolean = + when (this) { + ProfileTabKey.MEDIA, + ProfileTabKey.FILES, + ProfileTabKey.MUSIC, + ProfileTabKey.VOICE, + ProfileTabKey.LINKS, + ProfileTabKey.GIFS -> true + + else -> false + } + +fun ProfileTabKey.toChatMembersFilterOrNull(): ChatMembersFilter? = + when (this) { + ProfileTabKey.MEMBERS -> ChatMembersFilter.Recent + ProfileTabKey.ADMINS -> ChatMembersFilter.Administrators + ProfileTabKey.RESTRICTED -> ChatMembersFilter.Restricted + ProfileTabKey.BANNED -> ChatMembersFilter.Banned + else -> null + } + fun ProfileTabKey.toProfileMediaFilter(): ProfileMediaFilter? = when (this) { ProfileTabKey.STORIES -> null @@ -91,7 +222,11 @@ fun ProfileTabKey.toProfileMediaFilter(): ProfileMediaFilter? = ProfileTabKey.VOICE -> ProfileMediaFilter.VOICE ProfileTabKey.LINKS -> ProfileMediaFilter.LINKS ProfileTabKey.GIFS -> ProfileMediaFilter.GIFS - ProfileTabKey.MEMBERS -> null + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED, + ProfileTabKey.SIMILAR -> null } fun ProfileTabKey.contentType(): ProfileTabContentType = @@ -100,7 +235,12 @@ fun ProfileTabKey.contentType(): ProfileTabContentType = ProfileTabKey.MEDIA, ProfileTabKey.GIFS -> ProfileTabContentType.MEDIA_GRID - ProfileTabKey.MEMBERS -> ProfileTabContentType.MEMBERS_LIST + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED -> ProfileTabContentType.MEMBERS_LIST + + ProfileTabKey.SIMILAR -> ProfileTabContentType.CHAT_LIST ProfileTabKey.FILES, ProfileTabKey.MUSIC, ProfileTabKey.VOICE, @@ -113,6 +253,10 @@ fun ProfileTabKey.titleRes(): Int = ProfileTabKey.STORIES -> R.string.tab_stories ProfileTabKey.MEDIA -> R.string.tab_media ProfileTabKey.MEMBERS -> R.string.tab_members + ProfileTabKey.ADMINS -> R.string.tab_admins + ProfileTabKey.RESTRICTED -> R.string.tab_restricted + ProfileTabKey.BANNED -> R.string.tab_banned + ProfileTabKey.SIMILAR -> R.string.tab_similar ProfileTabKey.FILES -> R.string.tab_files ProfileTabKey.MUSIC -> R.string.tab_music ProfileTabKey.VOICE -> R.string.tab_voice diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt index 130f3d513..2bf95d953 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt @@ -25,29 +25,43 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile +import androidx.compose.material.icons.automirrored.rounded.Login import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.AssignmentTurnedIn +import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.BrokenImage +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.PermMedia +import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.RemoveRedEye +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Shield import androidx.compose.material.icons.rounded.Verified import androidx.compose.material.icons.rounded.Videocam import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -55,12 +69,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -70,6 +89,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import org.koin.compose.koinInject +import org.monogram.domain.models.ChatModel import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -80,6 +100,7 @@ import org.monogram.presentation.core.media.VideoStickerPlayer import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.DateFormatManager @@ -89,6 +110,7 @@ import org.monogram.presentation.features.profile.ProfileComponent import org.monogram.presentation.features.profile.ProfileTabContentType import org.monogram.presentation.features.profile.ProfileTabKey import org.monogram.presentation.features.profile.ProfileTabSpec +import org.monogram.presentation.features.profile.isMemberTab import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.io.File @@ -105,17 +127,27 @@ fun LazyGridScope.profileMediaSection( onLoadMore: () -> Unit, onMemberClick: (Long) -> Unit = {}, onMemberLongClick: (Long) -> Unit = {}, + onChatClick: (Long) -> Unit = {}, onLoadMedia: (MessageModel) -> Unit = {}, + onSearchQueryChanged: (String) -> Unit = {}, + onSearchDismissed: () -> Unit = {}, onOpenActiveStory: (Int) -> Unit = {}, onOpenPostedStory: (Int) -> Unit = {}, - onOpenArchive: () -> Unit = {} + onOpenArchive: () -> Unit = {}, + onShowLogs: () -> Unit = {}, + onShowStatistics: () -> Unit = {}, + onShowRevenueStatistics: () -> Unit = {}, + onToggleJoinToSendMessages: (Boolean) -> Unit = {}, + onToggleJoinByRequest: (Boolean) -> Unit = {}, + onToggleHiddenMembers: (Boolean) -> Unit = {}, + onToggleAggressiveAntiSpam: (Boolean) -> Unit = {} ) { if (tabs.isEmpty()) return val selectedIndex = tabs.indexOfFirst { it.key == selectedTabKey }.takeIf { it >= 0 } ?: 0 val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() } val selectedMessageTab = state.messageTabState(selectedTab.key) - val selectedMembersTab = state.membersTab + val selectedMembersTab = state.memberTabState(selectedTab.key) stickyHeader { Surface( @@ -180,6 +212,15 @@ fun LazyGridScope.profileMediaSection( } } } + + if (selectedTab.key.isMemberTab() && selectedMembersTab.isSearchActive) { + ProfileMemberSearchField( + query = selectedMembersTab.searchQuery, + isSearching = selectedMembersTab.isSearching, + onQueryChange = onSearchQueryChanged, + onDismiss = onSearchDismissed + ) + } } } } @@ -201,13 +242,44 @@ fun LazyGridScope.profileMediaSection( onLoadMedia = onLoadMedia ) - ProfileTabKey.MEMBERS -> membersList( - members = state.members, - isLoading = selectedMembersTab.isLoadingInitial, - canLoadMore = selectedMembersTab.canLoadMore, - onLoadMore = onLoadMore, - onMemberClick = onMemberClick, - onMemberLongClick = onMemberLongClick + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED -> { + if (selectedTab.key == ProfileTabKey.ADMINS) { + adminToolsSection( + state = state, + onShowLogs = onShowLogs, + onShowStatistics = onShowStatistics, + onShowRevenueStatistics = onShowRevenueStatistics, + onToggleJoinToSendMessages = onToggleJoinToSendMessages, + onToggleJoinByRequest = onToggleJoinByRequest, + onToggleHiddenMembers = onToggleHiddenMembers, + onToggleAggressiveAntiSpam = onToggleAggressiveAntiSpam + ) + } + membersList( + members = selectedMembersTab.visibleItems, + isLoading = selectedMembersTab.isLoadingInitial || + (selectedMembersTab.isSearching && selectedMembersTab.visibleItems.isEmpty()), + canLoadMore = selectedMembersTab.canLoadMore && + !selectedMembersTab.isSearching && + !selectedMembersTab.isSearchResultsVisible, + onLoadMore = onLoadMore, + onMemberClick = onMemberClick, + onMemberLongClick = onMemberLongClick, + emptyTextRes = if (selectedMembersTab.isSearchResultsVisible) { + R.string.no_results_found + } else { + R.string.empty_members + } + ) + } + + ProfileTabKey.SIMILAR -> chatsList( + chats = state.relatedChats, + isLoading = state.isSimilarChatsLoading, + onChatClick = onChatClick ) ProfileTabKey.FILES -> filesList( @@ -254,7 +326,12 @@ fun LazyGridScope.profileMediaSection( val itemCount = when (selectedTab.key) { ProfileTabKey.STORIES -> 0 ProfileTabKey.MEDIA -> state.mediaMessages.size - ProfileTabKey.MEMBERS -> state.members.size + ProfileTabKey.MEMBERS, + ProfileTabKey.ADMINS, + ProfileTabKey.RESTRICTED, + ProfileTabKey.BANNED -> selectedMembersTab.visibleItems.size + + ProfileTabKey.SIMILAR -> state.relatedChats.size ProfileTabKey.FILES -> state.fileMessages.size ProfileTabKey.MUSIC -> state.musicMessages.size ProfileTabKey.VOICE -> state.voiceMessages.size @@ -263,14 +340,15 @@ fun LazyGridScope.profileMediaSection( } val shouldAutoLoadMore = when (selectedTab.key) { ProfileTabKey.STORIES -> false - ProfileTabKey.MEMBERS -> { + ProfileTabKey.SIMILAR -> false + else -> if (selectedTab.key.isMemberTab()) { selectedMembersTab.canLoadMore && + !selectedMembersTab.isSearching && + !selectedMembersTab.isSearchResultsVisible && !selectedMembersTab.isLoadingInitial && !selectedMembersTab.isLoadingNext && - selectedMembersTab.items.isNotEmpty() - } - - else -> { + selectedMembersTab.visibleItems.isNotEmpty() + } else { selectedMessageTab.canLoadMore && !selectedMessageTab.isLoadingInitial && !selectedMessageTab.isLoadingNext && @@ -288,11 +366,11 @@ fun LazyGridScope.profileMediaSection( } val showMembersPaginationSkeleton = - selectedTab.key == ProfileTabKey.MEMBERS && + selectedTab.key.isMemberTab() && selectedMembersTab.isLoadingNext && - selectedMembersTab.items.isNotEmpty() + selectedMembersTab.visibleItems.isNotEmpty() val showMediaPaginationSkeleton = - selectedTab.key != ProfileTabKey.MEMBERS && + !selectedTab.key.isMemberTab() && selectedTab.key != ProfileTabKey.STORIES && selectedMessageTab.isLoadingNext && selectedMessageTab.items.isNotEmpty() @@ -347,6 +425,250 @@ fun LazyGridScope.profileMediaSection( } } +private fun LazyGridScope.adminToolsSection( + state: ProfileComponent.State, + onShowLogs: () -> Unit, + onShowStatistics: () -> Unit, + onShowRevenueStatistics: () -> Unit, + onToggleJoinToSendMessages: (Boolean) -> Unit, + onToggleJoinByRequest: (Boolean) -> Unit, + onToggleHiddenMembers: (Boolean) -> Unit, + onToggleAggressiveAntiSpam: (Boolean) -> Unit +) { + val chat = state.chat ?: return + val fullInfo = state.fullInfo + val isGroupOrChannel = chat.isGroup || chat.isChannel + if (!isGroupOrChannel || !chat.isAdmin) return + + val pendingJoinRequestCount = chat.pendingJoinRequestCount + val hasStatisticsAccess = fullInfo?.canGetStatistics == true + val hasRevenueAccess = chat.isChannel && fullInfo?.canGetRevenueStatistics == true + val canToggleJoinToSendMessages = chat.isGroup + val canToggleJoinByRequest = + chat.isGroup && + chat.isSupergroup && + !chat.isChannel && + !chat.isAdministeredDirectMessagesGroup + val canToggleHiddenMembers = fullInfo?.canHideMembers == true + val canToggleAggressiveAntiSpam = fullInfo?.canToggleAggressiveAntiSpam == true + + val items = mutableListOf<@Composable (ItemPosition) -> Unit>() + + items += { pos -> + SettingsTile( + icon = Icons.Rounded.History, + title = stringResource(R.string.recent_actions_title), + subtitle = stringResource(R.string.recent_actions_subtitle), + iconColor = MaterialTheme.colorScheme.secondary, + position = pos, + onClick = onShowLogs + ) + } + + if (pendingJoinRequestCount > 0) { + items += { pos -> + SettingsTile( + icon = Icons.Rounded.AssignmentTurnedIn, + title = stringResource(R.string.profile_chat_join_requests), + subtitle = pluralStringResource( + R.plurals.profile_chat_join_requests_count, + pendingJoinRequestCount, + pendingJoinRequestCount + ), + iconColor = Color(0xFF8E24AA), + position = pos, + onClick = { } + ) + } + } + + if (hasStatisticsAccess) { + items += { pos -> + SettingsTile( + icon = Icons.Rounded.BarChart, + title = stringResource(R.string.statistics_title), + subtitle = stringResource(R.string.statistics_subtitle), + iconColor = Color(0xFF00BCD4), + position = pos, + onClick = onShowStatistics + ) + } + } + + if (hasRevenueAccess) { + items += { pos -> + SettingsTile( + icon = Icons.Rounded.Payments, + title = stringResource(R.string.revenue_title), + subtitle = stringResource(R.string.revenue_subtitle), + iconColor = Color(0xFFFF9800), + position = pos, + onClick = onShowRevenueStatistics + ) + } + } + + if (canToggleJoinToSendMessages) { + items += { pos -> + SettingsSwitchTile( + icon = Icons.AutoMirrored.Rounded.Login, + title = stringResource(R.string.permission_join_to_send_messages), + subtitle = stringResource(R.string.permission_join_to_send_messages_subtitle), + checked = chat.joinToSendMessages, + iconColor = Color(0xFF43A047), + position = pos, + onCheckedChange = onToggleJoinToSendMessages + ) + } + } + + if (canToggleJoinByRequest) { + items += { pos -> + SettingsSwitchTile( + icon = Icons.Rounded.AssignmentTurnedIn, + title = stringResource(R.string.profile_chat_join_by_request), + subtitle = stringResource(R.string.profile_chat_join_by_request_subtitle), + checked = chat.joinByRequest, + iconColor = Color(0xFF00897B), + position = pos, + onCheckedChange = onToggleJoinByRequest + ) + } + } + + if (canToggleHiddenMembers) { + items += { pos -> + SettingsSwitchTile( + icon = Icons.Rounded.Person, + title = stringResource(R.string.profile_chat_hidden_members), + subtitle = stringResource(R.string.profile_chat_hidden_members_subtitle), + checked = fullInfo?.hasHiddenMembers == true, + iconColor = MaterialTheme.colorScheme.secondary, + position = pos, + onCheckedChange = onToggleHiddenMembers + ) + } + } + + if (canToggleAggressiveAntiSpam) { + items += { pos -> + SettingsSwitchTile( + icon = Icons.Rounded.Shield, + title = stringResource(R.string.profile_chat_anti_spam), + subtitle = stringResource(R.string.profile_chat_anti_spam_subtitle), + checked = fullInfo?.hasAggressiveAntiSpamEnabled == true, + iconColor = MaterialTheme.colorScheme.tertiary, + position = pos, + onCheckedChange = onToggleAggressiveAntiSpam + ) + } + } + + if (items.isEmpty()) return + + item(span = { GridItemSpan(3) }, key = "admin_tools_section") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.profile_section_management), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + items.forEachIndexed { index, content -> + content(index.toItemPosition(items.size)) + } + } + } +} + +private fun Int.toItemPosition(total: Int): ItemPosition { + return when { + total <= 1 -> ItemPosition.STANDALONE + this == 0 -> ItemPosition.TOP + this == total - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } +} + +private fun LazyGridScope.chatsList( + chats: List, + isLoading: Boolean, + onChatClick: (Long) -> Unit +) { + val uniqueChats = chats.distinctBy { it.id } + + if (uniqueChats.isEmpty()) { + item(span = { GridItemSpan(3) }, key = "similar_chats_empty") { + if (isLoading) { + val shimmer = rememberShimmerBrush() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + repeat(4) { + MemberListSkeletonRow(shimmer = shimmer) + } + } + } else { + EmptyState(stringResource(R.string.empty_similar_chats)) + } + } + return + } + + itemsIndexed( + uniqueChats, + key = { _, chat -> "similar_${chat.id}" }, + span = { _, _ -> GridItemSpan(3) } + ) { _, chat -> + Column { + ListItem( + headlineContent = { + Text( + text = chat.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + val subtitle = when { + chat.memberCount > 0 -> pluralStringResource( + R.plurals.members_count_format, + chat.memberCount, + chat.memberCount + ) + + !chat.username.isNullOrBlank() -> "@${chat.username}" + !chat.description.isNullOrBlank() -> chat.description.orEmpty() + else -> stringResource(R.string.tab_similar) + } + Text( + text = subtitle, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + leadingContent = { + Avatar( + path = chat.avatarPath, + fallbackPath = chat.personalAvatarPath, + name = chat.title, + size = 42.dp, + isOnline = false + ) + }, + modifier = Modifier.clickable { onChatClick(chat.id) } + ) + HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) + } + } +} + @Composable private fun ScrollableRow( modifier: Modifier = Modifier, @@ -362,6 +684,89 @@ private fun ScrollableRow( } } +@Composable +private fun ProfileMemberSearchField( + query: String, + isSearching: Boolean, + onQueryChange: (String) -> Unit, + onDismiss: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.weight(1f) + ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { + Text(stringResource(R.string.search_hint)) + }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.search) + ) + }, + trailingIcon = { + when { + isSearching -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + query.isNotEmpty() -> { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent + ) + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.close_button) + ) + } + } + } +} + private fun LazyGridScope.storiesSection( state: ProfileComponent.State, onOpenActiveStory: (Int) -> Unit, @@ -580,7 +985,8 @@ private fun LazyGridScope.membersList( canLoadMore: Boolean, onLoadMore: () -> Unit, onMemberClick: (Long) -> Unit, - onMemberLongClick: (Long) -> Unit + onMemberLongClick: (Long) -> Unit, + emptyTextRes: Int ) { val uniqueMembers = members.distinctBy { it.user.id } @@ -596,7 +1002,7 @@ private fun LazyGridScope.membersList( } } else { item(span = { GridItemSpan(3) }) { - EmptyState(stringResource(R.string.empty_members)) + EmptyState(stringResource(emptyTextRes)) } } } else { diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index d42461465..95ed3cf38 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -36,12 +36,10 @@ import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.rounded.AlternateEmail import androidx.compose.material.icons.rounded.AssignmentTurnedIn -import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.Cake import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.ForwardToInbox -import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.LocationOn @@ -91,6 +89,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -336,6 +335,10 @@ fun ProfileInfoSection( onOpenMiniApp: (String, String, Long) -> Unit = { _, _, _ -> }, onSendMessage: () -> Unit = {}, onToggleMute: () -> Unit = {}, + onToggleJoinToSendMessages: (Boolean) -> Unit = {}, + onToggleJoinByRequest: (Boolean) -> Unit = {}, + onToggleHiddenMembers: (Boolean) -> Unit = {}, + onToggleAggressiveAntiSpam: (Boolean) -> Unit = {}, onShowQRCode: () -> Unit = {}, onEdit: () -> Unit = {}, exitAction: ChatExitAction = ChatExitAction.None, @@ -427,6 +430,18 @@ fun ProfileInfoSection( isGroupOrChannel -> chat?.isAdmin == true || chat?.permissions?.canChangeInfo == true else -> false } + val canToggleJoinToSendMessages = chat?.isGroup == true && canEdit + val canToggleJoinByRequest = + chat?.isGroup == true && + chat.isSupergroup && + !chat.isChannel && + chat.isAdmin && + !chat.isAdministeredDirectMessagesGroup + val canToggleHiddenMembers = isGroupOrChannel && fullInfo?.canHideMembers == true + val canToggleAggressiveAntiSpam = + isGroupOrChannel && fullInfo?.canToggleAggressiveAntiSpam == true + val pendingJoinRequestCount = chat?.pendingJoinRequestCount ?: 0 + val commonGroupsCount = fullInfo?.commonGroupsCount ?: 0 if (!isCurrentUser) { ProfileQuickActions( @@ -480,6 +495,23 @@ fun ProfileInfoSection( } } + if (!isGroupOrChannel && commonGroupsCount > 0) { + detailsItems.add { pos -> + SettingsTile( + icon = Icons.AutoMirrored.Filled.Chat, + title = stringResource(R.string.profile_common_groups), + subtitle = pluralStringResource( + R.plurals.profile_common_groups_count, + commonGroupsCount, + commonGroupsCount + ), + iconColor = MaterialTheme.colorScheme.secondary, + position = pos, + onClick = { } + ) + } + } + if (user?.type == UserTypeEnum.BOT && !state.botWebAppUrl.isNullOrEmpty()) { val botName = listOfNotNull(user.firstName, user.lastName).joinToString(" ").trim() managementItems.add { pos -> @@ -754,33 +786,6 @@ fun ProfileInfoSection( } } - if (chat?.isAdmin == true && isGroupOrChannel) { - managementItems.add { pos -> - SettingsTile( - icon = Icons.Rounded.History, - title = stringResource(R.string.recent_actions_title), - subtitle = stringResource(R.string.recent_actions_subtitle), - iconColor = MaterialTheme.colorScheme.secondary, - position = pos, - onClick = onShowLogs - ) - } - } - - val hasStatisticsAccess = isGroupOrChannel && fullInfo?.canGetStatistics == true - if (hasStatisticsAccess) { - managementItems.add { pos -> - SettingsTile( - icon = Icons.Rounded.BarChart, - title = stringResource(R.string.statistics_title), - subtitle = stringResource(R.string.statistics_subtitle), - iconColor = Color(0xFF00BCD4), - position = pos, - onClick = { onShowStatistics() } - ) - } - } - if (!isGroupOrChannel && isCurrentUser && fullInfo != null) { if (fullInfo.setChatBackground) { detailsItems.add { pos -> @@ -826,12 +831,12 @@ fun ProfileInfoSection( } if (isGroupOrChannel && fullInfo != null) { - if (fullInfo.hasAggressiveAntiSpamEnabled) { + if (fullInfo.hasAggressiveAntiSpamEnabled && !canToggleAggressiveAntiSpam) { detailsItems.add { pos -> SettingsTile( icon = Icons.Rounded.Shield, title = stringResource(R.string.profile_chat_anti_spam), - subtitle = stringResource(R.string.profile_chat_tools), + subtitle = stringResource(R.string.profile_chat_anti_spam_subtitle), iconColor = MaterialTheme.colorScheme.tertiary, position = pos, onClick = { } @@ -839,6 +844,19 @@ fun ProfileInfoSection( } } + if (fullInfo.hasHiddenMembers && !canToggleHiddenMembers) { + detailsItems.add { pos -> + SettingsTile( + icon = Icons.Rounded.Person, + title = stringResource(R.string.profile_chat_hidden_members), + subtitle = stringResource(R.string.profile_chat_hidden_members_subtitle), + iconColor = MaterialTheme.colorScheme.secondary, + position = pos, + onClick = { } + ) + } + } + if (fullInfo.hasSponsoredMessagesEnabled) { detailsItems.add { pos -> SettingsTile( @@ -886,30 +904,41 @@ fun ProfileInfoSection( } } - if (!isGroupOrChannel && fullInfo?.hasPrivateForwards == true) { + if (chat?.isGroup == true && chat.joinToSendMessages && !canToggleJoinToSendMessages) { detailsItems.add { pos -> SettingsTile( - icon = Icons.Rounded.ForwardToInbox, - title = stringResource(R.string.private_forwards_title), - subtitle = stringResource(R.string.private_forwards_subtitle), - iconColor = Color(0xFF5E35B1), + icon = Icons.AutoMirrored.Rounded.Login, + title = stringResource(R.string.permission_join_to_send_messages), + subtitle = stringResource(R.string.permission_join_to_send_messages_subtitle), + iconColor = Color(0xFF43A047), position = pos, onClick = { } ) } } - val hasRevenueAccess = chat?.isChannel == true && fullInfo?.canGetRevenueStatistics == true + if (chat?.joinByRequest == true && !canToggleJoinByRequest) { + detailsItems.add { pos -> + SettingsTile( + icon = Icons.Rounded.AssignmentTurnedIn, + title = stringResource(R.string.profile_chat_join_by_request), + subtitle = stringResource(R.string.profile_chat_join_by_request_subtitle), + iconColor = Color(0xFF00897B), + position = pos, + onClick = { } + ) + } + } - if (hasRevenueAccess) { - managementItems.add { pos -> + if (!isGroupOrChannel && fullInfo?.hasPrivateForwards == true) { + detailsItems.add { pos -> SettingsTile( - icon = Icons.Rounded.Payments, - title = stringResource(R.string.revenue_title), - subtitle = stringResource(R.string.revenue_subtitle), - iconColor = Color(0xFFFF9800), + icon = Icons.Rounded.ForwardToInbox, + title = stringResource(R.string.private_forwards_title), + subtitle = stringResource(R.string.private_forwards_subtitle), + iconColor = Color(0xFF5E35B1), position = pos, - onClick = { onShowRevenueStatistics() } + onClick = { } ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 6ac962a5e..f2c230936 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -835,9 +835,9 @@ class DefaultRootComponent( scope.launch { val me = userRepository.getMe() if (config.chatId == me.id) { - navigation.push(Config.EditProfile) + navigation.bringToFront(Config.EditProfile) } else { - navigation.push(Config.ChatEdit(config.chatId)) + navigation.bringToFront(Config.ChatEdit(config.chatId)) } } }, diff --git a/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt b/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt index af46aa640..4d385c3bc 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt @@ -5,8 +5,16 @@ import coil3.imageLoader import org.monogram.presentation.core.media.ExoPlayerCache import java.io.File +data class AppTempCacheUsage( + val size: Long, + val fileCount: Int +) { + val isEmpty: Boolean get() = size <= 0L || fileCount <= 0 +} + class CacheController(val context: Context, val exoPlayerCache: ExoPlayerCache) { - private val tdLibCacheRelativePath = "tdlib/files" + private val tdlibCacheRootName = "tdlib" + private val appTempFilesDirPrefixes = listOf("CIRCLE_FULL_") fun clearExo() { exoPlayerCache.clearCache(context) @@ -20,17 +28,77 @@ class CacheController(val context: Context, val exoPlayerCache: ExoPlayerCache) fun clearAllCache() { clearExo() clearImageLoader() - clearTdLibCache() + clearAppTempFiles() + clearAppInternalTempFiles() } - fun getCacheDir(): File? { + fun getAppTempUsage(): AppTempCacheUsage { + val files = buildList { + collectCacheTempFiles(context.cacheDir, this) + context.externalCacheDir?.let { collectCacheTempFiles(it, this) } + collectInternalTempFiles(context.filesDir, this) + } + return AppTempCacheUsage( + size = files.sumOf(File::length), + fileCount = files.size + ) + } + + fun getCacheDir(): File { return context.cacheDir } - private fun clearTdLibCache() { - File(context.cacheDir, tdLibCacheRelativePath).deleteRecursively() - context.externalCacheDir?.let { externalCacheDir -> - File(externalCacheDir, tdLibCacheRelativePath).deleteRecursively() + private fun clearAppTempFiles() { + clearDirectoryChildren(context.cacheDir) + context.externalCacheDir?.let(::clearDirectoryChildren) + } + + private fun clearDirectoryChildren(directory: File) { + val children = directory.listFiles() ?: return + children.forEach { child -> + if (child.name == tdlibCacheRootName) { + return@forEach + } + child.deleteRecursively() + } + } + + private fun collectCacheTempFiles(directory: File, destination: MutableList) { + val children = directory.listFiles() ?: return + children.forEach { child -> + if (child.name == tdlibCacheRootName) { + return@forEach + } + collectFilesRecursively(child, destination) + } + } + + private fun clearAppInternalTempFiles() { + val children = context.filesDir.listFiles() ?: return + children.forEach { child -> + if (appTempFilesDirPrefixes.any(child.name::startsWith)) { + child.deleteRecursively() + } + } + } + + private fun collectInternalTempFiles(directory: File, destination: MutableList) { + val children = directory.listFiles() ?: return + children.forEach { child -> + if (appTempFilesDirPrefixes.any(child.name::startsWith)) { + collectFilesRecursively(child, destination) + } + } + } + + private fun collectFilesRecursively(file: File, destination: MutableList) { + if (!file.exists()) return + if (file.isFile) { + destination += file + return + } + file.listFiles()?.forEach { child -> + collectFilesRecursively(child, destination) } } } \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt index 51c27b252..a8383d942 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt @@ -9,12 +9,22 @@ import com.arkivanov.decompose.value.update import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.monogram.domain.models.ChatStorageUsageModel +import org.monogram.domain.models.FileTypeStorageUsageModel +import org.monogram.domain.models.StorageCleanupResultModel +import org.monogram.domain.models.StorageUsageBreakdownModel import org.monogram.domain.models.StorageUsageModel +import org.monogram.domain.repository.MessageDisplayer import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext +import java.util.Locale + +internal const val AppTempChatId: Long = Long.MIN_VALUE +private const val AppTempFileType = "AppTemp" interface StorageUsageComponent { val state: Value @@ -27,6 +37,8 @@ interface StorageUsageComponent { data class State( val usage: StorageUsageModel? = null, + val breakdown: StorageUsageBreakdownModel? = null, + val appTempUsage: AppTempCacheUsage = AppTempCacheUsage(0L, 0), val isLoading: Boolean = true, val cacheLimitSize: Long = -1L, val autoClearCacheTime: Int = -1, @@ -43,6 +55,8 @@ class DefaultStorageUsageComponent( private val appPreferences: AppPreferences = container.preferences.appPreferences private val stickerRepository: StickerRepository = container.repositories.stickerRepository private val cacheController: CacheController = container.utils.cacheController + private val stringProvider: StringProvider = container.utils.stringProvider() + private val messageDisplayer: MessageDisplayer = container.utils.messageDisplayer() private val _state = MutableValue(StorageUsageComponent.State()) override val state: Value = _state @@ -67,8 +81,21 @@ class DefaultStorageUsageComponent( private fun loadStatistics() { _state.update { it.copy(isLoading = true) } scope.launch { - val usage = storageRepository.getStorageUsage() - _state.update { it.copy(usage = usage, isLoading = false) } + val appTempUsage = cacheController.getAppTempUsage() + val usage = mergeStorageUsageWithAppTemp( + storageUsage = storageRepository.getStorageUsage(), + appTempUsage = appTempUsage, + appTempTitle = stringProvider.getString("storage_other_cache") + ) + val breakdown = storageRepository.getStorageUsageBreakdown() + _state.update { + it.copy( + usage = usage, + breakdown = breakdown, + appTempUsage = appTempUsage, + isLoading = false + ) + } } } @@ -90,21 +117,37 @@ class DefaultStorageUsageComponent( @OptIn(UnstableApi::class, ExperimentalCoilApi::class) override fun onClearAllClicked() { scope.launch { - val success = storageRepository.clearStorage() - if (success) { - stickerRepository.clearCache() - cacheController.clearAllCache() - loadStatistics() - } + val appTempUsage = cacheController.getAppTempUsage() + val cleanupResult = storageRepository.clearStorage() + stickerRepository.clearCache() + cacheController.clearAllCache() + showCleanupMessage(cleanupResult, appTempUsage.size) + loadStatistics() } } override fun onClearChatClicked(chatId: Long) { - scope.launch { - val success = storageRepository.clearStorage(chatId) - if (success) { + if (chatId == AppTempChatId) { + scope.launch { + val appTempUsage = cacheController.getAppTempUsage() + stickerRepository.clearCache() + cacheController.clearAllCache() + showCleanupMessage( + cleanupResult = StorageCleanupResultModel( + tdlibFreedSize = 0L, + tdlibFreedFileCount = 0, + tdlibCleanupSucceeded = true + ), + appTempFreedSize = appTempUsage.size + ) loadStatistics() } + return + } + scope.launch { + val cleanupResult = storageRepository.clearStorage(chatId) + showCleanupMessage(cleanupResult) + loadStatistics() } } @@ -124,4 +167,80 @@ class DefaultStorageUsageComponent( _state.update { it.copy(isStorageOptimizerEnabled = enabled) } } } + + private fun showCleanupMessage( + cleanupResult: StorageCleanupResultModel, + appTempFreedSize: Long = 0L + ) { + val totalFreedSize = cleanupResult.tdlibFreedSize + appTempFreedSize + val message = when { + totalFreedSize > 0L && cleanupResult.tdlibCleanupSucceeded -> { + stringProvider.getString( + "storage_cleanup_result_freed", + formatStorageSize(totalFreedSize) + ) + } + + totalFreedSize > 0L -> { + stringProvider.getString( + "storage_cleanup_result_partial", + formatStorageSize(totalFreedSize) + ) + } + + cleanupResult.tdlibCleanupSucceeded -> { + stringProvider.getString("storage_cleanup_result_empty") + } + + else -> { + stringProvider.getString("storage_cleanup_result_failed") + } + } + messageDisplayer.show(message) + } +} + +internal fun mergeStorageUsageWithAppTemp( + storageUsage: StorageUsageModel?, + appTempUsage: AppTempCacheUsage, + appTempTitle: String +): StorageUsageModel? { + if (appTempUsage.isEmpty) { + return storageUsage + } + + val appTempEntry = ChatStorageUsageModel( + chatId = AppTempChatId, + chatTitle = appTempTitle, + size = appTempUsage.size, + fileCount = appTempUsage.fileCount, + byFileType = listOf( + FileTypeStorageUsageModel( + fileType = AppTempFileType, + size = appTempUsage.size, + fileCount = appTempUsage.fileCount + ) + ) + ) + + return if (storageUsage == null) { + StorageUsageModel( + totalSize = appTempUsage.size, + fileCount = appTempUsage.fileCount, + chatStats = listOf(appTempEntry) + ) + } else { + storageUsage.copy( + totalSize = storageUsage.totalSize + appTempUsage.size, + fileCount = storageUsage.fileCount + appTempUsage.fileCount, + chatStats = storageUsage.chatStats + appTempEntry + ) + } +} + +private fun formatStorageSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val exp = (Math.log(bytes.toDouble()) / Math.log(1024.0)).toInt() + val pre = "KMGTPE"[exp - 1] + return String.format(Locale.US, "%.1f %cB", bytes / Math.pow(1024.0, exp.toDouble()), pre) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageContent.kt index f49f7326f..92b996319 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageContent.kt @@ -8,16 +8,54 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.AutoFixHigh +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.SdStorage +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,10 +71,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ChatStorageUsageModel +import org.monogram.domain.models.StorageUsageBreakdownModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsTile -import java.util.* +import java.util.Locale import kotlin.math.roundToInt private val ChartColors = listOf( @@ -124,6 +163,22 @@ fun StorageUsageContent(component: StorageUsageComponent) { } } + if (state.breakdown != null || !state.appTempUsage.isEmpty) { + item { + SectionHeader(stringResource(R.string.storage_breakdown_header)) + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + StorageBreakdownCard( + breakdown = state.breakdown, + appTempUsage = state.appTempUsage + ) + } + } + } + item { Column(modifier = Modifier.fillMaxWidth()) { Spacer(Modifier.height(24.dp)) @@ -681,6 +736,61 @@ private fun SectionHeader(text: String) { private data class ChartSegment(val value: Long, val count: Int, val color: Color, val label: String) +@Composable +private fun StorageBreakdownCard( + breakdown: StorageUsageBreakdownModel?, + appTempUsage: AppTempCacheUsage +) { + val rows = buildList { + if (breakdown != null) { + add( + stringResource(R.string.storage_breakdown_tdlib_media) to breakdown.tdlibMediaSize + ) + add( + stringResource(R.string.storage_breakdown_tdlib_database) to breakdown.tdlibDatabaseSize + ) + add( + stringResource(R.string.storage_breakdown_tdlib_logs) to breakdown.tdlibLogsSize + ) + add( + stringResource(R.string.storage_breakdown_language_pack) to breakdown.languagePackDatabaseSize + ) + } + if (!appTempUsage.isEmpty) { + add( + stringResource(R.string.storage_breakdown_app_temp) to appTempUsage.size + ) + } + }.filter { it.second > 0L } + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + rows.forEachIndexed { index, (label, size) -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = formatSize(size), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) + } + if (index < rows.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } + } + } +} + private fun formatFileType(type: String): String { return type.replace("FileType", "") .replace(Regex("(?Сообщений пока нет Закреплено Упоминания + Реакции Скрыто из основного списка Чатов пока нет Начните новую беседу @@ -253,6 +254,13 @@ Ограничения Инструменты чата Агрессивный антиспам + Автоматически фильтрует подозрительные вступления и спам-активность + Скрытые участники + Скрывает список участников от обычных участников чата + Вступление по заявке + Новые участники смогут вступать только после одобрения администратора + Заявки на вступление + Общие группы Sponsoring-сообщения включены Личные сообщения привязаны %1$d бустов от вас @@ -857,6 +865,16 @@ Хранить медиа Оптимизатор хранилища Фоновая очистка памяти + Разбивка хранилища + Медиакэш TDLib + База данных TDLib + Логи TDLib + База данных языковых пакетов + Временный кэш приложения + Освобождено %1$s + Освобождено %1$s, но очистку TDLib не удалось полностью подтвердить. + Кэшированные файлы не были удалены. + Не удалось подтвердить очистку кэша. Подробности Память свободна Кэшированные файлы не найдены. @@ -1359,12 +1377,17 @@ Медиа Истории Участники + Админы + Ограниченные + Забаненные + Похожие Файлы Музыка Голос Ссылки GIF Участники не найдены + Похожие чаты не найдены Медиа не найдены Истории не найдены Аудиозаписи не найдены @@ -2354,6 +2377,18 @@ %1$d секунда %1$d секунд + + Ожидает одобрения %1$d заявка + Ожидают одобрения %1$d заявки + Ожидают одобрения %1$d заявок + Ожидают одобрения %1$d заявки + + + %1$d общая группа + %1$d общие группы + %1$d общих групп + %1$d общие группы + Сегодня Показать все Поиск... diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 080cad177..54c06087f 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -177,6 +177,7 @@ No messages yet Pinned Mentions + Reactions Hidden from the main list No chats yet Start a new conversation @@ -282,6 +283,13 @@ Restrictions Chat tools Aggressive anti-spam + Automatically filter suspicious joins and spam activity + Hidden members + Hide the member list from regular participants + Join by request + Require admins to approve new members before they can join + Join requests + Groups in common Sponsored messages enabled Direct messages linked %1$d boosts from you @@ -505,6 +513,14 @@ %1$d second %1$d seconds + + %1$d pending approval + %1$d pending approvals + + + %1$d shared group + %1$d shared groups + Save to Downloads Cocoon Summary @@ -1293,6 +1309,16 @@ Auto-Clear Cache Storage Optimizer Background storage optimization + Storage Breakdown + TDLib media cache + TDLib database + TDLib logs + Language pack database + App temporary cache + Freed %1$s + Freed %1$s, but TDLib cleanup couldn\'t be fully confirmed. + No cached files were removed. + Cache cleanup couldn\'t be confirmed. Detailed Usage Storage is Clean No cached files found. @@ -1989,12 +2015,17 @@ Media Stories Members + Admins + Restricted + Banned + Similar Files Audio Voice Links GIFs No members found + No similar chats found No media found No stories found No audio found diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt index b49fad665..9cbb6da64 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt @@ -11,9 +11,10 @@ class ChatInitialLoadPolicyTest { val key = buildChatInitialLoadKey( chatId = 1L, effectiveThreadId = null, - initialMessageId = null, + explicitMessageId = null, savedViewport = null, firstUnreadMessageId = null, + unreadCount = 0, rootMessageId = null ) @@ -25,9 +26,14 @@ class ChatInitialLoadPolicyTest { val key = buildChatInitialLoadKey( chatId = 1L, effectiveThreadId = 2L, - initialMessageId = 10L, - savedViewport = ChatViewportCacheEntry(20L, 4, false), + explicitMessageId = 10L, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 20L, + anchorOffsetPx = 4, + atBottom = false + ), firstUnreadMessageId = 30L, + unreadCount = 1, rootMessageId = 40L ) @@ -39,20 +45,68 @@ class ChatInitialLoadPolicyTest { val previous = buildChatInitialLoadKey( chatId = 1L, effectiveThreadId = 2L, - initialMessageId = null, - savedViewport = ChatViewportCacheEntry(20L, 4, false), + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 20L, + anchorOffsetPx = 4, + atBottom = false + ), firstUnreadMessageId = null, + unreadCount = 0, rootMessageId = null ) val next = buildChatInitialLoadKey( chatId = 1L, effectiveThreadId = 2L, - initialMessageId = null, - savedViewport = ChatViewportCacheEntry(21L, 4, false), + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 21L, + anchorOffsetPx = 4, + atBottom = false + ), firstUnreadMessageId = null, + unreadCount = 0, rootMessageId = null ) assertTrue(shouldStartInitialLoad(previous, next, hasStartedForCurrentContext = true)) } + + @Test + fun `same anchor with different source starts a new initial load`() { + val savedViewportKey = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = null, + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 42L, + anchorOffsetPx = 0, + atBottom = false + ), + firstUnreadMessageId = null, + unreadCount = 0, + rootMessageId = null + ) + val explicitMessageKey = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = null, + explicitMessageId = 42L, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 42L, + anchorOffsetPx = 0, + atBottom = false + ), + firstUnreadMessageId = null, + unreadCount = 0, + rootMessageId = null + ) + + assertTrue( + shouldStartInitialLoad( + savedViewportKey, + explicitMessageKey, + hasStartedForCurrentContext = true + ) + ) + } } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicyTest.kt index 8caa54370..7c4d88f66 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicyTest.kt @@ -11,6 +11,7 @@ class ChatViewportRestorePolicyTest { @Test fun `explicit message wins over saved viewport and unread`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = 99L, savedViewport = ChatViewportCacheEntry( anchorMessageId = 42L, @@ -23,19 +24,24 @@ class ChatViewportRestorePolicyTest { val around = target as InitialChatScrollTarget.AroundMessage assertEquals(99L, around.messageId) + assertEquals(InitialChatScrollTargetOrigin.ExplicitMessage, around.origin) assertTrue(around.highlight) assertFalse(around.backfillNewerAfterInitialLoad) assertEquals(99L, (around.command as ChatScrollCommand.JumpToMessage).messageId) + assertEquals("around.explicit", target.perfTargetName()) } @Test fun `saved non-bottom viewport wins over unread on repeat open`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = ChatViewportCacheEntry( anchorMessageId = 42L, + anchorAliasIds = listOf(41L, 43L), anchorOffsetPx = 12, - atBottom = false + atBottom = false, + readFully = false ), firstUnreadMessageId = 50L, isComments = false @@ -44,14 +50,60 @@ class ChatViewportRestorePolicyTest { val around = target as InitialChatScrollTarget.AroundMessage val command = around.command as ChatScrollCommand.RestoreViewport assertEquals(42L, around.messageId) + assertEquals(InitialChatScrollTargetOrigin.SavedViewport, around.origin) assertFalse(around.highlight) assertFalse(around.backfillNewerAfterInitialLoad) + assertEquals(listOf(41L, 43L), command.anchorAliasIds) assertEquals(12, command.anchorOffsetPx) + assertEquals("around.saved_viewport", target.perfTargetName()) + } + + @Test + fun `saved read fully viewport yields to unread`() { + val target = resolveInitialChatScrollTarget( + chatId = 1L, + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 42L, + anchorOffsetPx = 12, + atBottom = false, + readFully = true + ), + firstUnreadMessageId = 50L, + isComments = false + ) + + val around = target as InitialChatScrollTarget.AroundMessage + assertEquals(50L, around.messageId) + assertEquals(InitialChatScrollTargetOrigin.FirstUnread, around.origin) + } + + @Test + fun `saved viewport can restore from alias when primary anchor is absent`() { + val target = resolveInitialChatScrollTarget( + chatId = 1L, + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = null, + anchorAliasIds = listOf(77L, 78L), + anchorOffsetPx = 8, + atBottom = false, + readFully = false + ), + firstUnreadMessageId = null, + isComments = false + ) + + val around = target as InitialChatScrollTarget.AroundMessage + val command = around.command as ChatScrollCommand.RestoreViewport + assertEquals(77L, around.messageId) + assertEquals(listOf(77L, 78L), command.anchorAliasIds) } @Test fun `unread wins when saved viewport is absent without backfill for small unread`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = null, firstUnreadMessageId = 50L, @@ -61,6 +113,7 @@ class ChatViewportRestorePolicyTest { val around = target as InitialChatScrollTarget.AroundMessage assertEquals(50L, around.messageId) + assertEquals(InitialChatScrollTargetOrigin.FirstUnread, around.origin) assertFalse(around.highlight) assertFalse(around.backfillNewerAfterInitialLoad) } @@ -68,6 +121,7 @@ class ChatViewportRestorePolicyTest { @Test fun `large unread root chat requests newer backfill`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = null, firstUnreadMessageId = 50L, @@ -80,11 +134,13 @@ class ChatViewportRestorePolicyTest { assertEquals(50L, around.messageId) assertFalse(around.highlight) assertTrue(around.backfillNewerAfterInitialLoad) + assertEquals("around.first_unread", target.perfTargetName()) } @Test fun `large unread comments do not request newer backfill`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = null, firstUnreadMessageId = 50L, @@ -99,6 +155,7 @@ class ChatViewportRestorePolicyTest { @Test fun `saved at-bottom with unread opens around first unread`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = ChatViewportCacheEntry( anchorMessageId = null, @@ -118,6 +175,7 @@ class ChatViewportRestorePolicyTest { @Test fun `saved at-bottom without unread opens bottom`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = ChatViewportCacheEntry( anchorMessageId = null, @@ -129,24 +187,31 @@ class ChatViewportRestorePolicyTest { ) val bottom = target as InitialChatScrollTarget.Bottom + assertEquals(InitialChatScrollTargetOrigin.BottomSavedViewport, bottom.origin) assertTrue((bottom.command as ChatScrollCommand.RestoreViewport).atBottom) + assertEquals("bottom.bottom_saved", target.perfTargetName()) } @Test fun `comments without saved viewport open from start`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = null, firstUnreadMessageId = 50L, isComments = true ) - assertTrue((target as InitialChatScrollTarget.Comments).command is ChatScrollCommand.ScrollToStart) + val comments = target as InitialChatScrollTarget.Comments + assertEquals(InitialChatScrollTargetOrigin.CommentsStart, comments.origin) + assertTrue(comments.command is ChatScrollCommand.ScrollToStart) + assertEquals("comments.comments_start", target.perfTargetName()) } @Test fun `comments with saved at-bottom and unread restore saved viewport`() { val target = resolveInitialChatScrollTarget( + chatId = 1L, explicitMessageId = null, savedViewport = ChatViewportCacheEntry( anchorMessageId = null, @@ -157,8 +222,31 @@ class ChatViewportRestorePolicyTest { isComments = true ) - val command = - (target as InitialChatScrollTarget.Comments).command as ChatScrollCommand.RestoreViewport + val comments = target as InitialChatScrollTarget.Comments + assertEquals(InitialChatScrollTargetOrigin.CommentsSavedViewport, comments.origin) + val command = comments.command as ChatScrollCommand.RestoreViewport assertTrue(command.atBottom) + assertEquals("comments.comments_saved", target.perfTargetName()) + } + + @Test + fun `saved viewport with mismatched anchor chat id is ignored`() { + val target = resolveInitialChatScrollTarget( + chatId = 1L, + explicitMessageId = null, + savedViewport = ChatViewportCacheEntry( + anchorMessageId = 42L, + anchorOffsetPx = 12, + atBottom = false, + readFully = false, + anchorChatId = 2L + ), + firstUnreadMessageId = null, + isComments = false + ) + + val bottom = target as InitialChatScrollTarget.Bottom + assertEquals(InitialChatScrollTargetOrigin.BottomFallback, bottom.origin) + assertTrue(bottom.command is ChatScrollCommand.ScrollToBottom) } } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStackTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStackTest.kt new file mode 100644 index 000000000..89b8ab89a --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportReturnStackTest.kt @@ -0,0 +1,54 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.monogram.domain.models.ChatViewportCacheEntry + +class ChatViewportReturnStackTest { + @Test + fun `push appends distinct return target and trims to max size`() { + val viewport = ChatViewportCacheEntry(returnToMessageIds = listOf(1L, 2L, 3L)) + + val updated = pushViewportReturnTarget( + viewport = viewport, + returnTargetMessageId = 4L, + maxSize = 3 + ) + + assertEquals(listOf(2L, 3L, 4L), updated?.returnToMessageIds) + } + + @Test + fun `push does not duplicate existing return target`() { + val viewport = ChatViewportCacheEntry(returnToMessageIds = listOf(1L, 2L, 3L)) + + val updated = pushViewportReturnTarget( + viewport = viewport, + returnTargetMessageId = 2L, + maxSize = 5 + ) + + assertEquals(listOf(1L, 3L, 2L), updated?.returnToMessageIds) + } + + @Test + fun `pop returns last target and removes it from viewport`() { + val viewport = ChatViewportCacheEntry(returnToMessageIds = listOf(10L, 20L, 30L)) + + val result = popViewportReturnTarget(viewport) + + assertEquals(30L, result.targetMessageId) + assertEquals(listOf(10L, 20L), result.viewport?.returnToMessageIds) + } + + @Test + fun `pop from empty stack keeps viewport and returns null target`() { + val viewport = ChatViewportCacheEntry(returnToMessageIds = emptyList()) + + val result = popViewportReturnTarget(viewport) + + assertNull(result.targetMessageId) + assertEquals(emptyList(), result.viewport?.returnToMessageIds) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/SearchDateJumpPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/SearchDateJumpPolicyTest.kt new file mode 100644 index 000000000..eea9bc2d9 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/SearchDateJumpPolicyTest.kt @@ -0,0 +1,66 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SearchDateJumpPolicyTest { + @Test + fun `single day root chat without query resolves date jump`() { + val request = resolveSearchDateJumpRequest( + query = "", + senderId = null, + fromEpochSeconds = 1_700_000_000, + toEpochSeconds = 1_700_086_399, + isThreadScoped = false + ) + + requireNotNull(request) + assertEquals(1_700_000_000, request.fromEpochSeconds) + assertEquals(1_700_086_399, request.targetEpochSeconds) + } + + @Test + fun `multi day range keeps regular search`() { + assertNull( + resolveSearchDateJumpRequest( + query = "", + senderId = null, + fromEpochSeconds = 1_700_000_000, + toEpochSeconds = 1_700_172_799, + isThreadScoped = false + ) + ) + } + + @Test + fun `query sender or thread scope disables date jump`() { + assertNull( + resolveSearchDateJumpRequest( + query = "hello", + senderId = null, + fromEpochSeconds = 1, + toEpochSeconds = 10, + isThreadScoped = false + ) + ) + assertNull( + resolveSearchDateJumpRequest( + query = "", + senderId = 42L, + fromEpochSeconds = 1, + toEpochSeconds = 10, + isThreadScoped = false + ) + ) + assertNull( + resolveSearchDateJumpRequest( + query = "", + senderId = null, + fromEpochSeconds = 1, + toEpochSeconds = 10, + isThreadScoped = true + ) + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicyTest.kt new file mode 100644 index 000000000..89ea625a7 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadShortcutPolicyTest.kt @@ -0,0 +1,118 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ChatComponent + +class UnreadShortcutPolicyTest { + @Test + fun `latest loaded unread mention is preferred as shortcut target`() { + val action = resolveUnreadShortcutAction( + messages = listOf( + message(id = 11L, hasUnreadMention = true), + message(id = 12L, hasUnreadMention = false), + message(id = 14L, hasUnreadMention = true) + ), + targetChatId = CHAT_ID, + isLatestLoaded = true, + isOldestLoaded = false, + type = UnreadShortcutType.Mention + ) + + assertEquals(14L, (action as UnreadShortcutAction.ScrollToMessage).messageId) + } + + @Test + fun `missing unread shortcut target falls back to loading newer first`() { + val action = resolveUnreadShortcutAction( + messages = listOf(message(id = 11L), message(id = 12L)), + targetChatId = CHAT_ID, + isLatestLoaded = false, + isOldestLoaded = false, + type = UnreadShortcutType.Reaction + ) + + assertEquals(UnreadShortcutAction.LoadNewer, action) + } + + @Test + fun `missing unread shortcut target falls back to loading older after latest is loaded`() { + val action = resolveUnreadShortcutAction( + messages = listOf(message(id = 11L), message(id = 12L)), + targetChatId = CHAT_ID, + isLatestLoaded = true, + isOldestLoaded = false, + type = UnreadShortcutType.Mention + ) + + assertEquals(UnreadShortcutAction.LoadOlder, action) + } + + @Test + fun `clearing unread mentions resets count and loaded flags for active chat only`() { + val state = ChatComponent.State( + chatId = CHAT_ID, + unreadMentionCount = 3, + unreadReactionCount = 2, + messages = listOf( + message(id = 11L, hasUnreadMention = true), + message(id = 12L, chatId = OTHER_CHAT_ID, hasUnreadMention = true), + message(id = 13L, hasUnreadReactions = true) + ) + ) + + val cleared = state.clearUnreadShortcut( + targetChatId = CHAT_ID, + type = UnreadShortcutType.Mention + ) + + assertEquals(0, cleared.unreadMentionCount) + assertEquals(2, cleared.unreadReactionCount) + assertFalse(cleared.messages.first { it.id == 11L }.hasUnreadMention) + assertTrue(cleared.messages.first { it.id == 12L }.hasUnreadMention) + assertTrue(cleared.messages.first { it.id == 13L }.hasUnreadReactions) + } + + @Test + fun `clearing unread reactions also clears root message flag`() { + val state = ChatComponent.State( + chatId = CHAT_ID, + unreadReactionCount = 1, + rootMessage = message(id = 99L, hasUnreadReactions = true) + ) + + val cleared = state.clearUnreadShortcut( + targetChatId = CHAT_ID, + type = UnreadShortcutType.Reaction + ) + + assertEquals(0, cleared.unreadReactionCount) + assertFalse(cleared.rootMessage?.hasUnreadReactions == true) + } + + private fun message( + id: Long, + chatId: Long = CHAT_ID, + hasUnreadMention: Boolean = false, + hasUnreadReactions: Boolean = false + ): MessageModel = + MessageModel( + id = id, + date = id.toInt(), + isOutgoing = false, + senderName = "sender", + chatId = chatId, + content = MessageContent.Text("text"), + hasUnreadMention = hasUnreadMention, + hasUnreadReactions = hasUnreadReactions + ) + + private companion object { + const val CHAT_ID = 1L + const val OTHER_CHAT_ID = 2L + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/profile/ProfileTabsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/profile/ProfileTabsTest.kt new file mode 100644 index 000000000..1473668db --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/profile/ProfileTabsTest.kt @@ -0,0 +1,211 @@ +package org.monogram.presentation.features.profile + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.ChatModel + +class ProfileTabsTest { + @Test + fun `buildProfileTabSpecs injects member moderation tabs only when enabled`() { + val tabs = buildProfileTabSpecs( + isGroupOrChannel = true, + preferredTabKey = ProfileTabKey.RESTRICTED, + showMembers = true, + showAdministrators = true, + showRestricted = true, + showBanned = false + ) + + assertTrue(tabs.any { it.key == ProfileTabKey.MEMBERS }) + assertTrue(tabs.any { it.key == ProfileTabKey.ADMINS }) + assertTrue(tabs.any { it.key == ProfileTabKey.RESTRICTED && it.initiallySelected }) + assertFalse(tabs.any { it.key == ProfileTabKey.BANNED }) + } + + @Test + fun `private profile strips member tabs`() { + val tabs = buildProfileTabSpecs( + isGroupOrChannel = false, + preferredTabKey = ProfileTabKey.ADMINS, + showMembers = true, + showAdministrators = true, + showRestricted = true, + showBanned = true + ) + + assertFalse(tabs.any { it.key.isMemberTab() }) + assertEquals(ProfileTabKey.STORIES, tabs.first { it.initiallySelected }.key) + } + + @Test + fun `initial visible tabs keep media anchor and member moderation tabs`() { + val supportedTabs = buildProfileTabSpecs( + isGroupOrChannel = true, + preferredTabKey = null, + showMembers = true, + showAdministrators = true, + showRestricted = true, + showBanned = true + ) + + val visibleTabs = buildInitialVisibleProfileTabSpecs( + supportedTabs = supportedTabs, + preferredTabKey = null + ) + + assertTrue(visibleTabs.any { it.key == ProfileTabKey.STORIES }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.MEDIA }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.MEMBERS }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.ADMINS }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.RESTRICTED }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.BANNED }) + assertFalse(visibleTabs.any { it.key == ProfileTabKey.FILES }) + assertFalse(visibleTabs.any { it.key == ProfileTabKey.GIFS }) + } + + @Test + fun `initial visible tabs preserve preferred media tab`() { + val supportedTabs = buildProfileTabSpecs( + isGroupOrChannel = true, + preferredTabKey = ProfileTabKey.LINKS, + showMembers = false + ) + + val visibleTabs = buildInitialVisibleProfileTabSpecs( + supportedTabs = supportedTabs, + preferredTabKey = ProfileTabKey.LINKS + ) + + assertTrue(visibleTabs.any { it.key == ProfileTabKey.MEDIA }) + assertTrue(visibleTabs.any { it.key == ProfileTabKey.LINKS }) + assertFalse(visibleTabs.any { it.key == ProfileTabKey.FILES }) + assertFalse(visibleTabs.any { it.key == ProfileTabKey.MUSIC }) + } + + @Test + fun `members tab is hidden for channels when current user is not admin`() { + assertFalse( + shouldShowMembersTab( + chat = ChatModel( + id = 1L, + title = "Channel", + unreadCount = 0, + isChannel = true, + isAdmin = false + ), + fullInfo = ChatFullInfoModel(canGetMembers = true, memberCount = 120), + resolvedMemberCount = 120 + ) + ) + } + + @Test + fun `members tab is shown for channels when current user is admin`() { + assertTrue( + shouldShowMembersTab( + chat = ChatModel( + id = 1L, + title = "Channel", + unreadCount = 0, + isChannel = true, + isAdmin = true + ), + fullInfo = ChatFullInfoModel(canGetMembers = true, memberCount = 120), + resolvedMemberCount = 120 + ) + ) + } + + @Test + fun `members tab is hidden for groups with hidden members when current user is not admin`() { + assertFalse( + shouldShowMembersTab( + chat = ChatModel( + id = 2L, + title = "Group", + unreadCount = 0, + isGroup = true, + isAdmin = false + ), + fullInfo = ChatFullInfoModel( + canGetMembers = true, + memberCount = 50, + hasHiddenMembers = true + ), + resolvedMemberCount = 50 + ) + ) + } + + @Test + fun `members tab is shown for groups with hidden members when current user is admin`() { + assertTrue( + shouldShowMembersTab( + chat = ChatModel( + id = 2L, + title = "Group", + unreadCount = 0, + isGroup = true, + isAdmin = true + ), + fullInfo = ChatFullInfoModel( + canGetMembers = true, + memberCount = 50, + hasHiddenMembers = true + ), + resolvedMemberCount = 50 + ) + ) + } + + @Test + fun `admins tab stays visible for admin even when administrator count is unavailable`() { + assertTrue( + shouldShowAdminsTab( + chat = ChatModel( + id = 3L, + title = "Group", + unreadCount = 0, + isGroup = true, + isAdmin = true + ), + fullInfo = ChatFullInfoModel(administratorCount = 0) + ) + ) + } + + @Test + fun `moderation tabs are hidden for non admin users`() { + assertFalse( + shouldShowModerationTab( + chat = ChatModel( + id = 4L, + title = "Group", + unreadCount = 0, + isGroup = true, + isAdmin = false + ), + memberCount = 3 + ) + ) + } + + @Test + fun `moderation tabs are shown for admins when members exist`() { + assertTrue( + shouldShowModerationTab( + chat = ChatModel( + id = 4L, + title = "Group", + unreadCount = 0, + isGroup = true, + isAdmin = true + ), + memberCount = 3 + ) + ) + } +}