diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index 968a6a4b..075f0f55 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -172,9 +172,15 @@ fun MainContent( when (activeChild) { is RootComponent.Child.ChatDetailChild -> { - val chatState by activeChild.component.state.collectAsState() + val chatUiState by activeChild.component.chatUiState.collectAsState() + val appearanceState by activeChild.component.appearanceState.collectAsState() + val messagesState by activeChild.component.messagesState.collectAsState() + val mediaViewerState by activeChild.component.mediaViewerState.collectAsState() ChatContentViewers( - state = chatState, + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, + mediaViewerState = mediaViewerState, component = activeChild.component, localClipboard = localClipboard ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index 65be35a0..b5730ee3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -32,6 +32,14 @@ interface ChatComponent { val appPreferences: AppPreferences val stickerRepository: StickerRepository val state: StateFlow + val chatUiState: StateFlow + val selectionState: StateFlow + val searchState: StateFlow + val appearanceState: StateFlow + val messagesState: StateFlow + val inputState: StateFlow + val pinnedState: StateFlow + val mediaViewerState: StateFlow val repositoryMessage: MessageRepository val downloadUtils: IDownloadUtils @@ -339,4 +347,161 @@ interface ChatComponent { val lastReadInboxMessageId: Long = 0L, val unreadSeparatorLastReadInboxMessageId: Long = 0L, ) + + @Stable + data class MessagesState( + val chatId: Long = 0L, + val messages: List = emptyList(), + val isLoading: Boolean = false, + val isLoadingOlder: Boolean = false, + val isLoadingNewer: Boolean = false, + val scrollToMessageId: Long? = null, + val pendingScrollCommand: ChatScrollCommand? = null, + val highlightedMessageId: Long? = null, + val isAtBottom: Boolean = true, + val currentScrollMessageId: Long = 0L, + val lastScrollPosition: Long = 0L, + val lastSavedViewport: ChatViewportCacheEntry? = null, + val isLatestLoaded: Boolean = true, + val isOldestLoaded: Boolean = false, + val lastReadInboxMessageId: Long = 0L, + val unreadSeparatorCount: Int = 0, + val unreadSeparatorLastReadInboxMessageId: Long = 0L + ) + + @Stable + data class ChatUiState( + val chatId: Long = 0L, + val chatTitle: String = "Chat", + val chatAvatar: String? = null, + val chatPersonalAvatar: String? = null, + val chatEmojiStatus: String? = null, + val isGroup: Boolean = false, + val isChannel: Boolean = false, + val isSecretChat: Boolean = false, + val isOnline: Boolean = false, + val isVerified: Boolean = false, + val isSponsor: Boolean = false, + val canWrite: Boolean = false, + val isAdmin: Boolean = false, + val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, + val memberCount: Int = 0, + val onlineCount: Int = 0, + val unreadCount: Int = 0, + val unreadMentionCount: Int = 0, + val unreadReactionCount: Int = 0, + val userStatus: String? = null, + val typingAction: String? = null, + val pollVoters: List = emptyList(), + val showPollVoters: Boolean = false, + val isPollVotersLoading: Boolean = false, + val viewAsTopics: Boolean = false, + val topics: List = emptyList(), + val currentTopicId: Long? = null, + val rootMessage: MessageModel? = null, + val isLoadingTopics: Boolean = false, + val isWhitelistedInAdBlock: Boolean = false, + val isMuted: Boolean = false, + val showReportDialog: Boolean = false, + val showBotCommands: Boolean = false, + val currentUser: UserModel? = null, + val otherUser: UserModel? = null, + val isMember: Boolean = true, + val restrictUserId: Long? = null, + val isInstalledFromGooglePlay: Boolean = true + ) + + @Stable + data class MessageSelectionState( + val selectedMessageIds: Set = emptySet() + ) + + @Stable + data class SearchState( + val isSearchActive: Boolean = false, + val searchQuery: String = "" + ) + + @Stable + data class AppearanceState( + val fontSize: Float = 16f, + val letterSpacing: Float = 0f, + val bubbleRadius: Float = 18f, + val stickerSize: Float = 200f, + val wallpaper: String? = null, + val wallpaperModel: WallpaperModel? = null, + val isWallpaperBlurred: Boolean = false, + val wallpaperBlurIntensity: Int = 20, + val isWallpaperMoving: Boolean = false, + val wallpaperDimming: Int = 0, + val isWallpaperGrayscale: Boolean = false, + val isPlayerGesturesEnabled: Boolean = true, + val isPlayerDoubleTapSeekEnabled: Boolean = true, + val playerSeekDuration: Int = 10, + val isPlayerZoomEnabled: Boolean = true, + val autoDownloadMobile: Boolean = true, + val autoDownloadWifi: Boolean = true, + val autoDownloadRoaming: Boolean = false, + val autoDownloadFiles: Boolean = false, + val autoplayGifs: Boolean = true, + val autoplayVideos: Boolean = true, + val showLinkPreviews: Boolean = true, + val isChatAnimationsEnabled: Boolean = true + ) + + @Stable + data class InputState( + val chatId: Long = 0L, + val replyMessage: MessageModel? = null, + val editingMessage: MessageModel? = null, + val draftText: String = "", + val selectedStickerSet: StickerSetModel? = null, + val isBot: Boolean = false, + val botCommands: List = emptyList(), + val botMenuButton: BotMenuButtonModel = BotMenuButtonModel.Default, + val mentionSuggestions: List = emptyList(), + val inlineBotResults: InlineBotResultsModel? = null, + val currentInlineBotUsername: String? = null, + val currentInlineQuery: String? = null, + val isInlineBotLoading: Boolean = false, + val attachMenuBots: List = emptyList(), + val scheduledMessages: List = emptyList() + ) + + @Stable + data class PinnedState( + val pinnedMessage: MessageModel? = null, + val allPinnedMessages: List = emptyList(), + val showPinnedMessagesList: Boolean = false, + val isLoadingPinnedMessages: Boolean = false, + val pinnedMessageCount: Int = 0, + val pinnedMessageIndex: Int = 0 + ) + + @Stable + data class MediaViewerState( + val instantViewUrl: String? = null, + val youtubeUrl: String? = null, + val miniAppUrl: String? = null, + val miniAppName: String? = null, + val miniAppBotUserId: Long = 0L, + val showMiniAppTOS: Boolean = false, + val miniAppTOSBotUserId: Long = 0L, + val miniAppTOSUrl: String? = null, + val miniAppTOSName: String? = null, + val webViewUrl: String? = null, + val fullScreenImages: List? = null, + val fullScreenImageMessageIds: List = emptyList(), + val fullScreenCaptions: List = emptyList(), + val fullScreenStartIndex: Int = 0, + val fullScreenVideoMessageId: Long? = null, + val fullScreenVideoPath: String? = null, + val fullScreenVideoCaption: String? = null, + val invoiceSlug: String? = null, + val invoiceMessageId: Long? = null + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 2978b9a2..a38b2534 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -37,9 +37,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -57,6 +59,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -105,27 +108,12 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentBackground -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentList -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBar -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBarUiState -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatMessageOptionsMenu -import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem -import org.monogram.presentation.features.chats.currentChat.chatContent.ReportChatDialog -import org.monogram.presentation.features.chats.currentChat.chatContent.RestrictUserSheet -import org.monogram.presentation.features.chats.currentChat.chatContent.chatContentLeadingItemsCount -import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum -import org.monogram.presentation.features.chats.currentChat.chatContent.groupedIndexToLazyIndex -import org.monogram.presentation.features.chats.currentChat.chatContent.lazyIndexToGroupedIndex -import org.monogram.presentation.features.chats.currentChat.components.AdvancedCircularRecorderScreen -import org.monogram.presentation.features.chats.currentChat.components.ChatInputBar -import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarActions -import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarState -import org.monogram.presentation.features.chats.currentChat.components.MessageListShimmer -import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet +import org.monogram.presentation.features.chats.currentChat.chatContent.* +import org.monogram.presentation.features.chats.currentChat.components.* import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandsSheet import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler import org.monogram.presentation.features.chats.currentChat.components.chats.PollVotersSheet +import org.monogram.presentation.features.chats.currentChat.components.pins.PinnedMessageBar import org.monogram.presentation.features.chats.currentChat.components.pins.PinnedMessagesListSheet import org.monogram.presentation.features.chats.currentChat.editor.photo.PhotoEditorScreen import org.monogram.presentation.features.chats.currentChat.editor.video.VideoEditorScreen @@ -141,7 +129,14 @@ fun ChatContent( component: ChatComponent, isOverlay: Boolean = false, ) { - val state by component.state.collectAsState() + val chatUiState by component.chatUiState.collectAsState() + val selectionState by component.selectionState.collectAsState() + val searchState by component.searchState.collectAsState() + val appearanceState by component.appearanceState.collectAsState() + val messagesState by component.messagesState.collectAsState() + val inputState by component.inputState.collectAsState() + val pinnedState by component.pinnedState.collectAsState() + val mediaViewerState by component.mediaViewerState.collectAsState() val scrollState = rememberLazyListState() val context = LocalContext.current val density = LocalDensity.current @@ -163,7 +158,7 @@ fun ChatContent( var selectedMessageId by rememberSaveable { mutableStateOf(null) } val transformedMessageTexts = remember { mutableStateMapOf() } val originalMessageTexts = remember { mutableStateMapOf() } - val latestMessagesState = rememberUpdatedState(state.messages) + val latestMessagesState = rememberUpdatedState(messagesState.messages) val selectedMessageIdState = rememberUpdatedState(selectedMessageId) val displayMessages by remember { derivedStateOf { @@ -197,6 +192,7 @@ fun ChatContent( var editingPhotoPath by rememberSaveable { mutableStateOf(null) } var editingVideoPath by rememberSaveable { mutableStateOf(null) } var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } + var pendingUnpinMessage by rememberSaveable { mutableStateOf(null) } val groupedMessages by remember { derivedStateOf { groupMessagesByAlbum(displayMessages) } @@ -215,17 +211,18 @@ fun ChatContent( } } } - val isComments = state.rootMessage != null - val isForumList = state.viewAsTopics && state.currentTopicId == null + val isComments = chatUiState.rootMessage != null + val isForumList = chatUiState.viewAsTopics && chatUiState.currentTopicId == null var showScrollToBottomButton by remember { mutableStateOf(false) } - - val isAnyViewerOpen = state.fullScreenImages != null || - state.fullScreenVideoPath != null || - state.fullScreenVideoMessageId != null || - state.youtubeUrl != null || - state.instantViewUrl != null || - state.miniAppUrl != null || - state.webViewUrl != null || + var lastAutoScrollMessageCount by remember(chatUiState.chatId, chatUiState.currentTopicId) { mutableIntStateOf(0) } + + val isAnyViewerOpen = mediaViewerState.fullScreenImages != null || + mediaViewerState.fullScreenVideoPath != null || + mediaViewerState.fullScreenVideoMessageId != null || + mediaViewerState.youtubeUrl != null || + mediaViewerState.instantViewUrl != null || + mediaViewerState.miniAppUrl != null || + mediaViewerState.webViewUrl != null || editingPhotoPath != null || editingVideoPath != null || isRecordingVideo @@ -237,19 +234,19 @@ fun ChatContent( val leadingItems = chatContentLeadingItemsCount( isComments = isComments, showNavPadding = false, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, + isLoadingOlder = messagesState.isLoadingOlder, + isLoadingNewer = messagesState.isLoadingNewer, + isAtBottom = messagesState.isAtBottom, hasMessages = groupedMessages.isNotEmpty() ) val targetIndex = groupedIndexToLazyIndex(index, leadingItems) - scrollState.scrollToMessageIndex( - index = targetIndex, - align = ScrollAlign.Center, - animated = state.isChatAnimationsEnabled, - staged = true - ) + scrollState.scrollToMessageIndex( + index = targetIndex, + align = ScrollAlign.Center, + animated = appearanceState.isChatAnimationsEnabled, + staged = true + ) } } else { component.onPinnedMessageClick(msg) @@ -258,14 +255,14 @@ fun ChatContent( LaunchedEffect(Unit) { isVisible = true - if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) { + if (mediaViewerState.fullScreenVideoPath != null || mediaViewerState.fullScreenVideoMessageId != null) { component.onDismissVideo() } } - LaunchedEffect(state.messages) { + LaunchedEffect(messagesState.messages) { if (transformedMessageTexts.isEmpty() && originalMessageTexts.isEmpty()) return@LaunchedEffect - val ids = state.messages.map { it.id }.toSet() + val ids = messagesState.messages.map { it.id }.toSet() transformedMessageTexts.keys.toList().forEach { id -> if (id !in ids) { transformedMessageTexts.remove(id) @@ -276,22 +273,22 @@ fun ChatContent( // Initial Loading Delay logic LaunchedEffect( - state.isLoading, - state.messages.isEmpty(), - state.viewAsTopics, - state.currentTopicId, - state.isLoadingTopics, - state.rootMessage + messagesState.isLoading, + messagesState.messages.isEmpty(), + chatUiState.viewAsTopics, + chatUiState.currentTopicId, + chatUiState.isLoadingTopics, + chatUiState.rootMessage ) { - val isActuallyLoading = if (state.viewAsTopics && state.currentTopicId == null) { - state.isLoadingTopics && state.topics.isEmpty() - } else if (state.currentTopicId != null) { - state.isLoading && state.messages.isEmpty() && state.rootMessage == null + val isActuallyLoading = if (chatUiState.viewAsTopics && chatUiState.currentTopicId == null) { + chatUiState.isLoadingTopics && chatUiState.topics.isEmpty() + } else if (chatUiState.currentTopicId != null) { + messagesState.isLoading && messagesState.messages.isEmpty() && chatUiState.rootMessage == null } else { - state.isLoading && state.messages.isEmpty() + messagesState.isLoading && messagesState.messages.isEmpty() } if (isActuallyLoading) { - if (state.isChatAnimationsEnabled) delay(200) + if (appearanceState.isChatAnimationsEnabled) delay(200) showInitialLoading = true } else { showInitialLoading = false @@ -299,15 +296,15 @@ fun ChatContent( } // Unified command-based scrolling: restore, jump, bottom. - LaunchedEffect(state.pendingScrollCommand, isComments) { - val command = state.pendingScrollCommand ?: return@LaunchedEffect + LaunchedEffect(messagesState.pendingScrollCommand, isComments) { + val command = messagesState.pendingScrollCommand ?: return@LaunchedEffect val leadingItems = chatContentLeadingItemsCount( isComments = isComments, showNavPadding = false, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, + isLoadingOlder = messagesState.isLoadingOlder, + isLoadingNewer = messagesState.isLoadingNewer, + isAtBottom = messagesState.isAtBottom, hasMessages = groupedMessages.isNotEmpty() ) @@ -353,7 +350,7 @@ fun ChatContent( scrollState.scrollToMessageIndex( index = targetIndex, align = command.align, - animated = command.animated && state.isChatAnimationsEnabled, + animated = command.animated && appearanceState.isChatAnimationsEnabled, staged = true ) } @@ -363,7 +360,7 @@ fun ChatContent( is ChatScrollCommand.ScrollToBottom -> { scrollState.scrollToChatBottomStaged( isComments = isComments, - animated = command.animated && state.isChatAnimationsEnabled + animated = command.animated && appearanceState.isChatAnimationsEnabled ) component.onScrollCommandConsumed() } @@ -382,12 +379,12 @@ fun ChatContent( BottomVisibilitySnapshot( isAtBottom = scrollState.isAtBottom( isComments = isComments, - isLatestLoaded = state.isLatestLoaded + isLatestLoaded = messagesState.isLatestLoaded ), isNearBottom = scrollState.isNearBottom( isComments = isComments ), - unreadCount = state.unreadCount + unreadCount = chatUiState.unreadCount ) } .distinctUntilChanged() @@ -418,20 +415,20 @@ fun ChatContent( scrollState, groupedMessages, isComments, - state.isLatestLoaded, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom + messagesState.isLatestLoaded, + messagesState.isLoadingOlder, + messagesState.isLoadingNewer, + messagesState.isAtBottom ) { snapshotFlow { buildViewportSnapshot( scrollState = scrollState, groupedMessages = groupedMessages, isComments = isComments, - isLatestLoaded = state.isLatestLoaded, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, + isLatestLoaded = messagesState.isLatestLoaded, + isLoadingOlder = messagesState.isLoadingOlder, + isLoadingNewer = messagesState.isLoadingNewer, + isAtBottom = messagesState.isAtBottom, showNavPadding = false ) } @@ -447,21 +444,21 @@ fun ChatContent( scrollState, groupedMessages, isComments, - state.currentTopicId, - state.isLatestLoaded, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom + chatUiState.currentTopicId, + messagesState.isLatestLoaded, + messagesState.isLoadingOlder, + messagesState.isLoadingNewer, + messagesState.isAtBottom ) { onDispose { val viewport = buildViewportSnapshot( scrollState = scrollState, groupedMessages = groupedMessages, isComments = isComments, - isLatestLoaded = state.isLatestLoaded, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, + isLatestLoaded = messagesState.isLatestLoaded, + isLoadingOlder = messagesState.isLoadingOlder, + isLoadingNewer = messagesState.isLoadingNewer, + isAtBottom = messagesState.isAtBottom, showNavPadding = false ) if (viewport != null) { @@ -471,7 +468,7 @@ fun ChatContent( } // Performance: Update visible range for repository - LaunchedEffect(scrollState, groupedMessages, state.rootMessage) { + LaunchedEffect(scrollState, groupedMessages, chatUiState.rootMessage) { snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } .map { visibleItems -> val visibleIds = LinkedHashSet() @@ -481,7 +478,7 @@ fun ChatContent( val maxIndex = visibleItems.maxOf { it.index } visibleItems.forEach { item -> - val groupedIndex = if (state.rootMessage != null) item.index - 1 else item.index + val groupedIndex = if (chatUiState.rootMessage != null) item.index - 1 else item.index groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) @@ -496,7 +493,7 @@ fun ChatContent( val nearbyEnd = maxIndex + 5 for (index in nearbyStart..nearbyEnd) { if (index in minIndex..maxIndex) continue - val groupedIndex = if (state.rootMessage != null) index - 1 else index + val groupedIndex = if (chatUiState.rootMessage != null) index - 1 else index groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) @@ -521,22 +518,27 @@ fun ChatContent( // Auto-scroll to bottom when new messages arrive and we are already at the bottom val messageCount = groupedMessages.size - LaunchedEffect(messageCount, state.isLatestLoaded) { - if (isComments) return@LaunchedEffect + LaunchedEffect(messageCount, messagesState.isLatestLoaded, isComments) { + val previousMessageCount = lastAutoScrollMessageCount + lastAutoScrollMessageCount = messageCount + + if (isComments || previousMessageCount == 0 || messageCount <= previousMessageCount) { + return@LaunchedEffect + } val isAtBottomNow = scrollState.isAtBottom( isComments = isComments, - isLatestLoaded = state.isLatestLoaded + isLatestLoaded = messagesState.isLatestLoaded ) - if ((state.isAtBottom || isAtBottomNow) && - !state.isLoading && - !state.isLoadingOlder && - !state.isLoadingNewer && + if ((messagesState.isAtBottom || isAtBottomNow) && + !messagesState.isLoading && + !messagesState.isLoadingOlder && + !messagesState.isLoadingNewer && !scrollState.isScrollInProgress ) { scrollState.scrollToChatBottomStaged( isComments = isComments, - animated = state.isChatAnimationsEnabled + animated = appearanceState.isChatAnimationsEnabled ) } } @@ -550,8 +552,8 @@ fun ChatContent( } } - LaunchedEffect(state.showBotCommands, isRecordingVideo) { - if (state.showBotCommands || isRecordingVideo) { + LaunchedEffect(chatUiState.showBotCommands, isRecordingVideo) { + if (chatUiState.showBotCommands || isRecordingVideo) { focusManager.clearFocus(force = true) keyboardController?.hide() } @@ -580,47 +582,47 @@ fun ChatContent( val contentAlpha by animateFloatAsState( - targetValue = if (isVisible || !state.isChatAnimationsEnabled || isOverlay) 1f else 0f, - animationSpec = if (state.isChatAnimationsEnabled && !isOverlay) tween(300) else snap(), + targetValue = if (isVisible || !appearanceState.isChatAnimationsEnabled || isOverlay) 1f else 0f, + animationSpec = if (appearanceState.isChatAnimationsEnabled && !isOverlay) tween(300) else snap(), label = "ContentAlpha" ) val contentOffset by animateDpAsState( - targetValue = if (isVisible || !state.isChatAnimationsEnabled || isOverlay) 0.dp else 20.dp, - animationSpec = if (state.isChatAnimationsEnabled && !isOverlay) tween(300) else snap(), + targetValue = if (isVisible || !appearanceState.isChatAnimationsEnabled || isOverlay) 0.dp else 20.dp, + animationSpec = if (appearanceState.isChatAnimationsEnabled && !isOverlay) tween(300) else snap(), label = "ContentOffset" ) val showInputBar by remember( - state.isMember, - state.isChannel, - state.isGroup, - state.canWrite, - state.currentTopicId, - state.selectedMessageIds, - state.viewAsTopics, + chatUiState.isMember, + chatUiState.isChannel, + chatUiState.isGroup, + chatUiState.canWrite, + chatUiState.currentTopicId, + selectionState.selectedMessageIds, + chatUiState.viewAsTopics, isRecordingVideo ) { derivedStateOf { - (state.isMember || !state.isChannel && !state.isGroup) && - (state.canWrite || state.currentTopicId != null) && + (chatUiState.isMember || !chatUiState.isChannel && !chatUiState.isGroup) && + (chatUiState.canWrite || chatUiState.currentTopicId != null) && !isRecordingVideo && - state.selectedMessageIds.isEmpty() && - (!state.viewAsTopics || state.currentTopicId != null) + selectionState.selectedMessageIds.isEmpty() && + (!chatUiState.viewAsTopics || chatUiState.currentTopicId != null) } } var containerSize by remember { mutableStateOf(IntSize.Zero) } - var renderPinnedMessagesList by rememberSaveable { mutableStateOf(state.showPinnedMessagesList) } + var renderPinnedMessagesList by rememberSaveable { mutableStateOf(pinnedState.showPinnedMessagesList) } var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } - LaunchedEffect(state.showPinnedMessagesList) { - if (state.showPinnedMessagesList) { + LaunchedEffect(pinnedState.showPinnedMessagesList) { + if (pinnedState.showPinnedMessagesList) { renderPinnedMessagesList = true } } val requestPinnedMessagesListDismiss = { - if (state.showPinnedMessagesList) { + if (pinnedState.showPinnedMessagesList) { component.onDismissPinnedMessages() } } @@ -629,105 +631,101 @@ fun ChatContent( editingPhotoPath, editingVideoPath, selectedMessageId, - state.selectedMessageIds, - state.currentTopicId, - state.showBotCommands, - state.restrictUserId, - state.showPinnedMessagesList, - state.fullScreenImages, - state.fullScreenVideoPath, - state.fullScreenVideoMessageId, - state.miniAppUrl, - state.webViewUrl, - state.instantViewUrl, - state.youtubeUrl + selectionState.selectedMessageIds, + chatUiState.currentTopicId, + chatUiState.showBotCommands, + chatUiState.restrictUserId, + pinnedState.showPinnedMessagesList, + mediaViewerState.fullScreenImages, + mediaViewerState.fullScreenVideoPath, + mediaViewerState.fullScreenVideoMessageId, + mediaViewerState.miniAppUrl, + mediaViewerState.webViewUrl, + mediaViewerState.instantViewUrl, + mediaViewerState.youtubeUrl ) { 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 + selectionState.selectedMessageIds.isNotEmpty() || + chatUiState.currentTopicId != null || + chatUiState.showBotCommands || + chatUiState.restrictUserId != null || + pinnedState.showPinnedMessagesList || + mediaViewerState.fullScreenImages != null || + mediaViewerState.fullScreenVideoPath != null || + mediaViewerState.fullScreenVideoMessageId != null || + mediaViewerState.miniAppUrl != null || + mediaViewerState.webViewUrl != null || + mediaViewerState.instantViewUrl != null || + mediaViewerState.youtubeUrl != null } } - val selectedCount = state.selectedMessageIds.size - val selectedMessageIdSet by remember(state.selectedMessageIds) { - derivedStateOf { state.selectedMessageIds.toHashSet() } + val selectedCount = selectionState.selectedMessageIds.size + val selectedMessageIdSet by remember(selectionState.selectedMessageIds) { + derivedStateOf { selectionState.selectedMessageIds.toHashSet() } } - val canRevokeSelected by remember(state.messages, selectedMessageIdSet) { + val canRevokeSelected by remember(messagesState.messages, selectedMessageIdSet) { derivedStateOf { if (selectedMessageIdSet.isEmpty()) { false } else { - state.messages.any { it.id in selectedMessageIdSet && it.canBeDeletedForAllUsers } + messagesState.messages.any { it.id in selectedMessageIdSet && it.canBeDeletedForAllUsers } } } } val topBarUiState = remember( - state.currentTopicId, - state.rootMessage, - state.isGroup, - state.isChannel, - state.isAdmin, - state.permissions, - state.otherUser, - state.currentUser, - state.typingAction, - state.memberCount, - state.onlineCount, - state.topics, - state.chatTitle, - state.chatAvatar, - state.chatPersonalAvatar, - state.chatEmojiStatus, - state.isOnline, - state.isVerified, - state.isSponsor, - state.isWhitelistedInAdBlock, - state.isInstalledFromGooglePlay, - state.isMuted, - state.isSearchActive, - state.searchQuery, - state.pinnedMessage, - state.pinnedMessageCount + chatUiState.currentTopicId, + chatUiState.rootMessage, + chatUiState.isGroup, + chatUiState.isChannel, + chatUiState.isAdmin, + chatUiState.permissions, + chatUiState.otherUser, + chatUiState.currentUser, + chatUiState.typingAction, + chatUiState.memberCount, + chatUiState.onlineCount, + chatUiState.topics, + chatUiState.chatTitle, + chatUiState.chatAvatar, + chatUiState.chatPersonalAvatar, + chatUiState.chatEmojiStatus, + chatUiState.isOnline, + chatUiState.isVerified, + chatUiState.isSponsor, + chatUiState.isWhitelistedInAdBlock, + chatUiState.isInstalledFromGooglePlay, + chatUiState.isMuted, + searchState.isSearchActive, + searchState.searchQuery ) { ChatContentTopBarUiState( - currentTopicId = state.currentTopicId, - rootMessage = state.rootMessage, - isGroup = state.isGroup, - isChannel = state.isChannel, - isAdmin = state.isAdmin, - permissions = state.permissions, - otherUser = state.otherUser, - currentUser = state.currentUser, - typingAction = state.typingAction, - memberCount = state.memberCount, - onlineCount = state.onlineCount, - topics = state.topics, - chatTitle = state.chatTitle, - chatAvatar = state.chatAvatar, - chatPersonalAvatar = state.chatPersonalAvatar, - chatEmojiStatus = state.chatEmojiStatus, - isOnline = state.isOnline, - isVerified = state.isVerified, - isSponsor = state.isSponsor, - isWhitelistedInAdBlock = state.isWhitelistedInAdBlock, - isInstalledFromGooglePlay = state.isInstalledFromGooglePlay, - isMuted = state.isMuted, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, - pinnedMessage = state.pinnedMessage, - pinnedMessageCount = state.pinnedMessageCount + currentTopicId = chatUiState.currentTopicId, + rootMessage = chatUiState.rootMessage, + isGroup = chatUiState.isGroup, + isChannel = chatUiState.isChannel, + isAdmin = chatUiState.isAdmin, + permissions = chatUiState.permissions, + otherUser = chatUiState.otherUser, + currentUser = chatUiState.currentUser, + typingAction = chatUiState.typingAction, + memberCount = chatUiState.memberCount, + onlineCount = chatUiState.onlineCount, + topics = chatUiState.topics, + chatTitle = chatUiState.chatTitle, + chatAvatar = chatUiState.chatAvatar, + chatPersonalAvatar = chatUiState.chatPersonalAvatar, + chatEmojiStatus = chatUiState.chatEmojiStatus, + isOnline = chatUiState.isOnline, + isVerified = chatUiState.isVerified, + isSponsor = chatUiState.isSponsor, + isWhitelistedInAdBlock = chatUiState.isWhitelistedInAdBlock, + isInstalledFromGooglePlay = chatUiState.isInstalledFromGooglePlay, + isMuted = chatUiState.isMuted, + isSearchActive = searchState.isSearchActive, + searchQuery = searchState.searchQuery ) } @@ -752,7 +750,7 @@ fun ChatContent( translationY = contentOffset.toPx() } ) { - ChatContentBackground(state = state) + ChatContentBackground(state = appearanceState) } if (isTablet) { @@ -773,7 +771,7 @@ fun ChatContent( .fillMaxSize() .graphicsLayer { alpha = contentAlpha; translationY = contentOffset.toPx() } .semantics { contentDescription = "ChatContent" }, - containerColor = Color.Transparent, + containerColor = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerLow, topBar = { ChatContentTopBar( topBarState = topBarUiState, @@ -783,7 +781,7 @@ fun ChatContent( contentAlpha = contentAlpha, onBack = { keyboardController?.hide() - if (state.currentTopicId != null) { + if (chatUiState.currentTopicId != null) { component.onTopicClick(0) } else { component.onBackClicked() @@ -793,42 +791,59 @@ fun ChatContent( keyboardController?.hide() focusManager.clearFocus(force = true) }, - onPinnedMessageClick = { msg -> scrollToMessageState.value(msg) }, showBack = !isTablet ) }, bottomBar = { if (showInputBar) { - val inputBarState = - remember(state, pendingMediaPaths, pendingDocumentPaths) { + val inputReplyMarkup = remember(messagesState.messages) { + messagesState.messages.firstOrNull { it.replyMarkup is ReplyMarkupModel.ShowKeyboard }?.replyMarkup + } + val inputBarState = remember( + inputState, + pendingMediaPaths, + pendingDocumentPaths, + inputReplyMarkup, + chatUiState.topics, + chatUiState.currentTopicId, + chatUiState.permissions, + chatUiState.slowModeDelay, + chatUiState.slowModeDelayExpiresIn, + chatUiState.isCurrentUserRestricted, + chatUiState.restrictedUntilDate, + chatUiState.isAdmin, + chatUiState.isChannel, + chatUiState.currentUser, + chatUiState.isSecretChat + ) { ChatInputBarState( - replyMessage = state.replyMessage, - editingMessage = state.editingMessage, - draftText = state.draftText, + replyMessage = inputState.replyMessage, + editingMessage = inputState.editingMessage, + draftText = inputState.draftText, pendingMediaPaths = pendingMediaPaths, pendingDocumentPaths = pendingDocumentPaths, - isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed + isClosed = chatUiState.topics.find { it.id.toLong() == chatUiState.currentTopicId }?.isClosed ?: false, - permissions = state.permissions, - slowModeDelay = state.slowModeDelay, - slowModeDelayExpiresIn = state.slowModeDelayExpiresIn, - isCurrentUserRestricted = state.isCurrentUserRestricted, - restrictedUntilDate = state.restrictedUntilDate, - isAdmin = state.isAdmin, - isChannel = state.isChannel, - isBot = state.isBot, - botCommands = state.botCommands, - botMenuButton = state.botMenuButton, - replyMarkup = state.messages.firstOrNull { it.replyMarkup is ReplyMarkupModel.ShowKeyboard }?.replyMarkup, - mentionSuggestions = state.mentionSuggestions, - inlineBotResults = state.inlineBotResults, - currentInlineBotUsername = state.currentInlineBotUsername, - currentInlineQuery = state.currentInlineQuery, - isInlineBotLoading = state.isInlineBotLoading, - attachBots = state.attachMenuBots, - scheduledMessages = state.scheduledMessages, - isPremiumUser = state.currentUser?.isPremium == true, - isSecretChat = state.isSecretChat + permissions = chatUiState.permissions, + slowModeDelay = chatUiState.slowModeDelay, + slowModeDelayExpiresIn = chatUiState.slowModeDelayExpiresIn, + isCurrentUserRestricted = chatUiState.isCurrentUserRestricted, + restrictedUntilDate = chatUiState.restrictedUntilDate, + isAdmin = chatUiState.isAdmin, + isChannel = chatUiState.isChannel, + isBot = inputState.isBot, + botCommands = inputState.botCommands, + botMenuButton = inputState.botMenuButton, + replyMarkup = inputReplyMarkup, + mentionSuggestions = inputState.mentionSuggestions, + inlineBotResults = inputState.inlineBotResults, + currentInlineBotUsername = inputState.currentInlineBotUsername, + currentInlineQuery = inputState.currentInlineQuery, + isInlineBotLoading = inputState.isInlineBotLoading, + attachBots = inputState.attachMenuBots, + scheduledMessages = inputState.scheduledMessages, + isPremiumUser = chatUiState.currentUser?.isPremium == true, + isSecretChat = chatUiState.isSecretChat ) } @@ -924,14 +939,14 @@ fun ChatContent( component.onReplyMarkupButtonClick( 0, it, - if (state.isBot) state.chatId else 0L + if (inputState.isBot) inputState.chatId else 0L ) }, onOpenMiniApp = { url, name -> component.onOpenMiniApp( url, name, - if (state.isBot) state.chatId else 0L + if (inputState.isBot) inputState.chatId else 0L ) }, onMentionQueryChange = { component.onMentionQueryChange(it) }, @@ -968,7 +983,7 @@ fun ChatContent( appPreferences = component.appPreferences, stickerRepository = component.stickerRepository ) - } else if (!state.isMember && (state.isChannel || state.isGroup)) { + } else if (!chatUiState.isMember && (chatUiState.isChannel || chatUiState.isGroup)) { Box( modifier = Modifier .fillMaxWidth() @@ -991,27 +1006,90 @@ fun ChatContent( } } } + }, + floatingActionButton = { + AnimatedVisibility( + visible = showScrollToBottomButton, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut() + ) { + Box { + FloatingActionButton( + onClick = { + component.onScrollToBottom() + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.cd_scroll_to_bottom), + modifier = Modifier.size(24.dp) + ) + } + + AnimatedVisibility( + visible = chatUiState.unreadCount > 0, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-8).dp) + ) { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + shadowElevation = 4.dp + ) { + AnimatedContent( + targetState = chatUiState.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 + ) + } + } + } + } + } } ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .consumeWindowInsets(padding) - .onGloballyPositioned { coordinates -> - contentRect = Rect( - offset = coordinates.positionInWindow(), - size = coordinates.size.toSize() - ) - } - ) { - Box( + Surface( modifier = Modifier .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .onGloballyPositioned { coordinates -> + contentRect = Rect( + offset = coordinates.positionInWindow(), + size = coordinates.size.toSize() + ) + } .graphicsLayer { alpha = contentAlpha translationY = contentOffset.toPx() - } + }, + shape = if (isTablet) RoundedCornerShape(16.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + color = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surface ) { val currentKeyboardController = rememberUpdatedState(keyboardController) val currentFocusManager = rememberUpdatedState(focusManager) @@ -1194,125 +1272,85 @@ fun ChatContent( } } - ChatContentList( - showNavPadding = false, - state = state, - component = component, - scrollState = scrollState, - groupedMessages = groupedMessages, - onPhotoDownload = onPhotoDownloadStable, - onPhotoClick = onPhotoClickStable, - onVideoClick = onVideoClickStable, - onDocumentClick = onDocumentClickStable, - onAudioClick = onAudioClickStable, - onMessageOptionsClick = onMessageOptionsClickStable, - onGoToReply = onGoToReplyStable, - selectedMessageId = selectedMessageId, - onMessagePositionChange = onMessagePositionChangeStable, - onViaBotClick = onViaBotClickStable, - toProfile = toProfileStable, - downloadUtils = component.downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - - AnimatedVisibility( - visible = showScrollToBottomButton, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) + Box( + modifier = Modifier.fillMaxSize() ) { - Box { - FloatingActionButton( - onClick = { - component.onScrollToBottom() - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape, - modifier = Modifier.size(48.dp) - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.cd_scroll_to_bottom), - modifier = Modifier.size(24.dp) + ChatContentList( + showNavPadding = false, + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, + selectionState = selectionState, + component = component, + scrollState = scrollState, + groupedMessages = groupedMessages, + onPhotoDownload = onPhotoDownloadStable, + onPhotoClick = onPhotoClickStable, + onVideoClick = onVideoClickStable, + onDocumentClick = onDocumentClickStable, + onAudioClick = onAudioClickStable, + onMessageOptionsClick = onMessageOptionsClickStable, + onGoToReply = onGoToReplyStable, + selectedMessageId = selectedMessageId, + onMessagePositionChange = onMessagePositionChangeStable, + onViaBotClick = onViaBotClickStable, + toProfile = toProfileStable, + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + + val showPinned = pinnedState.pinnedMessage != null && + selectedCount == 0 && + chatUiState.rootMessage == null + androidx.compose.animation.AnimatedVisibility( + visible = showPinned, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.TopCenter) + ) { + pinnedState.pinnedMessage?.let { pinned -> + PinnedMessageBar( + message = pinned, + count = pinnedState.pinnedMessageCount, + onClose = { pendingUnpinMessage = pinned }, + onClick = { scrollToMessageState.value(pinned) }, + onShowAll = { component.onShowAllPinnedMessages() } ) } + } - AnimatedVisibility( - visible = state.unreadCount > 0, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), + if (isRecordingVideo) { + Box( modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = (-8).dp) + .fillMaxSize() + .background(Color.Black) + .zIndex(10f) ) { - 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 - ) + AdvancedCircularRecorderScreen( + onClose = { isRecordingVideo = false }, + onVideoRecorded = { file -> + isRecordingVideo = false + component.onVideoRecorded(file) } - } + ) } } - } - if (isRecordingVideo) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .zIndex(10f) + androidx.compose.animation.AnimatedVisibility( + visible = showInitialLoading, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(400)) ) { - AdvancedCircularRecorderScreen( - onClose = { isRecordingVideo = false }, - onVideoRecorded = { file -> - isRecordingVideo = false - component.onVideoRecorded(file) - } - ) - } - } - - AnimatedVisibility( - visible = showInitialLoading, - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(400)) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - ) { - MessageListShimmer( - isGroup = state.isGroup, - isChannel = state.isChannel - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + MessageListShimmer( + isGroup = chatUiState.isGroup, + isChannel = chatUiState.isChannel + ) + } } } } @@ -1322,24 +1360,38 @@ fun ChatContent( // Modals & Overlays + pendingUnpinMessage?.let { pinnedToUnpin -> + ConfirmationSheet( + icon = Icons.Rounded.PushPin, + title = stringResource(R.string.unpin_message_title), + description = stringResource(R.string.unpin_message_confirmation), + confirmText = stringResource(R.string.action_unpin), + onConfirm = { + component.onUnpinMessage(pinnedToUnpin) + pendingUnpinMessage = null + }, + onDismiss = { pendingUnpinMessage = null } + ) + } + if (renderPinnedMessagesList) { PinnedMessagesListSheet( - isVisible = state.showPinnedMessagesList, - allPinnedMessages = state.allPinnedMessages, - pinnedMessageCount = state.pinnedMessageCount, - isLoadingPinnedMessages = state.isLoadingPinnedMessages, - isGroup = state.isGroup, - isChannel = state.isChannel, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, - autoDownloadFiles = state.autoDownloadFiles, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, + isVisible = pinnedState.showPinnedMessagesList, + allPinnedMessages = pinnedState.allPinnedMessages, + pinnedMessageCount = pinnedState.pinnedMessageCount, + isLoadingPinnedMessages = pinnedState.isLoadingPinnedMessages, + isGroup = chatUiState.isGroup, + isChannel = chatUiState.isChannel, + fontSize = appearanceState.fontSize, + letterSpacing = appearanceState.letterSpacing, + bubbleRadius = appearanceState.bubbleRadius, + stickerSize = appearanceState.stickerSize, + autoDownloadMobile = appearanceState.autoDownloadMobile, + autoDownloadWifi = appearanceState.autoDownloadWifi, + autoDownloadRoaming = appearanceState.autoDownloadRoaming, + autoDownloadFiles = appearanceState.autoDownloadFiles, + autoplayGifs = appearanceState.autoplayGifs, + autoplayVideos = appearanceState.autoplayVideos, onDismissRequest = requestPinnedMessagesListDismiss, onHidden = { renderPinnedMessagesList = false @@ -1361,7 +1413,7 @@ fun ChatContent( ) } - state.selectedStickerSet?.let { stickerSet -> + inputState.selectedStickerSet?.let { stickerSet -> StickerSetSheet( stickerSet = stickerSet, onDismiss = { component.onDismissStickerSet() }, @@ -1369,10 +1421,10 @@ fun ChatContent( ) } - if (state.showPollVoters) { + if (chatUiState.showPollVoters) { PollVotersSheet( - voters = state.pollVoters, - isLoading = state.isPollVotersLoading, + voters = chatUiState.pollVoters, + isLoading = chatUiState.isPollVotersLoading, onUserClick = { component.onDismissVoters() component.toProfile(it) @@ -1381,23 +1433,28 @@ fun ChatContent( ) } - if (state.showBotCommands) { + if (chatUiState.showBotCommands) { BotCommandsSheet( - commands = state.botCommands, + commands = inputState.botCommands, onCommandClick = { component.onBotCommandClick(it) }, onDismiss = { component.onDismissBotCommands() } ) } - /*ChatContentViewers( - state = state, + ChatContentViewers( + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, + mediaViewerState = mediaViewerState, component = component, localClipboard = localClipboard - )*/ + ) selectedMessage?.let { msg -> ChatMessageOptionsMenu( - state = state, + chatUiState = chatUiState, + appearanceState = appearanceState, + pinnedState = pinnedState, component = component, selectedMessage = msg, menuOffset = menuOffset, @@ -1443,14 +1500,14 @@ fun ChatContent( ) } - if (state.showReportDialog) { + if (chatUiState.showReportDialog) { ReportChatDialog( onDismiss = { component.onDismissReportDialog() }, onReasonSelected = { component.onReportReasonSelected(it) } ) } - if (state.restrictUserId != null) { + if (chatUiState.restrictUserId != null) { RestrictUserSheet( onDismiss = { component.onDismissRestrictDialog() }, onConfirm = { permissions, untilDate -> component.onConfirmRestrict(permissions, untilDate) } @@ -1504,20 +1561,19 @@ fun ChatContent( BackHandler(enabled = isCustomBackHandlingEnabled) { if (editingPhotoPath != null) editingPhotoPath = null else if (editingVideoPath != null) editingVideoPath = null - else if (state.selectedMessageIds.isNotEmpty()) component.onClearSelection() + else if (selectionState.selectedMessageIds.isNotEmpty()) component.onClearSelection() else if (selectedMessageId != null) selectedMessageId = null - else if (state.showBotCommands) component.onDismissBotCommands() - else if (state.restrictUserId != null) component.onDismissRestrictDialog() - else if (state.showPinnedMessagesList && !isAnyViewerOpen) requestPinnedMessagesListDismiss() - else if (state.fullScreenImages != null) component.onDismissImages() - else if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) component.onDismissVideo() - else if (state.instantViewUrl != null) component.onDismissInstantView() - else if (state.youtubeUrl != null) component.onDismissYouTube() - else if (state.miniAppUrl != null) component.onDismissMiniApp() - else if (state.webViewUrl != null) component.onDismissWebView() - else if (state.currentTopicId != null) component.onTopicClick(0) + else if (chatUiState.showBotCommands) component.onDismissBotCommands() + else if (chatUiState.restrictUserId != null) component.onDismissRestrictDialog() + else if (pinnedState.showPinnedMessagesList && !isAnyViewerOpen) requestPinnedMessagesListDismiss() + else if (mediaViewerState.fullScreenImages != null) component.onDismissImages() + else if (mediaViewerState.fullScreenVideoPath != null || mediaViewerState.fullScreenVideoMessageId != null) component.onDismissVideo() + else if (mediaViewerState.instantViewUrl != null) component.onDismissInstantView() + else if (mediaViewerState.youtubeUrl != null) component.onDismissYouTube() + else if (mediaViewerState.miniAppUrl != null) component.onDismissMiniApp() + else if (mediaViewerState.webViewUrl != null) component.onDismissWebView() + else if (chatUiState.currentTopicId != null) component.onTopicClick(0) } - } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index f6ac2413..94449e21 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -9,13 +9,18 @@ import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -71,6 +76,7 @@ import org.monogram.presentation.settings.storage.CacheController import java.io.File import java.util.concurrent.ConcurrentHashMap +@OptIn(ExperimentalCoroutinesApi::class) class DefaultChatComponent( context: AppComponentContext, val chatId: Long, @@ -157,16 +163,188 @@ class DefaultChatComponent( ) ) + private fun ChatComponent.State.toMessagesState() = ChatComponent.MessagesState( + chatId = chatId, + messages = messages, + isLoading = isLoading, + isLoadingOlder = isLoadingOlder, + isLoadingNewer = isLoadingNewer, + scrollToMessageId = scrollToMessageId, + pendingScrollCommand = pendingScrollCommand, + highlightedMessageId = highlightedMessageId, + isAtBottom = isAtBottom, + currentScrollMessageId = currentScrollMessageId, + lastScrollPosition = lastScrollPosition, + lastSavedViewport = lastSavedViewport, + isLatestLoaded = isLatestLoaded, + isOldestLoaded = isOldestLoaded, + lastReadInboxMessageId = lastReadInboxMessageId, + unreadSeparatorCount = unreadSeparatorCount, + unreadSeparatorLastReadInboxMessageId = unreadSeparatorLastReadInboxMessageId + ) + + private fun ChatComponent.State.toChatUiState() = ChatComponent.ChatUiState( + chatId = chatId, + chatTitle = chatTitle, + chatAvatar = chatAvatar, + chatPersonalAvatar = chatPersonalAvatar, + chatEmojiStatus = chatEmojiStatus, + isGroup = isGroup, + isChannel = isChannel, + isSecretChat = isSecretChat, + isOnline = isOnline, + isVerified = isVerified, + isSponsor = isSponsor, + canWrite = canWrite, + isAdmin = isAdmin, + permissions = permissions, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + isCurrentUserRestricted = isCurrentUserRestricted, + restrictedUntilDate = restrictedUntilDate, + memberCount = memberCount, + onlineCount = onlineCount, + unreadCount = unreadCount, + unreadMentionCount = unreadMentionCount, + unreadReactionCount = unreadReactionCount, + userStatus = userStatus, + typingAction = typingAction, + pollVoters = pollVoters, + showPollVoters = showPollVoters, + isPollVotersLoading = isPollVotersLoading, + viewAsTopics = viewAsTopics, + topics = topics, + currentTopicId = currentTopicId, + rootMessage = rootMessage, + isLoadingTopics = isLoadingTopics, + isWhitelistedInAdBlock = isWhitelistedInAdBlock, + isMuted = isMuted, + showReportDialog = showReportDialog, + showBotCommands = showBotCommands, + currentUser = currentUser, + otherUser = otherUser, + isMember = isMember, + restrictUserId = restrictUserId, + isInstalledFromGooglePlay = isInstalledFromGooglePlay + ) + + private fun ChatComponent.State.toSelectionState() = ChatComponent.MessageSelectionState( + selectedMessageIds = selectedMessageIds + ) + + private fun ChatComponent.State.toSearchState() = ChatComponent.SearchState( + isSearchActive = isSearchActive, + searchQuery = searchQuery + ) + + private fun ChatComponent.State.toAppearanceState() = ChatComponent.AppearanceState( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + wallpaper = wallpaper, + wallpaperModel = wallpaperModel, + isWallpaperBlurred = isWallpaperBlurred, + wallpaperBlurIntensity = wallpaperBlurIntensity, + isWallpaperMoving = isWallpaperMoving, + wallpaperDimming = wallpaperDimming, + isWallpaperGrayscale = isWallpaperGrayscale, + isPlayerGesturesEnabled = isPlayerGesturesEnabled, + isPlayerDoubleTapSeekEnabled = isPlayerDoubleTapSeekEnabled, + playerSeekDuration = playerSeekDuration, + isPlayerZoomEnabled = isPlayerZoomEnabled, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + showLinkPreviews = showLinkPreviews, + isChatAnimationsEnabled = isChatAnimationsEnabled + ) + + private fun ChatComponent.State.toInputState() = ChatComponent.InputState( + chatId = chatId, + replyMessage = replyMessage, + editingMessage = editingMessage, + draftText = draftText, + selectedStickerSet = selectedStickerSet, + isBot = isBot, + botCommands = botCommands, + botMenuButton = botMenuButton, + mentionSuggestions = mentionSuggestions, + inlineBotResults = inlineBotResults, + currentInlineBotUsername = currentInlineBotUsername, + currentInlineQuery = currentInlineQuery, + isInlineBotLoading = isInlineBotLoading, + attachMenuBots = attachMenuBots, + scheduledMessages = scheduledMessages + ) + + private fun ChatComponent.State.toPinnedState() = ChatComponent.PinnedState( + pinnedMessage = pinnedMessage, + allPinnedMessages = allPinnedMessages, + showPinnedMessagesList = showPinnedMessagesList, + isLoadingPinnedMessages = isLoadingPinnedMessages, + pinnedMessageCount = pinnedMessageCount, + pinnedMessageIndex = pinnedMessageIndex + ) + + private fun ChatComponent.State.toMediaViewerState() = ChatComponent.MediaViewerState( + instantViewUrl = instantViewUrl, + youtubeUrl = youtubeUrl, + miniAppUrl = miniAppUrl, + miniAppName = miniAppName, + miniAppBotUserId = miniAppBotUserId, + showMiniAppTOS = showMiniAppTOS, + miniAppTOSBotUserId = miniAppTOSBotUserId, + miniAppTOSUrl = miniAppTOSUrl, + miniAppTOSName = miniAppTOSName, + webViewUrl = webViewUrl, + fullScreenImages = fullScreenImages, + fullScreenImageMessageIds = fullScreenImageMessageIds, + fullScreenCaptions = fullScreenCaptions, + fullScreenStartIndex = fullScreenStartIndex, + fullScreenVideoMessageId = fullScreenVideoMessageId, + fullScreenVideoPath = fullScreenVideoPath, + fullScreenVideoCaption = fullScreenVideoCaption, + invoiceSlug = invoiceSlug, + invoiceMessageId = invoiceMessageId + ) + private val store = ChatStoreFactory( storeFactory = DefaultStoreFactory(), component = this ).create() override val state: StateFlow = store.stateFlow + override val chatUiState: StateFlow = + state.selectState(state.value.toChatUiState()) { it.toChatUiState() } + override val selectionState: StateFlow = + state.selectState(state.value.toSelectionState()) { it.toSelectionState() } + override val searchState: StateFlow = + state.selectState(state.value.toSearchState()) { it.toSearchState() } + override val appearanceState: StateFlow = + state.selectState(state.value.toAppearanceState()) { it.toAppearanceState() } + override val messagesState: StateFlow = + state.selectState(state.value.toMessagesState()) { it.toMessagesState() } + override val inputState: StateFlow = + state.selectState(state.value.toInputState()) { it.toInputState() } + override val pinnedState: StateFlow = + state.selectState(state.value.toPinnedState()) { it.toPinnedState() } + override val mediaViewerState: StateFlow = + state.selectState(state.value.toMediaViewerState()) { it.toMediaViewerState() } private var availableWallpapers: List = emptyList() internal var allMembers: List = emptyList() + private inline fun StateFlow.selectState( + initialValue: T, + crossinline selector: (ChatComponent.State) -> T + ): StateFlow = map { selector(it) } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, initialValue) + init { setupLifecycle() setupCollectors() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt index d576b029..02bafaa9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt @@ -17,7 +17,7 @@ import java.io.File @Composable fun ChatContentBackground( - state: ChatComponent.State, + state: ChatComponent.AppearanceState, modifier: Modifier = Modifier ) { val wallpaper = state.wallpaperModel diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 8c6d262e..2a67edef 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -91,7 +91,10 @@ import java.io.File @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatContentList( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, + selectionState: ChatComponent.MessageSelectionState, component: ChatComponent, scrollState: LazyListState, groupedMessages: List, @@ -111,26 +114,27 @@ fun ChatContentList( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val isComments = state.rootMessage != null + val isComments = chatUiState.rootMessage != null val isScrolling by remember(scrollState) { derivedStateOf { scrollState.isScrollInProgress } } - val latestState by rememberUpdatedState(state) + val latestMessagesState by rememberUpdatedState(messagesState) + val latestChatUiState by rememberUpdatedState(chatUiState) var lastOlderLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } var lastNewerLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } val loadTriggerThrottleMs = 350L val unreadBoundaryIndex = remember( isComments, groupedMessages, - state.messages, - state.unreadSeparatorCount, - state.unreadSeparatorLastReadInboxMessageId + messagesState.messages, + messagesState.unreadSeparatorCount, + messagesState.unreadSeparatorLastReadInboxMessageId ) { - if (isComments || state.unreadSeparatorCount <= 0) { + if (isComments || messagesState.unreadSeparatorCount <= 0) { null // suppress in thread/comments mode } else { val boundaryItem = findFirstUnreadBoundary( - messages = state.messages, + messages = messagesState.messages, groupedItems = groupedMessages, - firstUnreadMessageId = state.unreadSeparatorLastReadInboxMessageId + firstUnreadMessageId = messagesState.unreadSeparatorLastReadInboxMessageId ) boundaryItem?.let { target -> groupedMessages.indexOfFirst { it.firstMessageId == target.firstMessageId } @@ -154,8 +158,9 @@ fun ChatContentList( } .distinctUntilChanged() .collect { (firstVisibleIndex, lastVisibleIndex) -> - val currentState = latestState - if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return@collect + val currentMessagesState = latestMessagesState + val currentChatUiState = latestChatUiState + if (currentMessagesState.isLoading || currentMessagesState.isLoadingOlder || currentMessagesState.isLoadingNewer) return@collect val nearStart = firstVisibleIndex <= 2 val nearEnd = lastVisibleIndex >= (groupedMessages.size - 3).coerceAtLeast(0) @@ -164,24 +169,24 @@ fun ChatContentList( if (isComments) { if (!scrollState.isScrollInProgress) return@collect - if (nearStart && !currentState.isOldestLoaded) { + if (nearStart && !currentMessagesState.isOldestLoaded) { if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastOlderLoadTriggerUptimeMs = now component.loadMore() } - } else if (nearEnd && !currentState.isLatestLoaded) { + } else if (nearEnd && !currentMessagesState.isLatestLoaded) { if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastNewerLoadTriggerUptimeMs = now component.loadNewer() } } } else { - if (nearEnd && !currentState.isOldestLoaded) { + if (nearEnd && !currentMessagesState.isOldestLoaded) { if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastOlderLoadTriggerUptimeMs = now component.loadMore() } - } else if (nearStart && !currentState.isAtBottom && !currentState.isLatestLoaded) { + } else if (nearStart && !currentMessagesState.isAtBottom && !currentMessagesState.isLatestLoaded) { if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastNewerLoadTriggerUptimeMs = now component.loadNewer() @@ -191,9 +196,9 @@ fun ChatContentList( } } - if (state.viewAsTopics && state.currentTopicId == null) { + if (chatUiState.viewAsTopics && chatUiState.currentTopicId == null) { TopicsList( - topics = state.topics, + topics = chatUiState.topics, onTopicClick = { component.onTopicClick(it.id) }, modifier = modifier ) @@ -208,13 +213,13 @@ fun ChatContentList( reverseLayout = !isComments, contentPadding = PaddingValues(vertical = 8.dp) ) { - if (isComments && state.isLoadingOlder && groupedMessages.isNotEmpty()) { + if (isComments && messagesState.isLoadingOlder && groupedMessages.isNotEmpty()) { item(key = "loading_older_top") { PagingLoadingIndicator() } } - if (!isComments && state.isLoadingNewer && !state.isAtBottom && groupedMessages.isNotEmpty()) { + if (!isComments && messagesState.isLoadingNewer && !messagesState.isAtBottom && groupedMessages.isNotEmpty()) { item(key = "loading_newer_bottom") { PagingLoadingIndicator() } @@ -223,18 +228,19 @@ fun ChatContentList( if (isComments) { item(key = "root_header") { RootMessageSection( - state, - component, - onPhotoClick, - onPhotoDownload, - onVideoClick, - onDocumentClick, - onAudioClick, - onMessageOptionsClick, - onGoToReply, - onViaBotClick, - toProfile, - downloadUtils, + chatUiState = chatUiState, + appearanceState = appearanceState, + component = component, + onPhotoClick = onPhotoClick, + onPhotoDownload = onPhotoDownload, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onMessageOptionsClick = onMessageOptionsClick, + onGoToReply = onGoToReply, + onViaBotClick = onViaBotClick, + toProfile = toProfile, + downloadUtils = downloadUtils, isAnyViewerOpen = isAnyViewerOpen ) } @@ -259,12 +265,14 @@ fun ChatContentList( MessageRowItem( item = item, - state = state, + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, component = component, olderMsg = olderMsg, newerMsg = newerMsg, - isSelected = isItemSelected(item, state.selectedMessageIds), - isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isSelected = isItemSelected(item, selectionState.selectedMessageIds), + isSelectionMode = selectionState.selectedMessageIds.isNotEmpty(), selectedMessageId = selectedMessageId, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, @@ -280,7 +288,7 @@ fun ChatContentList( downloadUtils = downloadUtils, isAnyViewerOpen = isAnyViewerOpen, showUnreadSeparator = index == unreadBoundaryIndex, - unreadCount = state.unreadSeparatorCount + unreadCount = messagesState.unreadSeparatorCount ) } } else { @@ -315,12 +323,14 @@ fun ChatContentList( MessageRowItem( item = item, - state = state, + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, component = component, olderMsg = olderMsg, newerMsg = newerMsg, - isSelected = isItemSelected(item, state.selectedMessageIds), - isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isSelected = isItemSelected(item, selectionState.selectedMessageIds), + isSelectionMode = selectionState.selectedMessageIds.isNotEmpty(), selectedMessageId = selectedMessageId, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, @@ -336,24 +346,24 @@ fun ChatContentList( downloadUtils = downloadUtils, isAnyViewerOpen = isAnyViewerOpen, showUnreadSeparator = index == unreadBoundaryIndex, - unreadCount = state.unreadSeparatorCount + unreadCount = messagesState.unreadSeparatorCount ) } } - if (isComments && state.isLoadingNewer && groupedMessages.isNotEmpty()) { + if (isComments && messagesState.isLoadingNewer && groupedMessages.isNotEmpty()) { item(key = "loading_newer_bottom") { PagingLoadingIndicator() } } - if (!isComments && state.isLoadingOlder && groupedMessages.isNotEmpty()) { + if (!isComments && messagesState.isLoadingOlder && groupedMessages.isNotEmpty()) { item(key = "loading_older_top") { PagingLoadingIndicator() } } - if (state.isLoading && groupedMessages.isNotEmpty() && !state.isLoadingOlder && !state.isLoadingNewer) { + if (messagesState.isLoading && groupedMessages.isNotEmpty() && !messagesState.isLoadingOlder && !messagesState.isLoadingNewer) { item(key = "loading_indicator") { PagingLoadingIndicator() } @@ -396,7 +406,9 @@ private fun PagingLoadingIndicator() { @Composable private fun MessageRowItem( item: GroupedMessageItem, - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, component: ChatComponent, olderMsg: MessageModel?, newerMsg: MessageModel?, @@ -423,7 +435,7 @@ private fun MessageRowItem( if (item is GroupedMessageItem.Single) item.message else (item as GroupedMessageItem.Album).messages.last() } - val shouldAnimateEntry = state.isChatAnimationsEnabled && !isScrolling + val shouldAnimateEntry = appearanceState.isChatAnimationsEnabled && !isScrolling val scale = remember(mainMsg.id) { Animatable( @@ -504,7 +516,9 @@ private fun MessageRowItem( MessageBubbleSwitcher( item = item, - state = state, + chatUiState = chatUiState, + appearanceState = appearanceState, + messagesState = messagesState, component = component, olderMsg = olderMsg, newerMsg = newerMsg, @@ -531,7 +545,9 @@ private fun MessageRowItem( @Composable private fun MessageBubbleSwitcher( item: GroupedMessageItem, - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, component: ChatComponent, olderMsg: MessageModel?, newerMsg: MessageModel?, @@ -550,8 +566,8 @@ private fun MessageBubbleSwitcher( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val isChannel = state.isChannel && state.currentTopicId == null - val isTopicClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed?: false + val isChannel = chatUiState.isChannel && chatUiState.currentTopicId == null + val isTopicClosed = chatUiState.topics.find { it.id.toLong() == chatUiState.currentTopicId }?.isClosed ?: false when (item) { is GroupedMessageItem.Single -> { @@ -562,10 +578,10 @@ private fun MessageBubbleSwitcher( msg = item.message, olderMsg = olderMsg, newerMsg = newerMsg, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, - highlighted = state.highlightedMessageId == item.message.id, + autoplayGifs = appearanceState.autoplayGifs, + autoplayVideos = appearanceState.autoplayVideos, + autoDownloadFiles = appearanceState.autoDownloadFiles, + highlighted = messagesState.highlightedMessageId == item.message.id, onHighlightConsumed = { component.onHighlightConsumed() }, onPhotoClick = { if (isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( @@ -640,16 +656,16 @@ private fun MessageBubbleSwitcher( it ) }, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, + fontSize = appearanceState.fontSize, + letterSpacing = appearanceState.letterSpacing, + bubbleRadius = appearanceState.bubbleRadius, + stickerSize = appearanceState.stickerSize, shouldReportPosition = item.message.id == selectedMessageId, onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = chatUiState.canWrite && !isSelectionMode, onReplySwipe = { component.onReplyMessage(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, @@ -661,19 +677,19 @@ private fun MessageBubbleSwitcher( msg = item.message, olderMsg = olderMsg, newerMsg = newerMsg, - isGroup = state.isGroup || state.currentTopicId != null, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stSize = state.stickerSize, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, - autoDownloadFiles = state.autoDownloadFiles, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - showLinkPreviews = state.showLinkPreviews, - highlighted = state.highlightedMessageId == item.message.id, + isGroup = chatUiState.isGroup || chatUiState.currentTopicId != null, + fontSize = appearanceState.fontSize, + letterSpacing = appearanceState.letterSpacing, + bubbleRadius = appearanceState.bubbleRadius, + stSize = appearanceState.stickerSize, + autoDownloadMobile = appearanceState.autoDownloadMobile, + autoDownloadWifi = appearanceState.autoDownloadWifi, + autoDownloadRoaming = appearanceState.autoDownloadRoaming, + autoDownloadFiles = appearanceState.autoDownloadFiles, + autoplayGifs = appearanceState.autoplayGifs, + autoplayVideos = appearanceState.autoplayVideos, + showLinkPreviews = appearanceState.showLinkPreviews, + highlighted = messagesState.highlightedMessageId == item.message.id, onHighlightConsumed = { component.onHighlightConsumed() }, onPhotoClick = { if (isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( @@ -754,7 +770,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), + canReply = chatUiState.canWrite && !isSelectionMode && (!isTopicClosed || chatUiState.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -768,13 +784,13 @@ private fun MessageBubbleSwitcher( messages = item.messages, olderMsg = olderMsg, newerMsg = newerMsg, - isGroup = state.isGroup || state.currentTopicId != null, + isGroup = chatUiState.isGroup || chatUiState.currentTopicId != null, isChannel = isChannel, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, + autoplayGifs = appearanceState.autoplayGifs, + autoplayVideos = appearanceState.autoplayVideos, + autoDownloadMobile = appearanceState.autoDownloadMobile, + autoDownloadWifi = appearanceState.autoDownloadWifi, + autoDownloadRoaming = appearanceState.autoDownloadRoaming, onPhotoClick = { if (isSelectionMode) component.onToggleMessageSelection(it.id) else handleAlbumPhotoClick( it, @@ -822,7 +838,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), + canReply = chatUiState.canWrite && !isSelectionMode && (!isTopicClosed || chatUiState.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -857,7 +873,8 @@ private fun SelectionIndicator(isSelected: Boolean, modifier: Modifier = Modifie @Composable private fun RootMessageSection( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, component: ChatComponent, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, onPhotoDownload: (Int) -> Unit, @@ -871,17 +888,17 @@ private fun RootMessageSection( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val root = state.rootMessage ?: return + val root = chatUiState.rootMessage ?: return Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 4.dp) ) { - if (state.isChannel) { + if (chatUiState.isChannel) { ChannelMessageBubbleContainer( msg = root, olderMsg = null, newerMsg = null, - autoplayGifs = state.autoplayGifs, autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, + autoplayGifs = appearanceState.autoplayGifs, autoplayVideos = appearanceState.autoplayVideos, + autoDownloadFiles = appearanceState.autoDownloadFiles, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { handleVideoClick(it, onVideoClick) }, @@ -897,10 +914,10 @@ private fun RootMessageSection( onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, onClosePoll = { component.onClosePoll(it) }, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, + fontSize = appearanceState.fontSize, + letterSpacing = appearanceState.letterSpacing, + bubbleRadius = appearanceState.bubbleRadius, + stickerSize = appearanceState.stickerSize, onCommentsClick = {}, showComments = false, toProfile = toProfile, onViaBotClick = onViaBotClick, @@ -911,14 +928,14 @@ private fun RootMessageSection( ) } else { MessageBubbleContainer( - msg = root, olderMsg = null, newerMsg = null, isGroup = state.isGroup, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stSize = state.stickerSize, - autoDownloadMobile = state.autoDownloadMobile, autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, autoDownloadFiles = state.autoDownloadFiles, - autoplayGifs = state.autoplayGifs, autoplayVideos = state.autoplayVideos, + msg = root, olderMsg = null, newerMsg = null, isGroup = chatUiState.isGroup, + fontSize = appearanceState.fontSize, + letterSpacing = appearanceState.letterSpacing, + bubbleRadius = appearanceState.bubbleRadius, + stSize = appearanceState.stickerSize, + autoDownloadMobile = appearanceState.autoDownloadMobile, autoDownloadWifi = appearanceState.autoDownloadWifi, + autoDownloadRoaming = appearanceState.autoDownloadRoaming, autoDownloadFiles = appearanceState.autoDownloadFiles, + autoplayGifs = appearanceState.autoplayGifs, autoplayVideos = appearanceState.autoplayVideos, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { handleVideoClick(it, onVideoClick) }, @@ -1272,4 +1289,3 @@ fun TopicItem( } } } - diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt index c0036798..1f0afc6d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt @@ -5,12 +5,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -27,7 +25,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Report import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -66,7 +63,6 @@ import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.util.rememberUserStatusText import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.chats.currentChat.components.ChatTopBar -import org.monogram.presentation.features.chats.currentChat.components.pins.PinnedMessageBar import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown @@ -95,9 +91,7 @@ data class ChatContentTopBarUiState( val isInstalledFromGooglePlay: Boolean, val isMuted: Boolean, val isSearchActive: Boolean, - val searchQuery: String, - val pinnedMessage: MessageModel?, - val pinnedMessageCount: Int + val searchQuery: String ) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -110,7 +104,6 @@ fun ChatContentTopBar( contentAlpha: Float, onBack: () -> Unit, onOpenMenu: () -> Unit = {}, - onPinnedMessageClick: (MessageModel) -> Unit, showBack: Boolean = true ) { val localClipboard = LocalClipboard.current @@ -123,7 +116,6 @@ fun ChatContentTopBar( (otherUserId != null && topBarState.currentUser?.id != otherUserId) var showDeleteSheet by rememberSaveable { mutableStateOf(false) } - var pendingUnpinMessage by remember { mutableStateOf(null) } val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() if (showDeleteSheet) { @@ -138,20 +130,6 @@ fun ChatContentTopBar( ) } - pendingUnpinMessage?.let { pinnedToUnpin -> - ConfirmationSheet( - icon = Icons.Rounded.PushPin, - title = stringResource(R.string.unpin_message_title), - description = stringResource(R.string.unpin_message_confirmation), - confirmText = stringResource(R.string.action_unpin), - onConfirm = { - component.onUnpinMessage(pinnedToUnpin) - pendingUnpinMessage = null - }, - onDismiss = { pendingUnpinMessage = null } - ) - } - Column( modifier = Modifier.graphicsLayer { alpha = contentAlpha @@ -375,22 +353,5 @@ fun ChatContentTopBar( ) } } - - val showPinned = topBarState.pinnedMessage != null && !isSelectionMode && topBarState.rootMessage == null - AnimatedVisibility( - visible = showPinned, - enter = expandVertically(), - exit = shrinkVertically() - ) { - topBarState.pinnedMessage?.let { pinned -> - PinnedMessageBar( - message = pinned, - count = topBarState.pinnedMessageCount, - onClose = { pendingUnpinMessage = pinned }, - onClick = { onPinnedMessageClick(pinned) }, - onShowAll = { component.onShowAllPinnedMessages() } - ) - } - } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt index a345eef7..80baccb0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt @@ -20,22 +20,25 @@ import org.monogram.presentation.features.webview.InternalWebView @Composable fun ChatContentViewers( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, + mediaViewerState: ChatComponent.MediaViewerState, component: ChatComponent, localClipboard: Clipboard ) { - InstantViewOverlay(state, component) - YouTubeOverlay(state, component, localClipboard) - MiniAppOverlay(state, component) - WebViewOverlay(state, component) - ImagesOverlay(state, component, localClipboard) - VideoOverlay(state, component, localClipboard) - InvoiceOverlay(state, component) - MiniAppTOSOverlay(state, component) + InstantViewOverlay(mediaViewerState, component) + YouTubeOverlay(chatUiState, messagesState, mediaViewerState, component, localClipboard) + MiniAppOverlay(chatUiState, mediaViewerState, component) + WebViewOverlay(mediaViewerState, component) + ImagesOverlay(chatUiState, appearanceState, messagesState, mediaViewerState, component, localClipboard) + VideoOverlay(chatUiState, appearanceState, messagesState, mediaViewerState, component, localClipboard) + InvoiceOverlay(chatUiState, mediaViewerState, component) + MiniAppTOSOverlay(mediaViewerState, component) } @Composable -private fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { +private fun InstantViewOverlay(state: ChatComponent.MediaViewerState, component: ChatComponent) { AnimatedVisibility( visible = state.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -55,21 +58,23 @@ private fun InstantViewOverlay(state: ChatComponent.State, component: ChatCompon @Composable private fun YouTubeOverlay( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + messagesState: ChatComponent.MessagesState, + mediaViewerState: ChatComponent.MediaViewerState, component: ChatComponent, localClipboard: Clipboard ) { AnimatedVisibility( - visible = state.youtubeUrl != null, + visible = mediaViewerState.youtubeUrl != null, enter = fadeIn(), exit = fadeOut() ) { - state.youtubeUrl?.let { url -> + mediaViewerState.youtubeUrl?.let { url -> YouTubeViewer( videoUrl = url, onDismiss = { component.onDismissYouTube() }, onForward = { - component.onForwardMessage(state.messages.find { + component.onForwardMessage(messagesState.messages.find { (it.content as? MessageContent.Text)?.text?.contains( url ) == true @@ -85,26 +90,30 @@ private fun YouTubeOverlay( ClipData.newPlainText("", AnnotatedString(it)) ) }, - isPipEnabled = !state.isInstalledFromGooglePlay + isPipEnabled = !chatUiState.isInstalledFromGooglePlay ) } } } @Composable -private fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) { +private fun MiniAppOverlay( + chatUiState: ChatComponent.ChatUiState, + mediaViewerState: ChatComponent.MediaViewerState, + component: ChatComponent +) { AnimatedVisibility( - visible = state.miniAppUrl != null, + visible = mediaViewerState.miniAppUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - if (state.miniAppUrl != null && state.miniAppName != null) { + if (mediaViewerState.miniAppUrl != null && mediaViewerState.miniAppName != null) { MiniAppViewer( - chatId = state.chatId, - botUserId = state.miniAppBotUserId, - baseUrl = state.miniAppUrl, - botName = state.chatTitle, - botAvatarPath = state.chatAvatar, + chatId = chatUiState.chatId, + botUserId = mediaViewerState.miniAppBotUserId, + baseUrl = mediaViewerState.miniAppUrl, + botName = chatUiState.chatTitle, + botAvatarPath = chatUiState.chatAvatar, webAppRepository = component.repositoryMessage, onDismiss = { component.onDismissMiniApp() } ) @@ -113,7 +122,7 @@ private fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) } @Composable -private fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) { +private fun WebViewOverlay(state: ChatComponent.MediaViewerState, component: ChatComponent) { AnimatedVisibility( visible = state.webViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -130,34 +139,41 @@ private fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) @Composable private fun ImagesOverlay( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, + mediaViewerState: ChatComponent.MediaViewerState, component: ChatComponent, localClipboard: Clipboard ) { AnimatedVisibility( - visible = state.fullScreenImages != null, + visible = mediaViewerState.fullScreenImages != null, enter = fadeIn() + scaleIn(initialScale = 0.9f), exit = fadeOut() + scaleOut(targetScale = 0.9f) ) { - state.fullScreenImages?.let { images -> - val autoDownload = remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { + mediaViewerState.fullScreenImages?.let { images -> + val autoDownload = remember( + appearanceState.autoDownloadWifi, + appearanceState.autoDownloadRoaming, + appearanceState.autoDownloadMobile + ) { when { - component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi - component.downloadUtils.isRoaming() -> state.autoDownloadRoaming - else -> state.autoDownloadMobile + component.downloadUtils.isWifiConnected() -> appearanceState.autoDownloadWifi + component.downloadUtils.isRoaming() -> appearanceState.autoDownloadRoaming + else -> appearanceState.autoDownloadMobile } } - val viewerItems = remember(images, state.fullScreenImageMessageIds, state.messages) { - if (state.fullScreenImageMessageIds.size == images.size) { - state.fullScreenImageMessageIds.mapIndexed { index, messageId -> - val message = state.messages.firstOrNull { it.id == messageId } + val viewerItems = remember(images, mediaViewerState.fullScreenImageMessageIds, messagesState.messages) { + if (mediaViewerState.fullScreenImageMessageIds.size == images.size) { + mediaViewerState.fullScreenImageMessageIds.mapIndexed { index, messageId -> + val message = messagesState.messages.firstOrNull { it.id == messageId } val resolvedPath = message?.displayMediaPathForViewer() ?: images[index] ViewerMediaItem(messageId = messageId, path = resolvedPath) } } else { images.map { path -> - val message = state.messages.firstOrNull { it.content.matchesDisplayPath(path) } + val message = messagesState.messages.firstOrNull { it.content.matchesDisplayPath(path) } ViewerMediaItem( messageId = message?.id ?: 0L, path = message?.displayMediaPathForViewer() ?: path @@ -168,24 +184,24 @@ private fun ImagesOverlay( val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } - var currentImageIndex by remember(viewerImages, state.fullScreenStartIndex) { + var currentImageIndex by remember(viewerImages, mediaViewerState.fullScreenStartIndex) { mutableIntStateOf( - state.fullScreenStartIndex.coerceIn( + mediaViewerState.fullScreenStartIndex.coerceIn( 0, (viewerImages.lastIndex).coerceAtLeast(0) ) ) } - val currentViewerMessage = remember(currentImageIndex, imageMessageIds, state.messages) { + val currentViewerMessage = remember(currentImageIndex, imageMessageIds, messagesState.messages) { imageMessageIds.getOrNull(currentImageIndex) ?.takeIf { it != 0L } - ?.let { id -> state.messages.firstOrNull { it.id == id } } + ?.let { id -> messagesState.messages.firstOrNull { it.id == id } } } - val imageDownloadingStates = remember(imageMessageIds, state.messages) { + val imageDownloadingStates = remember(imageMessageIds, messagesState.messages) { imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content + val content = messagesState.messages.firstOrNull { it.id == id }?.content when (content) { is MessageContent.Photo -> content.isDownloading else -> false @@ -193,9 +209,9 @@ private fun ImagesOverlay( } } - val imageDownloadProgressStates = remember(imageMessageIds, state.messages) { + val imageDownloadProgressStates = remember(imageMessageIds, messagesState.messages) { imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content + val content = messagesState.messages.firstOrNull { it.id == id }?.content when (content) { is MessageContent.Photo -> content.downloadProgress else -> 0f @@ -206,7 +222,7 @@ private fun ImagesOverlay( if (viewerImages.isNotEmpty()) { ImageViewer( images = viewerImages, - startIndex = state.fullScreenStartIndex.coerceIn(0, viewerImages.lastIndex), + startIndex = mediaViewerState.fullScreenStartIndex.coerceIn(0, viewerImages.lastIndex), onDismiss = component::onDismissImages, autoDownload = autoDownload, onPageChanged = { index -> @@ -215,23 +231,23 @@ private fun ImagesOverlay( imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) }, onForward = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + val msg = currentViewerMessage ?: messagesState.messages.find { it.content.matchesDisplayPath(path) } msg?.let { component.onForwardMessage(it) } }, onDelete = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + val msg = currentViewerMessage ?: messagesState.messages.find { it.content.matchesDisplayPath(path) } if (msg?.isOutgoing == true) { component.onDeleteMessage(msg, true) component.onDismissImages() } }, onCopyLink = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + val msg = currentViewerMessage ?: messagesState.messages.find { it.content.matchesDisplayPath(path) } val link = if (msg != null) { - if (!state.isGroup && !state.isChannel) { - "tg://openmessage?user_id=${state.chatId}&message_id=${msg.id shr 20}" + if (!chatUiState.isGroup && !chatUiState.isChannel) { + "tg://openmessage?user_id=${chatUiState.chatId}&message_id=${msg.id shr 20}" } else { - "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${msg.id shr 20}" + "https://t.me/c/${chatUiState.chatId.toString().removePrefix("-100")}/${msg.id shr 20}" } } else { path @@ -241,7 +257,7 @@ private fun ImagesOverlay( ) }, onCopyText = { path -> - val msg = state.messages.find { + val msg = messagesState.messages.find { when (val content = it.content) { is MessageContent.Photo -> content.path == path is MessageContent.Video -> content.path == path @@ -262,7 +278,7 @@ private fun ImagesOverlay( } }, onVideoClick = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + val msg = currentViewerMessage ?: messagesState.messages.find { it.content.matchesDisplayPath(path) } if (msg != null) { val mediaPath = msg.displayMediaPathForViewer() ?: path component.onOpenVideo( @@ -278,7 +294,7 @@ private fun ImagesOverlay( component.onOpenVideo(path = path, messageId = null, caption = null) } }, - captions = state.fullScreenCaptions, + captions = mediaViewerState.fullScreenCaptions, imageDownloadingStates = imageDownloadingStates, imageDownloadProgressStates = imageDownloadProgressStates, downloadUtils = component.downloadUtils @@ -290,12 +306,16 @@ private fun ImagesOverlay( @Composable private fun VideoOverlay( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + messagesState: ChatComponent.MessagesState, + mediaViewerState: ChatComponent.MediaViewerState, component: ChatComponent, localClipboard: Clipboard ) { val videoVisible = - (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null + (mediaViewerState.fullScreenVideoPath != null || mediaViewerState.fullScreenVideoMessageId != null) && + mediaViewerState.fullScreenImages == null AnimatedVisibility( visible = videoVisible, @@ -303,11 +323,11 @@ private fun VideoOverlay( exit = fadeOut() + scaleOut(targetScale = 0.9f) ) { if (videoVisible) { - val messageId = state.fullScreenVideoMessageId - val path = state.fullScreenVideoPath + val messageId = mediaViewerState.fullScreenVideoMessageId + val path = mediaViewerState.fullScreenVideoPath - val msg = remember(messageId, path, state.messages) { - state.messages.find { it.id == messageId } ?: state.messages.find { + val msg = remember(messageId, path, messagesState.messages) { + messagesState.messages.find { it.id == messageId } ?: messagesState.messages.find { it.content.matchesDisplayPath(path ?: "") } } @@ -325,18 +345,18 @@ private fun VideoOverlay( VideoViewer( path = finalPath, onDismiss = component::onDismissVideo, - isGesturesEnabled = state.isPlayerGesturesEnabled, - isDoubleTapSeekEnabled = state.isPlayerDoubleTapSeekEnabled, - seekDuration = state.playerSeekDuration, - isZoomEnabled = state.isPlayerZoomEnabled, + isGesturesEnabled = appearanceState.isPlayerGesturesEnabled, + isDoubleTapSeekEnabled = appearanceState.isPlayerDoubleTapSeekEnabled, + seekDuration = appearanceState.playerSeekDuration, + isZoomEnabled = appearanceState.isPlayerZoomEnabled, onForward = { videoPath -> - val forwardMsg = state.messages.find { + val forwardMsg = messagesState.messages.find { it.content.matchesDisplayPath(videoPath) } forwardMsg?.let { component.onForwardMessage(it) } }, onDelete = { videoPath -> - val deleteMsg = state.messages.find { + val deleteMsg = messagesState.messages.find { it.content.matchesDisplayPath(videoPath) } if (deleteMsg?.isOutgoing == true) { @@ -345,15 +365,15 @@ private fun VideoOverlay( } }, onCopyLink = { videoPath -> - val linkMsg = state.messages.find { + val linkMsg = messagesState.messages.find { it.content.matchesDisplayPath(videoPath) } val link = if (linkMsg != null) { - if (!state.isGroup && !state.isChannel) { - "tg://openmessage?user_id=${state.chatId}&message_id=${linkMsg.id shr 20}" + if (!chatUiState.isGroup && !chatUiState.isChannel) { + "tg://openmessage?user_id=${chatUiState.chatId}&message_id=${linkMsg.id shr 20}" } else { "https://t.me/c/${ - state.chatId.toString().removePrefix("-100") + chatUiState.chatId.toString().removePrefix("-100") }/${linkMsg.id shr 20}" } } else { @@ -364,7 +384,7 @@ private fun VideoOverlay( ) }, onCopyText = { videoPath -> - val textMsg = state.messages.find { + val textMsg = messagesState.messages.find { it.content.matchesDisplayPath(videoPath) } val textToCopy = when (val content = textMsg?.content) { @@ -378,10 +398,10 @@ private fun VideoOverlay( ) } }, - onSaveGif = if (state.messages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { + onSaveGif = if (messagesState.messages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { { videoPath -> component.onAddToGifs(videoPath) } } else null, - caption = state.fullScreenVideoCaption, + caption = mediaViewerState.fullScreenVideoCaption, fileId = fileId, supportsStreaming = supportsStreaming, downloadUtils = component.downloadUtils @@ -393,12 +413,16 @@ private fun VideoOverlay( } @Composable -private fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) { - if (state.invoiceSlug != null || state.invoiceMessageId != null) { +private fun InvoiceOverlay( + chatUiState: ChatComponent.ChatUiState, + mediaViewerState: ChatComponent.MediaViewerState, + component: ChatComponent +) { + if (mediaViewerState.invoiceSlug != null || mediaViewerState.invoiceMessageId != null) { InvoiceDialog( - slug = state.invoiceSlug, - chatId = state.chatId, - messageId = state.invoiceMessageId, + slug = mediaViewerState.invoiceSlug, + chatId = chatUiState.chatId, + messageId = mediaViewerState.invoiceMessageId, paymentRepository = component.repositoryMessage, fileRepository = component.repositoryMessage, onDismiss = { status -> component.onDismissInvoice(status) } @@ -407,7 +431,7 @@ private fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) } @Composable -private fun MiniAppTOSOverlay(state: ChatComponent.State, component: ChatComponent) { +private fun MiniAppTOSOverlay(state: ChatComponent.MediaViewerState, component: ChatComponent) { MiniAppTOSBottomSheet( isVisible = state.showMiniAppTOS, onDismiss = { component.onDismissMiniAppTOS() }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index a19d3279..0e1344af 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -36,7 +36,9 @@ import java.util.Locale @Composable fun ChatMessageOptionsMenu( - state: ChatComponent.State, + chatUiState: ChatComponent.ChatUiState, + appearanceState: ChatComponent.AppearanceState, + pinnedState: ChatComponent.PinnedState, component: ChatComponent, selectedMessage: MessageModel, menuOffset: Offset, @@ -54,14 +56,14 @@ fun ChatMessageOptionsMenu( ) { val nativeClipboard = localClipboard.nativeClipboard val messageRepository: MessageAiRepository = koinInject() - val canCheckViewersList = remember(state.isChannel, state.isGroup, state.memberCount) { - !state.isChannel && (!state.isGroup || state.memberCount in 1 until 100) + val canCheckViewersList = remember(chatUiState.isChannel, chatUiState.isGroup, chatUiState.memberCount) { + !chatUiState.isChannel && (!chatUiState.isGroup || chatUiState.memberCount in 1 until 100) } val messageViewsCount = remember(selectedMessage.viewCount, selectedMessage.views) { selectedMessage.viewCount ?: selectedMessage.views } - val shouldShowViewsInfo = remember(state.isChannel, messageViewsCount) { - state.isChannel && (messageViewsCount ?: 0) > 0 + val shouldShowViewsInfo = remember(chatUiState.isChannel, messageViewsCount) { + chatUiState.isChannel && (messageViewsCount ?: 0) > 0 } val index = groupedMessages.indexOfFirst { item -> @@ -105,10 +107,10 @@ fun ChatMessageOptionsMenu( } } - val canShowViewersList = remember(state.memberCount, state.isChannel, selectedMessage) { - state.memberCount in 1 until 100 && + val canShowViewersList = remember(chatUiState.memberCount, chatUiState.isChannel, selectedMessage) { + chatUiState.memberCount in 1 until 100 && selectedMessage.isOutgoing && - (selectedMessage.canGetReadReceipts || selectedMessage.canGetViewers || !state.isChannel) + (selectedMessage.canGetReadReceipts || selectedMessage.canGetViewers || !chatUiState.isChannel) } suspend fun reloadViewers() { @@ -141,7 +143,7 @@ fun ChatMessageOptionsMenu( val splitOffset = remember(selectedMessage, menuMessageSize, density, shouldShowSeparatePost) { if (!shouldShowSeparatePost) return@remember null - val isChannel = state.isChannel && state.currentTopicId == null + val isChannel = chatUiState.isChannel && chatUiState.currentTopicId == null val width = menuMessageSize.width.toFloat() when (val content = selectedMessage.content) { @@ -204,18 +206,18 @@ fun ChatMessageOptionsMenu( } val senderIsUser = selectedMessage.senderId > 0L - val canModerateInChat = (state.isGroup || state.isChannel) && state.isAdmin + val canModerateInChat = (chatUiState.isGroup || chatUiState.isChannel) && chatUiState.isAdmin val canBlockUser = !selectedMessage.isOutgoing && senderIsUser && - (canModerateInChat || (!state.isGroup && !state.isChannel)) - val canRestrictUser = canBlockUser && (state.isGroup || state.isChannel) && state.isAdmin - val isOtherUserDialog = state.otherUser?.id?.let { it != state.currentUser?.id } == true + (canModerateInChat || (!chatUiState.isGroup && !chatUiState.isChannel)) + val canRestrictUser = canBlockUser && (chatUiState.isGroup || chatUiState.isChannel) && chatUiState.isAdmin + val isOtherUserDialog = chatUiState.otherUser?.id?.let { it != chatUiState.currentUser?.id } == true val canReportMessage = !selectedMessage.isOutgoing && ( - state.isGroup || state.isChannel || + chatUiState.isGroup || chatUiState.isChannel || isOtherUserDialog ) - val canCopyLink = state.isGroup || state.isChannel - val canPinMessages = state.isAdmin || state.permissions.canPinMessages - val isPremiumUser = state.currentUser?.isPremium == true + val canCopyLink = chatUiState.isGroup || chatUiState.isChannel + val canPinMessages = chatUiState.isAdmin || chatUiState.permissions.canPinMessages + val isPremiumUser = chatUiState.currentUser?.isPremium == true val canUseTelegramSummary = isPremiumUser && !canRestoreOriginalText && canSummarize(selectedMessage) val canUseTelegramTranslator = @@ -230,9 +232,9 @@ fun ChatMessageOptionsMenu( } MessageOptionsMenu( message = menuMessage.copy(readDate = messageWithReadDate.readDate), - canWrite = state.canWrite, + canWrite = chatUiState.canWrite, canPinMessages = canPinMessages, - isPinned = selectedMessage.id == state.pinnedMessage?.id, + isPinned = selectedMessage.id == pinnedState.pinnedMessage?.id, messageOffset = menuOffset, messageSize = menuMessageSize, clickOffset = clickOffset, @@ -261,14 +263,14 @@ fun ChatMessageOptionsMenu( scope.launch { reloadViewers() } }, onViewerClick = { component.toProfile(it) }, - bubbleRadius = state.bubbleRadius, + bubbleRadius = appearanceState.bubbleRadius, splitOffset = splitOffset, onReply = { component.onReplyMessage(selectedMessage) onDismiss() }, onPin = { - if (selectedMessage.id == state.pinnedMessage?.id) component.onUnpinMessage(selectedMessage) else component.onPinMessage( + if (selectedMessage.id == pinnedState.pinnedMessage?.id) component.onUnpinMessage(selectedMessage) else component.onPinMessage( selectedMessage ) onDismiss() @@ -290,10 +292,10 @@ fun ChatMessageOptionsMenu( onDismiss() }, onCopyLink = { - val link = if (!state.isGroup && !state.isChannel) { - "tg://openmessage?user_id=${state.chatId}&message_id=${selectedMessage.id shr 20}" + val link = if (!chatUiState.isGroup && !chatUiState.isChannel) { + "tg://openmessage?user_id=${chatUiState.chatId}&message_id=${selectedMessage.id shr 20}" } else { - "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${selectedMessage.id shr 20}" + "https://t.me/c/${chatUiState.chatId.toString().removePrefix("-100")}/${selectedMessage.id shr 20}" } nativeClipboard.setPrimaryClip( @@ -367,7 +369,7 @@ fun ChatMessageOptionsMenu( }, onTelegramTranslator = { telegramAiScope.launch { - val languageCode = state.currentUser?.languageCode?.takeIf { it.isNotBlank() } + val languageCode = chatUiState.currentUser?.languageCode?.takeIf { it.isNotBlank() } ?: Locale.getDefault().language coRunCatching { messageRepository.translateMessage( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index f2842dbe..5bb35087 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -71,6 +71,7 @@ import androidx.window.core.layout.WindowSizeClass import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.TypingDots import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow @@ -115,6 +116,7 @@ fun ChatTopBar( var showMenu by rememberSaveable { mutableStateOf(false) } var showClearHistorySheet by rememberSaveable { mutableStateOf(false) } var showDeleteChatSheet by rememberSaveable { mutableStateOf(false) } + val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() val windowInsets = if (isTablet) WindowInsets(0, 0, 0, 0) else WindowInsets.statusBars val topInsetModifier = if (isTablet) { @@ -153,7 +155,7 @@ fun ChatTopBar( ) }, navigationIcon = { - IconButton(onClick = onSearchToggle) { + IconButton(onClick = onSearchToggle, shapes = iconButtonShapes) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back) @@ -162,11 +164,14 @@ fun ChatTopBar( }, actions = { if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChange("") }) { + IconButton(onClick = { onSearchQueryChange("") }, shapes = iconButtonShapes) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.action_clear)) } } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) ) } else { TopAppBar( @@ -290,7 +295,7 @@ fun ChatTopBar( }, navigationIcon = { if (showBack) { - IconButton(onClick = onBack) { + IconButton(onClick = onBack, shapes = iconButtonShapes) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back) @@ -302,12 +307,12 @@ fun ChatTopBar( IconButton(onClick = { onMenu() showMenu = true - }) { + }, shapes = iconButtonShapes) { Icon(Icons.Default.MoreVert, contentDescription = null) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessageBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessageBar.kt index 2d0ad2c9..b7418511 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessageBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessageBar.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -42,10 +41,10 @@ fun PinnedMessageBar( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp) + .padding(8.dp) .clickable(onClick = if (count > 1) onShowAll else onClick), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), - shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp), tonalElevation = 4.dp ) { Row( @@ -61,7 +60,7 @@ fun PinnedMessageBar( .background(MaterialTheme.colorScheme.primary, CircleShape) ) - WidthSpacer(16.dp) + WidthSpacer(12.dp) Column( modifier = Modifier