diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 163f28ef..3ddccd02 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -22,7 +22,7 @@ import org.monogram.domain.repository.PushProvider import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.LocalVideoPlayerPool import org.monogram.presentation.core.util.NightMode -import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler +import org.monogram.presentation.features.chats.conversation.ui.message.LocalLinkHandler import org.monogram.presentation.root.DefaultAppComponentContext import org.monogram.presentation.root.DefaultRootComponent import org.monogram.presentation.root.RootComponent diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index 06f54562..8a926d9e 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -37,8 +37,8 @@ import org.monogram.app.components.MobileLayout import org.monogram.app.components.ProxyConfirmSheet import org.monogram.app.components.TabletLayout import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled -import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentViewers -import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet +import org.monogram.presentation.features.chats.conversation.ui.StickerSetSheet +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentViewers import org.monogram.presentation.features.profile.ProfileViewers import org.monogram.presentation.features.stickers.core.toDomain import org.monogram.presentation.root.RootComponent @@ -172,12 +172,14 @@ fun MainContent( when (activeChild) { is RootComponent.Child.ChatDetailChild -> { - val chatState by activeChild.component.state.collectAsState() - ChatContentViewers( - state = chatState, - component = activeChild.component, - localClipboard = localClipboard - ) + if (isExpanded && isTabletInterfaceEnabled) { + val chatState by activeChild.component.state.collectAsState() + ChatContentViewers( + state = chatState, + component = activeChild.component, + localClipboard = localClipboard + ) + } } is RootComponent.Child.ProfileChild -> { val profileState by activeChild.component.state.subscribeAsState() diff --git a/app/src/main/java/org/monogram/app/components/ChatConfirmJoinSheet.kt b/app/src/main/java/org/monogram/app/components/ChatConfirmJoinSheet.kt index 7c9f6efe..0ffc9a42 100644 --- a/app/src/main/java/org/monogram/app/components/ChatConfirmJoinSheet.kt +++ b/app/src/main/java/org/monogram/app/components/ChatConfirmJoinSheet.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.app.R -import org.monogram.presentation.features.chats.chatList.components.AvatarTopAppBar +import org.monogram.presentation.features.chats.list.components.AvatarTopAppBar import org.monogram.presentation.root.RootComponent @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/org/monogram/app/components/RenderChild.kt b/app/src/main/java/org/monogram/app/components/RenderChild.kt index 593d3081..e464e73c 100644 --- a/app/src/main/java/org/monogram/app/components/RenderChild.kt +++ b/app/src/main/java/org/monogram/app/components/RenderChild.kt @@ -2,9 +2,9 @@ package org.monogram.app.components import androidx.compose.runtime.Composable import org.monogram.presentation.features.auth.AuthContent -import org.monogram.presentation.features.chats.chatList.ChatListContent -import org.monogram.presentation.features.chats.currentChat.ChatContent -import org.monogram.presentation.features.chats.newChat.NewChatContent +import org.monogram.presentation.features.chats.conversation.ChatContent +import org.monogram.presentation.features.chats.creation.NewChatContent +import org.monogram.presentation.features.chats.list.ChatListContent import org.monogram.presentation.features.profile.ProfileContent import org.monogram.presentation.features.profile.admin.AdminManageContent import org.monogram.presentation.features.profile.admin.ChatEditContent diff --git a/app/src/main/java/org/monogram/app/di/AppModule.kt b/app/src/main/java/org/monogram/app/di/AppModule.kt index cf75fc9b..be1265fe 100644 --- a/app/src/main/java/org/monogram/app/di/AppModule.kt +++ b/app/src/main/java/org/monogram/app/di/AppModule.kt @@ -23,6 +23,8 @@ import org.monogram.domain.repository.CacheProvider import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.ExternalNavigator import org.monogram.domain.repository.MessageDisplayer +import org.monogram.presentation.core.media.ExoPlayerCache +import org.monogram.presentation.core.media.VideoPlayerPool import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.BotPreferences import org.monogram.presentation.core.util.CachePreferences @@ -34,8 +36,6 @@ import org.monogram.presentation.core.util.ExternalNavigatorImpl import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.ToastMessageDisplayer import org.monogram.presentation.di.uiModule -import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool import org.monogram.presentation.settings.storage.CacheController @SuppressLint("WrongConstant") diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 02c3b53a..00d1f0f8 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -47,7 +47,15 @@ interface MessageRemoteDataSource { suspend fun getMessageThread(chatId: Long, messageId: Long): TdApi.MessageThreadInfo? suspend fun getMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long?): TdApi.Messages? suspend fun getChatHistory(chatId: Long, fromMessageId: Long, offset: Int, limit: Int): TdApi.Messages? - suspend fun searchChatMessages(chatId: Long, query: String, fromMessageId: Long, limit: Int, filter: TdApi.SearchMessagesFilter, threadId: Long?): TdApi.FoundChatMessages? + suspend fun searchChatMessages( + chatId: Long, + query: String, + fromMessageId: Long, + limit: Int, + filter: TdApi.SearchMessagesFilter, + threadId: Long?, + senderId: Long? = null + ): TdApi.FoundChatMessages? suspend fun getChatPinnedMessage(chatId: Long): TdApi.Message? suspend fun getPollVoters(chatId: Long, messageId: Long, optionId: Int, offset: Int, limit: Int): TdApi.PollVoters? suspend fun getMessageViewers(chatId: Long, messageId: Long): TdApi.MessageViewers? @@ -163,7 +171,8 @@ interface MessageRemoteDataSource { query: String, fromMessageId: Long, limit: Int, - threadId: Long? + threadId: Long?, + senderId: Long? = null ): SearchChatMessagesResult suspend fun getPollVotersModels( diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index 16b0f6f4..4500e4c0 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -118,6 +118,8 @@ class TdMessageRemoteDataSource( private suspend fun safeExecute(function: TdApi.Function): T? { return try { gateway.execute(function) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("TdMessageRemote", "Error executing ${function.javaClass.simpleName}", e) null @@ -370,12 +372,20 @@ class TdMessageRemoteDataSource( } } - override suspend fun searchChatMessages(chatId: Long, query: String, fromMessageId: Long, limit: Int, filter: TdApi.SearchMessagesFilter, threadId: Long?): TdApi.FoundChatMessages? { + override suspend fun searchChatMessages( + chatId: Long, + query: String, + fromMessageId: Long, + limit: Int, + filter: TdApi.SearchMessagesFilter, + threadId: Long?, + senderId: Long? + ): TdApi.FoundChatMessages? { val request = TdApi.SearchChatMessages().apply { this.chatId = chatId - this.topicId = if (threadId != null) TdApi.MessageTopicForum(threadId.toInt()) else null + this.topicId = resolveSearchTopicId(chatId, threadId) this.query = query - this.senderId = null + this.senderId = senderId?.let(TdApi::MessageSenderUser) this.fromMessageId = fromMessageId this.offset = 0 this.limit = limit @@ -384,8 +394,23 @@ class TdMessageRemoteDataSource( return safeExecute(request) } - override suspend fun searchMessages(chatId: Long, query: String, fromMessageId: Long, limit: Int, threadId: Long?): SearchChatMessagesResult { - val result = searchChatMessages(chatId, query, fromMessageId, limit, TdApi.SearchMessagesFilterEmpty(), threadId) + override suspend fun searchMessages( + chatId: Long, + query: String, + fromMessageId: Long, + limit: Int, + threadId: Long?, + senderId: Long? + ): SearchChatMessagesResult { + val result = searchChatMessages( + chatId = chatId, + query = query, + fromMessageId = fromMessageId, + limit = limit, + filter = TdApi.SearchMessagesFilterEmpty(), + threadId = threadId, + senderId = senderId + ) if (result != null) { val chat = getChat(chatId) val lastReadInbox = chat?.lastReadInboxMessageId ?: 0L @@ -395,16 +420,36 @@ class TdMessageRemoteDataSource( scope.async { try { withTimeout(5000) { messageMapper.mapMessageToModelSync(msg, lastReadInbox, lastReadOutbox, isChatOpen = true) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("TdMessageRemote", "Error mapping search message ${msg.id}", e) createFallbackMessage(msg) } } }.awaitAll() - return SearchChatMessagesResult(models, result.totalCount, result.nextFromMessageId) + val nextCursor = result.nextFromMessageId.takeIf { it != 0L } + ?: models.lastOrNull()?.id + ?: 0L + return SearchChatMessagesResult( + messages = models, + totalCount = result.totalCount, + nextFromMessageId = if (models.size < result.totalCount) nextCursor else 0L + ) } else return SearchChatMessagesResult(emptyList(), 0, 0L) } + private suspend fun resolveSearchTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { + if (threadId == null || threadId == 0L) return null + + val chat = getChat(chatId) + return if (chat?.viewAsTopics == true) { + TdApi.MessageTopicForum(threadId.toInt()) + } else { + TdApi.MessageTopicThread(threadId) + } + } + private suspend fun loadMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): List = withContext(dispatcherProvider.io) { val historyResult = getChatHistoryInternal(chatId, fromMessageId, offset, limit, threadId) ?: throw IllegalStateException( @@ -423,6 +468,8 @@ class TdMessageRemoteDataSource( async { try { withTimeout(5000) { messageMapper.mapMessageToModelSync(msg, lastReadInbox, lastReadOutbox, isChatOpen = true) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("TdMessageRemote", "Error mapping message ${msg.id}", e) createFallbackMessage(msg) diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index b814d2b4..c0d68010 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -842,9 +842,17 @@ class MessageRepositoryImpl( query: String, fromMessageId: Long, limit: Int, - threadId: Long? + threadId: Long?, + senderId: Long? ): SearchChatMessagesResult = withContext(dispatcherProvider.io) { - messageRemoteDataSource.searchMessages(chatId, query, fromMessageId, limit, threadId) + messageRemoteDataSource.searchMessages( + chatId, + query, + fromMessageId, + limit, + threadId, + senderId + ) } override fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) { diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index 20c5026e..fa249e18 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -184,7 +184,8 @@ interface MessageRepository : query: String, fromMessageId: Long = 0, limit: Int = 50, - threadId: Long? = null + threadId: Long? = null, + senderId: Long? = null ): SearchChatMessagesResult fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) diff --git a/presentation/src/main/cpp/native-lib.cpp b/presentation/src/main/cpp/native-lib.cpp index 97b29dbc..64fa8a70 100644 --- a/presentation/src/main/cpp/native-lib.cpp +++ b/presentation/src/main/cpp/native-lib.cpp @@ -517,7 +517,7 @@ bool processVideoNative(const char* inputPath, const char* outputPath, extern "C" { JNIEXPORT jlong JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_create( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_create( JNIEnv* env, jobject instance, jobject surface, jboolean useAlpha, jboolean removeBlackBg) { auto* renderer = new NativeVideoRenderer(env, instance, surface, useAlpha, removeBlackBg); renderer->start(); @@ -525,28 +525,28 @@ Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideo } JNIEXPORT void JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_destroy( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_destroy( JNIEnv* env, jobject /* this */, jlong handle) { auto* renderer = reinterpret_cast(handle); delete renderer; } JNIEXPORT void JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_updateSize( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_updateSize( JNIEnv* env, jobject /* this */, jlong handle, jint width, jint height) { auto* renderer = reinterpret_cast(handle); renderer->updateSize(width, height); } JNIEXPORT void JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_notifyFrameAvailable( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_notifyFrameAvailable( JNIEnv* env, jobject /* this */, jlong handle) { auto* renderer = reinterpret_cast(handle); renderer->onFrameAvailable(); } JNIEXPORT void JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_setFilter( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_setFilter( JNIEnv* env, jobject /* this */, jlong handle, jfloatArray matrix) { auto* renderer = reinterpret_cast(handle); if (matrix == nullptr) { @@ -559,12 +559,58 @@ Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideo } JNIEXPORT void JNICALL -Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_setOverlayTexture( +Java_org_monogram_presentation_core_media_NativeVideoRenderer_setOverlayTexture( JNIEnv* env, jobject /* this */, jlong handle, jint textureId) { auto* renderer = reinterpret_cast(handle); renderer->setOverlayTexture(textureId); } +JNIEXPORT jlong JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_create( + JNIEnv *env, jobject instance, jobject surface, jboolean useAlpha, jboolean removeBlackBg) { + return Java_org_monogram_presentation_core_media_NativeVideoRenderer_create( + env, instance, surface, useAlpha, removeBlackBg + ); +} + +JNIEXPORT void JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_destroy( + JNIEnv *env, jobject instance, jlong handle) { + Java_org_monogram_presentation_core_media_NativeVideoRenderer_destroy(env, instance, handle); +} + +JNIEXPORT void JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_updateSize( + JNIEnv *env, jobject instance, jlong handle, jint width, jint height) { + Java_org_monogram_presentation_core_media_NativeVideoRenderer_updateSize( + env, instance, handle, width, height + ); +} + +JNIEXPORT void JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_notifyFrameAvailable( + JNIEnv *env, jobject instance, jlong handle) { + Java_org_monogram_presentation_core_media_NativeVideoRenderer_notifyFrameAvailable( + env, instance, handle + ); +} + +JNIEXPORT void JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_setFilter( + JNIEnv *env, jobject instance, jlong handle, jfloatArray matrix) { + Java_org_monogram_presentation_core_media_NativeVideoRenderer_setFilter( + env, instance, handle, matrix + ); +} + +JNIEXPORT void JNICALL +Java_org_monogram_presentation_features_chats_currentChat_components_NativeVideoRenderer_setOverlayTexture( + JNIEnv *env, jobject instance, jlong handle, jint textureId) { + Java_org_monogram_presentation_core_media_NativeVideoRenderer_setOverlayTexture( + env, instance, handle, textureId + ); +} + JNIEXPORT jlong JNICALL Java_org_monogram_presentation_features_stickers_core_VpxWrapper_create(JNIEnv* env, jobject thiz) { return (jlong) new VpxDecoder(); @@ -603,7 +649,7 @@ Java_org_monogram_presentation_features_stickers_core_VpxWrapper_getHeight(JNIEn } JNIEXPORT jboolean JNICALL -Java_org_monogram_presentation_features_chats_currentChat_editor_video_VideoEditorUtils_processVideoNative( +Java_org_monogram_presentation_features_chats_conversation_editor_video_VideoEditorUtils_processVideoNative( JNIEnv* env, jclass clazz, jstring inputPath, jstring outputPath, jlong startMs, jlong endMs, @@ -629,4 +675,15 @@ Java_org_monogram_presentation_features_chats_currentChat_editor_video_VideoEdit return result; } -} \ No newline at end of file +JNIEXPORT jboolean JNICALL +Java_org_monogram_presentation_features_chats_currentChat_editor_video_VideoEditorUtils_processVideoNative( + JNIEnv *env, jclass clazz, + jstring inputPath, jstring outputPath, + jlong startMs, jlong endMs, + jint targetHeight, jint bitrate, jboolean muteAudio, jfloatArray filterMatrix) { + return Java_org_monogram_presentation_features_chats_conversation_editor_video_VideoEditorUtils_processVideoNative( + env, clazz, inputPath, outputPath, startMs, endMs, targetHeight, bitrate, muteAudio, filterMatrix + ); +} + +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt b/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt rename to presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt index 5579251e..31ed7fc4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.core.media import android.annotation.SuppressLint import android.app.Application @@ -348,7 +348,7 @@ fun VideoStickerPlayer( } lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) exoPlayer.removeListener(playerListener) - exoPlayer.setVideoSurface(null) + exoPlayer.clearVideoSurface() exoPlayer.stop() exoPlayer.clearMediaItems() videoPlayerPool.release(exoPlayer) @@ -367,8 +367,11 @@ fun VideoStickerPlayer( this.contentScale = contentScale this.configure(type.useAlphaChannel, type.removeBlackBackground) bindSurface { surface -> - if (!isDisposed.get()) { + if (isDisposed.get()) return@bindSurface + if (surface != null && surface.isValid) { exoPlayer.setVideoSurface(surface) + } else { + exoPlayer.clearVideoSurface() } } textureViewRef.value = this @@ -378,8 +381,11 @@ fun VideoStickerPlayer( view.contentScale = contentScale view.configure(type.useAlphaChannel, type.removeBlackBackground) view.bindSurface { surface -> - if (!isDisposed.get()) { + if (isDisposed.get()) return@bindSurface + if (surface != null && surface.isValid) { exoPlayer.setVideoSurface(surface) + } else { + exoPlayer.clearVideoSurface() } } textureViewRef.value = view @@ -687,10 +693,10 @@ class NativeVideoRenderer { class VideoGLTextureView(context: Context) : TextureView(context), TextureView.SurfaceTextureListener { var contentScale: ContentScale = ContentScale.Fit - var onSurfaceReady: ((Surface) -> Unit)? = null + var onSurfaceReady: ((Surface?) -> Unit)? = null set(value) { field = value - currentSurface?.let { surface -> value?.invoke(surface) } + dispatchCurrentSurfaceIfValid() } private var currentSurface: Surface? = null @@ -711,10 +717,20 @@ class VideoGLTextureView(context: Context) : TextureView(context), TextureView.S this.removeBlackBg = removeBlackBg } - fun bindSurface(onReady: (Surface) -> Unit) { + fun bindSurface(onReady: (Surface?) -> Unit) { onSurfaceReady = onReady } + private fun dispatchCurrentSurfaceIfValid() { + val surface = currentSurface ?: return + if (!surface.isValid) { + currentSurface = null + onSurfaceReady?.invoke(null) + return + } + onSurfaceReady?.invoke(surface) + } + fun setVideoSize(width: Int, height: Int) { this.videoWidth = width this.videoHeight = height @@ -765,7 +781,7 @@ class VideoGLTextureView(context: Context) : TextureView(context), TextureView.S nativeRenderer = NativeVideoRenderer() nativeRenderer?.onSurfaceReady = { surface -> currentSurface = surface - onSurfaceReady?.invoke(surface) + dispatchCurrentSurfaceIfValid() } nativeRenderer?.init(Surface(st), useAlpha, removeBlackBg) nativeRenderer?.setSize(width, height) @@ -776,9 +792,11 @@ class VideoGLTextureView(context: Context) : TextureView(context), TextureView.S } override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { + onSurfaceReady?.invoke(null) currentSurface = null nativeRenderer?.release() nativeRenderer = null + onSurfaceReady = null return true } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AvatarPlayer.kt b/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt similarity index 76% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AvatarPlayer.kt rename to presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt index 16ad5eef..48d970b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AvatarPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt @@ -1,8 +1,9 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.core.media import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import org.monogram.presentation.features.chats.conversation.ui.InlineVideoPlayer @Composable fun AvatarPlayer( diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt index 2e6b4b40..62cab58f 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt @@ -23,7 +23,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import org.monogram.presentation.R import org.monogram.presentation.core.util.generateColorFromHash -import org.monogram.presentation.features.chats.currentChat.components.AvatarPlayer +import org.monogram.presentation.core.media.AvatarPlayer import java.io.File @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt index 8a4ab8cf..85f960ff 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt @@ -22,7 +22,7 @@ import coil3.request.crossfade import coil3.size.Precision import coil3.size.Size import org.monogram.presentation.core.util.generateColorFromHash -import org.monogram.presentation.features.chats.currentChat.components.AvatarPlayer +import org.monogram.presentation.core.media.AvatarPlayer import java.io.File @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SectionHeader.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SectionHeader.kt index 3c06ffde..f6aede16 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/SectionHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SectionHeader.kt @@ -9,10 +9,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable -fun SectionHeader(text: String) { +fun SectionHeader(text: String, modifier: Modifier = Modifier) { Text( text = text, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp), + modifier = modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTextField.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTextField.kt new file mode 100644 index 00000000..95577959 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTextField.kt @@ -0,0 +1,102 @@ +package org.monogram.presentation.core.ui + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.monogram.presentation.core.ui.ItemPosition + +@Composable +fun SettingsTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + icon: ImageVector, + position: ItemPosition, + modifier: Modifier = Modifier, + enabled: Boolean = true, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + itemSpacing: Dp = 2.dp, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + trailingIcon: @Composable (() -> Unit)? = null +) { + val cornerRadius = 24.dp + val shape = when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + topStart = 4.dp, + topEnd = 4.dp + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = shape, + modifier = modifier.fillMaxWidth() + ) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(placeholder) }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + trailingIcon = trailingIcon, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.primary, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE && itemSpacing > 0.dp) { + Spacer(Modifier.height(itemSpacing)) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt index bc299cd4..0e56d73c 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt @@ -1,7 +1,7 @@ package org.monogram.presentation.core.util import androidx.compose.runtime.staticCompositionLocalOf -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool +import org.monogram.presentation.core.media.VideoPlayerPool val LocalVideoPlayerPool = staticCompositionLocalOf { error("VideoPlayerPool not provided") diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index 320e29ed..2bc8bb0e 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -57,8 +57,8 @@ import org.monogram.domain.repository.WallpaperRepository import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool +import org.monogram.presentation.core.media.ExoPlayerCache +import org.monogram.presentation.core.media.VideoPlayerPool import org.monogram.presentation.settings.storage.CacheController interface AppContainer { diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index f9282e3a..ed731cb6 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -58,8 +58,8 @@ import org.monogram.domain.repository.WallpaperRepository import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool +import org.monogram.presentation.core.media.ExoPlayerCache +import org.monogram.presentation.core.media.VideoPlayerPool import org.monogram.presentation.settings.storage.CacheController class KoinAppContainer(koin: Koin) : AppContainer { diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt index 4a340af6..9e1cf0b3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt @@ -102,7 +102,7 @@ import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.util.Country import org.monogram.presentation.core.util.CountryManager -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import java.util.Locale enum class ActiveField { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/AutoDownloadSuppression.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/AutoDownloadSuppression.kt similarity index 87% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/AutoDownloadSuppression.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/AutoDownloadSuppression.kt index ae93ab7e..a09f06cf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/AutoDownloadSuppression.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/AutoDownloadSuppression.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat +package org.monogram.presentation.features.chats.conversation import java.util.concurrent.ConcurrentHashMap 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/conversation/ChatComponent.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index 679424d5..3a3584c6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat +package org.monogram.presentation.features.chats.conversation import androidx.compose.runtime.Stable import androidx.compose.ui.platform.Clipboard @@ -174,6 +174,12 @@ interface ChatComponent { fun onToggleMute() fun onSearchToggle() fun onSearchQueryChange(query: String) + fun onSearchNextResult() + fun onSearchPreviousResult() + fun onSearchResultClick(index: Int) + fun onLoadMoreSearchResults() + fun onSearchSenderChange(user: UserModel?) + fun onSearchDateRangeChange(fromEpochSeconds: Int?, toEpochSeconds: Int?) fun onClearHistory() fun onDeleteChat() fun onReport() @@ -321,6 +327,15 @@ interface ChatComponent { val isMuted: Boolean = false, val isSearchActive: Boolean = false, val searchQuery: String = "", + val isSearchingMessages: Boolean = false, + val searchResults: List = emptyList(), + val searchResultsTotalCount: Int = 0, + val selectedSearchResultIndex: Int = -1, + val searchNextFromMessageId: Long = 0L, + val searchSender: UserModel? = null, + val searchAvailableSenders: List = emptyList(), + val searchDateFromEpochSeconds: Int? = null, + val searchDateToEpochSeconds: Int? = null, val showReportDialog: Boolean = false, val isBot: Boolean = false, val botCommands: List = emptyList(), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt new file mode 100644 index 00000000..086dd859 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt @@ -0,0 +1,1030 @@ +package org.monogram.presentation.features.chats.conversation + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.zIndex +import androidx.window.core.layout.WindowWidthSizeClass +import kotlinx.coroutines.launch +import org.monogram.domain.models.ForwardInfo +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled +import org.monogram.presentation.features.chats.conversation.ui.AdvancedCircularRecorderScreen +import org.monogram.presentation.features.chats.conversation.ui.ChatInputBar +import org.monogram.presentation.features.chats.conversation.ui.LocalVoicePlaybackController +import org.monogram.presentation.features.chats.conversation.ui.MessageListShimmer +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentBackground +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentEffects +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentList +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentOverlays +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentSearchOverlay +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentTopBar +import org.monogram.presentation.features.chats.conversation.ui.content.GroupedMessageItem +import org.monogram.presentation.features.chats.conversation.ui.content.chatContentLeadingItemsCount +import org.monogram.presentation.features.chats.conversation.ui.content.extractTextContent +import org.monogram.presentation.features.chats.conversation.ui.content.groupMessagesByAlbum +import org.monogram.presentation.features.chats.conversation.ui.content.groupedIndexToLazyIndex +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatChromeState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentPermissionState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatInputBarActions +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatInputBarState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatMessageListState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatSearchUiState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatTopBarUiState +import org.monogram.presentation.features.chats.conversation.ui.content.scrollToMessageIndex +import org.monogram.presentation.features.chats.conversation.ui.content.withUpdatedTextContent +import org.monogram.presentation.features.chats.conversation.ui.message.LocalLinkHandler +import org.monogram.presentation.features.chats.conversation.ui.message.LocalMessageRenderDependencies +import org.monogram.presentation.features.chats.conversation.ui.message.rememberChatMessageRenderDependencies +import org.monogram.presentation.features.chats.conversation.ui.rememberVoicePlaybackController +import java.io.File +import java.io.FileOutputStream + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChatContent( + component: ChatComponent, + isOverlay: Boolean = false, +) { + val state by component.state.collectAsState() + val scrollState = rememberLazyListState() + val context = LocalContext.current + val density = LocalDensity.current + val localClipboard = LocalClipboard.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && isTabletInterfaceEnabled + + var isVisible by remember { mutableStateOf(false) } + var showInitialLoading by remember { mutableStateOf(false) } + var isRecordingVideo by remember { mutableStateOf(false) } + var topOverlayHeight by remember { mutableStateOf(0.dp) } + + // Menu States + var selectedMessageId by rememberSaveable { mutableStateOf(null) } + val transformedMessageTexts = remember { mutableStateMapOf() } + val originalMessageTexts = remember { mutableStateMapOf() } + val latestMessagesState = rememberUpdatedState(state.messages) + val selectedMessageIdState = rememberUpdatedState(selectedMessageId) + val displayMessages by remember { + derivedStateOf { + val baseMessages = latestMessagesState.value + baseMessages.map { message -> + val transformedText = transformedMessageTexts[message.id] + val transformedMessage = if (transformedText != null) { + message.withUpdatedTextContent(transformedText) + } else { + message + } + + if (state.rootMessage != null && transformedMessage.replyToMsgId == state.rootMessage?.id) { + transformedMessage.copy( + replyToMsgId = null, + replyToMsg = null + ) + } else { + transformedMessage + } + } + } + } + val displayMessagesById by remember(displayMessages) { + derivedStateOf { displayMessages.associateBy(MessageModel::id) } + } + val selectedMessage by remember { + derivedStateOf { + val currentSelectedId = selectedMessageIdState.value + currentSelectedId?.let(displayMessagesById::get) + } + } + var menuOffset by remember { mutableStateOf(Offset.Zero) } + var menuMessageSize by remember { mutableStateOf(IntSize.Zero) } + var clickOffset by remember { mutableStateOf(Offset.Zero) } + var contentRect by remember { mutableStateOf(Rect.Zero) } + + var pendingMediaPaths by rememberSaveable { mutableStateOf>(emptyList()) } + var pendingDocumentPaths by rememberSaveable { mutableStateOf>(emptyList()) } + var editingPhotoPath by rememberSaveable { mutableStateOf(null) } + var editingVideoPath by rememberSaveable { mutableStateOf(null) } + var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } + + val groupedMessages by remember { + derivedStateOf { groupMessagesByAlbum(displayMessages) } + } + val groupedMessageIndexById by remember(groupedMessages) { + derivedStateOf { + buildMap { + groupedMessages.forEachIndexed { index, item -> + when (item) { + is GroupedMessageItem.Single -> put(item.message.id, index) + is GroupedMessageItem.Album -> item.messages.forEach { message -> + put(message.id, index) + } + } + } + } + } + } + val isComments = state.rootMessage != null + val isForumList = state.viewAsTopics && state.currentTopicId == null + var showScrollToBottomButton by remember { mutableStateOf(false) } + var showAllSearchResults by rememberSaveable( + state.chatId, + state.currentTopicId, + state.isSearchActive, + state.searchQuery + ) { + mutableStateOf(false) + } + var showSearchSenderPicker by rememberSaveable( + state.chatId, + state.currentTopicId, + state.isSearchActive + ) { + mutableStateOf(false) + } + var showSearchFilters by rememberSaveable( + state.chatId, + state.currentTopicId, + state.isSearchActive + ) { + mutableStateOf(false) + } + var hasUserScrolledAwayFromBottom by rememberSaveable(state.chatId, state.currentTopicId) { + mutableStateOf(false) + } + val isDragged by scrollState.interactionSource.collectIsDraggedAsState() + val searchUiState = rememberChatSearchUiState(state) + + val isAnyViewerOpen = state.fullScreenImages != null || + state.fullScreenVideoPath != null || + state.fullScreenVideoMessageId != null || + state.youtubeUrl != null || + state.instantViewUrl != null || + state.miniAppUrl != null || + state.webViewUrl != null || + editingPhotoPath != null || + editingVideoPath != null || + isRecordingVideo + + val scrollToMessageState = rememberUpdatedState(newValue = { msg: MessageModel -> + val index = groupedMessageIndexById[msg.id] ?: -1 + if (index != -1) { + coroutineScope.launch { + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val targetIndex = groupedIndexToLazyIndex(index, leadingItems) + + scrollState.scrollToMessageIndex( + index = targetIndex, + align = ScrollAlign.Center, + animated = state.isChatAnimationsEnabled, + staged = true + ) + } + } else { + component.onPinnedMessageClick(msg) + } + }) + + // Pick Media Result + val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> + val albumPaths = mutableListOf() + uris.forEach { uri -> + val mimeType = context.contentResolver.getType(uri) + val extension = when { + mimeType == "image/gif" -> "gif" + mimeType?.startsWith("video/") == true -> "mp4" + else -> "jpg" + } + val file = File(context.cacheDir, "temp_media_${System.nanoTime()}.$extension") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } + if (extension == "gif") component.onSendGifFile(file.absolutePath) + else albumPaths.add(file.absolutePath) + } + if (albumPaths.isNotEmpty()) pendingMediaPaths = (pendingMediaPaths + albumPaths).distinct() + if (albumPaths.isNotEmpty()) pendingDocumentPaths = emptyList() + } + + val shouldAnimateContentEntrance = state.isChatAnimationsEnabled && isOverlay + val contentAlpha by animateFloatAsState( + targetValue = if (isVisible || !shouldAnimateContentEntrance) 1f else 0f, + animationSpec = if (shouldAnimateContentEntrance) tween(300) else snap(), + label = "ContentAlpha" + ) + val contentOffset by animateDpAsState( + targetValue = if (isVisible || !shouldAnimateContentEntrance) 0.dp else 20.dp, + animationSpec = if (shouldAnimateContentEntrance) tween(300) else snap(), + label = "ContentOffset" + ) + + val permissionState = rememberChatContentPermissionState(state) + val messageRenderDependencies by rememberChatMessageRenderDependencies( + messages = remember(displayMessages, state.rootMessage) { + buildList { + addAll(displayMessages) + state.rootMessage?.let(::add) + } + } + ) + val voicePlaybackController = rememberVoicePlaybackController() + val messageListState = rememberChatMessageListState( + state = state, + displayMessages = displayMessages, + canSendAnything = permissionState.canSendAnything, + showInitialLoading = showInitialLoading + ) + val chromeState = rememberChatChromeState( + state = state, + isRecordingVideo = isRecordingVideo, + editingPhotoPath = editingPhotoPath, + editingVideoPath = editingVideoPath, + selectedMessageId = selectedMessageId + ) + + var containerSize by remember { mutableStateOf(IntSize.Zero) } + var renderPinnedMessagesList by rememberSaveable { mutableStateOf(state.showPinnedMessagesList) } + var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } + + ChatContentEffects( + component = component, + state = state, + scrollState = scrollState, + groupedMessages = groupedMessages, + groupedMessageIndexById = groupedMessageIndexById, + isComments = isComments, + isForumList = isForumList, + isDragged = isDragged, + isRecordingVideo = isRecordingVideo, + showInitialLoading = showInitialLoading, + hasUserScrolledAwayFromBottom = hasUserScrolledAwayFromBottom, + transformedMessageTexts = transformedMessageTexts, + originalMessageTexts = originalMessageTexts, + onVisible = { + isVisible = true + }, + onShowInitialLoadingChanged = { showInitialLoading = it }, + onHasUserScrolledAwayFromBottomChanged = { hasUserScrolledAwayFromBottom = it }, + onShowScrollToBottomButtonChanged = { showScrollToBottomButton = it }, + onHideKeyboardAndClearFocus = { force -> + focusManager.clearFocus(force = force) + keyboardController?.hide() + }, + onRenderPinnedMessagesListChanged = { renderPinnedMessagesList = it }, + onSearchFiltersChanged = { showSearchFilters = it }, + onSearchSenderPickerChanged = { showSearchSenderPicker = it } + ) + + val requestPinnedMessagesListDismiss = { + if (state.showPinnedMessagesList) { + component.onDismissPinnedMessages() + } + } + val topBarUiState = rememberChatTopBarUiState(state) + + CompositionLocalProvider( + LocalLinkHandler provides { component.onLinkClick(it) }, + LocalMessageRenderDependencies provides messageRenderDependencies, + LocalVoicePlaybackController provides voicePlaybackController + ) { + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val headerOverlayHeight = statusBarHeight + 16.dp + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .onGloballyPositioned { containerSize = it.size } + ) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = contentAlpha + translationY = contentOffset.toPx() + } + ) { + ChatContentBackground(state = state) + } + + if (isTablet) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(headerOverlayHeight) + .graphicsLayer { + alpha = contentAlpha + translationY = contentOffset.toPx() + } + .background(MaterialTheme.colorScheme.surface) + ) + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = contentAlpha; translationY = contentOffset.toPx() } + .semantics { contentDescription = "ChatContent" }, + containerColor = Color.Transparent, + topBar = { + Box( + modifier = Modifier.onSizeChanged { + topOverlayHeight = with(density) { it.height.toDp() } + } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ChatContentTopBar( + topBarState = topBarUiState, + selectedCount = chromeState.selectedCount, + canRevokeSelected = chromeState.canRevokeSelected, + component = component, + contentAlpha = contentAlpha, + onBack = { + keyboardController?.hide() + if (state.currentTopicId != null) { + component.onTopicClick(0) + } else { + component.onBackClicked() + } + }, + onOpenMenu = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + }, + onPinnedMessageClick = { msg -> scrollToMessageState.value(msg) }, + showBack = !isTablet + ) + + } + } + }, + bottomBar = { + if (chromeState.showInputBar) { + val inputBarState = rememberChatInputBarState( + state = state, + pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths + ) + + val inputBarActions = rememberChatInputBarActions( + component = component, + state = state, + pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, + onPickMedia = { + pickMedia.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageAndVideo + ) + ) + }, + onHideKeyboardAndClearFocus = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + }, + onStartRecordingVideo = { + isRecordingVideo = true + }, + onSetPendingMediaPaths = { paths -> + pendingMediaPaths = paths + }, + onSetPendingDocumentPaths = { paths -> + pendingDocumentPaths = paths + }, + onEditMediaPath = { path -> + if (path.endsWith(".mp4")) { + editingVideoPath = path + } else { + editingPhotoPath = path + } + } + ) + + ChatInputBar( + state = inputBarState, + actions = inputBarActions, + appPreferences = component.appPreferences, + stickerRepository = component.stickerRepository + ) + } else if (chromeState.showJoinButton) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center + ) { + Button( + onClick = { component.onJoinChat() }, + shapes = ExpressiveDefaults.largeButtonShapes(), + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.MediumContainerHeight) + ) { + Text( + text = stringResource(R.string.action_join), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = if (!state.canWrite && !chromeState.showJoinButton) 0.dp else padding.calculateBottomPadding()) + .consumeWindowInsets(padding) + .onGloballyPositioned { coordinates -> + contentRect = Rect( + offset = coordinates.positionInWindow(), + size = coordinates.size.toSize() + ) + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = contentAlpha + translationY = contentOffset.toPx() + } + ) { + val currentKeyboardController = rememberUpdatedState(keyboardController) + val currentFocusManager = rememberUpdatedState(focusManager) + val currentIsVisible = rememberUpdatedState(isVisible) + val currentShowInitialLoading = rememberUpdatedState(showInitialLoading) + + val onPhotoDownloadStable: (Int) -> Unit = remember(component) { + { fileId: Int -> + if (fileId != 0) { + component.onDownloadFile(fileId) + } + } + } + + val onPhotoClickStable: (MessageModel, List, List, List, Int) -> Unit = + remember(component) { + { msg: MessageModel, paths: List, captions: List, messageIds: List, index: Int -> + val content = msg.content as? MessageContent.Photo + val clickedPath = paths.getOrNull(index) + ?.takeIf { it.isNotBlank() && File(it).exists() } + ?: content?.path?.takeIf { File(it).exists() } + + if (clickedPath != null) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + + val validItems = paths.mapIndexedNotNull { itemIndex, path -> + val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } + ?: return@mapIndexedNotNull null + Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) + } + + if (validItems.isNotEmpty()) { + val validPaths = validItems.map { it.second } + val validCaptions = validItems.map { it.third } + val validMessageIds = validItems.map { (itemIndex, _, _) -> + messageIds.getOrNull(itemIndex) ?: msg.id + } + val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } + .takeIf { it >= 0 } + ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } + ?: 0 + + component.onOpenImages( + images = validPaths, + captions = validCaptions, + startIndex = startIndex, + messageId = msg.id, + messageIds = validMessageIds + ) + } + } else { + content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) + } + Unit + } + } + + val onVideoClickStable: (MessageModel, String?, String?) -> Unit = + remember(component, scrollState) { + { msg: MessageModel, path: String?, caption: String? -> + if (!currentIsVisible.value || currentShowInitialLoading.value || scrollState.isScrollInProgress) { + Unit + } else { + val videoContent = msg.content as? MessageContent.Video + val supportsStreaming = videoContent?.supportsStreaming ?: false + val validPath = path?.takeIf { File(it).exists() } + + if (validPath != null || supportsStreaming) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) + } else { + val fileId = when (val c = msg.content) { + is MessageContent.Video -> c.fileId + is MessageContent.Gif -> c.fileId + else -> 0 + } + if (fileId != 0) { + component.onDownloadFile(fileId) + } + } + } + } + } + + val onDocumentClickStable: (MessageModel) -> Unit = remember(component) { + { msg: MessageModel -> + val doc = msg.content as? MessageContent.Document + if (doc != null) { + val validDocPath = doc.path?.takeIf { File(it).exists() } + if (validDocPath != null) { + val path = validDocPath.lowercase() + if (path.endsWith(".jpg") || path.endsWith(".png")) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenImages( + images = listOf(validDocPath), + captions = listOf(doc.caption), + startIndex = 0, + messageId = msg.id, + messageIds = listOf(msg.id) + ) + } else if (path.endsWith(".mp4")) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo( + path = validDocPath, + messageId = msg.id, + caption = doc.caption + ) + } else { + component.downloadUtils.openFile(validDocPath) + } + } else { + component.onDownloadFile(doc.fileId) + } + } + Unit + } + } + + val onAudioClickStable: (MessageModel) -> Unit = remember(component) { + { msg: MessageModel -> + val audio = msg.content as? MessageContent.Audio + if (audio != null) { + val validAudioPath = audio.path?.takeIf { File(it).exists() } + if (validAudioPath != null) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo( + path = validAudioPath, + messageId = msg.id, + caption = audio.caption + ) + } else { + component.onDownloadFile(audio.fileId) + } + } + Unit + } + } + + val onMessageOptionsClickStable: (MessageModel, Offset, IntSize, Offset) -> Unit = + remember(component) { + { msg: MessageModel, pos: Offset, size: IntSize, clickPos: Offset -> + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus(force = true) + selectedMessageId = msg.id + menuOffset = pos + menuMessageSize = size + clickOffset = clickPos + } + } + + val onGoToReplyStable: (MessageModel) -> Unit = remember(scrollToMessageState) { + { msg: MessageModel -> + scrollToMessageState.value(msg) + } + } + + val onMessagePositionChangeStable: (Offset, IntSize) -> Unit = remember { + { pos: Offset, size: IntSize -> + menuOffset = pos + menuMessageSize = size + } + } + + val onViaBotClickStable: (String) -> Unit = remember(component) { + { botUsername: String -> + val prefill = "@$botUsername " + component.onDraftChange(prefill) + component.onInlineQueryChange("", "") + } + } + + val toProfileStable: (Long) -> Unit = remember(component) { + { userId: Long -> + component.toProfile(userId) + } + } + + val onForwardOriginClickStable: (ForwardInfo) -> Unit = + remember(component) { + { forwardInfo -> + component.onForwardOriginClick(forwardInfo) + } + } + + ChatContentList( + showNavPadding = false, + topOverlayPadding = if ( + (state.viewAsTopics && state.currentTopicId == null) || + state.rootMessage != null + ) { + topOverlayHeight + } else { + 0.dp + }, + state = messageListState, + 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, + onForwardOriginClick = onForwardOriginClickStable, + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen, + bottomContentPadding = if (state.rootMessage != null && (chromeState.showInputBar || chromeState.showJoinButton)) 120.dp else 8.dp + ) + + AnimatedVisibility( + visible = state.isSearchActive, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 12.dp, vertical = 16.dp) + ) { + ChatContentSearchOverlay( + context = context, + query = state.searchQuery, + results = state.searchResults, + totalCount = state.searchResultsTotalCount, + selectedIndex = state.selectedSearchResultIndex, + isSearching = state.isSearchingMessages, + canLoadMore = searchUiState.canLoadMoreSearchResults, + showAllResults = showAllSearchResults, + showSearchFilters = showSearchFilters, + showSearchSenderPicker = showSearchSenderPicker, + hasFiltersApplied = searchUiState.hasSearchFiltersApplied, + selectedSender = state.searchSender, + searchSenderCandidates = searchUiState.searchSenderCandidates, + fromEpochSeconds = state.searchDateFromEpochSeconds, + toEpochSeconds = state.searchDateToEpochSeconds, + onLoadMore = component::onLoadMoreSearchResults, + onResultClick = { index -> + showAllSearchResults = false + component.onSearchResultClick(index) + }, + onPrevious = component::onSearchPreviousResult, + onNext = component::onSearchNextResult, + onToggleShowAll = { + showAllSearchResults = !showAllSearchResults + }, + onToggleFilters = { + val nextExpanded = !showSearchFilters + showSearchFilters = nextExpanded + if (!nextExpanded) { + showSearchSenderPicker = false + } + }, + onToggleSenderPicker = { + showSearchSenderPicker = !showSearchSenderPicker + }, + onSelectSender = { user -> + showSearchSenderPicker = false + component.onSearchSenderChange(user) + }, + onApplyDateRange = component::onSearchDateRangeChange + ) + } + + AnimatedVisibility( + visible = showScrollToBottomButton && !state.isSearchActive, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Box { + FloatingActionButton( + onClick = { + component.onScrollToBottom() + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp + ), + shape = CircleShape, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.cd_scroll_to_bottom), + modifier = Modifier.size(24.dp) + ) + } + + AnimatedVisibility( + visible = state.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 = 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 + ) + } + } + } + } + } + + if (isRecordingVideo) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .zIndex(10f) + ) { + 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 + ) + } + } + } + } + } + } + ChatContentOverlays( + state = state, + component = component, + localClipboard = localClipboard, + groupedMessages = groupedMessages, + isAnyViewerOpen = isAnyViewerOpen, + renderPinnedMessagesList = renderPinnedMessagesList, + requestPinnedMessagesListDismiss = requestPinnedMessagesListDismiss, + onPinnedSheetHidden = { + renderPinnedMessagesList = false + pendingPinnedSheetAction?.invoke() + pendingPinnedSheetAction = null + }, + onPinnedMessageClick = { + pendingPinnedSheetAction = { scrollToMessageState.value(it) } + requestPinnedMessagesListDismiss() + }, + selectedMessage = selectedMessage, + menuOffset = menuOffset, + menuMessageSize = menuMessageSize, + clickOffset = clickOffset, + contentRect = contentRect, + canRestoreOriginalText = selectedMessage?.let { msg -> + originalMessageTexts.containsKey(msg.id) + } == true, + onApplyTransformedText = { newText -> + val msg = selectedMessage ?: return@ChatContentOverlays + val originalText = msg.extractTextContent() + if (!originalText.isNullOrBlank() && !originalMessageTexts.containsKey(msg.id)) { + originalMessageTexts[msg.id] = originalText + } + transformedMessageTexts[msg.id] = newText + }, + onRestoreOriginalText = { + val msg = selectedMessage ?: return@ChatContentOverlays + if (!originalMessageTexts.containsKey(msg.id)) { + return@ChatContentOverlays + } + transformedMessageTexts.remove(msg.id) + originalMessageTexts.remove(msg.id) + }, + onDismissMessageOptions = { selectedMessageId = null }, + pendingBlockUserId = pendingBlockUserId, + onRequestBlockUser = { userId -> + pendingBlockUserId = userId + }, + onConfirmBlockUser = { userId -> + component.onBlockUser(userId) + pendingBlockUserId = null + }, + onDismissBlockUser = { pendingBlockUserId = null }, + editingPhotoPath = editingPhotoPath, + onClosePhotoEditor = { editingPhotoPath = null }, + onSavePhotoEditor = { newPath -> + val path = editingPhotoPath ?: return@ChatContentOverlays + val newList = pendingMediaPaths.toMutableList() + val index = newList.indexOf(path) + if (index != -1) { + newList[index] = newPath + pendingMediaPaths = newList + } + editingPhotoPath = null + }, + editingVideoPath = editingVideoPath, + onCloseVideoEditor = { editingVideoPath = null }, + onSaveVideoEditor = { newPath -> + val path = editingVideoPath ?: return@ChatContentOverlays + val newList = pendingMediaPaths.toMutableList() + val index = newList.indexOf(path) + if (index != -1) { + newList[index] = newPath + pendingMediaPaths = newList + } + editingVideoPath = null + }, + isCustomBackHandlingEnabled = chromeState.isCustomBackHandlingEnabled, + onBack = { + if (editingPhotoPath != null) editingPhotoPath = null + else if (editingVideoPath != null) editingVideoPath = null + else if (state.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.isSearchActive) component.onSearchToggle() + else if (state.currentTopicId != null) component.onTopicClick(0) + } + ) + } + } +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt similarity index 92% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt index b75e439e..49bc8850 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatScrollModels.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat +package org.monogram.presentation.features.chats.conversation import androidx.compose.runtime.Immutable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt index 2e48a83b..8c3329f3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat +package org.monogram.presentation.features.chats.conversation import androidx.compose.ui.platform.Clipboard import com.arkivanov.mvikotlin.core.store.Store @@ -11,6 +11,7 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.PollDraft +import org.monogram.domain.models.UserModel import java.io.File interface ChatStore : Store { @@ -144,6 +145,13 @@ interface ChatStore : Store component.handleAddToAdBlockWhitelist() is Intent.RemoveFromAdBlockWhitelist -> component.handleRemoveFromAdBlockWhitelist() is Intent.ToggleMute -> component.handleToggleMute() - - is Intent.SearchToggle -> component._state.update { - it.copy( - isSearchActive = !it.isSearchActive, - searchQuery = "" - ) - } - - is Intent.SearchQueryChange -> component._state.update { it.copy(searchQuery = intent.query) } + is Intent.SearchToggle -> component.handleSearchToggle() + is Intent.SearchQueryChange -> component.handleSearchQueryChange(intent.query) + is Intent.SearchNextResult -> component.handleSearchNextResult() + is Intent.SearchPreviousResult -> component.handleSearchPreviousResult() + is Intent.SearchResultClick -> component.handleSearchResultClick(intent.index) + is Intent.LoadMoreSearchResults -> component.loadMoreSearchResults() + is Intent.SearchSenderChange -> component.handleSearchSenderChange(intent.user) + is Intent.SearchDateRangeChange -> component.handleSearchDateRangeChange( + intent.fromEpochSeconds, + intent.toEpochSeconds + ) is Intent.ClearHistory -> component.handleClearHistory() is Intent.DeleteChat -> component.handleDeleteChat() 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/conversation/DefaultChatComponent.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index 6f90e6e3..67d25b8a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat +package org.monogram.presentation.features.chats.conversation import android.util.Log import androidx.compose.ui.platform.Clipboard @@ -58,16 +58,16 @@ import org.monogram.domain.repository.WallpaperRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope -import org.monogram.presentation.features.chats.currentChat.impl.loadChatInfo -import org.monogram.presentation.features.chats.currentChat.impl.loadDraft -import org.monogram.presentation.features.chats.currentChat.impl.loadMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadPinnedMessage -import org.monogram.presentation.features.chats.currentChat.impl.loadScheduledMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadWallpapers -import org.monogram.presentation.features.chats.currentChat.impl.observePreferences -import org.monogram.presentation.features.chats.currentChat.impl.observeUserUpdates -import org.monogram.presentation.features.chats.currentChat.impl.setupMessageCollectors -import org.monogram.presentation.features.chats.currentChat.impl.setupPinnedMessageCollector +import org.monogram.presentation.features.chats.conversation.logic.loadChatInfo +import org.monogram.presentation.features.chats.conversation.logic.loadDraft +import org.monogram.presentation.features.chats.conversation.logic.loadMessages +import org.monogram.presentation.features.chats.conversation.logic.loadPinnedMessage +import org.monogram.presentation.features.chats.conversation.logic.loadScheduledMessages +import org.monogram.presentation.features.chats.conversation.logic.loadWallpapers +import org.monogram.presentation.features.chats.conversation.logic.observePreferences +import org.monogram.presentation.features.chats.conversation.logic.observeUserUpdates +import org.monogram.presentation.features.chats.conversation.logic.setupMessageCollectors +import org.monogram.presentation.features.chats.conversation.logic.setupPinnedMessageCollector import org.monogram.presentation.root.AppComponentContext import org.monogram.presentation.settings.storage.CacheController import java.io.File @@ -116,6 +116,7 @@ class DefaultChatComponent( var draftSaveJob: Job? = null private var autoLoadJob: Job? = null private var mentionJob: Job? = null + internal var searchJob: Job? = null internal val reactionUpdateSuppressedUntil = ConcurrentHashMap() internal val remappedMessageIds = ConcurrentHashMap() internal val mediaDownloadRetryCount = ConcurrentHashMap() @@ -312,6 +313,7 @@ class DefaultChatComponent( try { allMembers = chatInfoRepository.getChatMembers(chatId, 0, 200, ChatMembersFilter.Recent) .map { it.user } + _state.update { it.copy(searchAvailableSenders = allMembers) } } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to load members", e) } @@ -591,6 +593,17 @@ class DefaultChatComponent( override fun onSearchToggle() = store.accept(ChatStore.Intent.SearchToggle) override fun onSearchQueryChange(query: String) = store.accept(ChatStore.Intent.SearchQueryChange(query)) + override fun onSearchNextResult() = store.accept(ChatStore.Intent.SearchNextResult) + override fun onSearchPreviousResult() = store.accept(ChatStore.Intent.SearchPreviousResult) + override fun onSearchResultClick(index: Int) = + store.accept(ChatStore.Intent.SearchResultClick(index)) + + override fun onLoadMoreSearchResults() = store.accept(ChatStore.Intent.LoadMoreSearchResults) + override fun onSearchSenderChange(user: UserModel?) = + store.accept(ChatStore.Intent.SearchSenderChange(user)) + + override fun onSearchDateRangeChange(fromEpochSeconds: Int?, toEpochSeconds: Int?) = + store.accept(ChatStore.Intent.SearchDateRangeChange(fromEpochSeconds, toEpochSeconds)) override fun onClearHistory() = store.accept(ChatStore.Intent.ClearHistory) override fun onDeleteChat() = store.accept(ChatStore.Intent.DeleteChat) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt index 01c133b8..076a446b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.editor.photo +package org.monogram.presentation.features.chats.conversation.editor.photo import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler @@ -43,8 +43,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.editor.photo.components.* -import org.monogram.presentation.features.chats.currentChat.editor.photo.crop.* +import org.monogram.presentation.features.chats.conversation.editor.photo.components.* +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.* import java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt index 1b534410..9d2ba9f6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo +package org.monogram.presentation.features.chats.conversation.editor.photo import android.content.Context import android.graphics.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/ColorSelector.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/ColorSelector.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/ColorSelector.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/ColorSelector.kt index 369d8c7a..9285ddc8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/ColorSelector.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/ColorSelector.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/DrawControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/DrawControls.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/DrawControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/DrawControls.kt index e5477ad1..31f51383 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/DrawControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/DrawControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/EditorTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/EditorTopBar.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/EditorTopBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/EditorTopBar.kt index 8d02a6dc..934dca17 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/EditorTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/EditorTopBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/FilterControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/FilterControls.kt similarity index 92% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/FilterControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/FilterControls.kt index 7aeee1e5..9520b748 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/FilterControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/FilterControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -20,8 +20,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.editor.photo.ImageFilter -import org.monogram.presentation.features.chats.currentChat.editor.photo.getPresetFilters +import org.monogram.presentation.features.chats.conversation.editor.photo.ImageFilter +import org.monogram.presentation.features.chats.conversation.editor.photo.getPresetFilters import java.io.File @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TextEntryDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TextEntryDialog.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TextEntryDialog.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TextEntryDialog.kt index 54cfe1f3..ab0ef250 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TextEntryDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TextEntryDialog.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControls.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControls.kt index c4fa9966..1233a05f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import androidx.compose.foundation.Canvas import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropEditorState.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropEditorState.kt index ad405297..d57daebc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropEditorState.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.crop +package org.monogram.presentation.features.chats.conversation.editor.photo.crop import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometry.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometry.kt index d742f860..aa03e6c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometry.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.crop +package org.monogram.presentation.features.chats.conversation.editor.photo.crop import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropOverlay.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropOverlay.kt index 1a884fd2..dc92bacd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropOverlay.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.crop +package org.monogram.presentation.features.chats.conversation.editor.photo.crop import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorScreen.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorScreen.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorScreen.kt index e1716189..9580fcb2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorScreen.kt @@ -1,6 +1,6 @@ @file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.editor.video +package org.monogram.presentation.features.chats.conversation.editor.video import android.widget.Toast import androidx.activity.compose.BackHandler @@ -41,13 +41,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.monogram.presentation.R import org.monogram.presentation.core.util.getMimeType -import org.monogram.presentation.features.chats.currentChat.components.VideoGLTextureView -import org.monogram.presentation.features.chats.currentChat.editor.photo.components.EditorTopBar -import org.monogram.presentation.features.chats.currentChat.editor.photo.components.TextEntryDialog -import org.monogram.presentation.features.chats.currentChat.editor.video.components.VideoCompressionControls -import org.monogram.presentation.features.chats.currentChat.editor.video.components.VideoFilterControls -import org.monogram.presentation.features.chats.currentChat.editor.video.components.VideoTextControls -import org.monogram.presentation.features.chats.currentChat.editor.video.components.VideoTrimControls +import org.monogram.presentation.core.media.VideoGLTextureView +import org.monogram.presentation.features.chats.conversation.editor.photo.components.EditorTopBar +import org.monogram.presentation.features.chats.conversation.editor.photo.components.TextEntryDialog +import org.monogram.presentation.features.chats.conversation.editor.video.components.VideoCompressionControls +import org.monogram.presentation.features.chats.conversation.editor.video.components.VideoFilterControls +import org.monogram.presentation.features.chats.conversation.editor.video.components.VideoTextControls +import org.monogram.presentation.features.chats.conversation.editor.video.components.VideoTrimControls import java.io.File import java.io.FileNotFoundException diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorUtils.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorUtils.kt index aa531d83..1a885c94 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/VideoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/VideoEditorUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.video +package org.monogram.presentation.features.chats.conversation.editor.video import android.content.Context import android.graphics.SurfaceTexture diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoCompressionControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoCompressionControls.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoCompressionControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoCompressionControls.kt index 7655832d..ec727710 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoCompressionControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoCompressionControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.video.components +package org.monogram.presentation.features.chats.conversation.editor.video.components import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -11,7 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.editor.video.VideoQuality +import org.monogram.presentation.features.chats.conversation.editor.video.VideoQuality @Composable fun VideoCompressionControls( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoFilterControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoFilterControls.kt similarity index 91% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoFilterControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoFilterControls.kt index f1ff9428..5bd2f54a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoFilterControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoFilterControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.video.components +package org.monogram.presentation.features.chats.conversation.editor.video.components import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -16,8 +16,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import org.monogram.presentation.features.chats.currentChat.editor.video.VideoFilter -import org.monogram.presentation.features.chats.currentChat.editor.video.getPresetVideoFilters +import org.monogram.presentation.features.chats.conversation.editor.video.VideoFilter +import org.monogram.presentation.features.chats.conversation.editor.video.getPresetVideoFilters @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTextControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTextControls.kt similarity index 92% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTextControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTextControls.kt index 26de03af..d6cf1a5f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTextControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTextControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.video.components +package org.monogram.presentation.features.chats.conversation.editor.video.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTrimControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTrimControls.kt similarity index 91% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTrimControls.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTrimControls.kt index 474adae3..d0ec146c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/video/components/VideoTrimControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/video/components/VideoTrimControls.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.video.components +package org.monogram.presentation.features.chats.conversation.editor.video.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -9,8 +9,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import org.monogram.presentation.features.chats.currentChat.editor.video.VideoTrimRange -import org.monogram.presentation.features.chats.currentChat.editor.video.formatDuration +import org.monogram.presentation.features.chats.conversation.editor.video.VideoTrimRange +import org.monogram.presentation.features.chats.conversation.editor.video.formatDuration @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/BotOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/bots/BotOperations.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/BotOperations.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/bots/BotOperations.kt index 198b787a..721cd6e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/BotOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/bots/BotOperations.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.CancellationException @@ -14,7 +14,7 @@ import org.monogram.domain.models.KeyboardButtonType import org.monogram.domain.models.MessageContent import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ChatMembersFilter -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handleMentionQueryChange( query: String?, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt index 6fc1b2d2..dd97cccf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -14,7 +14,7 @@ import org.monogram.domain.models.ChatType import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.domain.repository.ChatMemberStatus -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.loadChatInfo() { scope.launch { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/FileOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt similarity index 83% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/FileOperations.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt index 20a2a811..b666edea 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/FileOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt @@ -1,8 +1,8 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.launch -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handleDownloadFile(fileId: Int) { repositoryMessage.downloadFile(fileId, priority = 32) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt index b24924ce..a473fa63 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.content.ClipData import android.graphics.Bitmap @@ -16,10 +16,10 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.PollDraft -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent -import org.monogram.presentation.features.chats.currentChat.editor.video.VideoQuality -import org.monogram.presentation.features.chats.currentChat.editor.video.VideoTrimRange -import org.monogram.presentation.features.chats.currentChat.editor.video.processVideo +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.editor.video.VideoQuality +import org.monogram.presentation.features.chats.conversation.editor.video.VideoTrimRange +import org.monogram.presentation.features.chats.conversation.editor.video.processVideo import java.io.File import java.io.FileOutputStream @@ -54,13 +54,13 @@ internal fun DefaultChatComponent.handleSendSticker(stickerId: String) { val replyId = currentState.replyMessage?.id val threadId = currentState.effectiveThreadId() val targetChatId = currentState.effectiveThreadChatId(chatId) + onCancelReply() repositoryMessage.sendSticker( targetChatId, stickerId, replyToMsgId = replyId, threadId = threadId ) - onCancelReply() if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt index 1450a933..fe4922da 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update @@ -7,7 +7,7 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageReactionModel -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent private const val REACTION_UPDATE_SUPPRESSION_MS = 1500L diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index d666ea3b..d50fbb22 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.CancellationException @@ -17,10 +17,10 @@ import org.monogram.domain.models.MessageReactionModel import org.monogram.domain.models.MessageSendingState import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ReadUpdate -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent -import org.monogram.presentation.features.chats.currentChat.ScrollAlign +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.features.chats.conversation.ChatScrollCommand +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.ScrollAlign import java.io.File import kotlin.math.abs @@ -699,6 +699,8 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { animated = true ) ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to message", e) } finally { @@ -746,6 +748,8 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { scrollCommand = ChatScrollCommand.ScrollToBottom(animated = true) ) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to bottom", e) } finally { @@ -1408,6 +1412,7 @@ internal fun DefaultChatComponent.loadDraft() { internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { val id = if (topicId == 0) null else topicId.toLong() + resetSearchState(isSearchActive = false) _state.update { it.copy( currentTopicId = id, @@ -1428,6 +1433,7 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { scope.launch { val message = _state.value.messages.find { it.id == messageId } val threadContext = repositoryMessage.getMessageThreadContext(chatId, messageId) + resetSearchState(isSearchActive = false) _state.update { it.copy( currentTopicId = messageId, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageSelection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-selection/MessageSelection.kt similarity index 87% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageSelection.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-selection/MessageSelection.kt index 1a439ff8..0102d24e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageSelection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-selection/MessageSelection.kt @@ -1,8 +1,8 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handleToggleMessageSelection(messageId: Long) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MiniAppOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/miniapp/MiniAppOperations.kt similarity index 92% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MiniAppOperations.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/miniapp/MiniAppOperations.kt index 5d19239b..29c44855 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MiniAppOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/miniapp/MiniAppOperations.kt @@ -1,9 +1,9 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.flow.update -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handleOpenMiniApp(url: String, name: String, botUserId: Long) { if (botUserId != 0L && !botPreferences.getWebappPermission(botUserId, "tos_accepted")) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt index 3bebe5c7..7cb13e7b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.flow.launchIn @@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel -import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent -import org.monogram.presentation.features.chats.currentChat.ScrollAlign +import org.monogram.presentation.features.chats.conversation.ChatScrollCommand +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.ScrollAlign internal fun DefaultChatComponent.loadPinnedMessage() { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PollOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/polls/PollOperations.kt similarity index 87% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PollOperations.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/polls/PollOperations.kt index ce85ce27..0a2d7e47 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PollOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/polls/PollOperations.kt @@ -1,8 +1,8 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handlePollOptionClick(messageId: Long, optionId: Int) { scope.launch { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Preferences.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Preferences.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt index 89fc59bd..75a87641 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Preferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.flow.combine @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import org.monogram.domain.models.WallpaperModel -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.observePreferences(availableWallpapers: List) { appPreferences.fontSize diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt new file mode 100644 index 00000000..e9232b71 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/search/SearchMessages.kt @@ -0,0 +1,458 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.UserModel +import org.monogram.presentation.features.chats.conversation.ChatScrollCommand +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.ScrollAlign + +private const val SEARCH_DEBOUNCE_MS = 250L +private const val SEARCH_PAGE_SIZE = 20 +private const val SEARCH_FETCH_ATTEMPTS = 8 + +private fun hasDateFilter(fromEpochSeconds: Int?, toEpochSeconds: Int?): Boolean { + return fromEpochSeconds != null || toEpochSeconds != null +} + +private fun DefaultChatComponent.hasSearchCriteria(state: org.monogram.presentation.features.chats.conversation.ChatComponent.State): Boolean { + return state.searchQuery.isNotBlank() || + state.searchSender != null || + state.searchDateFromEpochSeconds != null || + state.searchDateToEpochSeconds != null +} + +private fun DefaultChatComponent.hasMoreSearchResults(state: org.monogram.presentation.features.chats.conversation.ChatComponent.State): Boolean { + return state.searchResults.size < state.searchResultsTotalCount || + state.searchNextFromMessageId != 0L +} + +internal fun DefaultChatComponent.handleSearchToggle() { + searchJob?.cancel() + val isSearchActive = _state.value.isSearchActive + _state.update { + it.copy( + isSearchActive = !isSearchActive, + searchQuery = "", + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L, + searchSender = null, + searchDateFromEpochSeconds = null, + searchDateToEpochSeconds = null + ) + } +} + +internal fun DefaultChatComponent.resetSearchState(isSearchActive: Boolean = _state.value.isSearchActive) { + searchJob?.cancel() + _state.update { + it.copy( + isSearchActive = isSearchActive, + searchQuery = "", + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L, + searchSender = null, + searchDateFromEpochSeconds = null, + searchDateToEpochSeconds = null + ) + } +} + +internal fun DefaultChatComponent.handleSearchQueryChange(query: String) { + searchJob?.cancel() + _state.update { it.copy(searchQuery = query) } + + val currentState = _state.value + if ( + query.isBlank() && + currentState.searchSender == null && + currentState.searchDateFromEpochSeconds == null && + currentState.searchDateToEpochSeconds == null + ) { + _state.update { + it.copy( + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L + ) + } + return + } + + startSearch(query = query, sender = currentState.searchSender, withDebounce = true) +} + +internal fun DefaultChatComponent.handleSearchSenderChange(user: UserModel?) { + searchJob?.cancel() + val currentQuery = _state.value.searchQuery + _state.update { it.copy(searchSender = user) } + + if ( + currentQuery.isBlank() && + user == null && + _state.value.searchDateFromEpochSeconds == null && + _state.value.searchDateToEpochSeconds == null + ) { + _state.update { + it.copy( + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L + ) + } + return + } + + startSearch(query = currentQuery, sender = user, withDebounce = false) +} + +internal fun DefaultChatComponent.handleSearchDateRangeChange( + fromEpochSeconds: Int?, + toEpochSeconds: Int? +) { + searchJob?.cancel() + _state.update { + it.copy( + searchDateFromEpochSeconds = fromEpochSeconds, + searchDateToEpochSeconds = toEpochSeconds + ) + } + + val updatedState = _state.value + if ( + updatedState.searchQuery.isBlank() && + updatedState.searchSender == null && + fromEpochSeconds == null && + toEpochSeconds == null + ) { + _state.update { + it.copy( + isSearchingMessages = false, + searchResults = emptyList(), + searchResultsTotalCount = 0, + selectedSearchResultIndex = -1, + searchNextFromMessageId = 0L + ) + } + return + } + + startSearch( + query = updatedState.searchQuery, + sender = updatedState.searchSender, + fromEpochSeconds = fromEpochSeconds, + toEpochSeconds = toEpochSeconds, + withDebounce = false + ) +} + +internal fun DefaultChatComponent.handleSearchNextResult() { + val currentState = _state.value + val results = currentState.searchResults + if (results.isEmpty()) return + + val currentIndex = currentState.selectedSearchResultIndex.takeIf { it in results.indices } ?: 0 + val nextIndex = currentIndex + 1 + + if (nextIndex < results.size) { + handleSearchResultClick(nextIndex) + return + } + + val canLoadMore = hasMoreSearchResults(currentState) + if (!canLoadMore) { + handleSearchResultClick(0) + return + } + + scope.launch { + val previousSize = _state.value.searchResults.size + val updatedResults = appendMoreSearchResults() ?: return@launch + val targetIndex = previousSize.coerceAtMost(updatedResults.lastIndex) + if (targetIndex >= 0) { + handleSearchResultClick(targetIndex) + } + } +} + +internal fun DefaultChatComponent.handleSearchPreviousResult() { + val results = _state.value.searchResults + if (results.isEmpty()) return + val currentIndex = _state.value.selectedSearchResultIndex.takeIf { it in results.indices } ?: 0 + val previousIndex = if (currentIndex == 0) results.lastIndex else currentIndex - 1 + handleSearchResultClick(previousIndex) +} + +internal fun DefaultChatComponent.handleSearchResultClick(index: Int) { + val results = _state.value.searchResults + if (index !in results.indices) return + _state.update { it.copy(selectedSearchResultIndex = index) } + scrollToSearchResult(index, results) + + val stateAfterSelection = _state.value + val isNearLoadedTail = index >= results.lastIndex - 2 + if (isNearLoadedTail && hasMoreSearchResults(stateAfterSelection) && !stateAfterSelection.isSearchingMessages) { + scope.launch { + appendMoreSearchResults() + } + } +} + +internal fun DefaultChatComponent.loadMoreSearchResults() { + searchJob?.cancel() + searchJob = scope.launch { + appendMoreSearchResults() + } +} + +private suspend fun DefaultChatComponent.appendMoreSearchResults(): List? { + val currentState = _state.value + if (currentState.isSearchingMessages) return currentState.searchResults + if (!hasSearchCriteria(currentState)) return currentState.searchResults + if (!hasMoreSearchResults(currentState)) return currentState.searchResults + + val targetChatId = activeThreadChatId() + val targetThreadId = activeThreadId() + val query = currentState.searchQuery.trim() + val senderId = currentState.searchSender?.id + val fromEpochSeconds = currentState.searchDateFromEpochSeconds + val toEpochSeconds = currentState.searchDateToEpochSeconds + val isDateFiltered = hasDateFilter(fromEpochSeconds, toEpochSeconds) + + return try { + _state.update { it.copy(isSearchingMessages = true) } + + var requestCursor: Long? = currentState.searchNextFromMessageId.takeIf { it != 0L } + var latestState = currentState + var mergedList = currentState.searchResults + var totalCount = currentState.searchResultsTotalCount + var shouldStopPaging = false + + repeat(SEARCH_FETCH_ATTEMPTS) { + if (shouldStopPaging) return@repeat + val fromMessageId = requestCursor + ?: latestState.searchResults.lastOrNull()?.id + ?: 0L + + val result = repositoryMessage.searchMessages( + chatId = targetChatId, + query = query, + fromMessageId = fromMessageId, + limit = SEARCH_PAGE_SIZE, + threadId = targetThreadId, + senderId = senderId + ) + val filteredPage = result.messages.filterByDateRange(fromEpochSeconds, toEpochSeconds) + + val stillRelevant = _state.value.isSearchActive && + _state.value.searchQuery.trim() == query && + _state.value.searchSender?.id == senderId && + _state.value.searchDateFromEpochSeconds == fromEpochSeconds && + _state.value.searchDateToEpochSeconds == toEpochSeconds && + activeThreadChatId() == targetChatId && + activeThreadId() == targetThreadId + if (!stillRelevant) return null + + val mergedResults = + LinkedHashMap(mergedList.size + filteredPage.size) + mergedList.forEach { mergedResults[it.id] = it } + filteredPage.forEach { mergedResults[it.id] = it } + val resolvedNextCursor = result.nextFromMessageId.takeIf { it != 0L } + ?: result.messages.lastOrNull()?.id + ?: 0L + val shouldKeepPaging = resolvedNextCursor != 0L + val updatedResults = mergedResults.values.toList() + val didGrow = updatedResults.size > mergedList.size + totalCount = if (isDateFiltered) { + if (shouldKeepPaging) { + maxOf(totalCount, updatedResults.size + 1) + } else { + updatedResults.size + } + } else { + maxOf(totalCount, result.totalCount, updatedResults.size) + } + + mergedList = updatedResults + latestState = latestState.copy( + searchResults = updatedResults, + searchResultsTotalCount = totalCount, + searchNextFromMessageId = if (shouldKeepPaging) resolvedNextCursor else 0L + ) + + shouldStopPaging = (!shouldKeepPaging) || + resolvedNextCursor == fromMessageId || + (!isDateFiltered && didGrow) || + (isDateFiltered && didGrow && filteredPage.isNotEmpty()) + requestCursor = resolvedNextCursor.takeIf { !shouldStopPaging } + } + + _state.update { + if ( + it.isSearchActive && + it.searchQuery.trim() == query && + it.searchSender?.id == senderId && + it.searchDateFromEpochSeconds == fromEpochSeconds && + it.searchDateToEpochSeconds == toEpochSeconds && + activeThreadChatId() == targetChatId && + activeThreadId() == targetThreadId + ) { + latestState.copy(isSearchingMessages = false) + } else { + it + } + } + mergedList + } catch (e: CancellationException) { + _state.update { it.copy(isSearchingMessages = false) } + throw e + } catch (_: Exception) { + _state.update { it.copy(isSearchingMessages = false) } + null + } +} + +private fun DefaultChatComponent.startSearch( + query: String, + sender: UserModel?, + fromEpochSeconds: Int? = _state.value.searchDateFromEpochSeconds, + toEpochSeconds: Int? = _state.value.searchDateToEpochSeconds, + withDebounce: Boolean +) { + val targetChatId = activeThreadChatId() + val targetThreadId = activeThreadId() + searchJob = scope.launch { + try { + _state.update { it.copy(isSearchingMessages = true) } + if (withDebounce) { + delay(SEARCH_DEBOUNCE_MS) + } + + val normalizedQuery = query.trim() + val senderId = sender?.id + val result = repositoryMessage.searchMessages( + chatId = targetChatId, + query = normalizedQuery, + fromMessageId = 0L, + limit = SEARCH_PAGE_SIZE, + threadId = targetThreadId, + senderId = senderId + ) + var filteredMessages = + result.messages.filterByDateRange(fromEpochSeconds, toEpochSeconds) + var nextCursor = result.nextFromMessageId + val isDateFiltered = hasDateFilter(fromEpochSeconds, toEpochSeconds) + var attempts = 0 + + while (filteredMessages.size < SEARCH_PAGE_SIZE && nextCursor != 0L && attempts < SEARCH_FETCH_ATTEMPTS) { + attempts++ + val nextResult = repositoryMessage.searchMessages( + chatId = targetChatId, + query = normalizedQuery, + fromMessageId = nextCursor, + limit = SEARCH_PAGE_SIZE, + threadId = targetThreadId, + senderId = senderId + ) + filteredMessages = (filteredMessages + nextResult.messages.filterByDateRange( + fromEpochSeconds, + toEpochSeconds + )) + .distinctBy(MessageModel::id) + val resolvedNextCursor = nextResult.nextFromMessageId.takeIf { it != 0L } + ?: nextResult.messages.lastOrNull()?.id + ?: 0L + if (resolvedNextCursor == nextCursor) break + nextCursor = resolvedNextCursor + } + + val resolvedTotalCount = if (isDateFiltered) { + if (nextCursor != 0L) { + filteredMessages.size + 1 + } else { + filteredMessages.size + } + } else { + maxOf(result.totalCount, filteredMessages.size) + } + + val stillRelevant = _state.value.isSearchActive && + _state.value.searchQuery.trim() == normalizedQuery && + _state.value.searchSender?.id == senderId && + _state.value.searchDateFromEpochSeconds == fromEpochSeconds && + _state.value.searchDateToEpochSeconds == toEpochSeconds && + activeThreadChatId() == targetChatId && + activeThreadId() == targetThreadId + if (!stillRelevant) return@launch + + _state.update { + it.copy( + isSearchingMessages = false, + searchResults = filteredMessages, + searchResultsTotalCount = resolvedTotalCount, + selectedSearchResultIndex = if (filteredMessages.isNotEmpty()) 0 else -1, + searchNextFromMessageId = nextCursor + ) + } + + if (filteredMessages.isNotEmpty()) { + scrollToSearchResult(0, filteredMessages) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _state.update { + if (it.searchQuery == query && it.searchSender?.id == sender?.id) { + it.copy(isSearchingMessages = false) + } else { + it + } + } + } + } +} + +private fun List.filterByDateRange( + fromEpochSeconds: Int?, + toEpochSeconds: Int? +): List { + return filter { message -> + val isAfterStart = fromEpochSeconds == null || message.date >= fromEpochSeconds + val isBeforeEnd = toEpochSeconds == null || message.date <= toEpochSeconds + isAfterStart && isBeforeEnd + } +} + +private fun DefaultChatComponent.scrollToSearchResult(index: Int, results: List) { + val message = results.getOrNull(index) ?: return + val state = _state.value + val isAlreadyLoaded = state.messages.any { it.id == message.id } + if (isAlreadyLoaded) { + _state.update { + it.copy( + highlightedMessageId = message.id, + pendingScrollCommand = ChatScrollCommand.JumpToMessage( + messageId = message.id, + highlight = true, + align = ScrollAlign.Center, + animated = true + ) + ) + } + } else { + scrollToMessage(message.id) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Stickers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/stickers/Stickers.kt similarity index 80% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Stickers.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/stickers/Stickers.kt index 5c2cc20f..79b8fbbf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/Stickers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/stickers/Stickers.kt @@ -1,9 +1,9 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import android.util.Log import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.handleStickerClick(setId: Long) { if (setId == 0L) return diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ThreadContext.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt similarity index 78% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ThreadContext.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt index 60dcb420..74294711 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ThreadContext.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt @@ -1,8 +1,8 @@ -package org.monogram.presentation.features.chats.currentChat.impl +package org.monogram.presentation.features.chats.conversation.logic import org.monogram.domain.models.MessageModel -import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun ChatComponent.State.effectiveThreadChatId(baseChatId: Long): Long { return currentThreadChatId ?: baseChatId diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt index a41e74c4..e1ef0a40 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AdvancedCircularRecorderScreen.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import android.Manifest import android.annotation.SuppressLint diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt similarity index 60% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt index 972ad59d..b418dada 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import android.content.res.Configuration import androidx.compose.animation.core.Animatable @@ -18,10 +18,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -39,24 +38,18 @@ import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelAlbumMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.ChatAlbumMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelAlbumMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.ChatAlbumMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.MessageViaBotAttribution +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyMarkupView @Composable -fun AlbumMessageBubbleContainer( +internal fun AlbumMessageBubbleContainer( messages: List, - olderMsg: MessageModel? = null, - newerMsg: MessageModel? = null, - isGroup: Boolean, - isChannel: Boolean = false, - autoplayGifs: Boolean = true, - autoplayVideos: Boolean = true, - autoDownloadMobile: Boolean = false, - autoDownloadWifi: Boolean = false, - autoDownloadRoaming: Boolean = false, + appearance: MessageAppearanceConfig, + behavior: MessageRowBehaviorConfig, + uiFlags: MessageRowUiFlags = MessageRowUiFlags(), + senderGrouping: MessageSenderGrouping, onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit = {}, @@ -67,20 +60,14 @@ fun AlbumMessageBubbleContainer( onGoToReply: (MessageModel) -> Unit = {}, onReactionClick: (Long, String) -> Unit = { _, _ -> }, onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit = { _, _ -> }, - fontSize: Float = 16f, - bubbleRadius: Float = 16f, - shouldReportPosition: Boolean = false, onPositionChange: (Long, Offset, IntSize) -> Unit = { _, _, _ -> }, onCommentsClick: (Long) -> Unit = {}, showComments: Boolean = true, toProfile: (Long) -> Unit, onForwardOriginClick: (ForwardInfo) -> Unit = {}, onViaBotClick: (String) -> Unit = {}, - canReply: Boolean = false, onReplySwipe: (MessageModel) -> Unit = {}, - swipeEnabled: Boolean = true, - downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + downloadUtils: IDownloadUtils ) { if (messages.isEmpty()) return @@ -98,9 +85,9 @@ fun AlbumMessageBubbleContainer( val screenWidth = configuration.screenWidthDp.dp val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val maxWidth = remember(isChannel, isLandscape, screenWidth) { + val maxWidth = remember(behavior.isChannel, isLandscape, screenWidth) { when { - isChannel -> if (isLandscape) (screenWidth * 0.7f).coerceAtMost(600.dp) else (screenWidth * 0.94f).coerceAtMost( + behavior.isChannel -> if (isLandscape) (screenWidth * 0.7f).coerceAtMost(600.dp) else (screenWidth * 0.94f).coerceAtMost( 500.dp ) @@ -109,76 +96,50 @@ fun AlbumMessageBubbleContainer( } } - val isSameSenderAbove = remember( - olderMsg?.id, - olderMsg?.senderId, - olderMsg?.senderName, - olderMsg?.senderCustomTitle, - olderMsg?.date, - firstMsg.senderId, - firstMsg.senderName, - firstMsg.senderCustomTitle, - firstMsg.date - ) { - shouldGroupSenderBlock( - current = firstMsg, - neighbor = olderMsg, - dateBreak = olderMsg?.let { shouldShowDate(firstMsg, it) } ?: true - ) - } - val isSameSenderBelow = remember( - newerMsg?.id, - newerMsg?.senderId, - newerMsg?.senderName, - newerMsg?.senderCustomTitle, - newerMsg?.date, - lastMsg.senderId, - lastMsg.senderName, - lastMsg.senderCustomTitle, - lastMsg.date - ) { - shouldGroupSenderBlock( - current = lastMsg, - neighbor = newerMsg, - dateBreak = newerMsg?.let { shouldShowDate(it, lastMsg) } ?: true - ) - } - - val topSpacing = if (isChannel && !isSameSenderAbove) 12.dp else 2.dp - - var outerColumnPosition by remember { mutableStateOf(Offset.Zero) } - var bubblePosition by remember { mutableStateOf(Offset.Zero) } - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - + val topSpacing = if (behavior.isChannel && !senderGrouping.isSameSenderAbove) 12.dp else 2.dp val dragOffsetX = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + val layoutTracker = remember { MessageBubbleLayoutTracker() } + val onReplyClickState by rememberUpdatedState(onReplyClick) + val onPositionChangeState by rememberUpdatedState(onPositionChange) Column( modifier = Modifier .fillMaxWidth() - .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } + .onGloballyPositioned { layoutTracker.outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing, bottom = 2.dp) .offset { IntOffset(dragOffsetX.value.toInt(), 0) } .fastReplyPointer( - canReply = canReply, + canReply = behavior.canReply && behavior.swipeEnabled, dragOffsetX = dragOffsetX, - scope = rememberCoroutineScope(), + scope = coroutineScope, onReplySwipe = { onReplySwipe(lastMsg) }, maxWidth = maxWidth.value ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + clickPos + ) } }, onLongPress = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + clickPos + ) } } ) @@ -186,11 +147,11 @@ fun AlbumMessageBubbleContainer( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isChannel) Arrangement.Center else if (isOutgoing) Arrangement.End else Arrangement.Start, + horizontalArrangement = if (behavior.isChannel) Arrangement.Center else if (isOutgoing) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom ) { - if (isGroup && !isOutgoing && !isChannel) { - if (!isSameSenderBelow) { + if (behavior.isGroup && !isOutgoing && !behavior.isChannel) { + if (!senderGrouping.isSameSenderBelow) { Avatar( path = firstMsg.senderAvatar, fallbackPath = firstMsg.senderPersonalAvatar, @@ -209,18 +170,22 @@ fun AlbumMessageBubbleContainer( ) { Column( modifier = Modifier - .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) + .then(if (behavior.isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) .widthIn(max = maxWidth) - .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) + .then(if (behavior.isChannel) Modifier.fillMaxWidth() else Modifier) .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + layoutTracker.bubblePosition = coordinates.positionInWindow() + layoutTracker.bubbleSize = coordinates.size + if (uiFlags.shouldReportPosition) { + onPositionChangeState( + lastMsg.id, + layoutTracker.bubblePosition, + layoutTracker.bubbleSize + ) } } ) { - if (isGroup && !isOutgoing && !isChannel && !isSameSenderAbove) { + if (behavior.isGroup && !isOutgoing && !behavior.isChannel && !senderGrouping.isSameSenderAbove) { Text( text = firstMsg.senderName, style = MaterialTheme.typography.labelSmall, @@ -229,16 +194,16 @@ fun AlbumMessageBubbleContainer( ) } - if (isChannel) { + if (behavior.isChannel) { ChannelAlbumMessageBubble( messages = orderedMessages, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + autoplayGifs = appearance.autoplayGifs, + autoplayVideos = appearance.autoplayVideos, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, @@ -247,9 +212,9 @@ fun AlbumMessageBubbleContainer( onCancelDownload = onCancelDownload, onLongClick = { offset -> onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onReplyClick = onGoToReply, @@ -259,23 +224,23 @@ fun AlbumMessageBubbleContainer( toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), - fontSize = fontSize, - bubbleRadius = bubbleRadius, + fontSize = appearance.fontSize, + bubbleRadius = appearance.bubbleRadius, downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen ) } else { ChatAlbumMessageBubble( messages = orderedMessages, isOutgoing = isOutgoing, - isGroup = isGroup, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, + isGroup = behavior.isGroup, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + autoplayGifs = appearance.autoplayGifs, + autoplayVideos = appearance.autoplayVideos, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, @@ -284,9 +249,9 @@ fun AlbumMessageBubbleContainer( onCancelDownload = onCancelDownload, onLongClick = { offset -> onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onReplyClick = onGoToReply, @@ -294,9 +259,9 @@ fun AlbumMessageBubbleContainer( toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, modifier = Modifier, - fontSize = fontSize, + fontSize = appearance.fontSize, downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen ) } @@ -325,3 +290,4 @@ fun AlbumMessageBubbleContainer( } } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChannelMessageBubbleContainer.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChannelMessageBubbleContainer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChannelMessageBubbleContainer.kt index ebf40ea4..636a2acd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChannelMessageBubbleContainer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import android.content.res.Configuration import androidx.compose.animation.Animatable @@ -37,15 +37,15 @@ import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelGifMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelPhotoMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelTextMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelVideoMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelVoiceMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.DocumentMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView +import org.monogram.presentation.features.chats.conversation.ui.content.shouldShowDate +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelGifMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelPhotoMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelTextMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelVideoMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelVoiceMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.DocumentMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.MessageViaBotAttribution +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyMarkupView @Composable fun ChannelMessageBubbleContainer( @@ -305,3 +305,4 @@ fun ChannelMessageBubbleContainer( } } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt similarity index 88% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt index 9a2358c8..06365677 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import android.Manifest import android.content.pm.PackageManager @@ -51,29 +51,34 @@ import org.monogram.domain.models.StickerModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.camera.CameraScreen -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarActions -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarComposerSection -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarState -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ClosedTopicBar -import org.monogram.presentation.features.chats.currentChat.components.inputbar.FullScreenEditorSheet -import org.monogram.presentation.features.chats.currentChat.components.inputbar.InputBarMode -import org.monogram.presentation.features.chats.currentChat.components.inputbar.RestrictedInputBar -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduledMessagesSheet -import org.monogram.presentation.features.chats.currentChat.components.inputbar.SlowModeInputBar -import org.monogram.presentation.features.chats.currentChat.components.inputbar.applyMentionSuggestion -import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildEditingMessageTextValue -import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds -import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempDocumentPath -import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempPath -import org.monogram.presentation.features.chats.currentChat.components.inputbar.declaredPermissions -import org.monogram.presentation.features.chats.currentChat.components.inputbar.extractEntities -import org.monogram.presentation.features.chats.currentChat.components.inputbar.hasAllPermissions -import org.monogram.presentation.features.chats.currentChat.components.inputbar.isInlineBotPrefillText -import org.monogram.presentation.features.chats.currentChat.components.inputbar.parseInlineQueryInput -import org.monogram.presentation.features.chats.currentChat.components.inputbar.rememberVoiceRecorder +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarActions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarCapabilities +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarComposerSection +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarState +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ClosedTopicBar +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ComposerAttachmentState +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ComposerBotState +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ComposerRowState +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ComposerSuggestionState +import org.monogram.presentation.features.chats.conversation.ui.inputbar.FullScreenEditorSheet +import org.monogram.presentation.features.chats.conversation.ui.inputbar.InputBarMode +import org.monogram.presentation.features.chats.conversation.ui.inputbar.RestrictedInputBar +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ScheduleDatePickerDialog +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ScheduleTimePickerDialog +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ScheduledMessagesSheet +import org.monogram.presentation.features.chats.conversation.ui.inputbar.SlowModeInputBar +import org.monogram.presentation.features.chats.conversation.ui.inputbar.applyMentionSuggestion +import org.monogram.presentation.features.chats.conversation.ui.inputbar.buildEditingMessageTextValue +import org.monogram.presentation.features.chats.conversation.ui.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempDocumentPath +import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempPath +import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.extractEntities +import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.isInlineBotPrefillText +import org.monogram.presentation.features.chats.conversation.ui.inputbar.parseInlineQueryInput +import org.monogram.presentation.features.chats.conversation.ui.inputbar.rememberVoiceRecorder +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.gallery.GalleryScreen import org.monogram.presentation.features.gallery.components.PollComposerSheet import java.util.Calendar @@ -150,6 +155,31 @@ fun ChatInputBar( canWriteText || canOpenAttachSheet || canSendStickers || canSendVoice || canSendVideoNotes || canSendPolls } } + val capabilities = remember( + canWriteText, + canSendPhotos, + canSendVideos, + canSendDocuments, + canSendAudios, + canOpenAttachSheet, + canSendStickers, + canSendVoice, + canSendVideoNotes, + canSendAnything + ) { + ChatInputBarCapabilities( + canWriteText = canWriteText, + canSendPhotos = canSendPhotos, + canSendVideos = canSendVideos, + canSendDocuments = canSendDocuments, + canSendAudios = canSendAudios, + canOpenAttachSheet = canOpenAttachSheet, + canSendStickers = canSendStickers, + canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, + canSendAnything = canSendAnything + ) + } val context = LocalContext.current val emojiStyle by appPreferences.emojiStyle.collectAsState() @@ -648,45 +678,49 @@ fun ChatInputBar( InputBarMode.Composer -> ChatInputBarComposerSection( editingMessage = state.editingMessage, replyMessage = state.replyMessage, - pendingMediaPaths = state.pendingMediaPaths, - pendingDocumentPaths = state.pendingDocumentPaths, - mentionSuggestions = state.mentionSuggestions, - filteredCommands = filteredCommands, - currentInlineBotUsername = state.currentInlineBotUsername.takeIf { canSendStickers }, - isInlineBotLoading = canSendStickers && state.isInlineBotLoading, - inlineBotResults = state.inlineBotResults.takeIf { canSendStickers }, - isBot = state.isBot, - botMenuButton = state.botMenuButton, - botCommands = state.botCommands, - scheduledMessagesCount = state.scheduledMessages.size, - textValue = textValue, + attachments = ComposerAttachmentState( + pendingMediaPaths = state.pendingMediaPaths, + pendingDocumentPaths = state.pendingDocumentPaths, + scheduledMessagesCount = state.scheduledMessages.size + ), + suggestions = ComposerSuggestionState( + mentionSuggestions = state.mentionSuggestions, + filteredCommands = filteredCommands, + currentInlineBotUsername = state.currentInlineBotUsername.takeIf { canSendStickers }, + isInlineBotLoading = canSendStickers && state.isInlineBotLoading, + inlineBotResults = state.inlineBotResults.takeIf { canSendStickers }, + replyMarkup = state.replyMarkup, + isGifSearchFocused = isGifSearchFocused + ), + botState = ComposerBotState( + isBot = state.isBot, + botMenuButton = state.botMenuButton, + botCommands = state.botCommands + ), + rowState = ComposerRowState( + textValue = textValue, + editingMessage = state.editingMessage, + isStickerMenuVisible = isStickerMenuVisible, + closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, + isKeyboardVisible = isKeyboardVisible, + stickerMenuHeight = stickerMenuHeight, + showFullScreenEditor = showFullScreenEditor, + currentMessageLength = currentMessageLength, + maxMessageLength = maxMessageLength, + isOverMessageLimit = isOverMessageLimit, + showSendOptionsSheet = showSendOptionsSheet, + isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds + ), onTextValueChange = { textValue = it }, knownCustomEmojis = knownCustomEmojis, emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, - canWriteText = canWriteText, - canOpenAttachSheet = canOpenAttachSheet, + capabilities = capabilities, canSendAttachments = canSendPendingAttachments, - canShowBotActions = canWriteText, canPasteMediaFromClipboard = canUseMediaPicker && state.editingMessage == null, - canSendStickers = canSendStickers, - canSendVoice = canSendVoice, - canSendVideoNotes = canSendVideoNotes, - isStickerMenuVisible = isStickerMenuVisible, - closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, - isKeyboardVisible = isKeyboardVisible, - stickerMenuHeight = stickerMenuHeight, voiceRecorder = voiceRecorder, - isGifSearchFocused = isGifSearchFocused, - showFullScreenEditor = showFullScreenEditor, - currentMessageLength = currentMessageLength, - maxMessageLength = maxMessageLength, - isOverMessageLimit = isOverMessageLimit, - isVideoMessageMode = isVideoMessageMode, - isSlowModeActive = isSlowModeActive, - slowModeRemainingSeconds = slowModeRemainingSeconds, - replyMarkup = state.replyMarkup, - showSendOptionsSheet = showSendOptionsSheet, stickerRepository = stickerRepository, onCancelEdit = actions.onCancelEdit, onCancelReply = actions.onCancelReply, 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/conversation/ui/ChatTopBar.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatTopBar.kt index b2e9320a..47864499 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/conversation/ui/ChatTopBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring @@ -30,6 +30,7 @@ import androidx.compose.material.icons.automirrored.rounded.PlaylistAddCheck import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.CleaningServices import androidx.compose.material.icons.rounded.Close @@ -310,6 +311,12 @@ fun ChatTopBar( } }, actions = { + IconButton(onClick = onSearchToggle) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.action_search) + ) + } IconButton(onClick = { onMenu() showMenu = true diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt index 39ddfc70..e09287b0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade @@ -58,15 +58,17 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState import org.monogram.presentation.R +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.channels.formatDuration -import org.monogram.presentation.features.chats.currentChat.components.channels.formatViews -import org.monogram.presentation.features.chats.currentChat.components.chats.ChatTimestampInfo -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground -import org.monogram.presentation.features.chats.currentChat.components.chats.SpoilerWrapper +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.features.chats.conversation.ui.channel.formatDuration +import org.monogram.presentation.features.chats.conversation.ui.channel.formatViews +import org.monogram.presentation.features.chats.conversation.ui.message.ChatTimestampInfo +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground +import org.monogram.presentation.features.chats.conversation.ui.message.SpoilerWrapper @Composable fun CompactMediaMosaic( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/DateSeparator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/DateSeparator.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/DateSeparator.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/DateSeparator.kt index f9e70476..4ed5ee68 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/DateSeparator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/DateSeparator.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/FastReplyIndicator.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/FastReplyIndicator.kt index b11cee7e..288a97bb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/FastReplyIndicator.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/InlineVideoPlayer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/InlineVideoPlayer.kt similarity index 87% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/InlineVideoPlayer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/InlineVideoPlayer.kt index 7dba9edb..d18f153d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/InlineVideoPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/InlineVideoPlayer.kt @@ -1,9 +1,11 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.media3.common.PlaybackException +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType @Composable fun InlineVideoPlayer( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt new file mode 100644 index 00000000..649e6ec6 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt @@ -0,0 +1,773 @@ +package org.monogram.presentation.features.chats.conversation.ui + +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import kotlinx.coroutines.delay +import org.monogram.domain.models.ForwardInfo +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.features.chats.conversation.ui.message.AudioMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.ContactMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.DocumentMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.GifMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.LocationMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.MessageViaBotAttribution +import org.monogram.presentation.features.chats.conversation.ui.message.PhotoMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.PollMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyMarkupView +import org.monogram.presentation.features.chats.conversation.ui.message.StickerMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.TextMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.VenueMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.VideoMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.VideoNoteBubble +import org.monogram.presentation.features.chats.conversation.ui.message.VoiceMessageBubble + +@Composable +internal fun MessageBubbleContainer( + msg: MessageModel, + newerMsg: MessageModel?, + appearance: MessageAppearanceConfig, + behavior: MessageRowBehaviorConfig, + uiFlags: MessageRowUiFlags = MessageRowUiFlags(), + senderGrouping: MessageSenderGrouping, + onHighlightConsumed: () -> Unit = {}, + onPhotoClick: (MessageModel) -> Unit, + onDownloadPhoto: (Int) -> Unit = {}, + onVideoClick: (MessageModel) -> Unit = {}, + onDocumentClick: (MessageModel) -> Unit = {}, + onAudioClick: (MessageModel) -> Unit = {}, + onCancelDownload: (Int) -> Unit = {}, + onReplyClick: (Offset, IntSize, Offset) -> Unit, + onGoToReply: (MessageModel) -> Unit = {}, + onReactionClick: (Long, String) -> Unit = { _, _ -> }, + onStickerClick: (Long) -> Unit = {}, + onPollOptionClick: (Long, Int) -> Unit = { _, _ -> }, + onRetractVote: (Long) -> Unit = {}, + onShowVoters: (Long, Int) -> Unit = { _, _ -> }, + onClosePoll: (Long) -> Unit = {}, + onInstantViewClick: ((String) -> Unit)? = null, + onYouTubeClick: ((String) -> Unit)? = null, + onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit = { _, _ -> }, + onPositionChange: (Long, Offset, IntSize) -> Unit = { _, _, _ -> }, + toProfile: (Long) -> Unit, + onForwardOriginClick: (ForwardInfo) -> Unit = {}, + onViaBotClick: (String) -> Unit = {}, + onReplySwipe: (MessageModel) -> Unit = {}, + downloadUtils: IDownloadUtils +) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + val maxWidth = remember(isLandscape, screenWidth) { + if (isLandscape) { + (screenWidth * 0.6f).coerceAtMost(450.dp) + } else { + (screenWidth * 0.85f).coerceAtMost(360.dp) + } + } + + val isOutgoing = msg.isOutgoing + val topSpacing = if (!senderGrouping.isSameSenderAbove) 8.dp else 2.dp + val dragOffsetX = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + val layoutTracker = remember { MessageBubbleLayoutTracker() } + val onReplyClickState by rememberUpdatedState(onReplyClick) + val onPositionChangeState by rememberUpdatedState(onPositionChange) + val onReplySwipeState by rememberUpdatedState(onReplySwipe) + + val onBubbleClick: (Offset) -> Unit = remember(msg.id) { + { offset -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + } + } + val onBubbleCenterClick: () -> Unit = remember(msg.id) { + { + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + (layoutTracker.bubbleSize.toSize() / 2f).toOffset() + ) + } + } + + MessageHighlightLayer( + highlighted = uiFlags.isHighlighted, + onHighlightConsumed = onHighlightConsumed + ) { + MessageBubbleGestureLayer( + modifier = Modifier.padding(top = topSpacing), + canReply = behavior.canReply, + swipeEnabled = behavior.swipeEnabled, + dragOffsetX = dragOffsetX, + scope = coroutineScope, + maxWidth = maxWidth.value, + onReplySwipe = { onReplySwipeState(msg) }, + layoutTracker = layoutTracker, + onOutsideBubblePress = { clickPosition -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + clickPosition + ) + } + ) { + MessageBubbleShell( + isOutgoing = isOutgoing, + avatar = { + MessageAvatar( + avatarPath = msg.senderAvatar, + fallbackPath = msg.senderPersonalAvatar, + senderName = msg.senderName, + senderId = msg.senderId, + isVisible = behavior.isGroup && !isOutgoing && !senderGrouping.isSameSenderBelow, + toProfile = toProfile + ) + if (behavior.isGroup && !isOutgoing) { + Spacer(modifier = Modifier.width(8.dp)) + } + }, + content = { + Box(modifier = Modifier.wrapContentSize()) { + MessageBubbleContentHost( + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(max = maxWidth) + .onGloballyPositioned { coordinates -> + layoutTracker.bubblePosition = coordinates.positionInWindow() + layoutTracker.bubbleSize = coordinates.size + if (uiFlags.shouldReportPosition) { + onPositionChangeState( + msg.id, + layoutTracker.bubblePosition, + layoutTracker.bubbleSize + ) + } + }, + msg = msg, + newerMsg = newerMsg, + isOutgoing = isOutgoing, + senderGrouping = senderGrouping, + isGroup = behavior.isGroup, + appearance = appearance, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleClick, + onBubbleCenterLongClick = onBubbleCenterClick, + onGoToReply = onGoToReply, + onReactionClick = onReactionClick, + onStickerClick = onStickerClick, + onPollOptionClick = onPollOptionClick, + onRetractVote = onRetractVote, + onShowVoters = onShowVoters, + onClosePoll = onClosePoll, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + onReplyMarkupButtonClick = onReplyMarkupButtonClick, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + onViaBotClick = onViaBotClick, + downloadUtils = downloadUtils, + isAnyViewerOpen = behavior.isAnyViewerOpen + ) + + FastReplyIndicator( + modifier = Modifier.align(Alignment.CenterEnd), + dragOffsetX = dragOffsetX, + isOutgoing = isOutgoing, + maxWidth = maxWidth + ) + } + } + ) + } + } +} + +@Composable +private fun MessageHighlightLayer( + highlighted: Boolean, + onHighlightConsumed: () -> Unit, + content: @Composable () -> Unit +) { + val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + val animatedColor = remember { androidx.compose.animation.Animatable(Color.Transparent) } + + LaunchedEffect(highlighted) { + if (highlighted) { + animatedColor.animateTo(highlightColor, animationSpec = tween(300)) + delay(450) + animatedColor.animateTo(Color.Transparent, animationSpec = tween(1800)) + onHighlightConsumed() + } + } + + Box( + modifier = Modifier.background(animatedColor.value, RoundedCornerShape(12.dp)) + ) { + content() + } +} + +@Composable +private fun MessageBubbleGestureLayer( + modifier: Modifier = Modifier, + canReply: Boolean, + swipeEnabled: Boolean, + dragOffsetX: Animatable, + scope: kotlinx.coroutines.CoroutineScope, + maxWidth: Float, + onReplySwipe: () -> Unit, + layoutTracker: MessageBubbleLayoutTracker, + onOutsideBubblePress: (Offset) -> Unit, + content: @Composable () -> Unit +) { + val onOutsideBubblePressState by rememberUpdatedState(onOutsideBubblePress) + + Column( + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { layoutTracker.outerColumnPosition = it.positionInWindow() } + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply && swipeEnabled, + dragOffsetX = dragOffsetX, + scope = scope, + onReplySwipe = onReplySwipe, + maxWidth = maxWidth + ) + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) + if (!bubbleRect.contains(clickPos)) { + onOutsideBubblePressState(clickPos) + } + }, + onLongPress = { offset -> + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) + if (!bubbleRect.contains(clickPos)) { + onOutsideBubblePressState(clickPos) + } + } + ) + } + ) { + content() + } +} + +@Composable +private fun MessageBubbleShell( + isOutgoing: Boolean, + avatar: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + avatar() + content() + } +} + +@Composable +private fun MessageBubbleContentHost( + modifier: Modifier = Modifier, + msg: MessageModel, + newerMsg: MessageModel?, + isOutgoing: Boolean, + senderGrouping: MessageSenderGrouping, + isGroup: Boolean, + appearance: MessageAppearanceConfig, + onPhotoClick: (MessageModel) -> Unit, + onDownloadPhoto: (Int) -> Unit, + onVideoClick: (MessageModel) -> Unit, + onDocumentClick: (MessageModel) -> Unit, + onAudioClick: (MessageModel) -> Unit, + onCancelDownload: (Int) -> Unit, + onBubbleClick: (Offset) -> Unit, + onBubbleLongClick: (Offset) -> Unit, + onBubbleCenterLongClick: () -> Unit, + onGoToReply: (MessageModel) -> Unit, + onReactionClick: (Long, String) -> Unit, + onStickerClick: (Long) -> Unit, + onPollOptionClick: (Long, Int) -> Unit, + onRetractVote: (Long) -> Unit, + onShowVoters: (Long, Int) -> Unit, + onClosePoll: (Long) -> Unit, + onInstantViewClick: ((String) -> Unit)?, + onYouTubeClick: ((String) -> Unit)?, + onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit, + toProfile: (Long) -> Unit, + onForwardOriginClick: (ForwardInfo) -> Unit, + onViaBotClick: (String) -> Unit, + downloadUtils: IDownloadUtils, + isAnyViewerOpen: Boolean +) { + Column( + modifier = modifier, + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + ) { + MessageContentSelector( + msg = msg, + newerMsg = newerMsg, + isOutgoing = isOutgoing, + senderGrouping = senderGrouping, + isGroup = isGroup, + appearance = appearance, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onBubbleClick = onBubbleClick, + onBubbleLongClick = onBubbleLongClick, + onBubbleCenterLongClick = onBubbleCenterLongClick, + onGoToReply = onGoToReply, + onReactionClick = onReactionClick, + onStickerClick = onStickerClick, + onPollOptionClick = onPollOptionClick, + onRetractVote = onRetractVote, + onShowVoters = onShowVoters, + onClosePoll = onClosePoll, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + + MessageReplyMarkup( + msg = msg, + onReplyMarkupButtonClick = onReplyMarkupButtonClick + ) + + MessageViaBotAttribution( + msg = msg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + ) + } +} + +@Composable +private fun MessageAvatar( + avatarPath: String?, + fallbackPath: String?, + senderName: String, + senderId: Long, + isVisible: Boolean, + toProfile: (Long) -> Unit +) { + if (isVisible) { + Avatar( + path = avatarPath, + fallbackPath = fallbackPath, + name = senderName, + size = 40.dp, + isLocal = avatarPath?.contains("local") ?: false, + onClick = { toProfile(senderId) } + ) + } else { + Spacer(modifier = Modifier.width(40.dp)) + } +} + +@Composable +private fun MessageContentSelector( + msg: MessageModel, + newerMsg: MessageModel?, + isOutgoing: Boolean, + senderGrouping: MessageSenderGrouping, + isGroup: Boolean, + appearance: MessageAppearanceConfig, + onPhotoClick: (MessageModel) -> Unit, + onDownloadPhoto: (Int) -> Unit, + onVideoClick: (MessageModel) -> Unit, + onDocumentClick: (MessageModel) -> Unit, + onAudioClick: (MessageModel) -> Unit, + onCancelDownload: (Int) -> Unit, + onBubbleClick: (Offset) -> Unit, + onBubbleLongClick: (Offset) -> Unit, + onBubbleCenterLongClick: () -> Unit, + onGoToReply: (MessageModel) -> Unit, + onReactionClick: (Long, String) -> Unit, + onStickerClick: (Long) -> Unit, + onPollOptionClick: (Long, Int) -> Unit, + onRetractVote: (Long) -> Unit, + onShowVoters: (Long, Int) -> Unit, + onClosePoll: (Long) -> Unit, + onInstantViewClick: ((String) -> Unit)?, + onYouTubeClick: ((String) -> Unit)?, + toProfile: (Long) -> Unit, + onForwardOriginClick: (ForwardInfo) -> Unit, + downloadUtils: IDownloadUtils, + isAnyViewerOpen: Boolean = false +) { + Column( + modifier = Modifier.width(IntrinsicSize.Max), + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + ) { + when (val content = msg.content) { + is MessageContent.Text -> { + TextMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + isGroup = isGroup, + showLinkPreviews = appearance.showLinkPreviews, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + onClick = onBubbleClick, + onLongClick = onBubbleLongClick, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick + ) + } + + is MessageContent.Sticker -> { + StickerMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + stickerSize = appearance.stickerSize, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onStickerClick = { onStickerClick(it) }, + onLongClick = onBubbleCenterLongClick + ) + } + + is MessageContent.Photo -> { + PhotoMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils + ) + } + + is MessageContent.Video -> { + VideoMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + autoplayVideos = appearance.autoplayVideos, + onVideoClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } + + is MessageContent.VideoNote -> { + VideoNoteBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + onVideoClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) } + ) + } + + is MessageContent.Voice -> { + VoiceMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + autoDownloadFiles = appearance.autoDownloadFiles, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + onVoiceClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + downloadUtils = downloadUtils + ) + } + + is MessageContent.Gif -> { + GifMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + autoplayGifs = appearance.autoplayGifs, + onGifClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } + + is MessageContent.Document -> { + DocumentMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + autoDownloadFiles = appearance.autoDownloadFiles, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + onDocumentClick = onDocumentClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onForwardOriginClick = onForwardOriginClick, + downloadUtils = downloadUtils + ) + } + + is MessageContent.Audio -> { + AudioMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + autoDownloadFiles = appearance.autoDownloadFiles, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } + + is MessageContent.Contact -> { + ContactMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + isGroup = isGroup, + onClick = { onGoToReply(msg) }, + onLongClick = onBubbleCenterLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + showReactions = msg.reactions.isNotEmpty() + ) + } + + is MessageContent.Poll -> { + PollMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + onOptionClick = { onPollOptionClick(msg.id, it) }, + onRetractVote = { onRetractVote(msg.id) }, + onLongClick = onBubbleLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onShowVoters = { onShowVoters(msg.id, it) }, + onClosePoll = { onClosePoll(msg.id) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick + ) + } + + is MessageContent.Location -> { + LocationMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + bubbleRadius = appearance.bubbleRadius, + onClick = { onGoToReply(msg) }, + onLongClick = onBubbleCenterLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick + ) + } + + is MessageContent.Venue -> { + VenueMessageBubble( + content = content, + msg = msg, + isOutgoing = isOutgoing, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + isGroup = isGroup, + bubbleRadius = appearance.bubbleRadius, + onClick = { onGoToReply(msg) }, + onLongClick = onBubbleCenterLongClick, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick + ) + } + + else -> { + // Fallback + } + } + } +} + +@Composable +private fun MessageReplyMarkup( + msg: MessageModel, + onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit +) { + msg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } + ) + } +} + +private fun androidx.compose.ui.geometry.Size.toOffset() = Offset(width, height) + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageListShimmer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageListShimmer.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageListShimmer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageListShimmer.kt index 57632374..f7693248 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageListShimmer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageListShimmer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.animation.core.* import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt new file mode 100644 index 00000000..7b49c6a2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt @@ -0,0 +1,53 @@ +package org.monogram.presentation.features.chats.conversation.ui + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize + +@Immutable +internal data class MessageAppearanceConfig( + val fontSize: Float, + val letterSpacing: Float, + val bubbleRadius: Float, + val stickerSize: Float, + val showLinkPreviews: Boolean = true, + val autoplayGifs: Boolean = true, + val autoplayVideos: Boolean = true, + val autoDownloadMobile: Boolean = false, + val autoDownloadWifi: Boolean = false, + val autoDownloadRoaming: Boolean = false, + val autoDownloadFiles: Boolean = false +) + +@Immutable +internal data class MessageRowBehaviorConfig( + val isGroup: Boolean, + val isChannel: Boolean, + val isTopicClosed: Boolean, + val canReply: Boolean, + val swipeEnabled: Boolean, + val isSelectionMode: Boolean, + val isAnyViewerOpen: Boolean +) + +@Immutable +internal data class MessageRowUiFlags( + val isSelected: Boolean = false, + val isHighlighted: Boolean = false, + val showUnreadSeparator: Boolean = false, + val unreadCount: Int = 0, + val shouldReportPosition: Boolean = false +) + +@Immutable +internal data class MessageSenderGrouping( + val isSameSenderAbove: Boolean, + val isSameSenderBelow: Boolean +) + +internal class MessageBubbleLayoutTracker { + var outerColumnPosition: Offset = Offset.Zero + var bubblePosition: Offset = Offset.Zero + var bubbleSize: IntSize = IntSize.Zero +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt new file mode 100644 index 00000000..9b0ff2a8 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt @@ -0,0 +1,46 @@ +package org.monogram.presentation.features.chats.conversation.ui + +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ui.content.GroupedMessageItem +import org.monogram.presentation.features.chats.conversation.ui.content.shouldShowDate + +internal fun shouldGroupSenderBlock( + current: MessageModel, + neighbor: MessageModel?, + dateBreak: Boolean +): Boolean { + if (neighbor == null) return false + if (current.senderId <= 0L || neighbor.senderId <= 0L) return false + if (current.senderId != neighbor.senderId) return false + if (current.senderName != neighbor.senderName) return false + if (current.senderCustomTitle != neighbor.senderCustomTitle) return false + return !dateBreak +} + +internal fun buildSenderGrouping( + item: GroupedMessageItem, + olderMsg: MessageModel?, + newerMsg: MessageModel? +): MessageSenderGrouping { + val firstMsg = when (item) { + is GroupedMessageItem.Single -> item.message + is GroupedMessageItem.Album -> item.messages.first() + } + val lastMsg = when (item) { + is GroupedMessageItem.Single -> item.message + is GroupedMessageItem.Album -> item.messages.last() + } + + return MessageSenderGrouping( + isSameSenderAbove = shouldGroupSenderBlock( + current = firstMsg, + neighbor = olderMsg, + dateBreak = olderMsg?.let { shouldShowDate(firstMsg, it) } ?: true + ), + isSameSenderBelow = shouldGroupSenderBlock( + current = lastMsg, + neighbor = newerMsg, + dateBreak = newerMsg?.let { shouldShowDate(it, lastMsg) } ?: true + ) + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ServiceMessage.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ServiceMessage.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt index 08c8d211..ac334322 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ServiceMessage.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/StickerSetSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/StickerSetSheet.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/StickerSetSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/StickerSetSheet.kt index 729ebe03..63d35666 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/StickerSetSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/StickerSetSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import android.content.ClipData import android.content.ClipboardManager diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/UnreadMessagesSeparator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/UnreadMessagesSeparator.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/UnreadMessagesSeparator.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/UnreadMessagesSeparator.kt index 557d9b7f..647a9072 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/UnreadMessagesSeparator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/UnreadMessagesSeparator.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/VoicePlayer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/VoicePlayer.kt new file mode 100644 index 00000000..fa3069de --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/VoicePlayer.kt @@ -0,0 +1,167 @@ +package org.monogram.presentation.features.chats.conversation.ui + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Immutable +data class VoicePlaybackUiState( + val isPlaying: Boolean = false, + val progress: Float = 0f, + val currentPosition: Long = 0L, + val duration: Long = 0L +) + +interface VoicePlaybackController { + fun stateFor(messageId: Long, fallbackDurationSeconds: Int): VoicePlaybackUiState + fun togglePlayPause(messageId: Long, path: String?) + fun seekTo(messageId: Long, positionFraction: Float) +} + +private class EmptyVoicePlaybackController : VoicePlaybackController { + override fun stateFor(messageId: Long, fallbackDurationSeconds: Int): VoicePlaybackUiState { + return VoicePlaybackUiState(duration = fallbackDurationSeconds * 1000L) + } + + override fun togglePlayPause(messageId: Long, path: String?) = Unit + + override fun seekTo(messageId: Long, positionFraction: Float) = Unit +} + +val LocalVoicePlaybackController = staticCompositionLocalOf { + EmptyVoicePlaybackController() +} + +@Composable +fun rememberVoicePlaybackController(): VoicePlaybackController { + val context = LocalContext.current + val player = remember { + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF + } + } + val controller = remember(player) { ExoPlayerVoicePlaybackController(player) } + + LaunchedEffect(controller.activeMessageId, controller.isPlaying) { + while (isActive && controller.isPlaying) { + controller.updateProgress() + delay(50) + } + } + + androidx.compose.runtime.DisposableEffect(player) { + onDispose { + player.release() + } + } + + return controller +} + +private class ExoPlayerVoicePlaybackController( + private val player: ExoPlayer +) : VoicePlaybackController { + var activeMessageId by mutableStateOf(null) + private set + private var activePath by mutableStateOf(null) + + var isPlaying by mutableStateOf(false) + private set + private var progress by mutableFloatStateOf(0f) + private var currentPosition by mutableLongStateOf(0L) + private var duration by mutableLongStateOf(0L) + + init { + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + if (!playing) { + updateProgress() + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + duration = player.duration.coerceAtLeast(0L) + updateProgress() + } + + Player.STATE_ENDED -> { + isPlaying = false + progress = 0f + currentPosition = 0L + player.seekTo(0L) + player.pause() + } + } + } + }) + } + + override fun stateFor(messageId: Long, fallbackDurationSeconds: Int): VoicePlaybackUiState { + val isActiveMessage = activeMessageId == messageId + return if (isActiveMessage) { + VoicePlaybackUiState( + isPlaying = isPlaying, + progress = progress, + currentPosition = currentPosition, + duration = duration.takeIf { it > 0L } ?: fallbackDurationSeconds * 1000L + ) + } else { + VoicePlaybackUiState(duration = fallbackDurationSeconds * 1000L) + } + } + + override fun togglePlayPause(messageId: Long, path: String?) { + if (path == null) return + + if (activeMessageId != messageId || activePath != path) { + activeMessageId = messageId + activePath = path + progress = 0f + currentPosition = 0L + duration = 0L + player.setMediaItem(MediaItem.fromUri(Uri.parse(path))) + player.prepare() + player.play() + return + } + + if (player.isPlaying) { + player.pause() + } else { + player.play() + } + } + + override fun seekTo(messageId: Long, positionFraction: Float) { + if (activeMessageId != messageId) return + val totalDuration = player.duration + if (totalDuration <= 0L) return + player.seekTo((positionFraction.coerceIn(0f, 1f) * totalDuration).toLong()) + updateProgress() + } + + fun updateProgress() { + if (activeMessageId == null) return + currentPosition = player.currentPosition.coerceAtLeast(0L) + val total = player.duration.coerceAtLeast(0L) + duration = total + progress = if (total > 0L) currentPosition.toFloat() / total.toFloat() else 0f + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt index 0129a411..69255851 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -48,15 +48,15 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji -import org.monogram.presentation.features.chats.currentChat.components.chats.formatFileSize -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent +import org.monogram.presentation.features.chats.conversation.ui.CompactMediaMosaic +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.conversation.ui.message.formatFileSize +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageInlineContent @Composable fun ChannelAlbumMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelCommentsButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelCommentsButton.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelCommentsButton.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelCommentsButton.kt index 0c5bb4f7..afc88398 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelCommentsButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelCommentsButton.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt index 4d840402..1096add4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize @@ -67,17 +67,17 @@ import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType -import org.monogram.presentation.features.chats.currentChat.components.chats.BigEmojiContent -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageTextRenderData +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType +import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable fun ChannelGifMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt similarity index 58% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt index 5b6386b0..dbc27430 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import android.content.res.Configuration import androidx.compose.animation.Animatable @@ -19,10 +19,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -42,25 +41,27 @@ import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.FastReplyIndicator -import org.monogram.presentation.features.chats.currentChat.components.chats.AudioMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.DocumentMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView -import org.monogram.presentation.features.chats.currentChat.components.chats.StickerMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.fastReplyPointer +import org.monogram.presentation.features.chats.conversation.ui.FastReplyIndicator +import org.monogram.presentation.features.chats.conversation.ui.MessageAppearanceConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageBubbleLayoutTracker +import org.monogram.presentation.features.chats.conversation.ui.MessageRowBehaviorConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageRowUiFlags +import org.monogram.presentation.features.chats.conversation.ui.MessageSenderGrouping +import org.monogram.presentation.features.chats.conversation.ui.fastReplyPointer +import org.monogram.presentation.features.chats.conversation.ui.message.AudioMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.DocumentMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.MessageViaBotAttribution +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyMarkupView +import org.monogram.presentation.features.chats.conversation.ui.message.StickerMessageBubble @Composable -fun ChannelMessageBubbleContainer( +internal fun ChannelMessageBubbleContainer( msg: MessageModel, - olderMsg: MessageModel?, newerMsg: MessageModel?, - autoplayGifs: Boolean = true, - autoplayVideos: Boolean = true, - autoDownloadFiles: Boolean = false, - showLinkPreviews: Boolean = true, - highlighted: Boolean = false, + appearance: MessageAppearanceConfig, + behavior: MessageRowBehaviorConfig, + uiFlags: MessageRowUiFlags = MessageRowUiFlags(), + senderGrouping: MessageSenderGrouping, onHighlightConsumed: () -> Unit = {}, onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, @@ -70,9 +71,6 @@ fun ChannelMessageBubbleContainer( onCancelDownload: (Int) -> Unit = {}, onReplyClick: (Offset, IntSize, Offset) -> Unit, onGoToReply: (MessageModel) -> Unit = {}, - autoDownloadMobile: Boolean = false, - autoDownloadWifi: Boolean = false, - autoDownloadRoaming: Boolean = false, onReactionClick: (Long, String) -> Unit = { _, _ -> }, onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit = { _, _ -> }, onStickerClick: (Long) -> Unit = {}, @@ -82,21 +80,14 @@ fun ChannelMessageBubbleContainer( onClosePoll: (Long) -> Unit = {}, onInstantViewClick: ((String) -> Unit)? = null, onYouTubeClick: ((String) -> Unit)? = null, - fontSize: Float, - letterSpacing: Float, - bubbleRadius: Float, - stickerSize: Float = 200f, - shouldReportPosition: Boolean = false, onPositionChange: (Long, Offset, IntSize) -> Unit = { _, _, _ -> }, onCommentsClick: (Long) -> Unit = {}, showComments: Boolean = true, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, onViaBotClick: (String) -> Unit = {}, - canReply: Boolean = true, onReplySwipe: (MessageModel) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false, ) { val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp @@ -108,16 +99,13 @@ fun ChannelMessageBubbleContainer( (screenWidth * 0.94f).coerceAtMost(500.dp) } - val isSameSenderAbove = olderMsg?.senderId == msg.senderId && !shouldShowDate(msg, olderMsg) - val isSameSenderBelow = newerMsg != null && newerMsg.senderId == msg.senderId && !shouldShowDate(newerMsg, msg) - - val topSpacing = if (!isSameSenderAbove) 12.dp else 2.dp + val topSpacing = if (!senderGrouping.isSameSenderAbove) 12.dp else 2.dp val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) val animatedColor = remember { Animatable(Color.Transparent) } - LaunchedEffect(highlighted) { - if (highlighted) { + LaunchedEffect(uiFlags.isHighlighted) { + if (uiFlags.isHighlighted) { animatedColor.animateTo(highlightColor, animationSpec = tween(300)) delay(450) animatedColor.animateTo(Color.Transparent, animationSpec = tween(1800)) @@ -125,40 +113,50 @@ fun ChannelMessageBubbleContainer( } } - var outerColumnPosition by remember { mutableStateOf(Offset.Zero) } - var bubblePosition by remember { mutableStateOf(Offset.Zero) } - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - val dragOffsetX = remember { androidx.compose.animation.core.Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + val layoutTracker = remember { MessageBubbleLayoutTracker() } + val onReplyClickState by rememberUpdatedState(onReplyClick) + val onPositionChangeState by rememberUpdatedState(onPositionChange) Column( modifier = Modifier .fillMaxWidth() .background(animatedColor.value, RoundedCornerShape(12.dp)) - .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } + .onGloballyPositioned { layoutTracker.outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) .offset { IntOffset(dragOffsetX.value.toInt(), 0) } .fastReplyPointer( - canReply = canReply, + canReply = behavior.canReply && behavior.swipeEnabled, dragOffsetX = dragOffsetX, - scope = rememberCoroutineScope(), + scope = coroutineScope, onReplySwipe = { onReplySwipe(msg) }, maxWidth = maxWidth.value ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + clickPos + ) } }, onLongPress = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) + val clickPos = layoutTracker.outerColumnPosition + offset + val bubbleRect = + Rect(layoutTracker.bubblePosition, layoutTracker.bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + clickPos + ) } } ) @@ -178,10 +176,14 @@ fun ChannelMessageBubbleContainer( .widthIn(max = maxWidth) .fillMaxWidth() .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) + layoutTracker.bubblePosition = coordinates.positionInWindow() + layoutTracker.bubbleSize = coordinates.size + if (uiFlags.shouldReportPosition) { + onPositionChangeState( + msg.id, + layoutTracker.bubblePosition, + layoutTracker.bubbleSize + ) } } ) { @@ -190,21 +192,29 @@ fun ChannelMessageBubbleContainer( ChannelTextMessageBubble( content = content, msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - showLinkPreviews = showLinkPreviews, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + showLinkPreviews = appearance.showLinkPreviews, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onInstantViewClick = onInstantViewClick, onYouTubeClick = onYouTubeClick, onClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) }, onLongClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) }, onCommentsClick = onCommentsClick, showComments = showComments, @@ -218,22 +228,22 @@ fun ChannelMessageBubbleContainer( ChannelPhotoMessageBubble( content = content, msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onCancelDownload = onCancelDownload, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onReplyClick = onGoToReply, @@ -251,22 +261,22 @@ fun ChannelMessageBubbleContainer( ChannelVideoMessageBubble( content = content, msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayVideos = autoplayVideos, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + autoplayVideos = appearance.autoplayVideos, onVideoClick = onVideoClick, onCancelDownload = onCancelDownload, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onReplyClick = onGoToReply, @@ -277,7 +287,7 @@ fun ChannelMessageBubbleContainer( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen ) } @@ -286,21 +296,21 @@ fun ChannelMessageBubbleContainer( content = content, msg = msg, isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + autoDownloadFiles = appearance.autoDownloadFiles, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, onDocumentClick = onDocumentClick, onCancelDownload = onCancelDownload, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, toProfile = toProfile, @@ -317,21 +327,21 @@ fun ChannelMessageBubbleContainer( content = content, msg = msg, isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + autoDownloadFiles = appearance.autoDownloadFiles, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, toProfile = toProfile, @@ -347,22 +357,22 @@ fun ChannelMessageBubbleContainer( ChannelGifMessageBubble( content = content, msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayGifs = autoplayGifs, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, + autoDownloadMobile = appearance.autoDownloadMobile, + autoDownloadWifi = appearance.autoDownloadWifi, + autoDownloadRoaming = appearance.autoDownloadRoaming, + autoplayGifs = appearance.autoplayGifs, onGifClick = onVideoClick, onCancelDownload = onCancelDownload, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onReplyClick = onGoToReply, @@ -373,7 +383,7 @@ fun ChannelMessageBubbleContainer( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen ) } @@ -382,15 +392,15 @@ fun ChannelMessageBubbleContainer( content = content, msg = msg, isOutgoing = false, - stickerSize = stickerSize, + stickerSize = appearance.stickerSize, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onStickerClick = { onStickerClick(content.setId) }, onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + (layoutTracker.bubbleSize.toSize() / 2f).toOffset() ) }, toProfile = toProfile, @@ -402,11 +412,11 @@ fun ChannelMessageBubbleContainer( ChannelPollMessageBubble( content = content, msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, + isSameSenderAbove = senderGrouping.isSameSenderAbove, + isSameSenderBelow = senderGrouping.isSameSenderBelow, + fontSize = appearance.fontSize, + letterSpacing = appearance.letterSpacing, + bubbleRadius = appearance.bubbleRadius, onOptionClick = { onPollOptionClick(msg.id, it) }, onRetractVote = { onRetractVote(msg.id) }, onShowVoters = { onShowVoters(msg.id, it) }, @@ -414,10 +424,10 @@ fun ChannelMessageBubbleContainer( onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset ) }, onCommentsClick = onCommentsClick, @@ -458,3 +468,4 @@ fun ChannelMessageBubbleContainer( private fun IntSize.toSize() = androidx.compose.ui.geometry.Size(width.toFloat(), height.toFloat()) private fun androidx.compose.ui.geometry.Size.toOffset() = Offset(width, height) + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageUtils.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageUtils.kt index 42e9961a..dd9cbe7d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import android.content.Context import org.monogram.presentation.R diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt index 4acf3161..4a990332 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -45,16 +45,16 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.chats.BigEmojiContent -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageTextRenderData +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground +import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable fun ChannelPhotoMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPollMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPollMessageBubble.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPollMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPollMessageBubble.kt index 29b53844..2f2f062f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPollMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPollMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -9,7 +9,7 @@ import androidx.compose.ui.geometry.Offset import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel -import org.monogram.presentation.features.chats.currentChat.components.chats.PollMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.PollMessageBubble @Composable fun ChannelPollMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt similarity index 91% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt index 96eb4b12..5af074b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelTextMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,14 +29,14 @@ import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.DateFormatManager -import org.monogram.presentation.features.chats.currentChat.components.chats.BigEmojiContent -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.LinkPreview -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageSendingStatusIcon -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageTextRenderData +import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.LinkPreview +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.MessageSendingStatusIcon +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable fun ChannelTextMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index 49596631..4562341e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -58,18 +58,18 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType -import org.monogram.presentation.features.chats.currentChat.components.chats.BigEmojiContent -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction -import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageTextRenderData +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType +import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground +import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable fun ChannelVideoMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVoiceMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt similarity index 91% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVoiceMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt index 044258d8..d845010f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVoiceMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.channels +package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,11 +23,11 @@ import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent -import org.monogram.presentation.features.chats.currentChat.components.chats.VoiceRow +import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent +import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata +import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView +import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.VoiceRow @Composable fun ChannelVoiceMessageBubble( 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/conversation/ui/content/ChatContentBackground.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBackground.kt index d576b029..5c667b79 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/conversation/ui/content/ChatContentBackground.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -10,7 +10,7 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import org.monogram.presentation.features.chats.currentChat.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.settings.chatSettings.components.WallpaperBackground import java.io.File @@ -59,3 +59,4 @@ fun ChatContentBackground( ) } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt new file mode 100644 index 00000000..5ba02b16 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt @@ -0,0 +1,416 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import org.monogram.domain.models.UserModel +import org.monogram.presentation.features.chats.conversation.ChatComponent + +@Immutable +internal data class ChatContentPermissionState( + val canWriteText: Boolean, + val canSendAnything: Boolean +) + +@Immutable +internal data class ChatContentSearchUiState( + val canLoadMoreSearchResults: Boolean, + val searchSenderCandidates: List, + val hasSearchFiltersApplied: Boolean +) + +@Immutable +internal data class ChatContentChromeState( + val showInputBar: Boolean, + val showJoinButton: Boolean, + val isCustomBackHandlingEnabled: Boolean, + val selectedCount: Int, + val canRevokeSelected: Boolean +) + +@Composable +internal fun rememberChatContentPermissionState( + state: ChatComponent.State +): ChatContentPermissionState { + val canWriteText by remember(state.isAdmin, state.permissions.canSendBasicMessages) { + derivedStateOf { state.isAdmin || state.permissions.canSendBasicMessages } + } + val canSendPhotos by remember(state.isAdmin, state.permissions.canSendPhotos) { + derivedStateOf { state.isAdmin || state.permissions.canSendPhotos } + } + val canSendVideos by remember(state.isAdmin, state.permissions.canSendVideos) { + derivedStateOf { state.isAdmin || state.permissions.canSendVideos } + } + val canSendDocuments by remember(state.isAdmin, state.permissions.canSendDocuments) { + derivedStateOf { state.isAdmin || state.permissions.canSendDocuments } + } + val canSendAudios by remember(state.isAdmin, state.permissions.canSendAudios) { + derivedStateOf { state.isAdmin || state.permissions.canSendAudios } + } + val canUseMediaPicker by remember(canSendPhotos, canSendVideos) { + derivedStateOf { canSendPhotos || canSendVideos } + } + val canUseDocumentPicker by remember(canSendDocuments, canSendAudios) { + derivedStateOf { canSendDocuments || canSendAudios } + } + val canSendPolls by remember(state.isAdmin, state.permissions.canSendPolls) { + derivedStateOf { state.isAdmin || state.permissions.canSendPolls } + } + val canOpenAttachSheet by remember( + canUseMediaPicker, + canUseDocumentPicker, + canSendPolls, + state.attachMenuBots + ) { + derivedStateOf { + canUseMediaPicker || canUseDocumentPicker || canSendPolls || state.attachMenuBots.isNotEmpty() + } + } + val canSendStickers by remember(state.isAdmin, state.permissions.canSendOtherMessages) { + derivedStateOf { state.isAdmin || state.permissions.canSendOtherMessages } + } + val canSendVoice by remember(state.isAdmin, state.permissions.canSendVoiceNotes) { + derivedStateOf { state.isAdmin || state.permissions.canSendVoiceNotes } + } + val canSendVideoNotes by remember(state.isAdmin, state.permissions.canSendVideoNotes) { + derivedStateOf { state.isAdmin || state.permissions.canSendVideoNotes } + } + val canSendAnything by remember( + canWriteText, + canOpenAttachSheet, + canSendStickers, + canSendVoice, + canSendVideoNotes, + canSendPolls + ) { + derivedStateOf { + canWriteText || canOpenAttachSheet || canSendStickers || canSendVoice || canSendVideoNotes || canSendPolls + } + } + + return remember(canWriteText, canSendAnything) { + ChatContentPermissionState( + canWriteText = canWriteText, + canSendAnything = canSendAnything + ) + } +} + +@Composable +internal fun rememberChatMessageListState( + state: ChatComponent.State, + displayMessages: List, + canSendAnything: Boolean, + showInitialLoading: Boolean +): ChatMessageListUiState { + return remember( + state.chatId, + state.currentTopicId, + displayMessages, + state.selectedMessageIds, + state.unreadSeparatorCount, + state.unreadSeparatorLastReadInboxMessageId, + state.viewAsTopics, + state.topics, + state.rootMessage, + state.isLoading, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom, + state.isLatestLoaded, + state.isOldestLoaded, + state.isGroup, + state.isChannel, + state.isAdmin, + state.canWrite, + canSendAnything, + state.highlightedMessageId, + state.fontSize, + state.letterSpacing, + state.bubbleRadius, + state.stickerSize, + state.autoDownloadMobile, + state.autoDownloadWifi, + state.autoDownloadRoaming, + state.autoDownloadFiles, + state.autoplayGifs, + state.autoplayVideos, + state.showLinkPreviews, + state.isChatAnimationsEnabled, + showInitialLoading, + state.pendingScrollCommand + ) { + ChatMessageListUiState( + chatId = state.chatId, + currentTopicId = state.currentTopicId, + messages = displayMessages, + selectedMessageIds = state.selectedMessageIds, + unreadSeparatorCount = state.unreadSeparatorCount, + unreadSeparatorLastReadInboxMessageId = state.unreadSeparatorLastReadInboxMessageId, + viewAsTopics = state.viewAsTopics, + topics = state.topics, + rootMessage = state.rootMessage, + isLoading = state.isLoading, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + isLatestLoaded = state.isLatestLoaded, + isOldestLoaded = state.isOldestLoaded, + isGroup = state.isGroup, + isChannel = state.isChannel, + isAdmin = state.isAdmin, + canWrite = state.canWrite, + canSendAnything = canSendAnything, + highlightedMessageId = state.highlightedMessageId, + 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, + showLinkPreviews = state.showLinkPreviews, + isChatAnimationsEnabled = state.isChatAnimationsEnabled, + suppressEntryAnimations = showInitialLoading || state.pendingScrollCommand != null + ) + } +} + +@Composable +internal fun rememberChatTopBarUiState( + state: ChatComponent.State +): ChatContentTopBarUiState { + return 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 + ) { + 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 = if (state.isSearchActive) null else state.pinnedMessage, + pinnedMessageCount = if (state.isSearchActive) 0 else state.pinnedMessageCount + ) + } +} + +@Composable +internal fun rememberChatSearchUiState( + state: ChatComponent.State +): ChatContentSearchUiState { + val canLoadMoreSearchResults by remember( + state.searchNextFromMessageId, + state.searchResults.size, + state.searchResultsTotalCount + ) { + derivedStateOf { + state.searchResults.size < state.searchResultsTotalCount || + state.searchNextFromMessageId != 0L + } + } + val searchSenderCandidates by remember(state.searchAvailableSenders, state.otherUser) { + derivedStateOf { + buildList { + addAll(state.searchAvailableSenders) + state.otherUser?.let(::add) + }.distinctBy(UserModel::id) + } + } + val hasSearchFiltersApplied by remember( + state.searchSender, + state.searchDateFromEpochSeconds, + state.searchDateToEpochSeconds + ) { + derivedStateOf { + state.searchSender != null || + state.searchDateFromEpochSeconds != null || + state.searchDateToEpochSeconds != null + } + } + + return remember( + canLoadMoreSearchResults, + searchSenderCandidates, + hasSearchFiltersApplied + ) { + ChatContentSearchUiState( + canLoadMoreSearchResults = canLoadMoreSearchResults, + searchSenderCandidates = searchSenderCandidates, + hasSearchFiltersApplied = hasSearchFiltersApplied + ) + } +} + +@Composable +internal fun rememberChatChromeState( + state: ChatComponent.State, + isRecordingVideo: Boolean, + editingPhotoPath: String?, + editingVideoPath: String?, + selectedMessageId: Long? +): ChatContentChromeState { + val showInputBar by remember( + state.isChannel, + state.isGroup, + state.canWrite, + state.isCurrentUserRestricted, + state.currentTopicId, + state.selectedMessageIds, + state.viewAsTopics, + state.isSearchActive, + isRecordingVideo + ) { + derivedStateOf { + (state.canWrite || state.isCurrentUserRestricted) && + !isRecordingVideo && + !state.isSearchActive && + state.selectedMessageIds.isEmpty() && + (!state.viewAsTopics || state.currentTopicId != null) + } + } + + val showJoinButton by remember( + showInputBar, + state.isMember, + state.isChannel, + state.isGroup, + state.canWrite, + state.isCurrentUserRestricted, + state.selectedMessageIds, + state.viewAsTopics, + state.currentTopicId, + state.isSearchActive, + isRecordingVideo + ) { + derivedStateOf { + !showInputBar && + !state.isSearchActive && + !state.isMember && + (state.isChannel || state.isGroup) && + !state.canWrite && + !state.isCurrentUserRestricted && + !isRecordingVideo && + state.selectedMessageIds.isEmpty() && + (!state.viewAsTopics || state.currentTopicId != null) + } + } + + val isCustomBackHandlingEnabled by remember( + 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, + state.isSearchActive + ) { + derivedStateOf { + editingPhotoPath != null || + editingVideoPath != null || + selectedMessageId != null || + state.selectedMessageIds.isNotEmpty() || + state.currentTopicId != null || + state.showBotCommands || + state.restrictUserId != null || + state.showPinnedMessagesList || + state.fullScreenImages != null || + state.fullScreenVideoPath != null || + state.fullScreenVideoMessageId != null || + state.miniAppUrl != null || + state.webViewUrl != null || + state.instantViewUrl != null || + state.youtubeUrl != null || + state.isSearchActive + } + } + + val selectedCount = state.selectedMessageIds.size + val selectedMessageIdSet by remember(state.selectedMessageIds) { + derivedStateOf { state.selectedMessageIds.toHashSet() } + } + val canRevokeSelected by remember(state.messages, selectedMessageIdSet) { + derivedStateOf { + if (selectedMessageIdSet.isEmpty()) { + false + } else { + state.messages.any { it.id in selectedMessageIdSet && it.canBeDeletedForAllUsers } + } + } + } + + return remember( + showInputBar, + showJoinButton, + isCustomBackHandlingEnabled, + selectedCount, + canRevokeSelected + ) { + ChatContentChromeState( + showInputBar = showInputBar, + showJoinButton = showJoinButton, + isCustomBackHandlingEnabled = isCustomBackHandlingEnabled, + selectedCount = selectedCount, + canRevokeSelected = canRevokeSelected + ) + } +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt new file mode 100644 index 00000000..6c3d7d8a --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt @@ -0,0 +1,387 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatScrollCommand +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent + +@Composable +internal fun ChatContentEffects( + component: ChatComponent, + state: ChatComponent.State, + scrollState: LazyListState, + groupedMessages: List, + groupedMessageIndexById: Map, + isComments: Boolean, + isForumList: Boolean, + isDragged: Boolean, + isRecordingVideo: Boolean, + showInitialLoading: Boolean, + hasUserScrolledAwayFromBottom: Boolean, + transformedMessageTexts: MutableMap, + originalMessageTexts: MutableMap, + onVisible: () -> Unit, + onShowInitialLoadingChanged: (Boolean) -> Unit, + onHasUserScrolledAwayFromBottomChanged: (Boolean) -> Unit, + onShowScrollToBottomButtonChanged: (Boolean) -> Unit, + onHideKeyboardAndClearFocus: (Boolean) -> Unit, + onRenderPinnedMessagesListChanged: (Boolean) -> Unit, + onSearchFiltersChanged: (Boolean) -> Unit, + onSearchSenderPickerChanged: (Boolean) -> Unit +) { + val latestUiState = rememberUpdatedState(state) + val firstGroupedMessageId = groupedMessages.firstOrNull()?.firstMessageId + val lastGroupedMessageId = groupedMessages.lastOrNull()?.firstMessageId + + LaunchedEffect(Unit) { + onVisible() + if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) { + component.onDismissVideo() + } + } + + LaunchedEffect(state.messages) { + if (transformedMessageTexts.isEmpty() && originalMessageTexts.isEmpty()) return@LaunchedEffect + val ids = state.messages.map { it.id }.toSet() + transformedMessageTexts.keys.toList().forEach { id -> + if (id !in ids) { + transformedMessageTexts.remove(id) + originalMessageTexts.remove(id) + } + } + } + + LaunchedEffect( + state.isLoading, + state.messages.isEmpty(), + state.viewAsTopics, + state.currentTopicId, + state.isLoadingTopics, + state.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 + } else { + state.isLoading && state.messages.isEmpty() + } + if (isActuallyLoading) { + if (state.isChatAnimationsEnabled) delay(200) + onShowInitialLoadingChanged(true) + } else { + onShowInitialLoadingChanged(false) + } + } + + LaunchedEffect( + state.pendingScrollCommand, + isComments, + groupedMessages.size, + firstGroupedMessageId, + lastGroupedMessageId + ) { + val command = state.pendingScrollCommand ?: return@LaunchedEffect + + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + + when (command) { + is ChatScrollCommand.RestoreViewport -> { + if (command.atBottom || command.anchorMessageId == null) { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) + } else { + val groupedIndex = groupedMessageIndexById[command.anchorMessageId] + ?: awaitGroupedIndex( + messageId = command.anchorMessageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.restoreViewportAtIndex( + targetIndex = targetIndex, + anchorOffsetPx = command.anchorOffsetPx + ) + } else { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) + } + } + component.onScrollCommandConsumed() + } + + is ChatScrollCommand.JumpToMessage -> { + val groupedIndex = groupedMessageIndexById[command.messageId] + ?: awaitGroupedIndex( + messageId = command.messageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.scrollToMessageIndex( + index = targetIndex, + align = command.align, + animated = command.animated && state.isChatAnimationsEnabled, + staged = true + ) + } + component.onScrollCommandConsumed() + } + + is ChatScrollCommand.ScrollToBottom -> { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = command.animated && state.isChatAnimationsEnabled + ) + component.onScrollCommandConsumed() + } + + is ChatScrollCommand.ScrollToStart -> { + scrollState.scrollToChatStartStaged( + animated = command.animated && state.isChatAnimationsEnabled + ) + component.onScrollCommandConsumed() + } + } + } + + LaunchedEffect( + scrollState, + isComments, + isForumList, + showInitialLoading, + isDragged, + hasUserScrolledAwayFromBottom + ) { + var lastReportedBottomState: Boolean? = null + snapshotFlow { + val currentState = latestUiState.value + BottomVisibilitySnapshot( + isAtBottom = scrollState.isAtBottom( + isComments = isComments, + isLatestLoaded = currentState.isLatestLoaded + ), + isNearBottom = scrollState.isNearBottom(isComments = isComments), + unreadCount = currentState.unreadCount + ) + } + .distinctUntilChanged() + .collectLatest { snapshot -> + if (lastReportedBottomState != snapshot.isAtBottom) { + component.onBottomReached(snapshot.isAtBottom) + lastReportedBottomState = snapshot.isAtBottom + } + + if (snapshot.isNearBottom) { + onHasUserScrolledAwayFromBottomChanged(false) + } else if (isDragged) { + onHasUserScrolledAwayFromBottomChanged(true) + } + + val shouldShow = !isForumList && + !showInitialLoading && + (snapshot.unreadCount > 0 || (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom)) + + if (shouldShow) { + onShowScrollToBottomButtonChanged(true) + } else { + delay(120) + val keepVisible = snapshot.unreadCount > 0 || + (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom) + if (!keepVisible) { + onShowScrollToBottomButtonChanged(false) + } + } + } + } + + LaunchedEffect( + scrollState, + groupedMessages.size, + firstGroupedMessageId, + lastGroupedMessageId, + isComments, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + snapshotFlow { + buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + } + .filterNotNull() + .distinctUntilChanged() + .debounce(120) + .collect { viewport -> + component.updateViewport(viewport) + } + } + + DisposableEffect( + scrollState, + groupedMessages.size, + firstGroupedMessageId, + lastGroupedMessageId, + isComments, + state.currentTopicId, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + onDispose { + val viewport = buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + if (viewport != null) { + component.updateViewport(viewport) + } + } + } + + LaunchedEffect(scrollState, groupedMessages.size, firstGroupedMessageId, lastGroupedMessageId) { + snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } + .map { visibleItems -> + val currentState = latestUiState.value + val leadingItemsCount = chatContentLeadingItemsCount( + isComments = currentState.rootMessage != null, + showNavPadding = false, + isLoadingOlder = currentState.isLoadingOlder, + isLoadingNewer = currentState.isLoadingNewer, + isAtBottom = currentState.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val visibleIds = LinkedHashSet() + val nearbyIds = LinkedHashSet() + if (visibleItems.isNotEmpty()) { + val minIndex = visibleItems.minOf { it.index } + val maxIndex = visibleItems.maxOf { it.index } + + visibleItems.forEach { item -> + val groupedIndex = lazyIndexToGroupedIndex(item.index, leadingItemsCount) + groupedMessages.getOrNull(groupedIndex)?.let { grouped -> + when (grouped) { + is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + visibleIds.add(message.id) + } + } + } + } + + val nearbyStart = (minIndex - 5).coerceAtLeast(0) + val nearbyEnd = maxIndex + 5 + for (index in nearbyStart..nearbyEnd) { + if (index in minIndex..maxIndex) continue + val groupedIndex = lazyIndexToGroupedIndex(index, leadingItemsCount) + groupedMessages.getOrNull(groupedIndex)?.let { grouped -> + when (grouped) { + is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + nearbyIds.add(message.id) + } + } + } + } + } + val visibleIdList = visibleIds.toList() + visibleIdList to nearbyIds.filterNot(visibleIds::contains) + } + .distinctUntilChanged() + .debounce(100) + .collect { (visibleIds, nearbyIds) -> + (component as? DefaultChatComponent)?.let { + it.repositoryMessage.updateVisibleRange(it.chatId, visibleIds, nearbyIds) + } + } + } + + LaunchedEffect(groupedMessages.size, state.isLatestLoaded) { + if (isComments) return@LaunchedEffect + + val isAtBottomNow = scrollState.isAtBottom( + isComments = isComments, + isLatestLoaded = state.isLatestLoaded + ) + if ((state.isAtBottom || isAtBottomNow) && + !state.isLoading && + !state.isLoadingOlder && + !state.isLoadingNewer && + !scrollState.isScrollInProgress + ) { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = state.isChatAnimationsEnabled + ) + } + } + + LaunchedEffect(isDragged) { + if (isDragged) { + onHideKeyboardAndClearFocus(false) + } + } + + LaunchedEffect(state.showBotCommands, isRecordingVideo) { + if (state.showBotCommands || isRecordingVideo) { + onHideKeyboardAndClearFocus(true) + } + } + + LaunchedEffect(state.showPinnedMessagesList) { + if (state.showPinnedMessagesList) { + onRenderPinnedMessagesListChanged(true) + } + } + + LaunchedEffect(state.isSearchActive) { + if (state.isSearchActive) { + onSearchFiltersChanged(false) + onSearchSenderPickerChanged(false) + if (state.showPinnedMessagesList) { + component.onDismissPinnedMessages() + } + } + } +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt new file mode 100644 index 00000000..9c6da16b --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt @@ -0,0 +1,173 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarActions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ChatInputBarState +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +@Composable +internal fun rememberChatInputBarState( + state: ChatComponent.State, + pendingMediaPaths: List, + pendingDocumentPaths: List +): ChatInputBarState { + return remember(state, pendingMediaPaths, pendingDocumentPaths) { + ChatInputBarState( + replyMessage = state.replyMessage, + editingMessage = state.editingMessage, + draftText = state.draftText, + pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, + isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed + ?: false, + permissions = state.effectiveInputPermissions ?: 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 + ) + } +} + +@Composable +internal fun rememberChatInputBarActions( + component: ChatComponent, + state: ChatComponent.State, + pendingMediaPaths: List, + pendingDocumentPaths: List, + onPickMedia: () -> Unit, + onHideKeyboardAndClearFocus: () -> Unit, + onStartRecordingVideo: () -> Unit, + onSetPendingMediaPaths: (List) -> Unit, + onSetPendingDocumentPaths: (List) -> Unit, + onEditMediaPath: (String) -> Unit +): ChatInputBarActions { + return remember(component, state, pendingMediaPaths, pendingDocumentPaths) { + ChatInputBarActions( + onSend = { text, entities, options -> + component.onSendMessage(text, entities, options) + }, + onStickerClick = component::onSendSticker, + onGifClick = component::onSendGif, + onAttachClick = onPickMedia, + onCameraClick = { + onHideKeyboardAndClearFocus() + onStartRecordingVideo() + }, + onSendVoice = component::onSendVoice, + onCancelReply = component::onCancelReply, + onCancelEdit = component::onCancelEdit, + onSaveEdit = component::onSaveEditedMessage, + onDraftChange = component::onDraftChange, + onTyping = component::onTyping, + onCancelMedia = { onSetPendingMediaPaths(emptyList()) }, + onSendMedia = { paths, caption, captionEntities, options -> + if (options.sendAsDocument) { + if (paths.size > 1) { + component.onSendAlbum(paths, caption, captionEntities, options) + } else { + paths.firstOrNull()?.let { + component.onSendDocument(it, caption, captionEntities, options) + } + } + } else if (paths.size > 1) { + component.onSendAlbum(paths, caption, captionEntities, options) + } else { + paths.firstOrNull()?.let { + if (it.endsWith(".mp4")) { + component.onSendVideo(it, caption, captionEntities, options) + } else { + component.onSendPhoto(it, caption, captionEntities, options) + } + } + } + onSetPendingMediaPaths(emptyList()) + onSetPendingDocumentPaths(emptyList()) + }, + onSendDocuments = { paths, caption, captionEntities, options -> + paths.forEachIndexed { index, path -> + component.onSendDocument( + path, + caption = if (index == 0) caption else "", + captionEntities = if (index == 0) captionEntities else emptyList(), + sendOptions = options + ) + } + onSetPendingDocumentPaths(emptyList()) + onSetPendingMediaPaths(emptyList()) + }, + onMediaOrderChange = { + onSetPendingMediaPaths(it) + if (it.isNotEmpty()) { + onSetPendingDocumentPaths(emptyList()) + } + }, + onDocumentOrderChange = { + onSetPendingDocumentPaths(it) + if (it.isNotEmpty()) { + onSetPendingMediaPaths(emptyList()) + } + }, + onMediaClick = onEditMediaPath, + onShowBotCommands = { + onHideKeyboardAndClearFocus() + component.onShowBotCommands() + }, + onReplyMarkupButtonClick = { + component.onReplyMarkupButtonClick( + 0, + it, + if (state.isBot) state.chatId else 0L + ) + }, + onOpenMiniApp = { url, name -> + component.onOpenMiniApp( + url, + name, + if (state.isBot) state.chatId else 0L + ) + }, + onMentionQueryChange = component::onMentionQueryChange, + onInlineQueryChange = component::onInlineQueryChange, + onLoadMoreInlineResults = component::onLoadMoreInlineResults, + onSendInlineResult = component::onSendInlineResult, + onInlineSwitchPm = { botUsername, parameter -> + val encodedParameter = URLEncoder.encode( + parameter, + StandardCharsets.UTF_8.name() + ) + component.onLinkClick("https://t.me/$botUsername?start=$encodedParameter") + }, + onAttachBotClick = { bot -> + component.onOpenAttachBot(bot.botUserId, bot.name) + }, + onSendPoll = component::onSendPoll, + onRefreshScheduledMessages = component::onRefreshScheduledMessages, + onEditScheduledMessage = component::onEditMessage, + onDeleteScheduledMessage = component::onDeleteMessage, + onSendScheduledNow = component::onSendScheduledNow + ) + } +} + 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/conversation/ui/content/ChatContentList.kt similarity index 80% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt index b01026c8..f6f79859 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/conversation/ui/content/ChatContentList.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import android.os.SystemClock import androidx.compose.animation.AnimatedVisibility @@ -84,13 +84,18 @@ import org.monogram.domain.models.TopicModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer -import org.monogram.presentation.features.chats.currentChat.components.DateSeparator -import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer -import org.monogram.presentation.features.chats.currentChat.components.ServiceMessage -import org.monogram.presentation.features.chats.currentChat.components.UnreadMessagesSeparator -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelMessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ui.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.DateSeparator +import org.monogram.presentation.features.chats.conversation.ui.MessageAppearanceConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.MessageRowBehaviorConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageRowUiFlags +import org.monogram.presentation.features.chats.conversation.ui.MessageSenderGrouping +import org.monogram.presentation.features.chats.conversation.ui.ServiceMessage +import org.monogram.presentation.features.chats.conversation.ui.UnreadMessagesSeparator +import org.monogram.presentation.features.chats.conversation.ui.buildSenderGrouping +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelMessageBubbleContainer import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.io.File @@ -171,6 +176,7 @@ fun ChatContentList( bottomContentPadding: Dp = 8.dp ) { val isComments = state.isComments + val appearance = state.toAppearanceConfig() val isScrolling by remember(scrollState) { derivedStateOf { scrollState.isScrollInProgress } } val latestState by rememberUpdatedState(state) var lastOlderLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } @@ -207,17 +213,22 @@ fun ChatContentList( } } } + val unreadBoundaryGroupId = remember(unreadBoundaryIndex, groupedMessages) { + unreadBoundaryIndex + ?.takeIf { it in groupedMessages.indices } + ?.let { groupedMessages[it].firstMessageId } + } var hasUnreadSeparatorBeenVisible by rememberSaveable( state.chatId, state.currentTopicId, - unreadBoundaryIndex, + unreadBoundaryGroupId, state.unreadSeparatorLastReadInboxMessageId, state.unreadSeparatorCount ) { mutableStateOf(false) } var hasUnreadSeparatorDismissed by rememberSaveable( state.chatId, state.currentTopicId, - unreadBoundaryIndex, + unreadBoundaryGroupId, state.unreadSeparatorLastReadInboxMessageId, state.unreadSeparatorCount ) { mutableStateOf(false) } @@ -417,13 +428,22 @@ fun ChatContentList( MessageRowItem( item = item, - state = state, + appearance = appearance, component = component, olderMsg = olderMsg, newerMsg = newerMsg, - isSelected = isItemSelected(item, state.selectedMessageIds), - isSelectionMode = state.selectedMessageIds.isNotEmpty(), - selectedMessageId = selectedMessageId, + behavior = state.toBehaviorConfig( + isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isAnyViewerOpen = isAnyViewerOpen + ), + uiFlags = MessageRowUiFlags( + isSelected = isItemSelected(item, state.selectedMessageIds), + isHighlighted = isItemHighlighted(item, state.highlightedMessageId), + showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, + unreadCount = state.unreadSeparatorCount, + shouldReportPosition = item.lastMessageId == selectedMessageId + ), + rootMessageId = state.rootMessage?.id, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, onVideoClick = onVideoClick, @@ -435,13 +455,11 @@ fun ChatContentList( onViaBotClick = onViaBotClick, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, + isChatAnimationsEnabled = state.isChatAnimationsEnabled, isScrolling = isScrolling, isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId), onEntryAnimationConsumed = { pendingEntryAnimationIds.remove(it) }, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen, - showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, - unreadCount = state.unreadSeparatorCount + downloadUtils = downloadUtils ) } } else { @@ -476,13 +494,22 @@ fun ChatContentList( MessageRowItem( item = item, - state = state, + appearance = appearance, component = component, olderMsg = olderMsg, newerMsg = newerMsg, - isSelected = isItemSelected(item, state.selectedMessageIds), - isSelectionMode = state.selectedMessageIds.isNotEmpty(), - selectedMessageId = selectedMessageId, + behavior = state.toBehaviorConfig( + isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isAnyViewerOpen = isAnyViewerOpen + ), + uiFlags = MessageRowUiFlags( + isSelected = isItemSelected(item, state.selectedMessageIds), + isHighlighted = isItemHighlighted(item, state.highlightedMessageId), + showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, + unreadCount = state.unreadSeparatorCount, + shouldReportPosition = item.lastMessageId == selectedMessageId + ), + rootMessageId = state.rootMessage?.id, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, onVideoClick = onVideoClick, @@ -494,13 +521,11 @@ fun ChatContentList( onViaBotClick = onViaBotClick, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, + isChatAnimationsEnabled = state.isChatAnimationsEnabled, isScrolling = isScrolling, isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId), onEntryAnimationConsumed = { pendingEntryAnimationIds.remove(it) }, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen, - showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, - unreadCount = state.unreadSeparatorCount + downloadUtils = downloadUtils ) } } @@ -560,13 +585,13 @@ private fun PagingLoadingIndicator() { @Composable private fun MessageRowItem( item: GroupedMessageItem, - state: ChatMessageListUiState, + appearance: MessageAppearanceConfig, component: ChatComponent, olderMsg: MessageModel?, newerMsg: MessageModel?, - isSelected: Boolean, - isSelectionMode: Boolean, - selectedMessageId: Long?, + behavior: MessageRowBehaviorConfig, + uiFlags: MessageRowUiFlags, + rootMessageId: Long?, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, onPhotoDownload: (Int) -> Unit, onVideoClick: (MessageModel, String?, String?) -> Unit, @@ -578,20 +603,18 @@ private fun MessageRowItem( onViaBotClick: (String) -> Unit, toProfile: (Long) -> Unit, onForwardOriginClick: (ForwardInfo) -> Unit, + isChatAnimationsEnabled: Boolean, isScrolling: Boolean, isEntryAnimationPending: Boolean, onEntryAnimationConsumed: (Long) -> Unit, - downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false, - showUnreadSeparator: Boolean, - unreadCount: Int + downloadUtils: IDownloadUtils ) { val mainMsg = remember(item) { if (item is GroupedMessageItem.Single) item.message else (item as GroupedMessageItem.Album).messages.last() } val shouldAnimateEntry = - state.isChatAnimationsEnabled && isEntryAnimationPending && !isScrolling + isChatAnimationsEnabled && isEntryAnimationPending && !isScrolling val scale = remember(mainMsg.id) { Animatable( @@ -631,10 +654,13 @@ private fun MessageRowItem( } val backgroundColor by animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, + targetValue = if (uiFlags.isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, label = "bg" ) - val horizontalPadding by animateDpAsState(if (isSelectionMode) 16.dp else 8.dp, label = "padding") + val horizontalPadding by animateDpAsState( + if (behavior.isSelectionMode) 16.dp else 8.dp, + label = "padding" + ) Box( modifier = Modifier @@ -649,7 +675,7 @@ private fun MessageRowItem( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - enabled = isSelectionMode, + enabled = behavior.isSelectionMode, onClick = { component.onToggleMessageSelection(mainMsg.id) } ) ) { @@ -659,8 +685,15 @@ private fun MessageRowItem( .padding(horizontal = horizontalPadding, vertical = 1.dp), verticalAlignment = Alignment.Bottom ) { - AnimatedVisibility(visible = isSelectionMode, enter = expandHorizontally(), exit = shrinkHorizontally()) { - SelectionIndicator(isSelected = isSelected, modifier = Modifier.padding(end = 12.dp, bottom = 4.dp)) + AnimatedVisibility( + visible = behavior.isSelectionMode, + enter = expandHorizontally(), + exit = shrinkHorizontally() + ) { + SelectionIndicator( + isSelected = uiFlags.isSelected, + modifier = Modifier.padding(end = 12.dp, bottom = 4.dp) + ) } Column(modifier = Modifier.weight(1f)) { @@ -669,21 +702,22 @@ private fun MessageRowItem( Spacer(modifier = Modifier.height(16.dp)) } - AnimatedVisibility(visible = showUnreadSeparator && !isScrolling) { + AnimatedVisibility(visible = uiFlags.showUnreadSeparator) { Column { - UnreadMessagesSeparator(unreadCount = unreadCount) + UnreadMessagesSeparator(unreadCount = uiFlags.unreadCount) Spacer(modifier = Modifier.height(16.dp)) } } MessageBubbleSwitcher( item = item, - state = state, + appearance = appearance, component = component, olderMsg = olderMsg, newerMsg = newerMsg, - isSelectionMode = isSelectionMode, - selectedMessageId = selectedMessageId, + behavior = behavior, + uiFlags = uiFlags, + rootMessageId = rootMessageId, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, onVideoClick = onVideoClick, @@ -695,8 +729,7 @@ private fun MessageRowItem( onViaBotClick = onViaBotClick, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } } @@ -706,12 +739,13 @@ private fun MessageRowItem( @Composable private fun MessageBubbleSwitcher( item: GroupedMessageItem, - state: ChatMessageListUiState, + appearance: MessageAppearanceConfig, component: ChatComponent, olderMsg: MessageModel?, newerMsg: MessageModel?, - isSelectionMode: Boolean, - selectedMessageId: Long?, + behavior: MessageRowBehaviorConfig, + uiFlags: MessageRowUiFlags, + rootMessageId: Long?, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, onPhotoDownload: (Int) -> Unit, onVideoClick: (MessageModel, String?, String?) -> Unit, @@ -723,61 +757,66 @@ private fun MessageBubbleSwitcher( onViaBotClick: (String) -> Unit, toProfile: (Long) -> Unit, onForwardOriginClick: (ForwardInfo) -> Unit, - downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + downloadUtils: IDownloadUtils ) { - val isChannel = state.isChannelFeed - val isTopicClosed = state.isCurrentTopicClosed - val sanitizedItem = remember(item, state.rootMessage) { - item.withSuppressedRootReply(state.rootMessage?.id) + val sanitizedItem = remember(item, rootMessageId) { + item.withSuppressedRootReply(rootMessageId) } - val sanitizedOlderMsg = remember(olderMsg, state.rootMessage) { - olderMsg?.suppressRootReply(state.rootMessage?.id) + val sanitizedOlderMsg = remember(olderMsg, rootMessageId) { + olderMsg?.suppressRootReply(rootMessageId) } - val sanitizedNewerMsg = remember(newerMsg, state.rootMessage) { - newerMsg?.suppressRootReply(state.rootMessage?.id) + val sanitizedNewerMsg = remember(newerMsg, rootMessageId) { + newerMsg?.suppressRootReply(rootMessageId) + } + val senderGrouping = remember(sanitizedItem, sanitizedOlderMsg, sanitizedNewerMsg) { + buildSenderGrouping( + item = sanitizedItem, + olderMsg = sanitizedOlderMsg, + newerMsg = sanitizedNewerMsg + ) } when (sanitizedItem) { is GroupedMessageItem.Single -> { if (sanitizedItem.message.content is MessageContent.Service) { ServiceMessage(service = sanitizedItem.message.content as MessageContent.Service) - } else if (isChannel) { + } else if (behavior.isChannel) { ChannelMessageBubbleContainer( msg = sanitizedItem.message, - olderMsg = sanitizedOlderMsg, newerMsg = sanitizedNewerMsg, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, - highlighted = state.highlightedMessageId == sanitizedItem.message.id, + appearance = appearance, + behavior = behavior, + uiFlags = uiFlags, + senderGrouping = senderGrouping, onHighlightConsumed = { component.onHighlightConsumed() }, onPhotoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( it, onPhotoClick ) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( it, onVideoClick ) }, onDocumentClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( it ) }, onAudioClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( it ) }, onCancelDownload = { component.onCancelDownloadFile(it) }, onReplyClick = { pos, size, click -> - if (isSelectionMode) component.onToggleMessageSelection(sanitizedItem.message.id) else onMessageOptionsClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else onMessageOptionsClick( sanitizedItem.message, pos, size, @@ -786,7 +825,7 @@ private fun MessageBubbleSwitcher( }, onGoToReply = onGoToReply, onReactionClick = { id, r -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( id, r ) @@ -799,94 +838,81 @@ private fun MessageBubbleSwitcher( ) }, onStickerClick = { - if (isSelectionMode) component.onToggleMessageSelection(sanitizedItem.message.id) else component.onStickerClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else component.onStickerClick( it ) }, onPollOptionClick = { id, opt -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( id, opt ) }, onRetractVote = { - if (isSelectionMode) component.onToggleMessageSelection(it) else component.onRetractVote( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it) else component.onRetractVote( it ) }, onShowVoters = { id, opt -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onShowVoters( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onShowVoters( id, opt ) }, onClosePoll = { - if (isSelectionMode) component.onToggleMessageSelection(it) else component.onClosePoll( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it) else component.onClosePoll( it ) }, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, - shouldReportPosition = sanitizedItem.message.id == selectedMessageId, onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && state.canSendAnything, onReplySwipe = { component.onReplyMessage(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } else { MessageBubbleContainer( msg = sanitizedItem.message, - olderMsg = sanitizedOlderMsg, newerMsg = sanitizedNewerMsg, - 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 == sanitizedItem.message.id, + appearance = appearance, + behavior = behavior, + uiFlags = uiFlags, + senderGrouping = senderGrouping, onHighlightConsumed = { component.onHighlightConsumed() }, onPhotoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handlePhotoClick( it, onPhotoClick ) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( it, onVideoClick ) }, onDocumentClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( it ) }, onAudioClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( it ) }, onCancelDownload = { component.onCancelDownloadFile(it) }, onReplyClick = { pos, size, click -> - if (isSelectionMode) component.onToggleMessageSelection(sanitizedItem.message.id) else onMessageOptionsClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else onMessageOptionsClick( sanitizedItem.message, pos, size, @@ -895,7 +921,7 @@ private fun MessageBubbleSwitcher( }, onGoToReply = onGoToReply, onReactionClick = { id, r -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( id, r ) @@ -908,44 +934,42 @@ private fun MessageBubbleSwitcher( ) }, onStickerClick = { - if (isSelectionMode) component.onToggleMessageSelection(sanitizedItem.message.id) else component.onStickerClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else component.onStickerClick( it ) }, onPollOptionClick = { id, opt -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( id, opt ) }, onRetractVote = { - if (isSelectionMode) component.onToggleMessageSelection(it) else component.onRetractVote( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it) else component.onRetractVote( it ) }, onShowVoters = { id, opt -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onShowVoters( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onShowVoters( id, opt ) }, onClosePoll = { - if (isSelectionMode) component.onToggleMessageSelection(it) else component.onClosePoll( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it) else component.onClosePoll( it ) }, onInstantViewClick = { component.onOpenInstantView(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, - shouldReportPosition = sanitizedItem.message.id == selectedMessageId, onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin) && state.canSendAnything, onReplySwipe = { component.onReplyMessage(it) }, - swipeEnabled = !isSelectionMode, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } } @@ -953,17 +977,12 @@ private fun MessageBubbleSwitcher( is GroupedMessageItem.Album -> { AlbumMessageBubbleContainer( messages = sanitizedItem.messages, - olderMsg = sanitizedOlderMsg, - newerMsg = sanitizedNewerMsg, - isGroup = state.isGroup || state.currentTopicId != null, - isChannel = isChannel, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, + behavior = behavior, + appearance = appearance, + uiFlags = uiFlags, + senderGrouping = senderGrouping, onPhotoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handleAlbumPhotoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleAlbumPhotoClick( it, sanitizedItem.messages, onPhotoClick @@ -971,7 +990,7 @@ private fun MessageBubbleSwitcher( }, onDownloadPhoto = onPhotoDownload, onVideoClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else handleAlbumVideoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleAlbumVideoClick( it, sanitizedItem.messages, onPhotoClick, @@ -979,18 +998,18 @@ private fun MessageBubbleSwitcher( ) }, onDocumentClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( it ) }, onAudioClick = { - if (isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onAudioClick( it ) }, onCancelDownload = { component.onCancelDownloadFile(it) }, onReplyClick = { pos, size, click -> - if (isSelectionMode) component.onToggleMessageSelection(sanitizedItem.messages.last().id) else onMessageOptionsClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(sanitizedItem.messages.last().id) else onMessageOptionsClick( sanitizedItem.messages.last(), pos, size, @@ -999,22 +1018,18 @@ private fun MessageBubbleSwitcher( }, onGoToReply = onGoToReply, onReactionClick = { id, r -> - if (isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( + if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onSendReaction( id, r ) }, - shouldReportPosition = sanitizedItem.messages.last().id == selectedMessageId, onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin) && state.canSendAnything, onReplySwipe = { component.onReplyMessage(it) }, - swipeEnabled = !isSelectionMode, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } } @@ -1061,6 +1076,21 @@ private fun RootMessageSection( isAnyViewerOpen: Boolean = false ) { val root = state.rootMessage ?: return + val appearance = state.toAppearanceConfig() + val behavior = MessageRowBehaviorConfig( + isGroup = state.isGroup, + isChannel = state.isChannel, + isTopicClosed = state.isCurrentTopicClosed, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = isAnyViewerOpen + ) + val rootUiFlags = MessageRowUiFlags() + val senderGrouping = MessageSenderGrouping( + isSameSenderAbove = false, + isSameSenderBelow = false + ) Column( modifier = Modifier .fillMaxWidth() @@ -1068,9 +1098,12 @@ private fun RootMessageSection( ) { if (state.isChannel) { ChannelMessageBubbleContainer( - msg = root, olderMsg = null, newerMsg = null, - autoplayGifs = state.autoplayGifs, autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, + msg = root, + newerMsg = null, + appearance = appearance, + behavior = behavior, + uiFlags = rootUiFlags, + senderGrouping = senderGrouping, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { handleVideoClick(it, onVideoClick) }, @@ -1086,29 +1119,22 @@ 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, onCommentsClick = {}, showComments = false, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, onViaBotClick = onViaBotClick, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } 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, + newerMsg = null, + appearance = appearance, + behavior = behavior, + uiFlags = rootUiFlags, + senderGrouping = senderGrouping, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, onVideoClick = { handleVideoClick(it, onVideoClick) }, @@ -1126,12 +1152,10 @@ private fun RootMessageSection( onClosePoll = { component.onClosePoll(it) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, - swipeEnabled = false, onViaBotClick = onViaBotClick, onInstantViewClick = { component.onOpenInstantView(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + downloadUtils = downloadUtils ) } @@ -1158,6 +1182,49 @@ private fun isItemSelected(item: GroupedMessageItem, selectedIds: Set): Bo } } +private val GroupedMessageItem.lastMessageId: Long + get() = when (this) { + is GroupedMessageItem.Single -> message.id + is GroupedMessageItem.Album -> messages.last().id + } + +private fun isItemHighlighted(item: GroupedMessageItem, highlightedMessageId: Long?): Boolean { + if (highlightedMessageId == null) return false + return when (item) { + is GroupedMessageItem.Single -> item.message.id == highlightedMessageId + is GroupedMessageItem.Album -> item.messages.any { it.id == highlightedMessageId } + } +} + +private fun ChatMessageListUiState.toAppearanceConfig(): MessageAppearanceConfig = + MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + showLinkPreviews = showLinkPreviews, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles + ) + +private fun ChatMessageListUiState.toBehaviorConfig( + isSelectionMode: Boolean, + isAnyViewerOpen: Boolean +): MessageRowBehaviorConfig = + MessageRowBehaviorConfig( + isGroup = isGroup || currentTopicId != null, + isChannel = isChannelFeed, + isTopicClosed = isCurrentTopicClosed, + canReply = canWrite && !isSelectionMode && (!isCurrentTopicClosed || isAdmin) && canSendAnything, + swipeEnabled = !isSelectionMode, + isSelectionMode = isSelectionMode, + isAnyViewerOpen = isAnyViewerOpen + ) + private fun GroupedMessageItem.withSuppressedRootReply(rootMessageId: Long?): GroupedMessageItem { return when (this) { is GroupedMessageItem.Single -> copy(message = message.suppressRootReply(rootMessageId)) @@ -1489,3 +1556,4 @@ fun TopicItem( } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentMessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentMessageUtils.kt new file mode 100644 index 00000000..e6deb6f6 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentMessageUtils.kt @@ -0,0 +1,36 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel + +internal fun MessageModel.extractTextContent(): String? { + return when (val currentContent = content) { + is MessageContent.Text -> currentContent.text + is MessageContent.Photo -> currentContent.caption + is MessageContent.Video -> currentContent.caption + is MessageContent.Gif -> currentContent.caption + is MessageContent.Document -> currentContent.caption + is MessageContent.Audio -> currentContent.caption + else -> null + } +} + +internal fun MessageModel.withUpdatedTextContent(newText: String): MessageModel { + val updatedContent = when (val currentContent = content) { + is MessageContent.Text -> currentContent.copy( + text = newText, + entities = emptyList(), + webPage = null + ) + + is MessageContent.Photo -> currentContent.copy(caption = newText, entities = emptyList()) + is MessageContent.Video -> currentContent.copy(caption = newText, entities = emptyList()) + is MessageContent.Gif -> currentContent.copy(caption = newText, entities = emptyList()) + is MessageContent.Document -> currentContent.copy(caption = newText, entities = emptyList()) + is MessageContent.Audio -> currentContent.copy(caption = newText, entities = emptyList()) + else -> return this + } + + return copy(content = updatedContent) +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentOverlays.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentOverlays.kt new file mode 100644 index 00000000..c41b6591 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentOverlays.kt @@ -0,0 +1,218 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.zIndex +import androidx.window.core.layout.WindowWidthSizeClass +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.editor.photo.PhotoEditorScreen +import org.monogram.presentation.features.chats.conversation.editor.video.VideoEditorScreen +import org.monogram.presentation.features.chats.conversation.ui.StickerSetSheet +import org.monogram.presentation.features.chats.conversation.ui.message.BotCommandsSheet +import org.monogram.presentation.features.chats.conversation.ui.message.PollVotersSheet +import org.monogram.presentation.features.chats.conversation.ui.pins.PinnedMessagesListSheet + +@Composable +internal fun ChatContentOverlays( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard, + groupedMessages: List, + isAnyViewerOpen: Boolean, + renderPinnedMessagesList: Boolean, + requestPinnedMessagesListDismiss: () -> Unit, + onPinnedSheetHidden: () -> Unit, + onPinnedMessageClick: (MessageModel) -> Unit, + selectedMessage: MessageModel?, + menuOffset: Offset, + menuMessageSize: IntSize, + clickOffset: Offset, + contentRect: Rect, + canRestoreOriginalText: Boolean, + onApplyTransformedText: (String) -> Unit, + onRestoreOriginalText: () -> Unit, + onDismissMessageOptions: () -> Unit, + pendingBlockUserId: Long?, + onRequestBlockUser: (Long) -> Unit, + onConfirmBlockUser: (Long) -> Unit, + onDismissBlockUser: () -> Unit, + editingPhotoPath: String?, + onClosePhotoEditor: () -> Unit, + onSavePhotoEditor: (String) -> Unit, + editingVideoPath: String?, + onCloseVideoEditor: () -> Unit, + onSaveVideoEditor: (String) -> Unit, + isCustomBackHandlingEnabled: Boolean, + onBack: () -> Unit +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && + isTabletInterfaceEnabled + 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, + onDismissRequest = requestPinnedMessagesListDismiss, + onHidden = onPinnedSheetHidden, + onMessageClick = onPinnedMessageClick, + onUnpin = component::onUnpinMessage, + onReplyClick = onPinnedMessageClick, + onReactionClick = { id, reaction -> + component.onSendReaction(id, reaction) + }, + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } + + state.selectedStickerSet?.let { stickerSet -> + StickerSetSheet( + stickerSet = stickerSet, + onDismiss = component::onDismissStickerSet, + onStickerClick = { _, path -> component.onSendSticker(path) } + ) + } + + if (state.showPollVoters) { + PollVotersSheet( + voters = state.pollVoters, + isLoading = state.isPollVotersLoading, + onUserClick = { + component.onDismissVoters() + component.toProfile(it) + }, + onDismiss = component::onDismissVoters + ) + } + + if (state.showBotCommands) { + BotCommandsSheet( + commands = state.botCommands, + onCommandClick = component::onBotCommandClick, + onDismiss = component::onDismissBotCommands + ) + } + + if (!isTablet) { + 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) + } + + selectedMessage?.let { msg -> + ChatMessageOptionsMenu( + state = state, + component = component, + selectedMessage = msg, + menuOffset = menuOffset, + menuMessageSize = menuMessageSize, + clickOffset = clickOffset, + contentRect = contentRect, + groupedMessages = groupedMessages, + downloadUtils = component.downloadUtils, + localClipboard = localClipboard, + canRestoreOriginalText = canRestoreOriginalText, + onApplyTransformedText = onApplyTransformedText, + onRestoreOriginalText = onRestoreOriginalText, + onBlockRequest = onRequestBlockUser, + onDismiss = onDismissMessageOptions + ) + } + + pendingBlockUserId?.let { userId -> + ConfirmationSheet( + icon = Icons.Rounded.Block, + title = stringResource(R.string.block_user_title), + description = stringResource(R.string.block_user_confirmation), + confirmText = stringResource(R.string.action_block), + onConfirm = { onConfirmBlockUser(userId) }, + onDismiss = onDismissBlockUser + ) + } + + if (state.showReportDialog) { + ReportChatDialog( + onDismiss = component::onDismissReportDialog, + onReasonSelected = component::onReportReasonSelected + ) + } + + if (state.restrictUserId != null) { + RestrictUserSheet( + onDismiss = component::onDismissRestrictDialog, + onConfirm = { permissions: ChatPermissionsModel, untilDate: Int -> + component.onConfirmRestrict(permissions, untilDate) + } + ) + } + + editingPhotoPath?.let { path -> + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(20f) + ) { + PhotoEditorScreen( + imagePath = path, + onClose = onClosePhotoEditor, + onSave = onSavePhotoEditor + ) + } + } + + editingVideoPath?.let { path -> + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(20f) + ) { + VideoEditorScreen( + videoPath = path, + onClose = onCloseVideoEditor, + onSave = onSaveVideoEditor + ) + } + } + + BackHandler(enabled = isCustomBackHandlingEnabled) { + onBack() + } +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt new file mode 100644 index 00000000..41c38a18 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt @@ -0,0 +1,243 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.presentation.features.chats.conversation.ScrollAlign +import kotlin.math.abs + +@Immutable +internal data class BottomVisibilitySnapshot( + val isAtBottom: Boolean, + val isNearBottom: Boolean, + val unreadCount: Int +) + +internal suspend fun LazyListState.scrollToMessageIndex( + index: Int, + align: ScrollAlign, + animated: Boolean, + staged: Boolean +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + val boundedIndex = index.coerceIn(0, total - 1) + val distance = abs(firstVisibleItemIndex - boundedIndex) + + if (staged && distance > 20) { + val coarseIndex = when { + boundedIndex > firstVisibleItemIndex -> (boundedIndex - 10).coerceAtLeast(0) + boundedIndex < firstVisibleItemIndex -> (boundedIndex + 10).coerceAtMost(total - 1) + else -> boundedIndex + } + scrollToItem(coarseIndex) + } + + scrollToItem(boundedIndex) + + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + val viewportCenter = (viewportStart + viewportEnd) / 2 + + val targetPosition = when (align) { + ScrollAlign.Start -> viewportStart + ScrollAlign.Center -> viewportCenter - (itemInfo.size / 2) + ScrollAlign.End -> viewportEnd - itemInfo.size + } + val delta = (itemInfo.offset - targetPosition).toFloat() + + if (abs(delta) > 1f) { + if (animated) { + animateScrollBy(delta) + } else { + scrollBy(delta) + } + } +} + +internal fun LazyListState.isAtBottom( + isComments: Boolean, + isLatestLoaded: Boolean +): Boolean { + if (!isLatestLoaded) return false + + val info = layoutInfo + val visible = info.visibleItemsInfo + if (visible.isEmpty()) return true + + return if (isComments) { + val lastVisible = visible.last() + lastVisible.index >= info.totalItemsCount - 1 && + abs((info.viewportEndOffset - (lastVisible.offset + lastVisible.size)).toFloat()) <= 40f + } else { + val firstVisible = visible.first() + firstVisible.index == 0 && + abs((firstVisible.offset - info.viewportStartOffset).toFloat()) <= 40f + } +} + +internal fun LazyListState.isNearBottom(isComments: Boolean): Boolean { + val info = layoutInfo + val visible = info.visibleItemsInfo + if (visible.isEmpty()) return true + + return if (isComments) { + val lastVisible = visible.last() + val distance = + abs((info.viewportEndOffset - (lastVisible.offset + lastVisible.size)).toFloat()) + lastVisible.index >= info.totalItemsCount - 2 && distance <= 240f + } else { + val firstVisible = visible.first() + val distance = abs((firstVisible.offset - info.viewportStartOffset).toFloat()) + firstVisible.index <= 1 && distance <= 240f + } +} + +internal suspend fun LazyListState.scrollToChatBottomStaged( + isComments: Boolean, + animated: Boolean +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + val targetIndex = if (isComments) total - 1 else 0 + val distance = abs(firstVisibleItemIndex - targetIndex) + + if (distance > 24) { + val coarse = if (isComments) { + (targetIndex - 8).coerceAtLeast(0) + } else { + (targetIndex + 8).coerceAtMost(total - 1) + } + scrollToItem(coarse) + } + + if (animated) { + animateScrollToItem(targetIndex) + } else { + scrollToItem(targetIndex) + } + + val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } + if (targetInfo != null) { + val delta = if (isComments) { + ((targetInfo.offset + targetInfo.size) - layoutInfo.viewportEndOffset).toFloat() + } else { + (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() + } + if (abs(delta) > 1f) { + scrollBy(delta) + } + } + + scrollToItem(targetIndex) +} + +internal suspend fun LazyListState.scrollToChatStartStaged( + animated: Boolean +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + if (animated) { + animateScrollToItem(0) + } else { + scrollToItem(0) + } + + val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 } + if (targetInfo != null) { + val delta = (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() + if (abs(delta) > 1f) { + scrollBy(delta) + } + } + + scrollToItem(0) +} + +internal suspend fun awaitGroupedIndex( + messageId: Long, + groupedMessageIndexByIdProvider: () -> Map, + timeoutMs: Long = 1200L +): Int? { + return withTimeoutOrNull(timeoutMs) { + snapshotFlow { groupedMessageIndexByIdProvider()[messageId] } + .filterNotNull() + .first() + } +} + +internal suspend fun LazyListState.restoreViewportAtIndex( + targetIndex: Int, + anchorOffsetPx: Int +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + val boundedIndex = targetIndex.coerceIn(0, total - 1) + + scrollToItem(boundedIndex) + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val desiredOffset = viewportStart + anchorOffsetPx + val delta = (itemInfo.offset - desiredOffset).toFloat() + + if (abs(delta) > 1f) { + scrollBy(delta) + } +} + +internal fun buildViewportSnapshot( + scrollState: LazyListState, + groupedMessages: List, + isComments: Boolean, + isLatestLoaded: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isAtBottom: Boolean, + showNavPadding: Boolean +): ChatViewportCacheEntry? { + if (groupedMessages.isEmpty()) { + return ChatViewportCacheEntry(atBottom = true) + } + + val atBottomNow = scrollState.isAtBottom( + isComments = isComments, + isLatestLoaded = isLatestLoaded + ) + if (atBottomNow) { + return ChatViewportCacheEntry(atBottom = true) + } + + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = showNavPadding, + isLoadingOlder = isLoadingOlder, + isLoadingNewer = isLoadingNewer, + isAtBottom = isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val info = scrollState.layoutInfo + val anchorItem = info.visibleItemsInfo.firstOrNull { itemInfo -> + val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItems) + groupedIndex in groupedMessages.indices + } ?: return null + + val groupedIndex = lazyIndexToGroupedIndex(anchorItem.index, leadingItems) + val anchorMessageId = groupedMessages.getOrNull(groupedIndex)?.firstMessageId ?: return null + + return ChatViewportCacheEntry( + anchorMessageId = anchorMessageId, + anchorOffsetPx = anchorItem.offset - info.viewportStartOffset, + atBottom = false + ) +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt new file mode 100644 index 00000000..e0ada145 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentSearchOverlay.kt @@ -0,0 +1,884 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import android.app.DatePickerDialog +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +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.automirrored.filled.List +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.UserModel +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.AvatarForChat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +internal fun ChatContentSearchOverlay( + context: Context, + query: String, + results: List, + totalCount: Int, + selectedIndex: Int, + isSearching: Boolean, + canLoadMore: Boolean, + showAllResults: Boolean, + showSearchFilters: Boolean, + showSearchSenderPicker: Boolean, + hasFiltersApplied: Boolean, + selectedSender: UserModel?, + searchSenderCandidates: List, + fromEpochSeconds: Int?, + toEpochSeconds: Int?, + onLoadMore: () -> Unit, + onResultClick: (Int) -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleShowAll: () -> Unit, + onToggleFilters: () -> Unit, + onToggleSenderPicker: () -> Unit, + onSelectSender: (UserModel?) -> Unit, + onApplyDateRange: (Int?, Int?) -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(280), + initialOffsetY = { it / 3 } + ) + + scaleIn( + animationSpec = tween(220), + initialScale = 0.96f + ), + exit = fadeOut(animationSpec = tween(160)) + + slideOutVertically( + animationSpec = tween(180), + targetOffsetY = { it / 4 } + ), + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + AnimatedVisibility( + visible = showAllResults && results.isNotEmpty(), + enter = fadeIn(animationSpec = tween(180)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { it / 8 } + ), + exit = fadeOut(animationSpec = tween(140)) + + slideOutVertically( + animationSpec = tween(180), + targetOffsetY = { it / 10 } + ) + ) { + SearchResultsListOverlay( + query = query, + results = results, + selectedIndex = selectedIndex, + isSearching = isSearching, + canLoadMore = canLoadMore, + onLoadMore = onLoadMore, + onResultClick = onResultClick + ) + } + + AnimatedVisibility( + visible = showSearchFilters && showSearchSenderPicker, + enter = fadeIn(animationSpec = tween(180)) + + slideInVertically( + animationSpec = tween(220), + initialOffsetY = { it / 6 } + ) + + scaleIn( + animationSpec = tween(200), + initialScale = 0.98f + ), + exit = fadeOut(animationSpec = tween(140)) + + slideOutVertically( + animationSpec = tween(160), + targetOffsetY = { it / 8 } + ) + ) { + SearchSenderPickerOverlay( + selectedSenderId = selectedSender?.id, + senders = searchSenderCandidates, + onSelectSender = onSelectSender + ) + } + + AnimatedVisibility( + visible = showSearchFilters, + enter = fadeIn(animationSpec = tween(180)) + + slideInVertically( + animationSpec = tween(220), + initialOffsetY = { it / 6 } + ) + + scaleIn( + animationSpec = tween(200), + initialScale = 0.98f + ), + exit = fadeOut(animationSpec = tween(140)) + + slideOutVertically( + animationSpec = tween(160), + targetOffsetY = { it / 8 } + ) + ) { + SearchFilterTray( + selectedSender = selectedSender, + fromEpochSeconds = fromEpochSeconds, + toEpochSeconds = toEpochSeconds, + onToggleSenderPicker = onToggleSenderPicker, + onApplyToday = { + val now = LocalDate.now() + onApplyDateRange( + toStartOfDayEpochSeconds(now), + toEndOfDayEpochSeconds(now) + ) + }, + onApplyLastDays = { days -> + val now = LocalDate.now() + val from = now.minusDays((days - 1).toLong()) + onApplyDateRange( + toStartOfDayEpochSeconds(from), + toEndOfDayEpochSeconds(now) + ) + }, + onResetDateRange = { onApplyDateRange(null, null) }, + onPickFromDate = { + showSearchDatePicker( + context = context, + initialEpochSeconds = fromEpochSeconds, + onDateSelected = { date -> + val nextFrom = toStartOfDayEpochSeconds(date) + val nextTo = toEpochSeconds + ?.let(::epochSecondsToLocalDate) + ?.let { currentTo -> + if (currentTo.isBefore(date)) { + toEndOfDayEpochSeconds(date) + } else { + toEndOfDayEpochSeconds(currentTo) + } + } + onApplyDateRange(nextFrom, nextTo) + } + ) + }, + onPickToDate = { + showSearchDatePicker( + context = context, + initialEpochSeconds = toEpochSeconds, + onDateSelected = { date -> + val nextTo = toEndOfDayEpochSeconds(date) + val nextFrom = fromEpochSeconds + ?.let(::epochSecondsToLocalDate) + ?.let { currentFrom -> + if (currentFrom.isAfter(date)) { + toStartOfDayEpochSeconds(date) + } else { + toStartOfDayEpochSeconds(currentFrom) + } + } + onApplyDateRange(nextFrom, nextTo) + } + ) + } + ) + } + + SearchNavigationPanel( + query = query, + results = results, + totalCount = totalCount, + selectedIndex = selectedIndex, + isSearching = isSearching, + showAllResults = showAllResults, + filtersExpanded = showSearchFilters, + hasFiltersApplied = hasFiltersApplied, + onPrevious = onPrevious, + onNext = onNext, + onToggleShowAll = onToggleShowAll, + onToggleFilters = onToggleFilters + ) + } + } +} + +@Composable +private fun SearchNavigationPanel( + query: String, + results: List, + totalCount: Int, + selectedIndex: Int, + isSearching: Boolean, + showAllResults: Boolean, + filtersExpanded: Boolean, + hasFiltersApplied: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleShowAll: () -> Unit, + onToggleFilters: () -> Unit +) { + val hasResults = results.isNotEmpty() + val selectedPosition = (selectedIndex + 1).takeIf { selectedIndex in results.indices } ?: 0 + val listIconRotation = animateFloatAsState( + targetValue = if (showAllResults) 90f else 0f, + animationSpec = tween(220), + label = "SearchListRotation" + ) + val statusText = when { + isSearching -> stringResource(R.string.search_results_loading) + query.isBlank() -> stringResource(R.string.no_results_found) + else -> stringResource(R.string.no_search_results_format, query) + } + val counterText = stringResource( + R.string.search_results_position_format, + selectedPosition, + totalCount.coerceAtLeast(results.size) + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + tonalElevation = 10.dp, + shadowElevation = 14.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (!hasResults) { + AnimatedContent( + targetState = statusText, + transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) }, + label = "SearchStatusText" + ) { text -> + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + onClick = onToggleFilters, + shape = CircleShape, + color = if (hasFiltersApplied || filtersExpanded) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.92f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f) + }, + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = if (hasFiltersApplied || filtersExpanded) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + + Surface( + onClick = onToggleShowAll, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f), + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier.padding(9.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + tint = if (showAllResults) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.graphicsLayer { + rotationZ = listIconRotation.value + } + ) + } + } + + Surface( + onClick = onPrevious, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f), + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier.padding(9.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.92f), + tonalElevation = 2.dp + ) { + AnimatedContent( + targetState = counterText, + transitionSpec = { + (fadeIn(tween(180)) + slideInVertically { it / 3 }) togetherWith + (fadeOut(tween(120)) + slideOutVertically { -it / 3 }) + }, + label = "SearchCounter" + ) { animatedCounter -> + Text( + text = animatedCounter, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + maxLines = 1 + ) + } + } + + Surface( + onClick = onNext, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f), + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier.padding(9.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } +} + +@Composable +private fun SearchFilterTray( + selectedSender: UserModel?, + fromEpochSeconds: Int?, + toEpochSeconds: Int?, + onToggleSenderPicker: () -> Unit, + onApplyToday: () -> Unit, + onApplyLastDays: (Int) -> Unit, + onResetDateRange: () -> Unit, + onPickFromDate: () -> Unit, + onPickToDate: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + tonalElevation = 10.dp, + shadowElevation = 14.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SearchSenderChip( + selectedSender = selectedSender, + onClick = onToggleSenderPicker + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + SearchMiniChip( + label = stringResource(R.string.search_date_all), + isActive = fromEpochSeconds == null && toEpochSeconds == null, + modifier = Modifier.weight(1f), + onClick = onResetDateRange + ) + SearchMiniChip( + label = stringResource(R.string.preview_date_today), + isActive = isTodayRange(fromEpochSeconds, toEpochSeconds), + modifier = Modifier.weight(1f), + onClick = onApplyToday + ) + SearchMiniChip( + label = stringResource(R.string.search_date_last_7_days), + isActive = matchesLastDaysRange(fromEpochSeconds, toEpochSeconds, 7), + modifier = Modifier.weight(1f), + onClick = { onApplyLastDays(7) } + ) + SearchMiniChip( + label = stringResource(R.string.search_date_last_30_days), + isActive = matchesLastDaysRange(fromEpochSeconds, toEpochSeconds, 30), + modifier = Modifier.weight(1f), + onClick = { onApplyLastDays(30) } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SearchRangeChip( + modifier = Modifier.weight(1f), + label = stringResource(R.string.search_date_from), + value = fromEpochSeconds?.let(::formatSearchDate), + onClick = onPickFromDate + ) + SearchRangeChip( + modifier = Modifier.weight(1f), + label = stringResource(R.string.search_date_to), + value = toEpochSeconds?.let(::formatSearchDate), + onClick = onPickToDate + ) + } + } + } +} + +@Composable +private fun SearchSenderPickerOverlay( + selectedSenderId: Long?, + senders: List, + onSelectSender: (UserModel?) -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + tonalElevation = 10.dp, + shadowElevation = 12.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.985f) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item("all_senders") { + SearchSenderRow( + title = stringResource(R.string.search_sender_all), + subtitle = stringResource(R.string.search_section_messages), + avatarPath = null, + isSelected = selectedSenderId == null, + onClick = { onSelectSender(null) } + ) + } + + itemsIndexed(senders, key = { _, user -> user.id }) { _, user -> + SearchSenderRow( + title = formatSearchSenderLabel(user), + subtitle = user.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, + avatarPath = user.avatarPath, + isSelected = selectedSenderId == user.id, + onClick = { onSelectSender(user) } + ) + } + } + } +} + +@Composable +private fun SearchSenderRow( + title: String, + subtitle: String?, + avatarPath: String?, + isSelected: Boolean, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp) + .clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.28f) + }, + tonalElevation = if (isSelected) 2.dp else 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarForChat( + path = avatarPath, + name = title, + size = 32.dp + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun formatSearchSenderLabel(user: UserModel): String { + return listOfNotNull( + user.firstName.takeIf { it.isNotBlank() }, + user.lastName?.takeIf { it.isNotBlank() } + ).joinToString(" ").ifBlank { + user.username?.takeIf { it.isNotBlank() } ?: user.id.toString() + } +} + +@Composable +private fun SearchSenderChip( + selectedSender: UserModel?, + onClick: () -> Unit +) { + val label = selectedSender?.let(::formatSearchSenderLabel) + ?: stringResource(R.string.search_sender_all) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.34f), + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarForChat( + path = selectedSender?.avatarPath, + name = label, + size = 30.dp + ) + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SearchMiniChip( + label: String, + isActive: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.28f) + }, + tonalElevation = if (isActive) 2.dp else 0.dp + ) { + Box( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SearchRangeChip( + label: String, + value: String?, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.28f), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + Text( + text = value ?: stringResource(R.string.cd_select_date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun isTodayRange(fromEpochSeconds: Int?, toEpochSeconds: Int?): Boolean { + val today = LocalDate.now() + return fromEpochSeconds?.let(::epochSecondsToLocalDate) == today && + toEpochSeconds?.let(::epochSecondsToLocalDate) == today +} + +private fun matchesLastDaysRange(fromEpochSeconds: Int?, toEpochSeconds: Int?, days: Int): Boolean { + val today = LocalDate.now() + return fromEpochSeconds?.let(::epochSecondsToLocalDate) == today.minusDays((days - 1).toLong()) && + toEpochSeconds?.let(::epochSecondsToLocalDate) == today +} + +private fun showSearchDatePicker( + context: Context, + initialEpochSeconds: Int?, + onDateSelected: (LocalDate) -> Unit +) { + val initialDate = initialEpochSeconds?.let(::epochSecondsToLocalDate) ?: LocalDate.now() + DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + onDateSelected(LocalDate.of(year, month + 1, dayOfMonth)) + }, + initialDate.year, + initialDate.monthValue - 1, + initialDate.dayOfMonth + ).show() +} + +private fun epochSecondsToLocalDate(epochSeconds: Int): LocalDate { + return Instant.ofEpochSecond(epochSeconds.toLong()) + .atZone(ZoneId.systemDefault()) + .toLocalDate() +} + +private fun toStartOfDayEpochSeconds(date: LocalDate): Int { + return date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond().toInt() +} + +private fun toEndOfDayEpochSeconds(date: LocalDate): Int { + return date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toEpochSecond().toInt() - 1 +} + +private fun formatSearchDate(epochSeconds: Int): String { + return epochSecondsToLocalDate(epochSeconds).format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) +} + +@Composable +private fun SearchResultsListOverlay( + query: String, + results: List, + selectedIndex: Int, + isSearching: Boolean, + canLoadMore: Boolean, + onLoadMore: () -> Unit, + onResultClick: (Int) -> Unit +) { + val listState = rememberLazyListState() + + LaunchedEffect(listState, results.size, canLoadMore, isSearching) { + if (!canLoadMore || isSearching) return@LaunchedEffect + + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { lastVisibleIndex -> + if (lastVisibleIndex >= results.lastIndex - 4) { + onLoadMore() + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + tonalElevation = 10.dp, + shadowElevation = 12.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.985f) + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 340.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + itemsIndexed(results, key = { _, message -> message.id }) { index, message -> + val preview = message.extractTextContent() + ?.replace('\n', ' ') + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: message.senderName.ifBlank { query } + val sender = message.senderName.ifBlank { + stringResource(R.string.search_section_messages) + } + val isSelected = index == selectedIndex + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onResultClick(index) }, + shape = RoundedCornerShape(18.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.32f) + }, + tonalElevation = if (isSelected) 2.dp else 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = sender, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = preview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2 + ) + } + } + } + } + + if (canLoadMore || isSearching) { + TextButton( + onClick = onLoadMore, + enabled = !isSearching, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text( + text = if (isSearching) { + stringResource(R.string.search_results_loading) + } else { + stringResource(R.string.action_show_more) + } + ) + } + } + } + } +} + 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/conversation/ui/content/ChatContentTopBar.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentTopBar.kt index c0036798..6d92617d 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/conversation/ui/content/ChatContentTopBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -64,9 +64,9 @@ 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.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.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ui.ChatTopBar +import org.monogram.presentation.features.chats.conversation.ui.pins.PinnedMessageBar import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown @@ -393,4 +393,4 @@ fun ChatContentTopBar( } } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt index 306e2638..65bfccd2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.runtime.Immutable import org.monogram.domain.models.MessageModel @@ -91,3 +91,4 @@ fun shouldShowDate(current: MessageModel, older: MessageModel?): Boolean { if (older == null) return true return !fmt.format(Date(msgTimestamp)).equals(fmt.format(Date(older.date.toLong() * 1000))) } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt new file mode 100644 index 00000000..589344a6 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt @@ -0,0 +1,457 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import android.content.ClipData +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.text.AnnotatedString +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.instantview.InstantViewer +import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.VideoViewer +import org.monogram.presentation.features.viewers.YouTubeViewer +import org.monogram.presentation.features.webapp.MiniAppViewer +import org.monogram.presentation.features.webapp.components.InvoiceDialog +import org.monogram.presentation.features.webapp.components.MiniAppTOSBottomSheet +import org.monogram.presentation.features.webview.InternalWebView + +@Composable +fun ChatContentViewers( + state: ChatComponent.State, + 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) +} + +@Composable +internal fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { + AnimatedVisibility( + visible = state.instantViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.instantViewUrl?.let { url -> + InstantViewer( + url = url, + messageRepository = component.repositoryMessage, + fileRepository = component.repositoryMessage, + onDismiss = { component.onDismissInstantView() }, + onOpenWebView = { component.onOpenWebView(it) } + ) + } + } +} + +@Composable +internal fun YouTubeOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { + AnimatedVisibility( + visible = state.youtubeUrl != null, + enter = fadeIn(), + exit = fadeOut() + ) { + state.youtubeUrl?.let { url -> + YouTubeViewer( + videoUrl = url, + onDismiss = { component.onDismissYouTube() }, + onForward = { + component.onForwardMessage(state.messages.find { + (it.content as? MessageContent.Text)?.text?.contains( + url + ) == true + } ?: return@YouTubeViewer) + }, + onCopyLink = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + onCopyText = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + isPipEnabled = !state.isInstalledFromGooglePlay + ) + } + } +} + +@Composable +internal fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) { + AnimatedVisibility( + visible = state.miniAppUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + if (state.miniAppUrl != null && state.miniAppName != null) { + MiniAppViewer( + chatId = state.chatId, + botUserId = state.miniAppBotUserId, + baseUrl = state.miniAppUrl, + botName = state.chatTitle, + botAvatarPath = state.chatAvatar, + webAppRepository = component.repositoryMessage, + onDismiss = { component.onDismissMiniApp() } + ) + } + } +} + +@Composable +internal fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) { + AnimatedVisibility( + visible = state.webViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.webViewUrl?.let { url -> + InternalWebView( + url = url, + onDismiss = { component.onDismissWebView() } + ) + } + } +} + +@Composable +internal fun ImagesOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { + state.fullScreenImages?.let { images -> + val autoDownload = + remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { + when { + component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi + component.downloadUtils.isRoaming() -> state.autoDownloadRoaming + else -> state.autoDownloadMobile + } + } + + val viewerItems = remember(images, state.fullScreenImageMessageIds, state.messages) { + val messageMap = state.messages.associateBy { it.id } + val items = if (state.fullScreenImageMessageIds.size == images.size) { + state.fullScreenImageMessageIds.mapIndexed { index, messageId -> + val message = messageMap[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) } + ViewerMediaItem( + messageId = message?.id ?: 0L, + path = message?.displayMediaPathForViewer() ?: path + ) + } + } + items.sortedBy { it.messageId } + } + + val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } + val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } + val startMessageId = state.fullScreenImageMessageIds.getOrNull(state.fullScreenStartIndex) + + val startIndex = remember(viewerItems, startMessageId) { + val index = viewerItems.indexOfFirst { it.messageId == startMessageId } + index + .takeIf { it != -1 } + ?.coerceIn(0, viewerItems.lastIndex.coerceAtLeast(0)) + ?: 0 + } + + var currentImageIndex by remember(viewerItems, startIndex) { + mutableIntStateOf( + startIndex.coerceIn(0, viewerItems.lastIndex.coerceAtLeast(0)) + ) + } + + val currentViewerMessage = remember(currentImageIndex, imageMessageIds, state.messages) { + imageMessageIds.getOrNull(currentImageIndex) + ?.takeIf { it != 0L } + ?.let { id -> state.messages.firstOrNull { it.id == id } } + } + + val imageDownloadingStates = remember(imageMessageIds, state.messages) { + imageMessageIds.map { id -> + val content = state.messages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.isDownloading + else -> false + } + } + } + + val imageDownloadProgressStates = remember(imageMessageIds, state.messages) { + imageMessageIds.map { id -> + val content = state.messages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.downloadProgress + else -> 0f + } + } + } + + if (viewerImages.isNotEmpty()) { + ImageViewer( + images = viewerImages, + startIndex = startIndex.coerceIn(0, viewerImages.lastIndex.coerceAtLeast(0)), + onDismiss = component::onDismissImages, + autoDownload = autoDownload, + onPageChanged = { index -> + currentImageIndex = index + imageMessageIds.getOrNull(index)?.takeIf { it != 0L } + ?.let(component::onDownloadHighRes) + imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L } + ?.let(component::onDownloadHighRes) + }, + onForward = { path -> + val msg = currentViewerMessage ?: state.messages.find { + it.content.matchesDisplayPath(path) + } + msg?.let { component.onForwardMessage(it) } + }, + onDelete = { path -> + val msg = currentViewerMessage ?: state.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 link = if (msg != null) { + if (!state.isGroup && !state.isChannel) { + "tg://openmessage?user_id=${state.chatId}&message_id=${msg.id shr 20}" + } else { + "https://t.me/c/${ + state.chatId.toString().removePrefix("-100") + }/${msg.id shr 20}" + } + } else { + path + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { path -> + val msg = state.messages.find { + when (val content = it.content) { + is MessageContent.Photo -> content.path == path + is MessageContent.Video -> content.path == path + is MessageContent.Gif -> content.path == path + else -> false + } + } + val textToCopy = when (val content = msg?.content) { + is MessageContent.Photo -> content.caption + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onVideoClick = { path -> + val msg = currentViewerMessage ?: state.messages.find { + it.content.matchesDisplayPath(path) + } + if (msg != null) { + val mediaPath = msg.displayMediaPathForViewer() ?: path + component.onOpenVideo( + path = mediaPath, + messageId = msg.id, + caption = when (val content = msg.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> null + } + ) + } else { + component.onOpenVideo(path = path, messageId = null, caption = null) + } + }, + captions = state.fullScreenCaptions, + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = component.downloadUtils + ) + } + } +} + +@Composable +internal fun VideoOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { + val videoVisible = + (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null + + if (videoVisible) { + val messageId = state.fullScreenVideoMessageId + val path = state.fullScreenVideoPath + + val msg = remember(messageId, path, state.messages) { + state.messages.find { it.id == messageId } ?: state.messages.find { + it.content.matchesDisplayPath(path ?: "") + } + } + + val videoContent = msg?.content as? MessageContent.Video + val gifContent = msg?.content as? MessageContent.Gif + + val fileId = videoContent?.fileId ?: gifContent?.fileId ?: 0 + val supportsStreaming = videoContent?.supportsStreaming ?: false + val finalPath = path ?: videoContent?.path ?: gifContent?.path ?: "" + + if (finalPath.isNotBlank() || (supportsStreaming && fileId != 0)) { + key(finalPath, fileId) { + VideoViewer( + path = finalPath, + onDismiss = component::onDismissVideo, + isGesturesEnabled = state.isPlayerGesturesEnabled, + isDoubleTapSeekEnabled = state.isPlayerDoubleTapSeekEnabled, + seekDuration = state.playerSeekDuration, + isZoomEnabled = state.isPlayerZoomEnabled, + onForward = { videoPath -> + val forwardMsg = state.messages.find { + it.content.matchesDisplayPath(videoPath) + } + forwardMsg?.let { component.onForwardMessage(it) } + }, + onDelete = { videoPath -> + val deleteMsg = state.messages.find { + it.content.matchesDisplayPath(videoPath) + } + if (deleteMsg?.isOutgoing == true) { + component.onDeleteMessage(deleteMsg, true) + component.onDismissVideo() + } + }, + onCopyLink = { videoPath -> + val linkMsg = state.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}" + } else { + "https://t.me/c/${ + state.chatId.toString().removePrefix("-100") + }/${linkMsg.id shr 20}" + } + } else { + videoPath + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { videoPath -> + val textMsg = state.messages.find { + it.content.matchesDisplayPath(videoPath) + } + val textToCopy = when (val content = textMsg?.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onSaveGif = if (state.messages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { + { videoPath -> component.onAddToGifs(videoPath) } + } else null, + caption = state.fullScreenVideoCaption, + fileId = fileId, + supportsStreaming = supportsStreaming, + downloadUtils = component.downloadUtils + ) + } + } + } +} + +@Composable +internal fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) { + if (state.invoiceSlug != null || state.invoiceMessageId != null) { + InvoiceDialog( + slug = state.invoiceSlug, + chatId = state.chatId, + messageId = state.invoiceMessageId, + paymentRepository = component.repositoryMessage, + fileRepository = component.repositoryMessage, + onDismiss = { status -> component.onDismissInvoice(status) } + ) + } +} + +@Composable +internal fun MiniAppTOSOverlay(state: ChatComponent.State, component: ChatComponent) { + MiniAppTOSBottomSheet( + isVisible = state.showMiniAppTOS, + onDismiss = { component.onDismissMiniAppTOS() }, + onAccept = { component.onAcceptMiniAppTOS() } + ) +} + +private data class ViewerMediaItem( + val messageId: Long, + val path: String +) + +private fun MessageModel.displayMediaPathForViewer(): String? { + return when (val content = content) { + is MessageContent.Photo -> content.path ?: content.thumbnailPath + is MessageContent.Video -> content.path ?: content.thumbnailPath + is MessageContent.Gif -> content.path + else -> null + } +} + +private fun MessageContent.matchesDisplayPath(path: String): Boolean { + return when (this) { + is MessageContent.Photo -> (this.path ?: this.thumbnailPath) == path + is MessageContent.Video -> this.path == path || this.thumbnailPath == path + is MessageContent.Gif -> this.path == path + else -> false + } +} + 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/conversation/ui/content/ChatMessageOptionsMenu.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMessageOptionsMenu.kt index d8aad2f4..98a4f809 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/conversation/ui/content/ChatMessageOptionsMenu.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import android.content.ClipData import android.util.Log @@ -36,7 +36,7 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching -import org.monogram.presentation.features.chats.currentChat.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.stickers.ui.menu.MessageOptionsMenu import org.monogram.presentation.features.stickers.ui.menu.MessagePackMenuOption import java.util.Locale @@ -625,3 +625,4 @@ private fun extractDownloadPath(content: MessageContent): String? { else -> null } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/DeleteMessagesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/DeleteMessagesSheet.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/DeleteMessagesSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/DeleteMessagesSheet.kt index af60c207..74a1bd62 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/DeleteMessagesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/DeleteMessagesSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -111,3 +111,4 @@ fun DeleteMessagesSheet( } } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ReportChatDialog.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ReportChatDialog.kt index 5bddb4e0..a1ce1e02 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ReportChatDialog.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.presentation.R -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition @OptIn(ExperimentalMaterial3Api::class) @@ -228,3 +228,4 @@ private data class ReportReason( val description: String, val icon: ImageVector ) + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/RestrictUserSheet.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/RestrictUserSheet.kt index 9bc9dac0..28d3d594 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/RestrictUserSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent +package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -263,3 +263,4 @@ private fun PermissionToggle( ) } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt new file mode 100644 index 00000000..449a74b3 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt @@ -0,0 +1,563 @@ +package org.monogram.presentation.features.chats.conversation.ui.inputbar + +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.StickerRepository +import org.monogram.presentation.R +import org.monogram.presentation.features.chats.conversation.ui.message.BotCommandSuggestions +import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu + +@Composable +internal fun ChatInputBarComposerSection( + modifier: Modifier = Modifier, + editingMessage: MessageModel?, + replyMessage: MessageModel?, + attachments: ComposerAttachmentState, + suggestions: ComposerSuggestionState, + botState: ComposerBotState, + rowState: ComposerRowState, + onTextValueChange: (TextFieldValue) -> Unit, + knownCustomEmojis: MutableMap, + emojiFontFamily: FontFamily, + focusRequester: FocusRequester, + capabilities: ChatInputBarCapabilities, + canSendAttachments: Boolean, + canPasteMediaFromClipboard: Boolean, + voiceRecorder: VoiceRecorderState, + stickerRepository: StickerRepository, + isTablet: Boolean = false, + onCancelEdit: () -> Unit, + onCancelReply: () -> Unit, + onCancelMedia: () -> Unit, + onCancelDocuments: () -> Unit, + onAddMedia: () -> Unit, + onAddDocuments: () -> Unit, + onMediaOrderChange: (List) -> Unit, + onDocumentOrderChange: (List) -> Unit, + onMediaClick: (String) -> Unit, + onPasteImages: (List) -> Unit, + onMentionClick: (UserModel) -> Unit, + onMentionQueryClear: () -> Unit, + onInlineResultClick: (String) -> Unit, + onInlineSwitchPmClick: (String) -> Unit, + onLoadMoreInlineResults: (String) -> Unit, + onCommandClick: (String) -> Unit, + onAttachClick: () -> Unit, + onStickerMenuToggle: () -> Unit, + onShowBotCommands: () -> Unit, + onOpenMiniApp: (String, String) -> Unit, + onInputFocus: () -> Unit, + onOpenFullScreenEditor: () -> Unit, + onOpenScheduledMessages: () -> Unit, + onSendWithOptions: (MessageSendOptions) -> Unit, + onShowSendOptionsMenu: () -> Unit, + onSendAsDocument: () -> Unit, + onCameraClick: () -> Unit, + onVideoModeToggle: () -> Unit, + onVoiceStart: () -> Unit, + onVoiceStop: (Boolean) -> Unit, + onVoiceLock: () -> Unit, + onSendSilent: () -> Unit, + onScheduleMessage: () -> Unit, + onOpenScheduledMessagesFromPopup: () -> Unit, + onDismissSendOptions: () -> Unit, + onStickerClick: (String) -> Unit, + onGifClick: (GifModel) -> Unit, + onGifSearchFocusedChange: (Boolean) -> Unit, + onReplyMarkupButtonClick: (KeyboardButtonModel) -> Unit +) { + val inputUiState = remember( + attachments.pendingMediaPaths, + attachments.pendingDocumentPaths, + botState, + rowState.textValue, + rowState.editingMessage, + rowState.isStickerMenuVisible, + capabilities.canSendStickers, + capabilities.canWriteText, + capabilities.canOpenAttachSheet, + canPasteMediaFromClipboard + ) { + InputTextFieldUiState( + textValue = rowState.textValue, + isBot = botState.isBot, + botMenuButton = botState.botMenuButton, + botCommands = botState.botCommands, + canSendStickers = capabilities.canSendStickers, + canWriteText = capabilities.canWriteText, + canShowBotActions = capabilities.canWriteText, + isStickerMenuVisible = rowState.isStickerMenuVisible, + canAttachMedia = rowState.editingMessage == null && + attachments.pendingMediaPaths.isEmpty() && + attachments.pendingDocumentPaths.isEmpty() && + capabilities.canOpenAttachSheet, + canPasteMediaFromClipboard = canPasteMediaFromClipboard, + pendingMediaPaths = attachments.pendingMediaPaths, + pendingDocumentPaths = attachments.pendingDocumentPaths, + showExpandEditorAction = rowState.textValue.text.contains('\n') || rowState.textValue.text.length > 150 + ) + } + val sendButtonState = remember( + rowState.textValue.text, + rowState.editingMessage, + attachments.pendingMediaPaths, + attachments.pendingDocumentPaths, + rowState.isOverMessageLimit, + capabilities.canWriteText, + canSendAttachments, + capabilities.canSendVoice, + capabilities.canSendVideoNotes, + rowState.isVideoMessageMode, + rowState.isSlowModeActive, + rowState.slowModeRemainingSeconds + ) { + InputBarSendButtonState( + isTextEmpty = rowState.textValue.text.isBlank(), + isEditing = rowState.editingMessage != null, + hasPendingAttachments = attachments.pendingMediaPaths.isNotEmpty() || attachments.pendingDocumentPaths.isNotEmpty(), + isOverCharLimit = rowState.isOverMessageLimit, + canWriteText = capabilities.canWriteText, + canSendAttachments = canSendAttachments, + canSendVoice = capabilities.canSendVoice, + canSendVideoNotes = capabilities.canSendVideoNotes, + isVideoMessageMode = rowState.isVideoMessageMode, + isSlowModeActive = rowState.isSlowModeActive, + slowModeRemainingSeconds = rowState.slowModeRemainingSeconds + ) + } + + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shape = if (isTablet) { + RoundedCornerShape(16.dp) + } else { + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .windowInsetsPadding( + if (rowState.isStickerMenuVisible) WindowInsets(0, 0, 0, 0) + else WindowInsets.navigationBars + ) + .animateContentSize() + ) { + InputPreviewSection( + editingMessage = editingMessage, + replyMessage = replyMessage, + pendingMediaPaths = attachments.pendingMediaPaths, + pendingDocumentPaths = attachments.pendingDocumentPaths, + onCancelEdit = onCancelEdit, + onCancelReply = onCancelReply, + onCancelMedia = onCancelMedia, + onCancelDocuments = onCancelDocuments, + onAddMedia = onAddMedia, + onAddDocuments = onAddDocuments, + onMediaOrderChange = onMediaOrderChange, + onDocumentOrderChange = onDocumentOrderChange, + onMediaClick = onMediaClick + ) + + AnimatedVisibility( + visible = suggestions.mentionSuggestions.isNotEmpty(), + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() + ) { + MentionSuggestions( + suggestions = suggestions.mentionSuggestions, + onMentionClick = { + onMentionClick(it) + onMentionQueryClear() + } + ) + } + + AnimatedVisibility( + visible = suggestions.filteredCommands.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + BotCommandSuggestions( + commands = suggestions.filteredCommands, + onCommandClick = onCommandClick, + modifier = Modifier.fillMaxWidth() + ) + } + + AnimatedVisibility( + visible = suggestions.currentInlineBotUsername != null || suggestions.isInlineBotLoading, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + InlineBotResults( + inlineBotResults = suggestions.inlineBotResults, + isInlineMode = suggestions.currentInlineBotUsername != null, + isLoading = suggestions.isInlineBotLoading, + onResultClick = onInlineResultClick, + onSwitchPmClick = onInlineSwitchPmClick, + onLoadMore = onLoadMoreInlineResults + ) + } + + AnimatedVisibility( + visible = !suggestions.isGifSearchFocused, + enter = expandVertically(animationSpec = tween(200)), + exit = shrinkVertically(animationSpec = tween(200)) + ) { + ComposerMainRow( + inputUiState = inputUiState, + sendButtonState = sendButtonState, + attachments = attachments, + voiceRecorder = voiceRecorder, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + onTextValueChange = onTextValueChange, + onStickerMenuToggle = onStickerMenuToggle, + onAttachClick = onAttachClick, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp, + onPasteImages = onPasteImages, + onInputFocus = onInputFocus, + onOpenFullScreenEditor = onOpenFullScreenEditor, + onOpenScheduledMessages = onOpenScheduledMessages, + onSendWithOptions = onSendWithOptions, + onShowSendOptionsMenu = onShowSendOptionsMenu, + onCameraClick = onCameraClick, + onVideoModeToggle = onVideoModeToggle, + onVoiceStart = onVoiceStart, + onVoiceStop = onVoiceStop, + onVoiceLock = onVoiceLock + ) + + ComposerSendOptionsPopup( + expanded = rowState.showSendOptionsSheet, + scheduledMessagesCount = attachments.scheduledMessagesCount, + showSendAsDocument = attachments.pendingMediaPaths.isNotEmpty(), + onDismiss = onDismissSendOptions, + onSendAsDocument = onSendAsDocument, + onSendSilent = onSendSilent, + onScheduleMessage = onScheduleMessage, + onOpenScheduledMessages = onOpenScheduledMessagesFromPopup + ) + } + + AnimatedVisibility( + visible = !voiceRecorder.isRecording && !rowState.showFullScreenEditor && rowState.currentMessageLength > 1000, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Text( + text = stringResource( + R.string.message_length_counter, + rowState.currentMessageLength, + rowState.maxMessageLength + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + textAlign = TextAlign.End, + style = MaterialTheme.typography.labelSmall, + color = if (rowState.isOverMessageLimit) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AnimatedVisibility( + visible = suggestions.replyMarkup is ReplyMarkupModel.ShowKeyboard && rowState.textValue.text.isEmpty() && !rowState.isStickerMenuVisible && !rowState.isKeyboardVisible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + val markup = suggestions.replyMarkup as? ReplyMarkupModel.ShowKeyboard + ?: return@AnimatedVisibility + KeyboardMarkupView( + markup = markup, + onButtonClick = onReplyMarkupButtonClick, + onOpenMiniApp = onOpenMiniApp + ) + } + + AnimatedVisibility( + visible = rowState.isStickerMenuVisible, + enter = slideInVertically( + animationSpec = tween(220), + initialOffsetY = { it }) + fadeIn(animationSpec = tween(170)), + exit = if (rowState.closeStickerMenuWithoutSlide) { + fadeOut(animationSpec = tween(90)) + } else { + slideOutVertically( + animationSpec = tween(170), + targetOffsetY = { it }) + fadeOut(animationSpec = tween(120)) + } + ) { + StickerEmojiMenu( + onStickerSelected = onStickerClick, + onEmojiSelected = { emoji, sticker -> + onTextValueChange( + insertEmojiAtSelection( + value = rowState.textValue, + emoji = emoji, + sticker = sticker, + knownCustomEmojis = knownCustomEmojis + ) + ) + }, + onGifSelected = onGifClick, + onSearchFocused = onGifSearchFocusedChange, + panelHeight = rowState.stickerMenuHeight, + canSendStickers = capabilities.canSendStickers, + stickerRepository = stickerRepository + ) + } + } + } +} + +@Composable +private fun ComposerMainRow( + inputUiState: InputTextFieldUiState, + sendButtonState: InputBarSendButtonState, + attachments: ComposerAttachmentState, + voiceRecorder: VoiceRecorderState, + knownCustomEmojis: MutableMap, + emojiFontFamily: FontFamily, + focusRequester: FocusRequester, + onTextValueChange: (TextFieldValue) -> Unit, + onStickerMenuToggle: () -> Unit, + onAttachClick: () -> Unit, + onShowBotCommands: () -> Unit, + onOpenMiniApp: (String, String) -> Unit, + onPasteImages: (List) -> Unit, + onInputFocus: () -> Unit, + onOpenFullScreenEditor: () -> Unit, + onOpenScheduledMessages: () -> Unit, + onSendWithOptions: (MessageSendOptions) -> Unit, + onShowSendOptionsMenu: () -> Unit, + onCameraClick: () -> Unit, + onVideoModeToggle: () -> Unit, + onVoiceStart: () -> Unit, + onVoiceStop: (Boolean) -> Unit, + onVoiceLock: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ComposerInputSlot( + modifier = Modifier.weight(1f), + uiState = inputUiState, + attachments = attachments, + voiceRecorder = voiceRecorder, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + onTextValueChange = onTextValueChange, + onStickerMenuToggle = onStickerMenuToggle, + onAttachClick = onAttachClick, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp, + onPasteImages = onPasteImages, + onInputFocus = onInputFocus, + onOpenFullScreenEditor = onOpenFullScreenEditor, + onVoiceStop = onVoiceStop + ) + + ComposerActionsSlot( + attachments = attachments, + sendButtonState = sendButtonState, + voiceRecorder = voiceRecorder, + onOpenScheduledMessages = onOpenScheduledMessages, + onSendWithOptions = onSendWithOptions, + onShowSendOptionsMenu = onShowSendOptionsMenu, + onCameraClick = onCameraClick, + onVideoModeToggle = onVideoModeToggle, + onVoiceStart = onVoiceStart, + onVoiceStop = onVoiceStop, + onVoiceLock = onVoiceLock + ) + } +} + +@Composable +private fun ComposerInputSlot( + modifier: Modifier = Modifier, + uiState: InputTextFieldUiState, + attachments: ComposerAttachmentState, + voiceRecorder: VoiceRecorderState, + knownCustomEmojis: MutableMap, + emojiFontFamily: FontFamily, + focusRequester: FocusRequester, + onTextValueChange: (TextFieldValue) -> Unit, + onStickerMenuToggle: () -> Unit, + onAttachClick: () -> Unit, + onShowBotCommands: () -> Unit, + onOpenMiniApp: (String, String) -> Unit, + onPasteImages: (List) -> Unit, + onInputFocus: () -> Unit, + onOpenFullScreenEditor: () -> Unit, + onVoiceStop: (Boolean) -> Unit, +) { + Box( + modifier = modifier + .padding(start = if (voiceRecorder.isRecording) 0.dp else 4.dp) + ) { + AnimatedContent( + targetState = voiceRecorder.isRecording, + transitionSpec = { (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) }, + label = "InputContent" + ) { isRecording -> + if (isRecording) { + RecordingUI( + voiceRecorderState = voiceRecorder, + onStop = { onVoiceStop(false) }, + onCancel = { onVoiceStop(true) }, + modifier = Modifier.fillMaxWidth() + ) + } else { + InputTextFieldContainer( + uiState = uiState, + onValueChange = { + onTextValueChange( + mergeInputTextValuePreservingAnnotations( + uiState.textValue, + it + ) + ) + }, + onRichTextValueChange = onTextValueChange, + onStickerMenuToggle = onStickerMenuToggle, + onAttachClick = onAttachClick, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + pendingMediaPaths = attachments.pendingMediaPaths, + pendingDocumentPaths = attachments.pendingDocumentPaths, + onPasteImages = onPasteImages, + onFocus = onInputFocus, + onOpenFullScreenEditor = onOpenFullScreenEditor, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun ComposerActionsSlot( + attachments: ComposerAttachmentState, + sendButtonState: InputBarSendButtonState, + voiceRecorder: VoiceRecorderState, + onOpenScheduledMessages: () -> Unit, + onSendWithOptions: (MessageSendOptions) -> Unit, + onShowSendOptionsMenu: () -> Unit, + onCameraClick: () -> Unit, + onVideoModeToggle: () -> Unit, + onVoiceStart: () -> Unit, + onVoiceStop: (Boolean) -> Unit, + onVoiceLock: () -> Unit +) { + if (!voiceRecorder.isLocked) { + if (attachments.scheduledMessagesCount > 0) { + IconButton(onClick = onOpenScheduledMessages) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = stringResource(R.string.action_scheduled_messages), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Box(contentAlignment = Alignment.CenterEnd) { + InputBarSendButton( + state = sendButtonState, + onSendWithOptions = onSendWithOptions, + onShowSendOptionsMenu = onShowSendOptionsMenu, + onCameraClick = onCameraClick, + onVideoModeToggle = onVideoModeToggle, + onVoiceStart = onVoiceStart, + onVoiceStop = onVoiceStop, + onVoiceLock = onVoiceLock + ) + } + } +} + +@Composable +private fun ComposerSendOptionsPopup( + expanded: Boolean, + scheduledMessagesCount: Int, + showSendAsDocument: Boolean, + onDismiss: () -> Unit, + onSendAsDocument: () -> Unit, + onSendSilent: () -> Unit, + onScheduleMessage: () -> Unit, + onOpenScheduledMessages: () -> Unit +) { + SendOptionsPopup( + expanded = expanded, + scheduledMessagesCount = scheduledMessagesCount, + showSendAsDocument = showSendAsDocument, + onDismiss = onDismiss, + onSendAsDocument = onSendAsDocument, + onSendSilent = onSendSilent, + onScheduleMessage = onScheduleMessage, + onOpenScheduledMessages = onOpenScheduledMessages + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarContract.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt similarity index 56% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarContract.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt index ee229899..9456233b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarContract.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.runtime.Immutable import org.monogram.domain.models.AttachMenuBotModel @@ -81,6 +81,95 @@ data class ChatInputBarActions( val onSendScheduledNow: (MessageModel) -> Unit = {}, ) +@Immutable +internal data class ChatInputBarCapabilities( + val canWriteText: Boolean, + val canSendPhotos: Boolean, + val canSendVideos: Boolean, + val canSendDocuments: Boolean, + val canSendAudios: Boolean, + val canOpenAttachSheet: Boolean, + val canSendStickers: Boolean, + val canSendVoice: Boolean, + val canSendVideoNotes: Boolean, + val canSendAnything: Boolean +) + +@Immutable +internal data class ComposerAttachmentState( + val pendingMediaPaths: List = emptyList(), + val pendingDocumentPaths: List = emptyList(), + val scheduledMessagesCount: Int = 0 +) + +@Immutable +internal data class ComposerSuggestionState( + val mentionSuggestions: List = emptyList(), + val filteredCommands: List = emptyList(), + val currentInlineBotUsername: String? = null, + val isInlineBotLoading: Boolean = false, + val inlineBotResults: InlineBotResultsModel? = null, + val replyMarkup: ReplyMarkupModel? = null, + val isGifSearchFocused: Boolean = false +) + +@Immutable +internal data class ComposerBotState( + val isBot: Boolean, + val botMenuButton: BotMenuButtonModel, + val botCommands: List +) + +@Immutable +internal data class ComposerRowState( + val textValue: androidx.compose.ui.text.input.TextFieldValue, + val editingMessage: MessageModel? = null, + val isStickerMenuVisible: Boolean = false, + val closeStickerMenuWithoutSlide: Boolean = false, + val isKeyboardVisible: Boolean = false, + val stickerMenuHeight: androidx.compose.ui.unit.Dp, + val showFullScreenEditor: Boolean = false, + val currentMessageLength: Int = 0, + val maxMessageLength: Int = 4096, + val isOverMessageLimit: Boolean = false, + val showSendOptionsSheet: Boolean = false, + val isVideoMessageMode: Boolean = false, + val isSlowModeActive: Boolean = false, + val slowModeRemainingSeconds: Int = 0, +) + +@Immutable +internal data class InputTextFieldUiState( + val textValue: androidx.compose.ui.text.input.TextFieldValue, + val isBot: Boolean, + val botMenuButton: BotMenuButtonModel, + val botCommands: List, + val canSendStickers: Boolean, + val canWriteText: Boolean, + val canShowBotActions: Boolean, + val isStickerMenuVisible: Boolean, + val canAttachMedia: Boolean, + val canPasteMediaFromClipboard: Boolean, + val pendingMediaPaths: List, + val pendingDocumentPaths: List, + val showExpandEditorAction: Boolean, +) + +@Immutable +internal data class InputBarSendButtonState( + val isTextEmpty: Boolean, + val isEditing: Boolean, + val hasPendingAttachments: Boolean, + val isOverCharLimit: Boolean, + val canWriteText: Boolean, + val canSendAttachments: Boolean, + val canSendVoice: Boolean, + val canSendVideoNotes: Boolean, + val isVideoMessageMode: Boolean, + val isSlowModeActive: Boolean, + val slowModeRemainingSeconds: Int, +) + internal enum class InputBarMode { Composer, SlowMode, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt index 62cc1141..3daff214 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.content.Context import android.content.pm.PackageManager diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ClosedTopicBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ClosedTopicBar.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ClosedTopicBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ClosedTopicBar.kt index af70db2a..011bdc8e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ClosedTopicBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ClosedTopicBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/EmojiInsertUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/EmojiInsertUtils.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/EmojiInsertUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/EmojiInsertUtils.kt index 5c3d259d..43e56b11 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/EmojiInsertUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/EmojiInsertUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorFindReplaceBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorFindReplaceBar.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorFindReplaceBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorFindReplaceBar.kt index b8072cb6..36c2596a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorFindReplaceBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorFindReplaceBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,7 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField @Composable fun FullScreenEditorFindReplaceBar( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorMarkdownPreview.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorMarkdownPreview.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorMarkdownPreview.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorMarkdownPreview.kt index b588c8e3..972230e4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorMarkdownPreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorMarkdownPreview.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt index 780aa35e..fe75e973 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.content.ClipData import android.widget.Toast @@ -129,8 +129,8 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.TextCompositionStyleModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField -import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle +import org.monogram.presentation.core.ui.SettingsTextField +import org.monogram.presentation.features.chats.conversation.ui.message.addEmojiStyle import org.monogram.presentation.features.profile.logs.components.calculateDiff import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu import org.monogram.presentation.features.stickers.ui.view.StickerImage diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorStorageAndSearch.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorStorageAndSearch.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorStorageAndSearch.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorStorageAndSearch.kt index 30ee1c95..423c03b1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorStorageAndSearch.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorStorageAndSearch.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorTemplatesSheet.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorTemplatesSheet.kt index 25349fab..688a2a16 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/FullScreenEditorTemplatesSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp import org.monogram.domain.repository.EditorSnippet import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InlineBotResults.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InlineBotResults.kt index 58535a8f..4056c197 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InlineBotResults.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.* import androidx.compose.animation.core.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarLeadingIcons.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarLeadingIcons.kt index 98c7a4a1..d002902d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarLeadingIcons.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarSendButton.kt similarity index 85% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarSendButton.kt index 12d6ef17..e53b5a0d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputBarSendButton.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform @@ -42,26 +42,13 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions @OptIn(ExperimentalFoundationApi::class) @Composable -fun InputBarSendButton( - textValue: TextFieldValue, - editingMessage: MessageModel?, - pendingMediaPaths: List, - pendingDocumentPaths: List, - isOverCharLimit: Boolean, - canWriteText: Boolean, - canSendAttachments: Boolean, - canSendVoice: Boolean, - canSendVideoNotes: Boolean, - isVideoMessageMode: Boolean, - isSlowModeActive: Boolean, - slowModeRemainingSeconds: Int, +internal fun InputBarSendButton( + state: InputBarSendButtonState, onSendWithOptions: (MessageSendOptions) -> Unit, onShowSendOptionsMenu: () -> Unit, onCameraClick: () -> Unit, @@ -71,26 +58,25 @@ fun InputBarSendButton( onVoiceLock: () -> Unit = {} ) { val haptic = LocalHapticFeedback.current - val isTextEmpty = textValue.text.isBlank() - val hasPendingAttachments = pendingMediaPaths.isNotEmpty() || pendingDocumentPaths.isNotEmpty() - val canSendContent = canWriteText || (hasPendingAttachments && canSendAttachments) - val isSlowModeBlocked = isSlowModeActive && editingMessage == null + val canSendContent = + state.canWriteText || (state.hasPendingAttachments && state.canSendAttachments) + val isSlowModeBlocked = state.isSlowModeActive && !state.isEditing val isSendEnabled = - (!isTextEmpty || editingMessage != null || hasPendingAttachments) && + (!state.isTextEmpty || state.isEditing || state.hasPendingAttachments) && canSendContent && - !isOverCharLimit && + !state.isOverCharLimit && !isSlowModeBlocked var isVoiceRecordingActive by remember { mutableStateOf(false) } val effectiveVideoMode = when { - !canSendVideoNotes -> false - !canSendVoice -> true - else -> isVideoMessageMode + !state.canSendVideoNotes -> false + !state.canSendVoice -> true + else -> state.isVideoMessageMode } - val canUseRecording = canSendVoice || canSendVideoNotes - val canToggleRecordingMode = canSendVoice && canSendVideoNotes + val canUseRecording = state.canSendVoice || state.canSendVideoNotes + val canToggleRecordingMode = state.canSendVoice && state.canSendVideoNotes val isRecordingMode = - isTextEmpty && editingMessage == null && !hasPendingAttachments && canUseRecording && !isSlowModeBlocked + state.isTextEmpty && !state.isEditing && !state.hasPendingAttachments && canUseRecording && !isSlowModeBlocked val backgroundColor by animateColorAsState( targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, @@ -103,18 +89,18 @@ fun InputBarSendButton( label = "ContentColor" ) - if (canWriteText || canSendVoice || canSendVideoNotes || (hasPendingAttachments && canSendAttachments)) { + if (state.canWriteText || state.canSendVoice || state.canSendVideoNotes || (state.hasPendingAttachments && state.canSendAttachments)) { val sendIcon = when { - hasPendingAttachments -> Icons.AutoMirrored.Filled.Send - editingMessage != null -> Icons.Default.Check - !isTextEmpty -> Icons.AutoMirrored.Filled.Send + state.hasPendingAttachments -> Icons.AutoMirrored.Filled.Send + state.isEditing -> Icons.Default.Check + !state.isTextEmpty -> Icons.AutoMirrored.Filled.Send effectiveVideoMode -> Icons.Default.Videocam else -> Icons.Outlined.Mic } - val canShowOptions = editingMessage == null && - (!isTextEmpty || (hasPendingAttachments && canSendAttachments)) && - (canWriteText || (hasPendingAttachments && canSendAttachments)) && - !isOverCharLimit && + val canShowOptions = !state.isEditing && + (!state.isTextEmpty || (state.hasPendingAttachments && state.canSendAttachments)) && + (state.canWriteText || (state.hasPendingAttachments && state.canSendAttachments)) && + !state.isOverCharLimit && !isSlowModeBlocked Box( @@ -210,7 +196,7 @@ fun InputBarSendButton( ) { if (isSlowModeBlocked) { Text( - text = formatSlowModeCountdown(slowModeRemainingSeconds), + text = formatSlowModeCountdown(state.slowModeRemainingSeconds), style = MaterialTheme.typography.labelSmall, color = contentColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt index 47c03a64..21cc3ec5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState @@ -65,8 +65,8 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent +import org.monogram.presentation.features.chats.conversation.ui.message.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageInlineContent import java.io.File import java.util.Collections diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextField.kt similarity index 86% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextField.kt index c9134466..58d4cfb0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextField.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.content.ClipData import android.content.ClipboardManager @@ -7,7 +7,6 @@ import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.content.ReceiveContentListener import androidx.compose.foundation.content.contentReceiver -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -28,13 +27,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.OutlinedTextFieldDefaults.FocusedBorderThickness -import androidx.compose.material3.OutlinedTextFieldDefaults.UnfocusedBorderThickness import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,7 +61,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp @@ -74,7 +70,7 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.StickerModel import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle +import org.monogram.presentation.features.chats.conversation.ui.message.addEmojiStyle import org.monogram.presentation.features.stickers.ui.view.StickerImage internal const val CUSTOM_EMOJI_TAG = "custom_emoji" @@ -129,9 +125,12 @@ fun InputTextField( var showPreLanguageDialog by rememberSaveable { mutableStateOf(false) } var preLanguageValue by rememberSaveable { mutableStateOf("") } val context = LocalContext.current + val currentOnPasteImages by rememberUpdatedState(onPasteImages) + val currentOnFocus by rememberUpdatedState(onFocus) + val currentOnRichTextValueChange by rememberUpdatedState(onRichTextValueChange) val emojiSize = 20.sp - val inlineContentMap = remember(knownCustomEmojis.size, knownCustomEmojis.hashCode()) { + val inlineContentMap = remember(knownCustomEmojis) { knownCustomEmojis.map { (id, sticker) -> id.toString() to InlineTextContent( Placeholder(emojiSize, emojiSize, PlaceholderVerticalAlign.Center) @@ -145,68 +144,100 @@ fun InputTextField( } val primaryColor = MaterialTheme.colorScheme.primary - val transformedTextState = remember(textValue.annotatedString, knownCustomEmojis, emojiFontFamily, primaryColor) { - val text = textValue.annotatedString - val emojiAnnotations = text.getStringAnnotations(CUSTOM_EMOJI_TAG, 0, text.length) - val mentionAnnotations = text.getStringAnnotations(MENTION_TAG, 0, text.length) - - val builder = AnnotatedString.Builder() - var lastIndex = 0 - val sortedEmojiAnnotations = emojiAnnotations.sortedBy { it.start } - - for (annotation in sortedEmojiAnnotations) { - if (annotation.start < lastIndex) continue - if (annotation.start > text.length || annotation.end > text.length) continue - builder.append(text.subSequence(lastIndex, annotation.start)) - val stickerId = annotation.item.toLongOrNull() - val originalEmoji = text.substring(annotation.start, annotation.end) - if (stickerId != null && knownCustomEmojis.containsKey(stickerId)) { - builder.appendInlineContent(stickerId.toString(), originalEmoji) - } else { - builder.append(originalEmoji) + val transformedTextState by remember( + textValue.annotatedString, + knownCustomEmojis, + emojiFontFamily, + primaryColor + ) { + derivedStateOf { + val text = textValue.annotatedString + val emojiAnnotations = text.getStringAnnotations(CUSTOM_EMOJI_TAG, 0, text.length) + val mentionAnnotations = text.getStringAnnotations(MENTION_TAG, 0, text.length) + + val builder = AnnotatedString.Builder() + var lastIndex = 0 + val sortedEmojiAnnotations = emojiAnnotations.sortedBy { it.start } + + for (annotation in sortedEmojiAnnotations) { + if (annotation.start < lastIndex) continue + if (annotation.start > text.length || annotation.end > text.length) continue + builder.append(text.subSequence(lastIndex, annotation.start)) + val stickerId = annotation.item.toLongOrNull() + val originalEmoji = text.substring(annotation.start, annotation.end) + if (stickerId != null && knownCustomEmojis.containsKey(stickerId)) { + builder.appendInlineContent(stickerId.toString(), originalEmoji) + } else { + builder.append(originalEmoji) + } + lastIndex = annotation.end } - lastIndex = annotation.end - } - if (lastIndex < text.length) builder.append(text.subSequence(lastIndex, text.length)) + if (lastIndex < text.length) builder.append(text.subSequence(lastIndex, text.length)) - val result = builder.toAnnotatedString() - val finalBuilder = AnnotatedString.Builder(result) + val result = builder.toAnnotatedString() + val finalBuilder = AnnotatedString.Builder(result) - // Add emoji style - finalBuilder.addEmojiStyle(result.text, emojiFontFamily) + finalBuilder.addEmojiStyle(result.text, emojiFontFamily) - // Add mention highlighting - mentionAnnotations.forEach { annotation -> - if (annotation.start < annotation.end && annotation.start >= 0 && annotation.end <= result.length) { - finalBuilder.addStyle(SpanStyle(color = primaryColor), annotation.start, annotation.end) + mentionAnnotations.forEach { annotation -> + if (annotation.start < annotation.end && annotation.start >= 0 && annotation.end <= result.length) { + finalBuilder.addStyle( + SpanStyle(color = primaryColor), + annotation.start, + annotation.end + ) + } } - } - text.getStringAnnotations(RICH_ENTITY_TAG, 0, text.length).forEach { annotation -> - val style = decodeRichEntity(annotation.item)?.toEditorStyle(primaryColor) - if (style != null && annotation.start < annotation.end && annotation.end <= result.length) { - finalBuilder.addStyle(style, annotation.start, annotation.end) + text.getStringAnnotations(RICH_ENTITY_TAG, 0, text.length).forEach { annotation -> + val style = decodeRichEntity(annotation.item)?.toEditorStyle(primaryColor) + if (style != null && annotation.start < annotation.end && annotation.end <= result.length) { + finalBuilder.addStyle(style, annotation.start, annotation.end) + } } - } - // Highlight @username style mentions that are not yet annotated - val mentionRegex = Regex("@(\\w+)") - mentionRegex.findAll(result.text).forEach { match -> - if (mentionAnnotations.none { it.start <= match.range.first && it.end >= match.range.last + 1 }) { - finalBuilder.addStyle(SpanStyle(color = primaryColor), match.range.first, match.range.last + 1) + val mentionRegex = Regex("@(\\w+)") + mentionRegex.findAll(result.text).forEach { match -> + if (mentionAnnotations.none { it.start <= match.range.first && it.end >= match.range.last + 1 }) { + finalBuilder.addStyle( + SpanStyle(color = primaryColor), + match.range.first, + match.range.last + 1 + ) + } } - } - TransformedText(finalBuilder.toAnnotatedString(), OffsetMapping.Identity) + TransformedText(finalBuilder.toAnnotatedString(), OffsetMapping.Identity) + } } - val hasCustomEmojis = knownCustomEmojis.isNotEmpty() && - textValue.annotatedString.getStringAnnotations(CUSTOM_EMOJI_TAG, 0, textValue.text.length).isNotEmpty() - val hasRichFormatting = textValue.annotatedString - .getStringAnnotations(RICH_ENTITY_TAG, 0, textValue.text.length) - .isNotEmpty() - val shouldUseOverlayText = - hasCustomEmojis || emojiFontFamily != FontFamily.Default || textValue.text.contains('@') || hasRichFormatting + val hasCustomEmojis by remember(textValue.annotatedString, knownCustomEmojis) { + derivedStateOf { + knownCustomEmojis.isNotEmpty() && + textValue.annotatedString.getStringAnnotations( + CUSTOM_EMOJI_TAG, + 0, + textValue.text.length + ).isNotEmpty() + } + } + val hasRichFormatting by remember(textValue.annotatedString) { + derivedStateOf { + textValue.annotatedString + .getStringAnnotations(RICH_ENTITY_TAG, 0, textValue.text.length) + .isNotEmpty() + } + } + val shouldUseOverlayText by remember( + textValue.text, + hasCustomEmojis, + hasRichFormatting, + emojiFontFamily + ) { + derivedStateOf { + hasCustomEmojis || emojiFontFamily != FontFamily.Default || textValue.text.contains('@') || hasRichFormatting + } + } val scrollState = rememberScrollState() val editorState = rememberTextFieldState(initialText = textValue.text) @@ -241,6 +272,13 @@ fun InputTextField( LaunchedEffect(textValue.text) { scrollState.scrollTo(scrollState.maxValue) } + val placeholderText = remember(pendingMediaPaths, pendingDocumentPaths, context) { + if (pendingMediaPaths.isNotEmpty() || pendingDocumentPaths.isNotEmpty()) { + context.getString(R.string.input_placeholder_caption) + } else { + context.getString(R.string.input_placeholder_message) + } + } val richTextBold = stringResource(R.string.rich_text_bold) val richTextItalic = stringResource(R.string.rich_text_italic) @@ -252,7 +290,10 @@ fun InputTextField( val richTextLink = stringResource(R.string.rich_text_link) val richTextClear = stringResource(R.string.rich_text_clear) val actionPasteImage = stringResource(R.string.action_paste_image) - val receiveContentListener = remember(context, canPasteMediaFromClipboard, onPasteImages) { + val clipboardImageUris = remember(context, canPasteMediaFromClipboard) { + if (canPasteMediaFromClipboard) extractImageUrisFromClipboard(context) else emptyList() + } + val receiveContentListener = remember(context, canPasteMediaFromClipboard) { ReceiveContentListener { transferableContent -> if (!canPasteMediaFromClipboard) return@ReceiveContentListener transferableContent @@ -261,7 +302,7 @@ fun InputTextField( if (imageUris.isEmpty()) { transferableContent } else { - onPasteImages(imageUris) + currentOnPasteImages(imageUris) null } } @@ -279,7 +320,7 @@ fun InputTextField( val fieldModifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) - .onFocusChanged { if (it.isFocused) onFocus() } + .onFocusChanged { if (it.isFocused) currentOnFocus() } .contentReceiver(receiveContentListener) .let { base -> when { @@ -308,11 +349,10 @@ fun InputTextField( } .appendTextContextMenuComponents { if (canPasteMediaFromClipboard) { - val imageUris = extractImageUrisFromClipboard(context) - if (imageUris.isNotEmpty()) { + if (clipboardImageUris.isNotEmpty()) { item(RichMenuActionPasteImage, actionPasteImage) { close() - onPasteImages(imageUris) + currentOnPasteImages(clipboardImageUris) } } } @@ -320,7 +360,7 @@ fun InputTextField( if (hasFormattableSelection(textValue)) { separator() item(RichMenuActionBold, richTextBold) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Bold @@ -329,7 +369,7 @@ fun InputTextField( close() } item(RichMenuActionItalic, richTextItalic) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Italic @@ -338,7 +378,7 @@ fun InputTextField( close() } item(RichMenuActionUnderline, richTextUnderline) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Underline @@ -347,7 +387,7 @@ fun InputTextField( close() } item(RichMenuActionStrike, richTextStrike) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Strikethrough @@ -356,7 +396,7 @@ fun InputTextField( close() } item(RichMenuActionSpoiler, richTextSpoiler) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Spoiler @@ -365,7 +405,7 @@ fun InputTextField( close() } item(RichMenuActionCode, richTextCode) { - onRichTextValueChange( + currentOnRichTextValueChange( toggleRichEntity( textValue, MessageEntityType.Code @@ -403,7 +443,11 @@ fun InputTextField( close() } item(RichMenuActionClear, richTextClear) { - onRichTextValueChange(clearRichFormatting(textValue)) + currentOnRichTextValueChange( + clearRichFormatting( + textValue + ) + ) close() } } @@ -446,10 +490,7 @@ fun InputTextField( ) { if (textValue.text.isEmpty()) { Text( - text = if (pendingMediaPaths.isNotEmpty() || pendingDocumentPaths.isNotEmpty()) - stringResource(R.string.input_placeholder_caption) - else - stringResource(R.string.input_placeholder_message), + text = placeholderText, style = textStyle, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth() @@ -500,7 +541,7 @@ fun InputTextField( onClick = { val normalizedUrl = normalizeUrl(linkValue) if (normalizedUrl != null && !textValue.selection.collapsed) { - onRichTextValueChange( + currentOnRichTextValueChange( applyRichEntity( textValue, MessageEntityType.TextUrl(normalizedUrl), @@ -538,7 +579,12 @@ fun InputTextField( confirmButton = { TextButton( onClick = { - onRichTextValueChange(applyPreEntity(textValue, preLanguageValue)) + currentOnRichTextValueChange( + applyPreEntity( + textValue, + preLanguageValue + ) + ) showPreLanguageDialog = false } ) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextFieldContainer.kt similarity index 80% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextFieldContainer.kt index d40f3cd3..28898aed 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputTextFieldContainer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.net.Uri import androidx.compose.animation.AnimatedContent @@ -29,7 +29,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -40,25 +41,15 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import org.monogram.domain.models.BotCommandModel import org.monogram.domain.models.BotMenuButtonModel -import org.monogram.domain.models.MessageModel import org.monogram.domain.models.StickerModel import org.monogram.presentation.R import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled @Composable -fun InputTextFieldContainer( - textValue: TextFieldValue, +internal fun InputTextFieldContainer( + uiState: InputTextFieldUiState, onValueChange: (TextFieldValue) -> Unit, onRichTextValueChange: (TextFieldValue) -> Unit = onValueChange, - isBot: Boolean, - botMenuButton: BotMenuButtonModel, - botCommands: List, - canSendStickers: Boolean, - canWriteText: Boolean, - canShowBotActions: Boolean, - isStickerMenuVisible: Boolean, - editingMessage: MessageModel?, - canOpenAttachSheet: Boolean, onStickerMenuToggle: () -> Unit, onAttachClick: () -> Unit, onShowBotCommands: () -> Unit, @@ -68,12 +59,15 @@ fun InputTextFieldContainer( focusRequester: FocusRequester, pendingMediaPaths: List, pendingDocumentPaths: List, - canPasteMediaFromClipboard: Boolean = false, onPasteImages: (List) -> Unit = {}, onFocus: () -> Unit = {}, onOpenFullScreenEditor: () -> Unit = {}, modifier: Modifier = Modifier ) { + val onAttachClickState by rememberUpdatedState(onAttachClick) + val onStickerMenuToggleState by rememberUpdatedState(onStickerMenuToggle) + val onOpenFullScreenEditorState by rememberUpdatedState(onOpenFullScreenEditor) + Surface( modifier = modifier, shape = RoundedCornerShape(24.dp), @@ -82,21 +76,12 @@ fun InputTextFieldContainer( val isTablet = LocalConfiguration.current.screenWidthDp >= 600 && LocalTabletInterfaceEnabled.current - val canAttachMedia = remember( - editingMessage, - pendingMediaPaths, - pendingDocumentPaths, - canOpenAttachSheet - ) { - editingMessage == null && pendingMediaPaths.isEmpty() && pendingDocumentPaths.isEmpty() && canOpenAttachSheet - } - Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) ) { AnimatedContent( - targetState = canAttachMedia, + targetState = uiState.canAttachMedia, transitionSpec = { (fadeIn() + scaleIn(initialScale = 0.85f)).togetherWith( fadeOut() + scaleOut(targetScale = 0.85f) @@ -106,7 +91,7 @@ fun InputTextFieldContainer( ) { showAttach -> if (showAttach) { IconButton( - onClick = onAttachClick, + onClick = { onAttachClickState() }, modifier = Modifier.size(40.dp) ) { Icon( @@ -120,47 +105,43 @@ fun InputTextFieldContainer( } } - val showBotActions = remember(isBot, textValue.text, canShowBotActions) { - isBot && canShowBotActions && textValue.text.isEmpty() - } - InputTextField( - textValue = textValue, + textValue = uiState.textValue, onValueChange = onValueChange, onRichTextValueChange = onRichTextValueChange, - canWriteText = canWriteText, + canWriteText = uiState.canWriteText, knownCustomEmojis = knownCustomEmojis, emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, pendingDocumentPaths = pendingDocumentPaths, - canPasteMediaFromClipboard = canPasteMediaFromClipboard, + canPasteMediaFromClipboard = uiState.canPasteMediaFromClipboard, onPasteImages = onPasteImages, onFocus = onFocus, modifier = Modifier.weight(1f) ) AnimatedVisibility( - visible = showBotActions, + visible = uiState.isBot && uiState.canShowBotActions && uiState.textValue.text.isEmpty(), enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End) ) { BotInputActions( - botMenuButton = botMenuButton, - botCommands = botCommands, + botMenuButton = uiState.botMenuButton, + botCommands = uiState.botCommands, onShowBotCommands = onShowBotCommands, onOpenMiniApp = onOpenMiniApp ) } - if (canWriteText) { + if (uiState.canWriteText) { AnimatedVisibility( - visible = isTablet || textValue.text.contains('\n') || textValue.text.length > 150, + visible = uiState.showExpandEditorAction || isTablet, enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End) ) { IconButton( - onClick = onOpenFullScreenEditor, + onClick = { onOpenFullScreenEditorState() }, modifier = Modifier .align(Alignment.CenterVertically) .size(36.dp) @@ -175,16 +156,16 @@ fun InputTextFieldContainer( } AnimatedVisibility( - visible = canSendStickers, + visible = uiState.canSendStickers, enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End) ) { IconButton( - onClick = onStickerMenuToggle, + onClick = { onStickerMenuToggleState() }, modifier = Modifier.size(40.dp) ) { Icon( - imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, + imageVector = if (uiState.isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/KeyboardMarkupView.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/KeyboardMarkupView.kt index 507b770e..148dcaf3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/KeyboardMarkupView.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.content.ClipData import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/MentionSuggestions.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/MentionSuggestions.kt index f4fe0961..7f83ed79 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/MentionSuggestions.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RecordingUI.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RecordingUI.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RecordingUI.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RecordingUI.kt index dcc71a55..6ba862a7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RecordingUI.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RecordingUI.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.* import androidx.compose.animation.core.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RestrictedInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RestrictedInputBar.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RestrictedInputBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RestrictedInputBar.kt index 7fb92bba..7479c73f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/RestrictedInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/RestrictedInputBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SchedulePickers.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SchedulePickers.kt index 1d13fd40..476a054e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SchedulePickers.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.text.format.DateFormat import androidx.compose.material3.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ScheduledMessagesSheet.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ScheduledMessagesSheet.kt index dd8c0bf2..68165055 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ScheduledMessagesSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SendOptionsPopup.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SendOptionsPopup.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SendOptionsPopup.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SendOptionsPopup.kt index bc678a8c..9e3f7a28 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SendOptionsPopup.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SendOptionsPopup.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SlowModeInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SlowModeInputBar.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SlowModeInputBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SlowModeInputBar.kt index f2e5bd08..b74ce3e0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SlowModeInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/SlowModeInputBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/VoiceRecorder.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/VoiceRecorder.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/VoiceRecorder.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/VoiceRecorder.kt index 8306d077..a5483332 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/VoiceRecorder.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/VoiceRecorder.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar +package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.Manifest import android.content.Context diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt index 00131469..eb712322 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -48,7 +48,7 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelCommentsButton +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelCommentsButton @Composable fun AudioMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandItem.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandItem.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandItem.kt index 47a354e7..1d60034b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandItem.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandSuggestions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandSuggestions.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandSuggestions.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandSuggestions.kt index 89ca3186..12140c8f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandSuggestions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandSuggestions.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.animation.* import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandsSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandsSheet.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandsSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandsSheet.kt index b5fac5fd..f929dfcc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/BotCommandsSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/BotCommandsSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt index ec49fd5f..7fe971e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,7 +33,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic +import org.monogram.presentation.features.chats.conversation.ui.CompactMediaMosaic @Composable fun ChatAlbumMessageBubble( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/CodeBlock.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/CodeBlock.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/CodeBlock.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/CodeBlock.kt index 387c7472..b64acb73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/CodeBlock.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/CodeBlock.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.content.ClipData import android.provider.Settings @@ -45,7 +45,7 @@ import org.koin.compose.koinInject import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.NightMode -import org.monogram.presentation.features.chats.currentChat.components.chats.code.CodeHighlighter +import org.monogram.presentation.features.chats.conversation.ui.message.code.CodeHighlighter import java.util.Calendar @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ContactMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ContactMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ContactMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ContactMessageBubble.kt index 0785293c..ef3f216c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ContactMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ContactMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt index 9f71cf0f..5d1978b7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -50,8 +50,8 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelCommentsButton +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelCommentsButton @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ForwardContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ForwardContent.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ForwardContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ForwardContent.kt index f5ea7bcd..07363902 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ForwardContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ForwardContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt index b89815c8..2572b6cb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -64,9 +64,9 @@ import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType @OptIn(UnstableApi::class, ExperimentalMaterial3ExpressiveApi::class) @kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt index 71885b06..eb4d120e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LocationMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LocationMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LocationMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LocationMessageBubble.kt index 73e825ac..247ed4c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LocationMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LocationMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt index 05b6c526..f88c469e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageReactionsView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt similarity index 78% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageReactionsView.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt index 8a90cd05..c20d824c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageReactionsView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -17,8 +17,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -27,17 +25,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.koin.compose.koinInject import org.monogram.domain.models.MessageReactionModel -import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.core.ui.Avatar -import org.monogram.presentation.core.util.AppPreferences -import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.features.stickers.ui.view.StickerImage @OptIn(ExperimentalLayoutApi::class) @@ -46,30 +39,9 @@ fun MessageReactionsView( reactions: List, onReactionClick: (String) -> Unit, modifier: Modifier = Modifier, - stickerRepository: StickerRepository = koinInject(), - appPreferences: AppPreferences = koinInject() + emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily, + customEmojiPathsById: Map = LocalMessageRenderDependencies.current.customEmojiPaths ) { - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - val customEmojiStickerSets by stickerRepository.customEmojiStickerSets.collectAsState() - - LaunchedEffect(Unit) { - coRunCatching { stickerRepository.loadCustomEmojiStickerSets() } - } - - val customEmojiFileIdsById = remember(customEmojiStickerSets) { - buildMap { - customEmojiStickerSets.forEach { set -> - set.stickers.forEach { sticker -> - val customEmojiId = sticker.customEmojiId - if (customEmojiId != null) { - put(customEmojiId, sticker.id) - } - } - } - } - } if (reactions.isEmpty()) return FlowRow( @@ -84,8 +56,7 @@ fun MessageReactionsView( reaction = reaction, onReactionClick = onReactionClick, emojiFontFamily = emojiFontFamily, - stickerRepository = stickerRepository, - customEmojiFileIdsById = customEmojiFileIdsById + customEmojiPathsById = customEmojiPathsById ) } } @@ -98,8 +69,7 @@ private fun MessageReactionItem( reaction: MessageReactionModel, onReactionClick: (String) -> Unit, emojiFontFamily: FontFamily, - stickerRepository: StickerRepository, - customEmojiFileIdsById: Map + customEmojiPathsById: Map ) { val customEmojiId = reaction.customEmojiId val emoji = reaction.emoji @@ -120,11 +90,8 @@ private fun MessageReactionItem( MaterialTheme.colorScheme.onSurfaceVariant } - val customEmojiFileId = customEmojiId?.let(customEmojiFileIdsById::get) - val customEmojiPath by if (customEmojiFileId != null && reaction.customEmojiPath == null) { - stickerRepository.getStickerFile(customEmojiFileId).collectAsState(initial = null) - } else { - remember { mutableStateOf(reaction.customEmojiPath) } + val customEmojiPath = remember(customEmojiId, reaction.customEmojiPath, customEmojiPathsById) { + reaction.customEmojiPath ?: customEmojiId?.let(customEmojiPathsById::get) } var showDropdown by remember { mutableStateOf(false) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt new file mode 100644 index 00000000..8caea365 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt @@ -0,0 +1,143 @@ +package org.monogram.presentation.features.chats.conversation.ui.message + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.StickerRepository +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.coRunCatching + +@Immutable +data class MessageRenderDependencies( + val emojiFontFamily: FontFamily = FontFamily.Default, + val customEmojiPaths: Map = emptyMap() +) + +internal val LocalMessageRenderDependencies = staticCompositionLocalOf { + MessageRenderDependencies() +} + +@Composable +internal fun rememberChatMessageRenderDependencies( + messages: List, + appPreferences: AppPreferences = koinInject(), + stickerRepository: StickerRepository = koinInject() +): State { + val context = LocalContext.current + val emojiStyle by appPreferences.emojiStyle.collectAsState() + val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } + val customEmojiStickerSets by stickerRepository.customEmojiStickerSets.collectAsState() + + LaunchedEffect(stickerRepository) { + coRunCatching { stickerRepository.loadCustomEmojiStickerSets() } + } + + val customEmojiFileIdsById = remember(customEmojiStickerSets) { + buildMap { + customEmojiStickerSets.forEach { set -> + set.stickers.forEach { sticker -> + val customEmojiId = sticker.customEmojiId + if (customEmojiId != null) { + put(customEmojiId, sticker.id) + } + } + } + } + } + val requests = remember(messages) { collectCustomEmojiRequests(messages) } + return produceState( + initialValue = MessageRenderDependencies( + emojiFontFamily = emojiFontFamily, + customEmojiPaths = requests.explicitPaths + ), + emojiFontFamily, + requests, + customEmojiFileIdsById, + stickerRepository + ) { + value = MessageRenderDependencies( + emojiFontFamily = emojiFontFamily, + customEmojiPaths = requests.explicitPaths + ) + + coroutineScope { + requests.ids.forEach { customEmojiId -> + launch { + val resolvedFlow = + customEmojiFileIdsById[customEmojiId]?.let(stickerRepository::getStickerFile) + ?: stickerRepository.getCustomEmojiFile(customEmojiId) + resolvedFlow.collectLatest { resolvedPath -> + val nextPath = resolvedPath ?: requests.explicitPaths[customEmojiId] + if (value.customEmojiPaths[customEmojiId] != nextPath) { + value = value.copy( + customEmojiPaths = value.customEmojiPaths + (customEmojiId to nextPath) + ) + } + } + } + } + } + } +} + +@Immutable +private data class CustomEmojiRequests( + val ids: Set, + val explicitPaths: Map +) + +private fun collectCustomEmojiRequests(messages: List): CustomEmojiRequests { + val ids = LinkedHashSet() + val explicitPaths = LinkedHashMap() + + fun appendEntities(entities: List) { + entities.forEach { entity -> + val type = entity.type as? MessageEntityType.CustomEmoji ?: return@forEach + ids += type.emojiId + if (type.path != null) { + explicitPaths[type.emojiId] = type.path + } + } + } + + messages.forEach { message -> + when (val content = message.content) { + is MessageContent.Text -> appendEntities(content.entities) + is MessageContent.Photo -> appendEntities(content.entities) + is MessageContent.Video -> appendEntities(content.entities) + is MessageContent.Document -> appendEntities(content.entities) + is MessageContent.Audio -> appendEntities(content.entities) + is MessageContent.Gif -> appendEntities(content.entities) + else -> Unit + } + + message.reactions.forEach { reaction -> + val customEmojiId = reaction.customEmojiId ?: return@forEach + ids += customEmojiId + if (reaction.customEmojiPath != null) { + explicitPaths[customEmojiId] = reaction.customEmojiPath + } + } + } + + return CustomEmojiRequests( + ids = ids, + explicitPaths = explicitPaths + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageSenderName.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageSenderName.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageSenderName.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageSenderName.kt index 9850114a..f8523401 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageSenderName.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageSenderName.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageText.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageText.kt index da2f6ddc..0b57caab 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageText.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.content.ClipData import android.os.Build @@ -29,7 +29,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType -import org.monogram.presentation.features.chats.currentChat.components.chats.model.isBlockElement +import org.monogram.presentation.features.chats.conversation.ui.message.model.isBlockElement @Composable fun MessageText( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageTextFormatter.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt similarity index 89% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageTextFormatter.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt index 88c5a45f..5e15ba47 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageTextFormatter.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -8,14 +8,10 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -27,11 +23,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.koin.compose.koinInject import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType -import org.monogram.domain.repository.StickerRepository -import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.stickers.ui.view.StickerImage @Immutable @@ -51,24 +44,16 @@ sealed interface BigEmojiItem { data class Custom(val path: String?) : BigEmojiItem } -@Composable private fun rememberResolvedCustomEmojiPaths( entities: List, - stickerRepository: StickerRepository = koinInject() + customEmojiPaths: Map ): List { val emojiEntities = entities.filter { it.type is MessageEntityType.CustomEmoji }.sortedBy { it.offset } return emojiEntities.map { entity -> val type = entity.type as MessageEntityType.CustomEmoji - key(entity.offset, entity.length, type.emojiId, type.path) { - val resolvedPath by if (type.path == null) { - stickerRepository.getCustomEmojiFile(type.emojiId).collectAsState(initial = null) - } else { - remember(type.path) { androidx.compose.runtime.mutableStateOf(type.path) } - } - resolvedPath - } + type.path ?: customEmojiPaths[type.emojiId] } } @@ -77,11 +62,13 @@ fun rememberMessageInlineContent( entities: List, fontSize: Float, isBigEmoji: Boolean = false, - stickerRepository: StickerRepository = koinInject() + customEmojiPaths: Map = LocalMessageRenderDependencies.current.customEmojiPaths ): Map { val emojiEntities = entities.filter { it.type is MessageEntityType.CustomEmoji }.sortedBy { it.offset } - val resolvedEmojiPaths = rememberResolvedCustomEmojiPaths(entities, stickerRepository) + val resolvedEmojiPaths = remember(entities, customEmojiPaths) { + rememberResolvedCustomEmojiPaths(entities, customEmojiPaths) + } return remember(emojiEntities, resolvedEmojiPaths, fontSize, isBigEmoji) { val map = mutableMapOf() @@ -109,25 +96,27 @@ fun rememberMessageTextRenderData( allowBigEmoji: Boolean = true, isOutgoing: Boolean = false, revealedSpoilers: List = emptyList(), - appPreferences: AppPreferences = koinInject(), - stickerRepository: StickerRepository = koinInject() + emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily, + customEmojiPaths: Map = LocalMessageRenderDependencies.current.customEmojiPaths ): MessageTextRenderData { val bigEmoji = remember(text, entities, allowBigEmoji) { allowBigEmoji && isBigEmoji(text, entities) } - val resolvedEmojiPaths = rememberResolvedCustomEmojiPaths(entities, stickerRepository) + val resolvedEmojiPaths = remember(entities, customEmojiPaths) { + rememberResolvedCustomEmojiPaths(entities, customEmojiPaths) + } val annotatedText = buildAnnotatedMessageTextWithEmoji( text = text, entities = entities, isOutgoing = isOutgoing, revealedSpoilers = revealedSpoilers, - appPreferences = appPreferences + emojiFontFamily = emojiFontFamily ) val inlineContent = rememberMessageInlineContent( entities = entities, fontSize = fontSize, isBigEmoji = bigEmoji, - stickerRepository = stickerRepository + customEmojiPaths = customEmojiPaths ) val bigEmojiItems = remember(text, entities, resolvedEmojiPaths, bigEmoji) { if (!bigEmoji) emptyList() else buildBigEmojiItems(text, entities, resolvedEmojiPaths) @@ -199,12 +188,8 @@ fun BigEmojiContent( items: List, sizeDp: Float, modifier: Modifier = Modifier, - appPreferences: AppPreferences = koinInject() + emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily ) { - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -234,12 +219,8 @@ fun buildAnnotatedMessageTextWithEmoji( entities: List, isOutgoing: Boolean = false, revealedSpoilers: List = emptyList(), - appPreferences: AppPreferences = koinInject() + emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily ): AnnotatedString { - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - val linkColor = MaterialTheme.colorScheme.primary val codeBackgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) val codeTextColor = MaterialTheme.colorScheme.primary diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt index fe6f893a..113730c3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageUtils.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.content.Context import androidx.compose.animation.AnimatedContent @@ -51,7 +51,7 @@ import org.monogram.domain.models.MessageSendingState import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.EmojiStyle -import org.monogram.presentation.features.chats.currentChat.components.channels.formatViews +import org.monogram.presentation.features.chats.conversation.ui.channel.formatViews import java.io.File import java.text.BreakIterator import java.text.SimpleDateFormat diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageViaBotAttribution.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageViaBotAttribution.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageViaBotAttribution.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageViaBotAttribution.kt index 94efd224..b250e589 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageViaBotAttribution.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageViaBotAttribution.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt index 7bd8cd17..174c98c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -50,7 +50,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollMessageBubble.kt index da5dfab6..d0e4a904 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollVotersSheet.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollVotersSheet.kt index 868f2b1e..eed48c27 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PollVotersSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/QuoteBlock.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/QuoteBlock.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/QuoteBlock.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/QuoteBlock.kt index 157efecc..9f3d5cd3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/QuoteBlock.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/QuoteBlock.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyContent.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyContent.kt index 8391ac5a..78bb2136 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyMarkupView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyMarkupView.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyMarkupView.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyMarkupView.kt index ec2eafd9..fc3a7fd4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ReplyMarkupView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ReplyMarkupView.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.content.ClipData import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShader.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShader.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShader.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShader.kt index 4305bf2c..364e2c67 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShader.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShader.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.os.Build import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShaderApi33.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShaderApi33.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShaderApi33.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShaderApi33.kt index eb70a536..7cb463b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/SpoilerShaderApi33.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/SpoilerShaderApi33.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import android.graphics.RuntimeShader import android.os.Build diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt index 6d79961f..d9ca636e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.annotation.OptIn import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextBlocks.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextBlocks.kt similarity index 80% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextBlocks.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextBlocks.kt index c100aca3..d3362ea2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextBlocks.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextBlocks.kt @@ -1,10 +1,10 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.runtime.Composable import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType -import org.monogram.presentation.features.chats.currentChat.components.chats.model.blockFor -import org.monogram.presentation.features.chats.currentChat.components.chats.model.inlineEntitiesForBlock +import org.monogram.presentation.features.chats.conversation.ui.message.model.blockFor +import org.monogram.presentation.features.chats.conversation.ui.message.model.inlineEntitiesForBlock @Composable internal fun TextBlocks( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt index 8228b5cd..ff9c2c36 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt similarity index 68% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index 3f371304..41539851 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -35,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,11 +60,15 @@ import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey -import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType +import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression + +private class VideoBubbleLayoutTracker { + var videoPosition: Offset = Offset.Zero +} @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -147,14 +152,18 @@ fun VideoMessageBubble( bottomStart = bottomStart ) - var videoPosition by remember { mutableStateOf(Offset.Zero) } + val layoutTracker = remember { VideoBubbleLayoutTracker() } var isMuted by remember { mutableStateOf(true) } - var currentPositionSeconds by remember { mutableIntStateOf(0) } var isVisible by remember { mutableStateOf(false) } val resources = LocalResources.current val screenHeightPx = remember { resources.displayMetrics.heightPixels } val revealedSpoilers = remember { mutableStateListOf() } var isMediaSpoilerRevealed by remember { mutableStateOf(!content.hasSpoiler) } + val currentPositionSecondsState = remember(msg.id, content.fileId) { mutableIntStateOf(0) } + val currentPositionSeconds = currentPositionSecondsState.intValue + val onLongClickState by rememberUpdatedState(onLongClick) + val onVideoClickState by rememberUpdatedState(onVideoClick) + val onCancelDownloadState by rememberUpdatedState(onCancelDownload) Column( modifier = modifier.onGloballyPositioned { @@ -208,7 +217,9 @@ fun VideoMessageBubble( .heightIn(min = 160.dp, max = 360.dp) .aspectRatio(ratio) .clipToBounds() - .onGloballyPositioned { videoPosition = it.positionInWindow() } + .onGloballyPositioned { + layoutTracker.videoPosition = it.positionInWindow() + } ) { if (hasPath || content.supportsStreaming) { @@ -224,30 +235,21 @@ fun VideoMessageBubble( reportProgress = true, onProgressUpdate = { pos -> val seconds = (pos / 1000).toInt() - if (seconds != currentPositionSeconds) { - currentPositionSeconds = seconds + if (seconds != currentPositionSecondsState.intValue) { + currentPositionSecondsState.intValue = seconds } }, fileId = content.fileId, thumbnailData = content.minithumbnail ) - Box( + VideoMuteToggle( modifier = Modifier .align(Alignment.TopEnd) - .padding(8.dp) - .size(30.dp) - .background(Color.Black.copy(alpha = 0.45f), CircleShape) - .clickable { isMuted = !isMuted }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, - contentDescription = stringResource(R.string.cd_toggle_sound), - tint = Color.White, - modifier = Modifier.size(16.dp) - ) - } + .padding(8.dp), + isMuted = isMuted, + onToggle = { isMuted = !isMuted } + ) } else { if (hasPath) { Image( @@ -304,119 +306,60 @@ fun VideoMessageBubble( } } } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Crop, - previewBlur = 14.dp - ) - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.25f)) - ) - - MediaLoadingAction( - isDownloading = content.isDownloading, - progress = content.downloadProgress, - idleIcon = if (content.supportsStreaming) Icons.Rounded.Stream else Icons.Default.Download, - idleContentDescription = if (content.supportsStreaming) { - stringResource(R.string.cd_stream) - } else { - stringResource(R.string.cd_download) - }, - onCancelClick = { - isAutoDownloadSuppressed = true - AutoDownloadSuppression.suppress(content.fileId) - onCancelDownload(content.fileId) - }, - onIdleClick = { - isAutoDownloadSuppressed = false - AutoDownloadSuppression.clear(content.fileId) - onVideoClick(msg) - } - ) - } + VideoLoadingLayer( + content = content, + onCancelDownload = { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(content.fileId) + onCancelDownloadState(content.fileId) + }, + onStartDownload = { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(content.fileId) + onVideoClickState(msg) + } + ) } - Box( - modifier = Modifier - .matchParentSize() - .pointerInput( - content.isDownloading, - content.fileId, - isMediaSpoilerRevealed, - stablePath, - content.supportsStreaming - ) { - detectTapGestures( - onTap = { - if (!isMediaSpoilerRevealed) { - isMediaSpoilerRevealed = true - } else if (content.isDownloading) { - isAutoDownloadSuppressed = true - AutoDownloadSuppression.suppress(content.fileId) - onCancelDownload(content.fileId) - } else { - isAutoDownloadSuppressed = false - AutoDownloadSuppression.clear(content.fileId) - onVideoClick(msg) - } - }, - onLongPress = { offset -> onLongClick(videoPosition + offset) } - ) - } + VideoInteractionOverlay( + modifier = Modifier.matchParentSize(), + content = content, + isMediaSpoilerRevealed = isMediaSpoilerRevealed, + videoPosition = { layoutTracker.videoPosition }, + onRevealSpoiler = { isMediaSpoilerRevealed = true }, + onCancelDownload = { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(content.fileId) + onCancelDownloadState(content.fileId) + }, + onOpenVideo = { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(content.fileId) + onVideoClickState(msg) + }, + onLongClick = { anchor -> onLongClickState(anchor) } ) - Box( + VideoPlaybackBadge( modifier = Modifier .align(Alignment.TopStart) - .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = if ((hasPath || content.supportsStreaming) && autoplayVideos) { - "${formatDuration(currentPositionSeconds)} / ${formatDuration(content.duration)}" - } else { - formatDuration(content.duration) - }, - style = MaterialTheme.typography.labelSmall, - color = Color.White - ) - } + .padding(8.dp), + durationSeconds = content.duration, + currentPositionSeconds = currentPositionSeconds, + showCurrentProgress = (hasPath || content.supportsStreaming) && autoplayVideos + ) - if (content.isUploading) { - Box( - modifier = Modifier - .matchParentSize() - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - if (content.uploadProgress > 0f) { - CircularWavyProgressIndicator( - progress = { content.uploadProgress }, - color = Color.White, - trackColor = Color.White.copy(alpha = 0.3f) - ) - } else { - LoadingIndicator( - color = Color.White - ) - } - } - } + VideoUploadOverlay( + isUploading = content.isUploading, + uploadProgress = content.uploadProgress + ) - SpoilerWrapper(isRevealed = isMediaSpoilerRevealed) { - Box(modifier = Modifier.fillMaxSize()) - } + VideoSpoilerOverlay( + isRevealed = isMediaSpoilerRevealed + ) if (content.caption.isEmpty() && showMetadata) { - Box( + VideoMetadataBadge( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) @@ -424,10 +367,10 @@ fun VideoMessageBubble( Color.Black.copy(alpha = 0.45f), RoundedCornerShape(12.dp) ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - MessageMetadata(msg, isOutgoing, Color.White) - } + .padding(horizontal = 6.dp, vertical = 2.dp), + msg = msg, + isOutgoing = isOutgoing + ) } } @@ -477,8 +420,8 @@ fun VideoMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(videoPosition + offset) }, - onLongClick = { offset -> onLongClick(videoPosition + offset) } + onClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) }, + onLongClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) } ) } if (showMetadata) { @@ -500,3 +443,169 @@ fun VideoMessageBubble( } } } + +@Composable +private fun VideoMuteToggle( + modifier: Modifier = Modifier, + isMuted: Boolean, + onToggle: () -> Unit +) { + Box( + modifier = modifier + .size(30.dp) + .background(Color.Black.copy(alpha = 0.45f), CircleShape) + .clickable(onClick = onToggle), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = stringResource(R.string.cd_toggle_sound), + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun VideoLoadingLayer( + content: MessageContent.Video, + onCancelDownload: () -> Unit, + onStartDownload: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + MediaLoadingBackground( + previewData = content.minithumbnail, + contentScale = ContentScale.Crop, + previewBlur = 14.dp + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.25f)) + ) + + MediaLoadingAction( + isDownloading = content.isDownloading, + progress = content.downloadProgress, + idleIcon = if (content.supportsStreaming) Icons.Rounded.Stream else Icons.Default.Download, + idleContentDescription = if (content.supportsStreaming) { + stringResource(R.string.cd_stream) + } else { + stringResource(R.string.cd_download) + }, + onCancelClick = onCancelDownload, + onIdleClick = onStartDownload + ) + } +} + +@Composable +private fun VideoInteractionOverlay( + modifier: Modifier = Modifier, + content: MessageContent.Video, + isMediaSpoilerRevealed: Boolean, + videoPosition: () -> Offset, + onRevealSpoiler: () -> Unit, + onCancelDownload: () -> Unit, + onOpenVideo: () -> Unit, + onLongClick: (Offset) -> Unit +) { + Box( + modifier = modifier.pointerInput( + content.isDownloading, + content.fileId, + isMediaSpoilerRevealed, + content.supportsStreaming + ) { + detectTapGestures( + onTap = { + if (!isMediaSpoilerRevealed) { + onRevealSpoiler() + } else if (content.isDownloading) { + onCancelDownload() + } else { + onOpenVideo() + } + }, + onLongPress = { offset -> onLongClick(videoPosition() + offset) } + ) + } + ) +} + +@Composable +private fun VideoPlaybackBadge( + modifier: Modifier = Modifier, + durationSeconds: Int, + currentPositionSeconds: Int, + showCurrentProgress: Boolean +) { + Box( + modifier = modifier + .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = if (showCurrentProgress) { + "${formatDuration(currentPositionSeconds)} / ${formatDuration(durationSeconds)}" + } else { + formatDuration(durationSeconds) + }, + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun VideoUploadOverlay( + isUploading: Boolean, + uploadProgress: Float +) { + if (!isUploading) return + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + if (uploadProgress > 0f) { + CircularWavyProgressIndicator( + progress = { uploadProgress }, + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f) + ) + } else { + LoadingIndicator( + color = Color.White + ) + } + } +} + +@Composable +private fun VideoSpoilerOverlay( + isRevealed: Boolean +) { + SpoilerWrapper(isRevealed = isRevealed) { + Box(modifier = Modifier.fillMaxSize()) + } +} + +@Composable +private fun VideoMetadataBadge( + modifier: Modifier = Modifier, + msg: MessageModel, + isOutgoing: Boolean +) { + Box(modifier = modifier) { + MessageMetadata(msg, isOutgoing, Color.White) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt index 9f060e9c..715cc616 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -59,7 +59,7 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager -import org.monogram.presentation.features.chats.currentChat.components.InlineVideoPlayer +import org.monogram.presentation.features.chats.conversation.ui.InlineVideoPlayer import org.monogram.presentation.features.stickers.ui.view.shimmerEffect import java.io.File import java.io.FileNotFoundException diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VoiceMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VoiceMessageBubble.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt index 6e399ca6..b81a4209 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VoiceMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats +package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -50,7 +50,7 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.rememberVoicePlayer +import org.monogram.presentation.features.chats.conversation.ui.LocalVoicePlaybackController @Composable fun VoiceMessageBubble( @@ -194,8 +194,11 @@ fun VoiceRow( onCancelDownload: (Int) -> Unit, isOutgoing: Boolean ) { - val playerState = rememberVoicePlayer(content.path) - playerState.ProgressUpdater() + val voicePlaybackController = LocalVoicePlaybackController.current + val playerState = voicePlaybackController.stateFor( + messageId = msg.id, + fallbackDurationSeconds = content.duration + ) Row( verticalAlignment = Alignment.CenterVertically, @@ -214,7 +217,7 @@ fun VoiceRow( } else if (content.path == null) { onVoiceClick(msg) } else { - playerState.togglePlayPause() + voicePlaybackController.togglePlayPause(msg.id, content.path) } }, contentAlignment = Alignment.Center diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/CodeHighlighter.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/CodeHighlighter.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/CodeHighlighter.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/CodeHighlighter.kt index 8d0571e4..2513a28d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/CodeHighlighter.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/CodeHighlighter.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats.code +package org.monogram.presentation.features.chats.conversation.ui.message.code import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/LanguagesConfigs.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/LanguagesConfigs.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/LanguagesConfigs.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/LanguagesConfigs.kt index a1d84bab..c80b9b3a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/code/LanguagesConfigs.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/code/LanguagesConfigs.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats.code +package org.monogram.presentation.features.chats.conversation.ui.message.code data class LanguageConfig( val keywords: Set, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/model/Mappers.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/model/Mappers.kt index 706267fd..2ccccb72 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/model/Mappers.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.chats.model +package org.monogram.presentation.features.chats.conversation.ui.message.model import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/Mapper.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/Mapper.kt similarity index 93% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/Mapper.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/Mapper.kt index 13b9dcfc..46dd36fe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/Mapper.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/Mapper.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.pins +package org.monogram.presentation.features.chats.conversation.ui.pins import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource 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/conversation/ui/pins/PinnedMessageBar.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessageBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessageBar.kt index 09222d5d..d913806a 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/conversation/ui/pins/PinnedMessageBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.pins +package org.monogram.presentation.features.chats.conversation.ui.pins import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessagesListSheet.kt similarity index 68% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessagesListSheet.kt index 759027df..239c4ea8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessagesListSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components.pins +package org.monogram.presentation.features.chats.conversation.ui.pins import androidx.compose.animation.core.animate import androidx.compose.animation.core.spring @@ -9,12 +9,40 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -32,13 +60,16 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem -import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum -import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer -import org.monogram.presentation.features.chats.currentChat.components.ChannelMessageBubbleContainer -import org.monogram.presentation.features.chats.currentChat.components.DateSeparator -import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.DateSeparator +import org.monogram.presentation.features.chats.conversation.ui.MessageAppearanceConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.MessageRowBehaviorConfig +import org.monogram.presentation.features.chats.conversation.ui.buildSenderGrouping +import org.monogram.presentation.features.chats.conversation.ui.channel.ChannelMessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.content.GroupedMessageItem +import org.monogram.presentation.features.chats.conversation.ui.content.groupMessagesByAlbum +import org.monogram.presentation.features.chats.conversation.ui.content.shouldShowDate import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -162,15 +193,15 @@ fun PinnedMessagesListSheet( return@draggable } - val shouldDismiss = - dismissOffsetY > dismissDistanceThresholdPx || - velocity > dismissVelocityThresholdPx + val shouldDismiss = + dismissOffsetY > dismissDistanceThresholdPx || + velocity > dismissVelocityThresholdPx - if (shouldDismiss) { - onDismissRequest() - } else { - scope.launch { - animate( + if (shouldDismiss) { + onDismissRequest() + } else { + scope.launch { + animate( initialValue = dismissOffsetY, targetValue = 0f, animationSpec = spring() @@ -273,41 +304,73 @@ fun PinnedMessagesListSheet( Box(modifier = Modifier.animateItem()) { if (isChannel) { if (item is GroupedMessageItem.Single) { + val behavior = MessageRowBehaviorConfig( + isGroup = false, + isChannel = true, + isTopicClosed = false, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = false + ) + val appearance = MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadFiles = autoDownloadFiles + ) ChannelMessageBubbleContainer( msg = item.message, - olderMsg = olderMsg, newerMsg = newerMsg, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadFiles = autoDownloadFiles, + appearance = appearance, + behavior = behavior, + senderGrouping = buildSenderGrouping( + item, + olderMsg, + newerMsg + ), onPhotoClick = { onMessageClick(it) }, onVideoClick = { onMessageClick(it) }, onDocumentClick = { onMessageClick(it) }, onReplyClick = { _, _, _ -> onMessageClick(item.message) }, onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - stickerSize = stickerSize, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { AlbumMessageBubbleContainer( messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = false, - isChannel = true, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, + appearance = MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos + ), + behavior = MessageRowBehaviorConfig( + isGroup = false, + isChannel = true, + isTopicClosed = false, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = false + ), + senderGrouping = buildSenderGrouping( + item, + olderMsg, + newerMsg + ), onPhotoClick = { onMessageClick(it) }, onVideoClick = { onMessageClick(it) }, onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, - canReply = false, downloadUtils = downloadUtils ) } @@ -315,19 +378,33 @@ fun PinnedMessagesListSheet( if (item is GroupedMessageItem.Single) { MessageBubbleContainer( msg = item.message, - olderMsg = olderMsg, newerMsg = newerMsg, - isGroup = isGroup, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - stSize = stickerSize, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoDownloadFiles = autoDownloadFiles, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, + appearance = MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles + ), + behavior = MessageRowBehaviorConfig( + isGroup = isGroup, + isChannel = false, + isTopicClosed = false, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = false + ), + senderGrouping = buildSenderGrouping( + item, + olderMsg, + newerMsg + ), onPhotoClick = { onMessageClick(it) }, onVideoClick = { onMessageClick(it) }, onDocumentClick = { onMessageClick(it) }, @@ -335,25 +412,39 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, - canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { AlbumMessageBubbleContainer( messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = isGroup, - isChannel = false, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, + appearance = MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos + ), + behavior = MessageRowBehaviorConfig( + isGroup = isGroup, + isChannel = false, + isTopicClosed = false, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = false + ), + senderGrouping = buildSenderGrouping( + item, + olderMsg, + newerMsg + ), onPhotoClick = { onMessageClick(it) }, onVideoClick = { onMessageClick(it) }, onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, - canReply = false, downloadUtils = downloadUtils ) } @@ -451,3 +542,4 @@ private fun PinnedMessagesLoadingSkeleton( } } } + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/DefaultNewChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/DefaultNewChatComponent.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/newChat/DefaultNewChatComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/creation/DefaultNewChatComponent.kt index b221d8d0..455f023b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/DefaultNewChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/DefaultNewChatComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.newChat +package org.monogram.presentation.features.chats.creation import com.arkivanov.mvikotlin.core.instancekeeper.getStore import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatComponent.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatComponent.kt index 77581abd..cb6cdc8f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.newChat +package org.monogram.presentation.features.chats.creation import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.UserModel diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatContent.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatContent.kt index 2ab9333f..7aad77a4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.newChat +package org.monogram.presentation.features.chats.creation import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -48,10 +48,10 @@ import org.monogram.presentation.core.ui.shimmerBackground import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.FileUtils import org.monogram.presentation.core.util.getUserStatusText -import org.monogram.presentation.features.chats.chatList.components.NewChannelContent -import org.monogram.presentation.features.chats.chatList.components.NewGroupContent -import org.monogram.presentation.features.chats.chatList.components.SectionHeader -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.features.chats.list.components.NewChannelContent +import org.monogram.presentation.features.chats.list.components.NewGroupContent +import org.monogram.presentation.core.ui.SectionHeader +import org.monogram.presentation.core.ui.SettingsTextField @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStore.kt similarity index 95% rename from presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStore.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStore.kt index cdbecc41..64156048 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStore.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.newChat +package org.monogram.presentation.features.chats.creation import com.arkivanov.mvikotlin.core.store.Store diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStoreFactory.kt similarity index 92% rename from presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStoreFactory.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStoreFactory.kt index 7de0bdf4..811e6c4c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/creation/NewChatStoreFactory.kt @@ -1,11 +1,11 @@ -package org.monogram.presentation.features.chats.newChat +package org.monogram.presentation.features.chats.creation import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import org.monogram.presentation.features.chats.newChat.NewChatStore.Intent -import org.monogram.presentation.features.chats.newChat.NewChatStore.Label +import org.monogram.presentation.features.chats.creation.NewChatStore.Intent +import org.monogram.presentation.features.chats.creation.NewChatStore.Label class NewChatStoreFactory( private val storeFactory: StoreFactory, 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 deleted file mode 100644 index 12bacc0d..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ /dev/null @@ -1,2025 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat - -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.interaction.collectIsDraggedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -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.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.rounded.Block -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.zIndex -import androidx.window.core.layout.WindowWidthSizeClass -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import org.monogram.domain.models.ChatViewportCacheEntry -import org.monogram.domain.models.ForwardInfo -import org.monogram.domain.models.MessageContent -import org.monogram.domain.models.MessageModel -import org.monogram.domain.models.ReplyMarkupModel -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.ChatMessageListUiState -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.MessageListShimmer -import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet -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.inputbar.ChatInputBarActions -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarState -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 -import java.io.File -import java.io.FileOutputStream -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import kotlin.math.abs - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun ChatContent( - component: ChatComponent, - isOverlay: Boolean = false, -) { - val state by component.state.collectAsState() - val scrollState = rememberLazyListState() - val context = LocalContext.current - val density = LocalDensity.current - val localClipboard = LocalClipboard.current - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - val coroutineScope = rememberCoroutineScope() - - val adaptiveInfo = currentWindowAdaptiveInfo() - val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current - val isTablet = - adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && isTabletInterfaceEnabled - - var isVisible by remember { mutableStateOf(false) } - var showInitialLoading by remember { mutableStateOf(false) } - var isRecordingVideo by remember { mutableStateOf(false) } - var topOverlayHeight by remember { mutableStateOf(0.dp) } - - // Menu States - var selectedMessageId by rememberSaveable { mutableStateOf(null) } - val transformedMessageTexts = remember { mutableStateMapOf() } - val originalMessageTexts = remember { mutableStateMapOf() } - val latestMessagesState = rememberUpdatedState(state.messages) - val selectedMessageIdState = rememberUpdatedState(selectedMessageId) - val displayMessages by remember { - derivedStateOf { - val baseMessages = latestMessagesState.value - baseMessages.map { message -> - val transformedText = transformedMessageTexts[message.id] - val transformedMessage = if (transformedText != null) { - message.withUpdatedTextContent(transformedText) - } else { - message - } - - if (state.rootMessage != null && transformedMessage.replyToMsgId == state.rootMessage?.id) { - transformedMessage.copy( - replyToMsgId = null, - replyToMsg = null - ) - } else { - transformedMessage - } - } - } - } - val displayMessagesById by remember(displayMessages) { - derivedStateOf { displayMessages.associateBy(MessageModel::id) } - } - val selectedMessage by remember { - derivedStateOf { - val currentSelectedId = selectedMessageIdState.value - currentSelectedId?.let(displayMessagesById::get) - } - } - var menuOffset by remember { mutableStateOf(Offset.Zero) } - var menuMessageSize by remember { mutableStateOf(IntSize.Zero) } - var clickOffset by remember { mutableStateOf(Offset.Zero) } - var contentRect by remember { mutableStateOf(Rect.Zero) } - - var pendingMediaPaths by rememberSaveable { mutableStateOf>(emptyList()) } - var pendingDocumentPaths by rememberSaveable { mutableStateOf>(emptyList()) } - var editingPhotoPath by rememberSaveable { mutableStateOf(null) } - var editingVideoPath by rememberSaveable { mutableStateOf(null) } - var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } - - val groupedMessages by remember { - derivedStateOf { groupMessagesByAlbum(displayMessages) } - } - val latestUiState = rememberUpdatedState(state) - val groupedMessageIndexById by remember(groupedMessages) { - derivedStateOf { - buildMap { - groupedMessages.forEachIndexed { index, item -> - when (item) { - is GroupedMessageItem.Single -> put(item.message.id, index) - is GroupedMessageItem.Album -> item.messages.forEach { message -> - put(message.id, index) - } - } - } - } - } - } - val isComments = state.rootMessage != null - val isForumList = state.viewAsTopics && state.currentTopicId == null - var showScrollToBottomButton by remember { mutableStateOf(false) } - var hasUserScrolledAwayFromBottom by rememberSaveable(state.chatId, state.currentTopicId) { - mutableStateOf(false) - } - val isDragged by scrollState.interactionSource.collectIsDraggedAsState() - - val isAnyViewerOpen = state.fullScreenImages != null || - state.fullScreenVideoPath != null || - state.fullScreenVideoMessageId != null || - state.youtubeUrl != null || - state.instantViewUrl != null || - state.miniAppUrl != null || - state.webViewUrl != null || - editingPhotoPath != null || - editingVideoPath != null || - isRecordingVideo - - val scrollToMessageState = rememberUpdatedState(newValue = { msg: MessageModel -> - val index = groupedMessageIndexById[msg.id] ?: -1 - if (index != -1) { - coroutineScope.launch { - val leadingItems = chatContentLeadingItemsCount( - isComments = isComments, - showNavPadding = false, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - hasMessages = groupedMessages.isNotEmpty() - ) - val targetIndex = groupedIndexToLazyIndex(index, leadingItems) - - scrollState.scrollToMessageIndex( - index = targetIndex, - align = ScrollAlign.Center, - animated = state.isChatAnimationsEnabled, - staged = true - ) - } - } else { - component.onPinnedMessageClick(msg) - } - }) - - LaunchedEffect(Unit) { - isVisible = true - if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) { - component.onDismissVideo() - } - } - - LaunchedEffect(state.messages) { - if (transformedMessageTexts.isEmpty() && originalMessageTexts.isEmpty()) return@LaunchedEffect - val ids = state.messages.map { it.id }.toSet() - transformedMessageTexts.keys.toList().forEach { id -> - if (id !in ids) { - transformedMessageTexts.remove(id) - originalMessageTexts.remove(id) - } - } - } - - // Initial Loading Delay logic - LaunchedEffect( - state.isLoading, - state.messages.isEmpty(), - state.viewAsTopics, - state.currentTopicId, - state.isLoadingTopics, - state.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 - } else { - state.isLoading && state.messages.isEmpty() - } - if (isActuallyLoading) { - if (state.isChatAnimationsEnabled) delay(200) - showInitialLoading = true - } else { - showInitialLoading = false - } - } - - // Unified command-based scrolling: restore, jump, bottom. - LaunchedEffect(state.pendingScrollCommand, isComments) { - val command = state.pendingScrollCommand ?: return@LaunchedEffect - - val leadingItems = chatContentLeadingItemsCount( - isComments = isComments, - showNavPadding = false, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - hasMessages = groupedMessages.isNotEmpty() - ) - - when (command) { - is ChatScrollCommand.RestoreViewport -> { - if (command.atBottom || command.anchorMessageId == null) { - scrollState.scrollToChatBottomStaged( - isComments = isComments, - animated = false - ) - } else { - val groupedIndex = groupedMessageIndexById[command.anchorMessageId] - ?: awaitGroupedIndex( - messageId = command.anchorMessageId, - groupedMessageIndexByIdProvider = { groupedMessageIndexById } - ) - ?: -1 - if (groupedIndex >= 0) { - val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) - scrollState.restoreViewportAtIndex( - targetIndex = targetIndex, - anchorOffsetPx = command.anchorOffsetPx - ) - } else { - scrollState.scrollToChatBottomStaged( - isComments = isComments, - animated = false - ) - } - } - component.onScrollCommandConsumed() - } - - is ChatScrollCommand.JumpToMessage -> { - val groupedIndex = groupedMessageIndexById[command.messageId] - ?: awaitGroupedIndex( - messageId = command.messageId, - groupedMessageIndexByIdProvider = { groupedMessageIndexById } - ) - ?: -1 - if (groupedIndex >= 0) { - val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) - scrollState.scrollToMessageIndex( - index = targetIndex, - align = command.align, - animated = command.animated && state.isChatAnimationsEnabled, - staged = true - ) - } - component.onScrollCommandConsumed() - } - - is ChatScrollCommand.ScrollToBottom -> { - scrollState.scrollToChatBottomStaged( - isComments = isComments, - animated = command.animated && state.isChatAnimationsEnabled - ) - component.onScrollCommandConsumed() - } - - is ChatScrollCommand.ScrollToStart -> { - scrollState.scrollToChatStartStaged( - animated = command.animated && state.isChatAnimationsEnabled - ) - component.onScrollCommandConsumed() - } - } - } - - // Unified bottom-status + bottom-button controller with hysteresis/debounce for smoothness. - LaunchedEffect( - scrollState, - isComments, - isForumList, - showInitialLoading, - isDragged - ) { - var lastReportedBottomState: Boolean? = null - snapshotFlow { - BottomVisibilitySnapshot( - isAtBottom = scrollState.isAtBottom( - isComments = isComments, - isLatestLoaded = state.isLatestLoaded - ), - isNearBottom = scrollState.isNearBottom( - isComments = isComments - ), - unreadCount = state.unreadCount - ) - } - .distinctUntilChanged() - .collectLatest { snapshot -> - if (lastReportedBottomState != snapshot.isAtBottom) { - component.onBottomReached(snapshot.isAtBottom) - lastReportedBottomState = snapshot.isAtBottom - } - - if (snapshot.isNearBottom) { - hasUserScrolledAwayFromBottom = false - } else if (isDragged) { - hasUserScrolledAwayFromBottom = true - } - - val shouldShow = !isForumList && - !showInitialLoading && - (snapshot.unreadCount > 0 || (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom)) - - if (shouldShow) { - showScrollToBottomButton = true - } else { - delay(120) - val keepVisible = snapshot.unreadCount > 0 || - (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom) - if (!keepVisible) { - showScrollToBottomButton = false - } - } - } - } - - // Save full viewport (anchor + pixel offset) for precise restore after reopen. - LaunchedEffect( - scrollState, - groupedMessages, - isComments, - state.isLatestLoaded, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom - ) { - snapshotFlow { - buildViewportSnapshot( - scrollState = scrollState, - groupedMessages = groupedMessages, - isComments = isComments, - isLatestLoaded = state.isLatestLoaded, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - showNavPadding = false - ) - } - .filterNotNull() - .distinctUntilChanged() - .debounce(120) - .collect { viewport -> - component.updateViewport(viewport) - } - } - - DisposableEffect( - scrollState, - groupedMessages, - isComments, - state.currentTopicId, - state.isLatestLoaded, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom - ) { - onDispose { - val viewport = buildViewportSnapshot( - scrollState = scrollState, - groupedMessages = groupedMessages, - isComments = isComments, - isLatestLoaded = state.isLatestLoaded, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - showNavPadding = false - ) - if (viewport != null) { - component.updateViewport(viewport) - } - } - } - - // Performance: Update visible range for repository - LaunchedEffect(scrollState, groupedMessages) { - snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } - .map { visibleItems -> - val currentState = latestUiState.value - val leadingItemsCount = chatContentLeadingItemsCount( - isComments = currentState.rootMessage != null, - showNavPadding = false, - isLoadingOlder = currentState.isLoadingOlder, - isLoadingNewer = currentState.isLoadingNewer, - isAtBottom = currentState.isAtBottom, - hasMessages = groupedMessages.isNotEmpty() - ) - val visibleIds = LinkedHashSet() - val nearbyIds = LinkedHashSet() - if (visibleItems.isNotEmpty()) { - val minIndex = visibleItems.minOf { it.index } - val maxIndex = visibleItems.maxOf { it.index } - - visibleItems.forEach { item -> - val groupedIndex = lazyIndexToGroupedIndex(item.index, leadingItemsCount) - groupedMessages.getOrNull(groupedIndex)?.let { grouped -> - when (grouped) { - is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) - is GroupedMessageItem.Album -> grouped.messages.forEach { message -> - visibleIds.add(message.id) - } - } - } - } - - val nearbyStart = (minIndex - 5).coerceAtLeast(0) - val nearbyEnd = maxIndex + 5 - for (index in nearbyStart..nearbyEnd) { - if (index in minIndex..maxIndex) continue - val groupedIndex = lazyIndexToGroupedIndex(index, leadingItemsCount) - groupedMessages.getOrNull(groupedIndex)?.let { grouped -> - when (grouped) { - is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) - is GroupedMessageItem.Album -> grouped.messages.forEach { message -> - nearbyIds.add(message.id) - } - } - } - } - } - val visibleIdList = visibleIds.toList() - visibleIdList to nearbyIds.filterNot(visibleIds::contains) - } - .distinctUntilChanged() - .debounce(100) - .collect { (visibleIds, nearbyIds) -> - (component as? DefaultChatComponent)?.let { - it.repositoryMessage.updateVisibleRange(it.chatId, visibleIds, nearbyIds) - } - } - } - - // 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 - - val isAtBottomNow = scrollState.isAtBottom( - isComments = isComments, - isLatestLoaded = state.isLatestLoaded - ) - if ((state.isAtBottom || isAtBottomNow) && - !state.isLoading && - !state.isLoadingOlder && - !state.isLoadingNewer && - !scrollState.isScrollInProgress - ) { - scrollState.scrollToChatBottomStaged( - isComments = isComments, - animated = state.isChatAnimationsEnabled - ) - } - } - - // Scroll Management - LaunchedEffect(isDragged) { - if (isDragged) { - focusManager.clearFocus() - keyboardController?.hide() - } - } - - LaunchedEffect(state.showBotCommands, isRecordingVideo) { - if (state.showBotCommands || isRecordingVideo) { - focusManager.clearFocus(force = true) - keyboardController?.hide() - } - } - - // Pick Media Result - val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> - val albumPaths = mutableListOf() - uris.forEach { uri -> - val mimeType = context.contentResolver.getType(uri) - val extension = when { - mimeType == "image/gif" -> "gif" - mimeType?.startsWith("video/") == true -> "mp4" - else -> "jpg" - } - val file = File(context.cacheDir, "temp_media_${System.nanoTime()}.$extension") - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(file).use { output -> input.copyTo(output) } - } - if (extension == "gif") component.onSendGifFile(file.absolutePath) - else albumPaths.add(file.absolutePath) - } - if (albumPaths.isNotEmpty()) pendingMediaPaths = (pendingMediaPaths + albumPaths).distinct() - if (albumPaths.isNotEmpty()) pendingDocumentPaths = emptyList() - } - - - val shouldAnimateContentEntrance = state.isChatAnimationsEnabled && isOverlay - val contentAlpha by animateFloatAsState( - targetValue = if (isVisible || !shouldAnimateContentEntrance) 1f else 0f, - animationSpec = if (shouldAnimateContentEntrance) tween(300) else snap(), - label = "ContentAlpha" - ) - val contentOffset by animateDpAsState( - targetValue = if (isVisible || !shouldAnimateContentEntrance) 0.dp else 20.dp, - animationSpec = if (shouldAnimateContentEntrance) tween(300) else snap(), - label = "ContentOffset" - ) - - val canWriteText by remember(state.isAdmin, state.permissions.canSendBasicMessages) { - derivedStateOf { state.isAdmin || state.permissions.canSendBasicMessages } - } - val canSendPhotos by remember(state.isAdmin, state.permissions.canSendPhotos) { - derivedStateOf { state.isAdmin || state.permissions.canSendPhotos } - } - val canSendVideos by remember(state.isAdmin, state.permissions.canSendVideos) { - derivedStateOf { state.isAdmin || state.permissions.canSendVideos } - } - val canSendDocuments by remember(state.isAdmin, state.permissions.canSendDocuments) { - derivedStateOf { state.isAdmin || state.permissions.canSendDocuments } - } - val canSendAudios by remember(state.isAdmin, state.permissions.canSendAudios) { - derivedStateOf { state.isAdmin || state.permissions.canSendAudios } - } - val canUseMediaPicker by remember(canSendPhotos, canSendVideos) { - derivedStateOf { canSendPhotos || canSendVideos } - } - val canUseDocumentPicker by remember(canSendDocuments, canSendAudios) { - derivedStateOf { canSendDocuments || canSendAudios } - } - val canSendPolls by remember(state.isAdmin, state.permissions.canSendPolls) { - derivedStateOf { state.isAdmin || state.permissions.canSendPolls } - } - val canOpenAttachSheet by remember( - canUseMediaPicker, - canUseDocumentPicker, - canSendPolls, - state.attachMenuBots - ) { - derivedStateOf { canUseMediaPicker || canUseDocumentPicker || canSendPolls || state.attachMenuBots.isNotEmpty() } - } - val canSendStickers by remember(state.isAdmin, state.permissions.canSendOtherMessages) { - derivedStateOf { state.isAdmin || state.permissions.canSendOtherMessages } - } - val canSendVoice by remember(state.isAdmin, state.permissions.canSendVoiceNotes) { - derivedStateOf { state.isAdmin || state.permissions.canSendVoiceNotes } - } - val canSendVideoNotes by remember(state.isAdmin, state.permissions.canSendVideoNotes) { - derivedStateOf { state.isAdmin || state.permissions.canSendVideoNotes } - } - val canSendAnything by remember( - canWriteText, - canOpenAttachSheet, - canSendStickers, - canSendVoice, - canSendVideoNotes, - canSendPolls - ) { - derivedStateOf { - canWriteText || canOpenAttachSheet || canSendStickers || canSendVoice || canSendVideoNotes || canSendPolls - } - } - - val messageListState = remember( - state.chatId, - state.currentTopicId, - displayMessages, - state.selectedMessageIds, - state.unreadSeparatorCount, - state.unreadSeparatorLastReadInboxMessageId, - state.viewAsTopics, - state.topics, - state.rootMessage, - state.isLoading, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom, - state.isLatestLoaded, - state.isOldestLoaded, - state.isGroup, - state.isChannel, - state.isAdmin, - state.canWrite, - canSendAnything, - state.highlightedMessageId, - state.fontSize, - state.letterSpacing, - state.bubbleRadius, - state.stickerSize, - state.autoDownloadMobile, - state.autoDownloadWifi, - state.autoDownloadRoaming, - state.autoDownloadFiles, - state.autoplayGifs, - state.autoplayVideos, - state.showLinkPreviews, - state.isChatAnimationsEnabled, - showInitialLoading, - state.pendingScrollCommand - ) { - ChatMessageListUiState( - chatId = state.chatId, - currentTopicId = state.currentTopicId, - messages = displayMessages, - selectedMessageIds = state.selectedMessageIds, - unreadSeparatorCount = state.unreadSeparatorCount, - unreadSeparatorLastReadInboxMessageId = state.unreadSeparatorLastReadInboxMessageId, - viewAsTopics = state.viewAsTopics, - topics = state.topics, - rootMessage = state.rootMessage, - isLoading = state.isLoading, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - isLatestLoaded = state.isLatestLoaded, - isOldestLoaded = state.isOldestLoaded, - isGroup = state.isGroup, - isChannel = state.isChannel, - isAdmin = state.isAdmin, - canWrite = state.canWrite, - canSendAnything = canSendAnything, - highlightedMessageId = state.highlightedMessageId, - 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, - showLinkPreviews = state.showLinkPreviews, - isChatAnimationsEnabled = state.isChatAnimationsEnabled, - suppressEntryAnimations = showInitialLoading || state.pendingScrollCommand != null - ) - } - - val showInputBar by remember( - state.isChannel, - state.isGroup, - state.canWrite, - state.isCurrentUserRestricted, - state.currentTopicId, - state.selectedMessageIds, - state.viewAsTopics, - isRecordingVideo - ) { - derivedStateOf { - (state.canWrite || state.isCurrentUserRestricted) && - !isRecordingVideo && - state.selectedMessageIds.isEmpty() && - (!state.viewAsTopics || state.currentTopicId != null) - } - } - - val showJoinButton by remember( - showInputBar, - state.isMember, - state.isChannel, - state.isGroup, - state.canWrite, - state.isCurrentUserRestricted, - state.selectedMessageIds, - state.viewAsTopics, - state.currentTopicId, - isRecordingVideo - ) { - derivedStateOf { - !showInputBar && - !state.isMember && - (state.isChannel || state.isGroup) && - !state.canWrite && - !state.isCurrentUserRestricted && - !isRecordingVideo && - state.selectedMessageIds.isEmpty() && - (!state.viewAsTopics || state.currentTopicId != null) - } - } - - var containerSize by remember { mutableStateOf(IntSize.Zero) } - var renderPinnedMessagesList by rememberSaveable { mutableStateOf(state.showPinnedMessagesList) } - var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } - - LaunchedEffect(state.showPinnedMessagesList) { - if (state.showPinnedMessagesList) { - renderPinnedMessagesList = true - } - } - - val requestPinnedMessagesListDismiss = { - if (state.showPinnedMessagesList) { - component.onDismissPinnedMessages() - } - } - - val isCustomBackHandlingEnabled by remember( - 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 - ) { - 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 - } - } - val selectedCount = state.selectedMessageIds.size - val selectedMessageIdSet by remember(state.selectedMessageIds) { - derivedStateOf { state.selectedMessageIds.toHashSet() } - } - val canRevokeSelected by remember(state.messages, selectedMessageIdSet) { - derivedStateOf { - if (selectedMessageIdSet.isEmpty()) { - false - } else { - state.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 - ) { - 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 - ) - } - - CompositionLocalProvider(LocalLinkHandler provides { component.onLinkClick(it) }) { - val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val headerOverlayHeight = statusBarHeight + 16.dp - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .onGloballyPositioned { containerSize = it.size } - ) { - Box( - modifier = Modifier - .fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = contentAlpha - translationY = contentOffset.toPx() - } - ) { - ChatContentBackground(state = state) - } - - if (isTablet) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(headerOverlayHeight) - .graphicsLayer { - alpha = contentAlpha - translationY = contentOffset.toPx() - } - .background(MaterialTheme.colorScheme.surface) - ) - } - - Scaffold( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = contentAlpha; translationY = contentOffset.toPx() } - .semantics { contentDescription = "ChatContent" }, - containerColor = Color.Transparent, - topBar = { - Box( - modifier = Modifier.onSizeChanged { - topOverlayHeight = with(density) { it.height.toDp() } - } - ) { - ChatContentTopBar( - topBarState = topBarUiState, - selectedCount = selectedCount, - canRevokeSelected = canRevokeSelected, - component = component, - contentAlpha = contentAlpha, - onBack = { - keyboardController?.hide() - if (state.currentTopicId != null) { - component.onTopicClick(0) - } else { - component.onBackClicked() - } - }, - onOpenMenu = { - keyboardController?.hide() - focusManager.clearFocus(force = true) - }, - onPinnedMessageClick = { msg -> scrollToMessageState.value(msg) }, - showBack = !isTablet - ) - } - }, - bottomBar = { - if (showInputBar) { - val inputBarState = - remember(state, pendingMediaPaths, pendingDocumentPaths) { - ChatInputBarState( - replyMessage = state.replyMessage, - editingMessage = state.editingMessage, - draftText = state.draftText, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, - isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed - ?: false, - permissions = state.effectiveInputPermissions - ?: 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 - ) - } - - val inputBarActions = - remember(component, pendingMediaPaths, pendingDocumentPaths) { - ChatInputBarActions( - onSend = { text, entities, options -> - component.onSendMessage( - text, - entities, - options - ) - }, - onStickerClick = { component.onSendSticker(it) }, - onGifClick = { component.onSendGif(it) }, - onAttachClick = { - pickMedia.launch( - PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageAndVideo - ) - ) - }, - onCameraClick = { - keyboardController?.hide() - focusManager.clearFocus(force = true) - isRecordingVideo = true - }, - onSendVoice = { path, duration, waveform -> - component.onSendVoice(path, duration, waveform) - }, - onCancelReply = { component.onCancelReply() }, - onCancelEdit = { component.onCancelEdit() }, - onSaveEdit = { t, e -> component.onSaveEditedMessage(t, e) }, - onDraftChange = { component.onDraftChange(it) }, - onTyping = { component.onTyping() }, - onCancelMedia = { pendingMediaPaths = emptyList() }, - onSendMedia = { paths, caption, captionEntities, options -> - if (options.sendAsDocument) { - if (paths.size > 1) { - component.onSendAlbum( - paths, - caption, - captionEntities, - options - ) - } else { - paths.firstOrNull()?.let { - component.onSendDocument( - it, - caption, - captionEntities, - options - ) - } - } - } else if (paths.size > 1) { - component.onSendAlbum( - paths, - caption, - captionEntities, - options - ) - } else { - paths.firstOrNull()?.let { - if (it.endsWith(".mp4")) component.onSendVideo( - it, - caption, - captionEntities, - options - ) - else component.onSendPhoto( - it, - caption, - captionEntities, - options - ) - } - } - pendingMediaPaths = emptyList() - pendingDocumentPaths = emptyList() - }, - onSendDocuments = { paths, caption, captionEntities, options -> - paths.forEachIndexed { index, path -> - component.onSendDocument( - path, - caption = if (index == 0) caption else "", - captionEntities = if (index == 0) captionEntities else emptyList(), - sendOptions = options - ) - } - pendingDocumentPaths = emptyList() - pendingMediaPaths = emptyList() - }, - onMediaOrderChange = { - pendingMediaPaths = it - if (it.isNotEmpty()) { - pendingDocumentPaths = emptyList() - } - }, - onDocumentOrderChange = { - pendingDocumentPaths = it - if (it.isNotEmpty()) { - pendingMediaPaths = emptyList() - } - }, - onMediaClick = { path -> - if (path.endsWith(".mp4")) { - editingVideoPath = path - } else { - editingPhotoPath = path - } - }, - onShowBotCommands = { - keyboardController?.hide() - focusManager.clearFocus(force = true) - component.onShowBotCommands() - }, - onReplyMarkupButtonClick = { - component.onReplyMarkupButtonClick( - 0, - it, - if (state.isBot) state.chatId else 0L - ) - }, - onOpenMiniApp = { url, name -> - component.onOpenMiniApp( - url, - name, - if (state.isBot) state.chatId else 0L - ) - }, - onMentionQueryChange = { component.onMentionQueryChange(it) }, - onInlineQueryChange = { bot, query -> - component.onInlineQueryChange(bot, query) - }, - onLoadMoreInlineResults = { offset -> - component.onLoadMoreInlineResults(offset) - }, - onSendInlineResult = { resultId -> component.onSendInlineResult(resultId) }, - onInlineSwitchPm = { botUsername, parameter -> - val encodedParameter = URLEncoder.encode( - parameter, - StandardCharsets.UTF_8.name() - ) - component.onLinkClick("https://t.me/$botUsername?start=$encodedParameter") - }, - onAttachBotClick = { bot -> - component.onOpenAttachBot(bot.botUserId, bot.name) - }, - onSendPoll = { poll -> - component.onSendPoll(poll) - }, - onRefreshScheduledMessages = { component.onRefreshScheduledMessages() }, - onEditScheduledMessage = { message -> component.onEditMessage(message) }, - onDeleteScheduledMessage = { message -> component.onDeleteMessage(message) }, - onSendScheduledNow = { message -> component.onSendScheduledNow(message) } - ) - } - - ChatInputBar( - state = inputBarState, - actions = inputBarActions, - appPreferences = component.appPreferences, - stickerRepository = component.stickerRepository - ) - } else if (showJoinButton) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp) - .windowInsetsPadding(WindowInsets.navigationBars), - contentAlignment = Alignment.Center - ) { - Button( - onClick = { component.onJoinChat() }, - shapes = ExpressiveDefaults.largeButtonShapes(), - modifier = Modifier - .fillMaxWidth() - .height(ButtonDefaults.MediumContainerHeight) - ) { - Text( - text = stringResource(R.string.action_join), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold - ) - } - } - } - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = if (!state.canWrite && !showJoinButton) 0.dp else padding.calculateBottomPadding()) - .consumeWindowInsets(padding) - .onGloballyPositioned { coordinates -> - contentRect = Rect( - offset = coordinates.positionInWindow(), - size = coordinates.size.toSize() - ) - } - ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = contentAlpha - translationY = contentOffset.toPx() - } - ) { - val currentKeyboardController = rememberUpdatedState(keyboardController) - val currentFocusManager = rememberUpdatedState(focusManager) - val currentIsVisible = rememberUpdatedState(isVisible) - val currentShowInitialLoading = rememberUpdatedState(showInitialLoading) - - val onPhotoDownloadStable: (Int) -> Unit = remember(component) { - { fileId: Int -> - if (fileId != 0) { - component.onDownloadFile(fileId) - } - } - } - - val onPhotoClickStable: (MessageModel, List, List, List, Int) -> Unit = - remember(component) { - { msg: MessageModel, paths: List, captions: List, messageIds: List, index: Int -> - val content = msg.content as? MessageContent.Photo - val clickedPath = paths.getOrNull(index) - ?.takeIf { it.isNotBlank() && File(it).exists() } - ?: content?.path?.takeIf { File(it).exists() } - - if (clickedPath != null) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - - val validItems = paths.mapIndexedNotNull { itemIndex, path -> - val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } - ?: return@mapIndexedNotNull null - Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) - } - - if (validItems.isNotEmpty()) { - val validPaths = validItems.map { it.second } - val validCaptions = validItems.map { it.third } - val validMessageIds = validItems.map { (itemIndex, _, _) -> - messageIds.getOrNull(itemIndex) ?: msg.id - } - val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } - .takeIf { it >= 0 } - ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } - ?: 0 - - component.onOpenImages( - images = validPaths, - captions = validCaptions, - startIndex = startIndex, - messageId = msg.id, - messageIds = validMessageIds - ) - } - } else { - content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) - } - Unit - } - } - - val onVideoClickStable: (MessageModel, String?, String?) -> Unit = - remember(component, scrollState) { - { msg: MessageModel, path: String?, caption: String? -> - if (!currentIsVisible.value || currentShowInitialLoading.value || scrollState.isScrollInProgress) { - Unit - } else { - val videoContent = msg.content as? MessageContent.Video - val supportsStreaming = videoContent?.supportsStreaming ?: false - val validPath = path?.takeIf { File(it).exists() } - - if (validPath != null || supportsStreaming) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) - } else { - val fileId = when (val c = msg.content) { - is MessageContent.Video -> c.fileId - is MessageContent.Gif -> c.fileId - else -> 0 - } - if (fileId != 0) { - component.onDownloadFile(fileId) - } - } - } - } - } - - val onDocumentClickStable: (MessageModel) -> Unit = remember(component) { - { msg: MessageModel -> - val doc = msg.content as? MessageContent.Document - if (doc != null) { - val validDocPath = doc.path?.takeIf { File(it).exists() } - if (validDocPath != null) { - val path = validDocPath.lowercase() - if (path.endsWith(".jpg") || path.endsWith(".png")) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenImages( - images = listOf(validDocPath), - captions = listOf(doc.caption), - startIndex = 0, - messageId = msg.id, - messageIds = listOf(msg.id) - ) - } else if (path.endsWith(".mp4")) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo( - path = validDocPath, - messageId = msg.id, - caption = doc.caption - ) - } else { - component.downloadUtils.openFile(validDocPath) - } - } else { - component.onDownloadFile(doc.fileId) - } - } - Unit - } - } - - val onAudioClickStable: (MessageModel) -> Unit = remember(component) { - { msg: MessageModel -> - val audio = msg.content as? MessageContent.Audio - if (audio != null) { - val validAudioPath = audio.path?.takeIf { File(it).exists() } - if (validAudioPath != null) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo( - path = validAudioPath, - messageId = msg.id, - caption = audio.caption - ) - } else { - component.onDownloadFile(audio.fileId) - } - } - Unit - } - } - - val onMessageOptionsClickStable: (MessageModel, Offset, IntSize, Offset) -> Unit = - remember(component) { - { msg: MessageModel, pos: Offset, size: IntSize, clickPos: Offset -> - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus(force = true) - selectedMessageId = msg.id - menuOffset = pos - menuMessageSize = size - clickOffset = clickPos - } - } - - val onGoToReplyStable: (MessageModel) -> Unit = remember(scrollToMessageState) { - { msg: MessageModel -> - scrollToMessageState.value(msg) - } - } - - val onMessagePositionChangeStable: (Offset, IntSize) -> Unit = remember { - { pos: Offset, size: IntSize -> - menuOffset = pos - menuMessageSize = size - } - } - - val onViaBotClickStable: (String) -> Unit = remember(component) { - { botUsername: String -> - val prefill = "@$botUsername " - component.onDraftChange(prefill) - component.onInlineQueryChange("", "") - } - } - - val toProfileStable: (Long) -> Unit = remember(component) { - { userId: Long -> - component.toProfile(userId) - } - } - - val onForwardOriginClickStable: (ForwardInfo) -> Unit = - remember(component) { - { forwardInfo -> - component.onForwardOriginClick(forwardInfo) - } - } - - ChatContentList( - showNavPadding = false, - topOverlayPadding = if ( - (state.viewAsTopics && state.currentTopicId == null) || - state.rootMessage != null - ) { - topOverlayHeight - } else { - 0.dp - }, - state = messageListState, - 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, - onForwardOriginClick = onForwardOriginClickStable, - downloadUtils = component.downloadUtils, - isAnyViewerOpen = isAnyViewerOpen, - bottomContentPadding = if (state.rootMessage != null && (showInputBar || showJoinButton)) 120.dp else 8.dp - ) - - AnimatedVisibility( - visible = showScrollToBottomButton, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) - ) { - Box { - FloatingActionButton( - onClick = { - component.onScrollToBottom() - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp - ), - shape = CircleShape, - modifier = Modifier.size(48.dp) - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.cd_scroll_to_bottom), - modifier = Modifier.size(24.dp) - ) - } - - AnimatedVisibility( - visible = state.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 = 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 - ) - } - } - } - } - } - - if (isRecordingVideo) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .zIndex(10f) - ) { - 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 - ) - } - } - } - } - } - } - - - // Modals & Overlays - 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, - onDismissRequest = requestPinnedMessagesListDismiss, - onHidden = { - renderPinnedMessagesList = false - pendingPinnedSheetAction?.invoke() - pendingPinnedSheetAction = null - }, - onMessageClick = { - pendingPinnedSheetAction = { scrollToMessageState.value(it) } - requestPinnedMessagesListDismiss() - }, - onUnpin = { component.onUnpinMessage(it) }, - onReplyClick = { - pendingPinnedSheetAction = { scrollToMessageState.value(it) } - requestPinnedMessagesListDismiss() - }, - onReactionClick = { id, r -> component.onSendReaction(id, r) }, - downloadUtils = component.downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } - - state.selectedStickerSet?.let { stickerSet -> - StickerSetSheet( - stickerSet = stickerSet, - onDismiss = { component.onDismissStickerSet() }, - onStickerClick = { _, path -> component.onSendSticker(path) } - ) - } - - if (state.showPollVoters) { - PollVotersSheet( - voters = state.pollVoters, - isLoading = state.isPollVotersLoading, - onUserClick = { - component.onDismissVoters() - component.toProfile(it) - }, - onDismiss = { component.onDismissVoters() } - ) - } - - if (state.showBotCommands) { - BotCommandsSheet( - commands = state.botCommands, - onCommandClick = { component.onBotCommandClick(it) }, - onDismiss = { component.onDismissBotCommands() } - ) - } - - /*ChatContentViewers( - state = state, - component = component, - localClipboard = localClipboard - )*/ - - selectedMessage?.let { msg -> - ChatMessageOptionsMenu( - state = state, - component = component, - selectedMessage = msg, - menuOffset = menuOffset, - menuMessageSize = menuMessageSize, - clickOffset = clickOffset, - contentRect = contentRect, - groupedMessages = groupedMessages, - downloadUtils = component.downloadUtils, - localClipboard = localClipboard, - canRestoreOriginalText = originalMessageTexts.containsKey(msg.id), - onApplyTransformedText = { newText -> - val originalText = msg.extractTextContent() - if (!originalText.isNullOrBlank() && !originalMessageTexts.containsKey(msg.id)) { - originalMessageTexts[msg.id] = originalText - } - transformedMessageTexts[msg.id] = newText - }, - onRestoreOriginalText = { - if (!originalMessageTexts.containsKey(msg.id)) { - return@ChatMessageOptionsMenu - } - transformedMessageTexts.remove(msg.id) - originalMessageTexts.remove(msg.id) - }, - onBlockRequest = { userId -> - pendingBlockUserId = userId - }, - onDismiss = { selectedMessageId = null } - ) - } - - pendingBlockUserId?.let { userId -> - ConfirmationSheet( - icon = Icons.Rounded.Block, - title = stringResource(R.string.block_user_title), - description = stringResource(R.string.block_user_confirmation), - confirmText = stringResource(R.string.action_block), - onConfirm = { - component.onBlockUser(userId) - pendingBlockUserId = null - }, - onDismiss = { pendingBlockUserId = null } - ) - } - - if (state.showReportDialog) { - ReportChatDialog( - onDismiss = { component.onDismissReportDialog() }, - onReasonSelected = { component.onReportReasonSelected(it) } - ) - } - - if (state.restrictUserId != null) { - RestrictUserSheet( - onDismiss = { component.onDismissRestrictDialog() }, - onConfirm = { permissions, untilDate -> component.onConfirmRestrict(permissions, untilDate) } - ) - } - - editingPhotoPath?.let { path -> - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(20f) - ) { - PhotoEditorScreen( - imagePath = path, - onClose = { editingPhotoPath = null }, - onSave = { newPath -> - val newList = pendingMediaPaths.toMutableList() - val index = newList.indexOf(path) - if (index != -1) { - newList[index] = newPath - pendingMediaPaths = newList - } - editingPhotoPath = null - } - ) - } - } - - editingVideoPath?.let { path -> - Box( - modifier = Modifier - .fillMaxSize() - .zIndex(20f) - ) { - VideoEditorScreen( - videoPath = path, - onClose = { editingVideoPath = null }, - onSave = { newPath -> - val newList = pendingMediaPaths.toMutableList() - val index = newList.indexOf(path) - if (index != -1) { - newList[index] = newPath - pendingMediaPaths = newList - } - editingVideoPath = null - } - ) - } - } - - BackHandler(enabled = isCustomBackHandlingEnabled) { - if (editingPhotoPath != null) editingPhotoPath = null - else if (editingVideoPath != null) editingVideoPath = null - else if (state.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) - } - } - } -} - -private fun MessageModel.extractTextContent(): String? { - return when (val c = content) { - is MessageContent.Text -> c.text - is MessageContent.Photo -> c.caption - is MessageContent.Video -> c.caption - is MessageContent.Gif -> c.caption - is MessageContent.Document -> c.caption - is MessageContent.Audio -> c.caption - else -> null - } -} - -private fun MessageModel.withUpdatedTextContent(newText: String): MessageModel { - val updatedContent = when (val c = content) { - is MessageContent.Text -> c.copy(text = newText, entities = emptyList(), webPage = null) - is MessageContent.Photo -> c.copy(caption = newText, entities = emptyList()) - is MessageContent.Video -> c.copy(caption = newText, entities = emptyList()) - is MessageContent.Gif -> c.copy(caption = newText, entities = emptyList()) - is MessageContent.Document -> c.copy(caption = newText, entities = emptyList()) - is MessageContent.Audio -> c.copy(caption = newText, entities = emptyList()) - else -> return this - } - return copy(content = updatedContent) -} - -private suspend fun LazyListState.scrollToMessageIndex( - index: Int, - align: ScrollAlign, - animated: Boolean, - staged: Boolean -) { - val total = layoutInfo.totalItemsCount - if (total <= 0) return - - val boundedIndex = index.coerceIn(0, total - 1) - val distance = abs(firstVisibleItemIndex - boundedIndex) - - if (staged && distance > 20) { - val coarseIndex = when { - boundedIndex > firstVisibleItemIndex -> (boundedIndex - 10).coerceAtLeast(0) - boundedIndex < firstVisibleItemIndex -> (boundedIndex + 10).coerceAtMost(total - 1) - else -> boundedIndex - } - scrollToItem(coarseIndex) - } - - scrollToItem(boundedIndex) - - val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return - val viewportStart = layoutInfo.viewportStartOffset - val viewportEnd = layoutInfo.viewportEndOffset - val viewportCenter = (viewportStart + viewportEnd) / 2 - - val targetPosition = when (align) { - ScrollAlign.Start -> viewportStart - ScrollAlign.Center -> viewportCenter - (itemInfo.size / 2) - ScrollAlign.End -> viewportEnd - itemInfo.size - } - val delta = (itemInfo.offset - targetPosition).toFloat() - - if (abs(delta) > 1f) { - if (animated) { - animateScrollBy(delta) - } else { - scrollBy(delta) - } - } -} - -private data class BottomVisibilitySnapshot( - val isAtBottom: Boolean, - val isNearBottom: Boolean, - val unreadCount: Int -) - -private fun LazyListState.isAtBottom( - isComments: Boolean, - isLatestLoaded: Boolean -): Boolean { - if (!isLatestLoaded) return false - - val info = layoutInfo - val visible = info.visibleItemsInfo - if (visible.isEmpty()) return true - - return if (isComments) { - val lastVisible = visible.last() - lastVisible.index >= info.totalItemsCount - 1 && - abs((info.viewportEndOffset - (lastVisible.offset + lastVisible.size)).toFloat()) <= 40f - } else { - val firstVisible = visible.first() - firstVisible.index == 0 && - abs((firstVisible.offset - info.viewportStartOffset).toFloat()) <= 40f - } -} - -private fun LazyListState.isNearBottom(isComments: Boolean): Boolean { - val info = layoutInfo - val visible = info.visibleItemsInfo - if (visible.isEmpty()) return true - - return if (isComments) { - val lastVisible = visible.last() - val distance = abs((info.viewportEndOffset - (lastVisible.offset + lastVisible.size)).toFloat()) - lastVisible.index >= info.totalItemsCount - 2 && distance <= 240f - } else { - val firstVisible = visible.first() - val distance = abs((firstVisible.offset - info.viewportStartOffset).toFloat()) - firstVisible.index <= 1 && distance <= 240f - } -} - -private suspend fun LazyListState.scrollToChatBottomStaged( - isComments: Boolean, - animated: Boolean -) { - val total = layoutInfo.totalItemsCount - if (total <= 0) return - - val targetIndex = if (isComments) total - 1 else 0 - val distance = abs(firstVisibleItemIndex - targetIndex) - - if (distance > 24) { - val coarse = if (isComments) { - (targetIndex - 8).coerceAtLeast(0) - } else { - (targetIndex + 8).coerceAtMost(total - 1) - } - scrollToItem(coarse) - } - - if (animated) { - animateScrollToItem(targetIndex) - } else { - scrollToItem(targetIndex) - } - - val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } - if (targetInfo != null) { - val delta = if (isComments) { - ((targetInfo.offset + targetInfo.size) - layoutInfo.viewportEndOffset).toFloat() - } else { - (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() - } - if (abs(delta) > 1f) { - scrollBy(delta) - } - } - - scrollToItem(targetIndex) -} - -private suspend fun LazyListState.scrollToChatStartStaged( - animated: Boolean -) { - val total = layoutInfo.totalItemsCount - if (total <= 0) return - - if (animated) { - animateScrollToItem(0) - } else { - scrollToItem(0) - } - - val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 } - if (targetInfo != null) { - val delta = (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() - if (abs(delta) > 1f) { - scrollBy(delta) - } - } - - scrollToItem(0) -} - -private suspend fun awaitGroupedIndex( - messageId: Long, - groupedMessageIndexByIdProvider: () -> Map, - timeoutMs: Long = 1200L -): Int? { - return withTimeoutOrNull(timeoutMs) { - snapshotFlow { groupedMessageIndexByIdProvider()[messageId] } - .filterNotNull() - .first() - } -} - -private suspend fun LazyListState.restoreViewportAtIndex( - targetIndex: Int, - anchorOffsetPx: Int -) { - val total = layoutInfo.totalItemsCount - if (total <= 0) return - val boundedIndex = targetIndex.coerceIn(0, total - 1) - - scrollToItem(boundedIndex) - val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return - val viewportStart = layoutInfo.viewportStartOffset - val desiredOffset = viewportStart + anchorOffsetPx - val delta = (itemInfo.offset - desiredOffset).toFloat() - - if (abs(delta) > 1f) { - scrollBy(delta) - } -} - -private fun buildViewportSnapshot( - scrollState: LazyListState, - groupedMessages: List, - isComments: Boolean, - isLatestLoaded: Boolean, - isLoadingOlder: Boolean, - isLoadingNewer: Boolean, - isAtBottom: Boolean, - showNavPadding: Boolean -): ChatViewportCacheEntry? { - if (groupedMessages.isEmpty()) { - return ChatViewportCacheEntry(atBottom = true) - } - - val atBottomNow = scrollState.isAtBottom( - isComments = isComments, - isLatestLoaded = isLatestLoaded - ) - if (atBottomNow) { - return ChatViewportCacheEntry(atBottom = true) - } - - val leadingItems = chatContentLeadingItemsCount( - isComments = isComments, - showNavPadding = showNavPadding, - isLoadingOlder = isLoadingOlder, - isLoadingNewer = isLoadingNewer, - isAtBottom = isAtBottom, - hasMessages = groupedMessages.isNotEmpty() - ) - val info = scrollState.layoutInfo - val anchorItem = info.visibleItemsInfo.firstOrNull { itemInfo -> - val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItems) - groupedIndex in groupedMessages.indices - } ?: return null - - val groupedIndex = lazyIndexToGroupedIndex(anchorItem.index, leadingItems) - val anchorMessageId = groupedMessages.getOrNull(groupedIndex)?.firstMessageId ?: return null - - return ChatViewportCacheEntry( - anchorMessageId = anchorMessageId, - anchorOffsetPx = anchorItem.offset - info.viewportStartOffset, - atBottom = false - ) -} 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 deleted file mode 100644 index dcdbfb29..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt +++ /dev/null @@ -1,448 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.chatContent - -import android.content.ClipData -import android.util.Log -import androidx.compose.animation.* -import androidx.compose.runtime.* -import androidx.compose.ui.platform.Clipboard -import androidx.compose.ui.text.AnnotatedString -import org.monogram.domain.models.MessageContent -import org.monogram.domain.models.MessageModel -import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.instantview.InstantViewer -import org.monogram.presentation.features.viewers.ImageViewer -import org.monogram.presentation.features.viewers.VideoViewer -import org.monogram.presentation.features.viewers.YouTubeViewer -import org.monogram.presentation.features.webapp.MiniAppViewer -import org.monogram.presentation.features.webapp.components.InvoiceDialog -import org.monogram.presentation.features.webapp.components.MiniAppTOSBottomSheet -import org.monogram.presentation.features.webview.InternalWebView - -@Composable -fun ChatContentViewers( - state: ChatComponent.State, - 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) -} - -@Composable -private fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { - AnimatedVisibility( - visible = state.instantViewUrl != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - state.instantViewUrl?.let { url -> - InstantViewer( - url = url, - messageRepository = component.repositoryMessage, - fileRepository = component.repositoryMessage, - onDismiss = { component.onDismissInstantView() }, - onOpenWebView = { component.onOpenWebView(it) } - ) - } - } -} - -@Composable -private fun YouTubeOverlay( - state: ChatComponent.State, - component: ChatComponent, - localClipboard: Clipboard -) { - AnimatedVisibility( - visible = state.youtubeUrl != null, - enter = fadeIn(), - exit = fadeOut() - ) { - state.youtubeUrl?.let { url -> - YouTubeViewer( - videoUrl = url, - onDismiss = { component.onDismissYouTube() }, - onForward = { - component.onForwardMessage(state.messages.find { - (it.content as? MessageContent.Text)?.text?.contains( - url - ) == true - } ?: return@YouTubeViewer) - }, - onCopyLink = { - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(it)) - ) - }, - onCopyText = { - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(it)) - ) - }, - isPipEnabled = !state.isInstalledFromGooglePlay - ) - } - } -} - -@Composable -private fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) { - AnimatedVisibility( - visible = state.miniAppUrl != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.miniAppUrl != null && state.miniAppName != null) { - MiniAppViewer( - chatId = state.chatId, - botUserId = state.miniAppBotUserId, - baseUrl = state.miniAppUrl, - botName = state.chatTitle, - botAvatarPath = state.chatAvatar, - webAppRepository = component.repositoryMessage, - onDismiss = { component.onDismissMiniApp() } - ) - } - } -} - -@Composable -private fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) { - AnimatedVisibility( - visible = state.webViewUrl != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - state.webViewUrl?.let { url -> - InternalWebView( - url = url, - onDismiss = { component.onDismissWebView() } - ) - } - } -} - -@Composable -private fun ImagesOverlay( - state: ChatComponent.State, - component: ChatComponent, - localClipboard: Clipboard -) { - AnimatedVisibility( - visible = state.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) { - when { - component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi - component.downloadUtils.isRoaming() -> state.autoDownloadRoaming - else -> state.autoDownloadMobile - } - } - - val viewerItems = remember(images, state.fullScreenImageMessageIds, state.messages) { - val messageMap = state.messages.associateBy { it.id } - val items = if (state.fullScreenImageMessageIds.size == images.size) { - state.fullScreenImageMessageIds.mapIndexed { index, messageId -> - val message = messageMap[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) } - ViewerMediaItem( - messageId = message?.id ?: 0L, - path = message?.displayMediaPathForViewer() ?: path - ) - } - } - items.sortedBy { it.messageId } - } - - val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } - val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } - val startMessageId = state.fullScreenImageMessageIds.getOrNull(state.fullScreenStartIndex) - - val startIndex = remember(viewerItems, startMessageId) { - val index = viewerItems.indexOfFirst { it.messageId == startMessageId } - index - .takeIf { it != -1 } - ?.coerceIn(0, viewerItems.lastIndex.coerceAtLeast(0)) - ?: 0 - } - - var currentImageIndex by remember(viewerItems, startIndex) { - mutableIntStateOf( - startIndex.coerceIn(0, viewerItems.lastIndex.coerceAtLeast(0)) - ) - } - - val currentViewerMessage = remember(currentImageIndex, imageMessageIds, state.messages) { - imageMessageIds.getOrNull(currentImageIndex) - ?.takeIf { it != 0L } - ?.let { id -> state.messages.firstOrNull { it.id == id } } - } - - val imageDownloadingStates = remember(imageMessageIds, state.messages) { - imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.isDownloading - else -> false - } - } - } - - val imageDownloadProgressStates = remember(imageMessageIds, state.messages) { - imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.downloadProgress - else -> 0f - } - } - } - - if (viewerImages.isNotEmpty()) { - ImageViewer( - images = viewerImages, - startIndex = startIndex.coerceIn(0, viewerImages.lastIndex.coerceAtLeast(0)), - onDismiss = component::onDismissImages, - autoDownload = autoDownload, - onPageChanged = { index -> - currentImageIndex = index - imageMessageIds.getOrNull(index)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) - imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) - }, - onForward = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } - msg?.let { component.onForwardMessage(it) } - }, - onDelete = { path -> - val msg = currentViewerMessage ?: state.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 link = if (msg != null) { - if (!state.isGroup && !state.isChannel) { - "tg://openmessage?user_id=${state.chatId}&message_id=${msg.id shr 20}" - } else { - "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${msg.id shr 20}" - } - } else { - path - } - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(link)) - ) - }, - onCopyText = { path -> - val msg = state.messages.find { - when (val content = it.content) { - is MessageContent.Photo -> content.path == path - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - else -> false - } - } - val textToCopy = when (val content = msg?.content) { - is MessageContent.Photo -> content.caption - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> "" - } - if (textToCopy.isNotEmpty()) { - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(textToCopy)) - ) - } - }, - onVideoClick = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } - if (msg != null) { - val mediaPath = msg.displayMediaPathForViewer() ?: path - component.onOpenVideo( - path = mediaPath, - messageId = msg.id, - caption = when (val content = msg.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> null - } - ) - } else { - component.onOpenVideo(path = path, messageId = null, caption = null) - } - }, - captions = state.fullScreenCaptions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, - downloadUtils = component.downloadUtils - ) - } - } - } -} - -@Composable -private fun VideoOverlay( - state: ChatComponent.State, - component: ChatComponent, - localClipboard: Clipboard -) { - val videoVisible = - (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null - - AnimatedVisibility( - visible = videoVisible, - enter = fadeIn() + scaleIn(initialScale = 0.9f), - exit = fadeOut() + scaleOut(targetScale = 0.9f) - ) { - if (videoVisible) { - val messageId = state.fullScreenVideoMessageId - val path = state.fullScreenVideoPath - - val msg = remember(messageId, path, state.messages) { - state.messages.find { it.id == messageId } ?: state.messages.find { - it.content.matchesDisplayPath(path ?: "") - } - } - - val videoContent = msg?.content as? MessageContent.Video - val gifContent = msg?.content as? MessageContent.Gif - - val fileId = videoContent?.fileId ?: gifContent?.fileId ?: 0 - val supportsStreaming = videoContent?.supportsStreaming ?: false - val finalPath = path ?: videoContent?.path ?: gifContent?.path ?: "" - - if (finalPath.isNotBlank() || (supportsStreaming && fileId != 0)) { - key(finalPath, fileId) { - Log.d("ChatContentViewers", "Rendering VideoViewer for $finalPath") - VideoViewer( - path = finalPath, - onDismiss = component::onDismissVideo, - isGesturesEnabled = state.isPlayerGesturesEnabled, - isDoubleTapSeekEnabled = state.isPlayerDoubleTapSeekEnabled, - seekDuration = state.playerSeekDuration, - isZoomEnabled = state.isPlayerZoomEnabled, - onForward = { videoPath -> - val forwardMsg = state.messages.find { - it.content.matchesDisplayPath(videoPath) - } - forwardMsg?.let { component.onForwardMessage(it) } - }, - onDelete = { videoPath -> - val deleteMsg = state.messages.find { - it.content.matchesDisplayPath(videoPath) - } - if (deleteMsg?.isOutgoing == true) { - component.onDeleteMessage(deleteMsg, true) - component.onDismissVideo() - } - }, - onCopyLink = { videoPath -> - val linkMsg = state.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}" - } else { - "https://t.me/c/${ - state.chatId.toString().removePrefix("-100") - }/${linkMsg.id shr 20}" - } - } else { - videoPath - } - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(link)) - ) - }, - onCopyText = { videoPath -> - val textMsg = state.messages.find { - it.content.matchesDisplayPath(videoPath) - } - val textToCopy = when (val content = textMsg?.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> "" - } - if (textToCopy.isNotEmpty()) { - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(textToCopy)) - ) - } - }, - onSaveGif = if (state.messages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { - { videoPath -> component.onAddToGifs(videoPath) } - } else null, - caption = state.fullScreenVideoCaption, - fileId = fileId, - supportsStreaming = supportsStreaming, - downloadUtils = component.downloadUtils - ) - } - } - } - } -} - -@Composable -private fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) { - if (state.invoiceSlug != null || state.invoiceMessageId != null) { - InvoiceDialog( - slug = state.invoiceSlug, - chatId = state.chatId, - messageId = state.invoiceMessageId, - paymentRepository = component.repositoryMessage, - fileRepository = component.repositoryMessage, - onDismiss = { status -> component.onDismissInvoice(status) } - ) - } -} - -@Composable -private fun MiniAppTOSOverlay(state: ChatComponent.State, component: ChatComponent) { - MiniAppTOSBottomSheet( - isVisible = state.showMiniAppTOS, - onDismiss = { component.onDismissMiniAppTOS() }, - onAccept = { component.onAcceptMiniAppTOS() } - ) -} - -private data class ViewerMediaItem( - val messageId: Long, - val path: String -) - -private fun MessageModel.displayMediaPathForViewer(): String? { - return when (val content = content) { - is MessageContent.Photo -> content.path ?: content.thumbnailPath - is MessageContent.Video -> content.path ?: content.thumbnailPath - is MessageContent.Gif -> content.path - else -> null - } -} - -private fun MessageContent.matchesDisplayPath(path: String): Boolean { - return when (this) { - is MessageContent.Photo -> (this.path ?: this.thumbnailPath) == path - is MessageContent.Video -> this.path == path || this.thumbnailPath == path - is MessageContent.Gif -> this.path == path - else -> false - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt deleted file mode 100644 index 596c8464..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt +++ /dev/null @@ -1,758 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.components - -import android.content.res.Configuration -import androidx.compose.animation.Animatable -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize -import kotlinx.coroutines.delay -import org.monogram.domain.models.ForwardInfo -import org.monogram.domain.models.InlineKeyboardButtonModel -import org.monogram.domain.models.MessageContent -import org.monogram.domain.models.MessageModel -import org.monogram.presentation.core.ui.Avatar -import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.chats.AudioMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.ContactMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.DocumentMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.GifMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.LocationMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution -import org.monogram.presentation.features.chats.currentChat.components.chats.PhotoMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.PollMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView -import org.monogram.presentation.features.chats.currentChat.components.chats.StickerMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.TextMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.VenueMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.VideoMessageBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.VideoNoteBubble -import org.monogram.presentation.features.chats.currentChat.components.chats.VoiceMessageBubble - -@Composable -fun MessageBubbleContainer( - msg: MessageModel, - olderMsg: MessageModel?, - newerMsg: MessageModel?, - isGroup: Boolean, - fontSize: Float, - letterSpacing: Float, - bubbleRadius: Float = 12f, - stSize: Float = 200f, - autoDownloadMobile: Boolean, - autoDownloadWifi: Boolean, - autoDownloadRoaming: Boolean, - autoDownloadFiles: Boolean, - autoplayGifs: Boolean, - autoplayVideos: Boolean, - showLinkPreviews: Boolean = true, - highlighted: Boolean = false, - onHighlightConsumed: () -> Unit = {}, - onPhotoClick: (MessageModel) -> Unit, - onDownloadPhoto: (Int) -> Unit = {}, - onVideoClick: (MessageModel) -> Unit = {}, - onDocumentClick: (MessageModel) -> Unit = {}, - onAudioClick: (MessageModel) -> Unit = {}, - onCancelDownload: (Int) -> Unit = {}, - onReplyClick: (Offset, IntSize, Offset) -> Unit, - onGoToReply: (MessageModel) -> Unit = {}, - onReactionClick: (Long, String) -> Unit = { _, _ -> }, - onStickerClick: (Long) -> Unit = {}, - onPollOptionClick: (Long, Int) -> Unit = { _, _ -> }, - onRetractVote: (Long) -> Unit = {}, - onShowVoters: (Long, Int) -> Unit = { _, _ -> }, - onClosePoll: (Long) -> Unit = {}, - onInstantViewClick: ((String) -> Unit)? = null, - onYouTubeClick: ((String) -> Unit)? = null, - onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit = { _, _ -> }, - shouldReportPosition: Boolean = false, - onPositionChange: (Long, Offset, IntSize) -> Unit = { _, _, _ -> }, - toProfile: (Long) -> Unit, - onForwardOriginClick: (ForwardInfo) -> Unit = {}, - onViaBotClick: (String) -> Unit = {}, - canReply: Boolean = true, - onReplySwipe: (MessageModel) -> Unit = {}, - swipeEnabled: Boolean = true, - downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false -) { - val configuration = LocalConfiguration.current - val screenWidth = configuration.screenWidthDp.dp - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - - val maxWidth = remember(isLandscape, screenWidth) { - if (isLandscape) { - (screenWidth * 0.6f).coerceAtMost(450.dp) - } else { - (screenWidth * 0.85f).coerceAtMost(360.dp) - } - } - - val isOutgoing = msg.isOutgoing - val isSameSenderAbove = remember( - olderMsg?.id, - olderMsg?.senderId, - olderMsg?.senderName, - olderMsg?.senderCustomTitle, - olderMsg?.date, - msg.senderId, - msg.senderName, - msg.senderCustomTitle, - msg.date - ) { - shouldGroupSenderBlock( - current = msg, - neighbor = olderMsg, - dateBreak = olderMsg?.let { shouldShowDate(msg, it) } ?: true - ) - } - val isSameSenderBelow = remember( - newerMsg?.id, - newerMsg?.senderId, - newerMsg?.senderName, - newerMsg?.senderCustomTitle, - newerMsg?.date, - msg.senderId, - msg.senderName, - msg.senderCustomTitle, - msg.date - ) { - shouldGroupSenderBlock( - current = msg, - neighbor = newerMsg, - dateBreak = newerMsg?.let { shouldShowDate(it, msg) } ?: true - ) - } - - val topSpacing = if (!isSameSenderAbove) 8.dp else 2.dp - - val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - val animatedColor = remember { Animatable(Color.Transparent) } - - LaunchedEffect(highlighted) { - if (highlighted) { - animatedColor.animateTo(highlightColor, animationSpec = tween(300)) - delay(450) - animatedColor.animateTo(Color.Transparent, animationSpec = tween(1800)) - onHighlightConsumed() - } - } - - var outerColumnPosition by remember { mutableStateOf(Offset.Zero) } - var bubblePosition by remember { mutableStateOf(Offset.Zero) } - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - - val dragOffsetX = remember { Animatable(0f) } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(animatedColor.value, RoundedCornerShape(12.dp)) - .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } - .padding(top = topSpacing) - .offset { IntOffset(dragOffsetX.value.toInt(), 0) } - .fastReplyPointer( - canReply = canReply, - dragOffsetX = dragOffsetX, - scope = rememberCoroutineScope(), - onReplySwipe = { onReplySwipe(msg) }, - maxWidth = maxWidth.value - ) - .pointerInput(Unit) { - detectTapGestures( - onTap = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) - if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) - } - }, - onLongPress = { offset -> - val clickPos = outerColumnPosition + offset - val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) - if (!bubbleRect.contains(clickPos)) { - onReplyClick(bubblePosition, bubbleSize, clickPos) - } - } - ) - } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, - verticalAlignment = Alignment.Bottom - ) { - MessageAvatar( - msg = msg, - isGroup = isGroup, - isOutgoing = isOutgoing, - isSameSenderBelow = isSameSenderBelow, - toProfile = toProfile - ) - - Box( - modifier = Modifier.wrapContentSize() - ) { - Column( - modifier = Modifier - .width(IntrinsicSize.Max) - .widthIn(max = maxWidth) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - }, - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start - ) { - MessageContentSelector( - msg = msg, - newerMsg = newerMsg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - isGroup = isGroup, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - stSize = stSize, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoDownloadFiles = autoDownloadFiles, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - showLinkPreviews = showLinkPreviews, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onReplyClick = onReplyClick, - onGoToReply = onGoToReply, - onReactionClick = onReactionClick, - onStickerClick = onStickerClick, - onPollOptionClick = onPollOptionClick, - onRetractVote = onRetractVote, - onShowVoters = onShowVoters, - onClosePoll = onClosePoll, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - bubblePosition = bubblePosition, - bubbleSize = bubbleSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - - MessageReplyMarkup( - msg = msg, - onReplyMarkupButtonClick = onReplyMarkupButtonClick - ) - - MessageViaBotAttribution( - msg = msg, - isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) - ) - } - - FastReplyIndicator( - modifier = Modifier.align(Alignment.CenterEnd), - dragOffsetX = dragOffsetX, - isOutgoing = isOutgoing, - maxWidth = maxWidth - ) - } - } - } -} - -@Composable -private fun MessageAvatar( - msg: MessageModel, - isGroup: Boolean, - isOutgoing: Boolean, - isSameSenderBelow: Boolean, - toProfile: (Long) -> Unit -) { - if (isGroup && !isOutgoing) { - if (!isSameSenderBelow) { - Avatar( - path = msg.senderAvatar, - fallbackPath = msg.senderPersonalAvatar, - name = msg.senderName, - size = 40.dp, - isLocal = msg.senderAvatar?.contains("local") ?: false, - onClick = { toProfile(msg.senderId) }) - } else { - Spacer(modifier = Modifier.width(40.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - } -} - -@Composable -private fun MessageContentSelector( - msg: MessageModel, - newerMsg: MessageModel?, - isOutgoing: Boolean, - isSameSenderAbove: Boolean, - isSameSenderBelow: Boolean, - isGroup: Boolean, - fontSize: Float, - letterSpacing: Float, - bubbleRadius: Float, - stSize: Float, - autoDownloadMobile: Boolean, - autoDownloadWifi: Boolean, - autoDownloadRoaming: Boolean, - autoDownloadFiles: Boolean, - autoplayGifs: Boolean, - autoplayVideos: Boolean, - showLinkPreviews: Boolean, - onPhotoClick: (MessageModel) -> Unit, - onDownloadPhoto: (Int) -> Unit, - onVideoClick: (MessageModel) -> Unit, - onDocumentClick: (MessageModel) -> Unit, - onAudioClick: (MessageModel) -> Unit, - onCancelDownload: (Int) -> Unit, - onReplyClick: (Offset, IntSize, Offset) -> Unit, - onGoToReply: (MessageModel) -> Unit, - onReactionClick: (Long, String) -> Unit, - onStickerClick: (Long) -> Unit, - onPollOptionClick: (Long, Int) -> Unit, - onRetractVote: (Long) -> Unit, - onShowVoters: (Long, Int) -> Unit, - onClosePoll: (Long) -> Unit, - onInstantViewClick: ((String) -> Unit)?, - onYouTubeClick: ((String) -> Unit)?, - toProfile: (Long) -> Unit, - onForwardOriginClick: (ForwardInfo) -> Unit, - bubblePosition: Offset, - bubbleSize: IntSize, - downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false -) { - Column( - modifier = Modifier.width(IntrinsicSize.Max), - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start - ) { - when (val content = msg.content) { - is MessageContent.Text -> { - TextMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - isGroup = isGroup, - showLinkPreviews = showLinkPreviews, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - onClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - onLongClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick - ) - } - - is MessageContent.Sticker -> { - StickerMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - stickerSize = stSize, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onStickerClick = { onStickerClick(it) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - } - ) - } - - is MessageContent.Photo -> { - PhotoMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils - ) - } - - is MessageContent.Video -> { - VideoMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayVideos = autoplayVideos, - onVideoClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } - - is MessageContent.VideoNote -> { - VideoNoteBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - onVideoClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) } - ) - } - - is MessageContent.Voice -> { - VoiceMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onVoiceClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - downloadUtils = downloadUtils - ) - } - - is MessageContent.Gif -> { - GifMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayGifs = autoplayGifs, - onGifClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } - - is MessageContent.Document -> { - DocumentMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onDocumentClick = onDocumentClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onForwardOriginClick = onForwardOriginClick, - downloadUtils = downloadUtils - ) - } - - is MessageContent.Audio -> { - AudioMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } - - is MessageContent.Contact -> { - ContactMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - isGroup = isGroup, - onClick = { onGoToReply(msg) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - showReactions = msg.reactions.isNotEmpty() - ) - } - - is MessageContent.Poll -> { - PollMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - onOptionClick = { onPollOptionClick(msg.id, it) }, - onRetractVote = { onRetractVote(msg.id) }, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onShowVoters = { onShowVoters(msg.id, it) }, - onClosePoll = { onClosePoll(msg.id) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick - ) - } - - is MessageContent.Location -> { - LocationMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - bubbleRadius = bubbleRadius, - onClick = { onGoToReply(msg) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick - ) - } - - is MessageContent.Venue -> { - VenueMessageBubble( - content = content, - msg = msg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - isGroup = isGroup, - bubbleRadius = bubbleRadius, - onClick = { onGoToReply(msg) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick - ) - } - - else -> { - // Fallback - } - } - } -} - -@Composable -private fun MessageReplyMarkup( - msg: MessageModel, - onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit -) { - msg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } - ) - } -} - -private fun androidx.compose.ui.geometry.Size.toOffset() = Offset(width, height) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt deleted file mode 100644 index 56522ed8..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.components - -import org.monogram.domain.models.MessageModel - -internal fun shouldGroupSenderBlock( - current: MessageModel, - neighbor: MessageModel?, - dateBreak: Boolean -): Boolean { - if (neighbor == null) return false - if (current.senderId <= 0L || neighbor.senderId <= 0L) return false - if (current.senderId != neighbor.senderId) return false - if (current.senderName != neighbor.senderName) return false - if (current.senderCustomTitle != neighbor.senderCustomTitle) return false - return !dateBreak -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/VoicePlayer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/VoicePlayer.kt deleted file mode 100644 index 9061df72..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/VoicePlayer.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.components - -import android.net.Uri -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive - -@Composable -fun rememberVoicePlayer(path: String?): VoicePlayerState { - val context = LocalContext.current - val player = remember { - ExoPlayer.Builder(context).build().apply { - repeatMode = Player.REPEAT_MODE_OFF - } - } - - val state = remember(path) { VoicePlayerState(player, path) } - - DisposableEffect(player) { - onDispose { - player.release() - } - } - - LaunchedEffect(path) { - if (path != null) { - player.setMediaItem(MediaItem.fromUri(Uri.parse(path))) - player.prepare() - } - } - - return state -} - -class VoicePlayerState( - private val player: ExoPlayer, - private val path: String? -) { - var isPlaying by mutableStateOf(false) - private set - var progress by mutableFloatStateOf(0f) - private set - var currentPosition by mutableLongStateOf(0L) - private set - var duration by mutableLongStateOf(0L) - private set - - init { - player.addListener(object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - isPlaying = playing - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - duration = player.duration - } else if (playbackState == Player.STATE_ENDED) { - isPlaying = false - progress = 0f - currentPosition = 0 - player.seekTo(0) - player.pause() - } - } - }) - } - - @Composable - fun ProgressUpdater() { - LaunchedEffect(isPlaying) { - while (isActive && isPlaying) { - currentPosition = player.currentPosition - val total = player.duration - if (total > 0) { - progress = currentPosition.toFloat() / total.toFloat() - } - delay(50) - } - } - } - - fun togglePlayPause() { - if (path == null) return - if (player.isPlaying) { - player.pause() - } else { - player.play() - } - } - - fun seekTo(pos: Float) { - val total = player.duration - if (total > 0) { - player.seekTo((pos * total).toLong()) - } - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt deleted file mode 100644 index 0e60e88c..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ /dev/null @@ -1,406 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.components.inputbar - -import android.net.Uri -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -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.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import org.monogram.domain.models.BotCommandModel -import org.monogram.domain.models.BotMenuButtonModel -import org.monogram.domain.models.GifModel -import org.monogram.domain.models.KeyboardButtonModel -import org.monogram.domain.models.MessageModel -import org.monogram.domain.models.MessageSendOptions -import org.monogram.domain.models.ReplyMarkupModel -import org.monogram.domain.models.StickerModel -import org.monogram.domain.models.UserModel -import org.monogram.domain.repository.StickerRepository -import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandSuggestions -import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu - -@Composable -fun ChatInputBarComposerSection( - modifier: Modifier = Modifier, - editingMessage: MessageModel?, - replyMessage: MessageModel?, - pendingMediaPaths: List, - pendingDocumentPaths: List, - mentionSuggestions: List, - filteredCommands: List, - currentInlineBotUsername: String?, - isInlineBotLoading: Boolean, - inlineBotResults: org.monogram.domain.repository.InlineBotResultsModel?, - isBot: Boolean, - botMenuButton: BotMenuButtonModel, - botCommands: List, - scheduledMessagesCount: Int, - textValue: TextFieldValue, - onTextValueChange: (TextFieldValue) -> Unit, - knownCustomEmojis: MutableMap, - emojiFontFamily: FontFamily, - focusRequester: FocusRequester, - canWriteText: Boolean, - canOpenAttachSheet: Boolean, - canSendAttachments: Boolean, - canShowBotActions: Boolean, - canPasteMediaFromClipboard: Boolean, - canSendStickers: Boolean, - canSendVoice: Boolean, - canSendVideoNotes: Boolean, - isStickerMenuVisible: Boolean, - closeStickerMenuWithoutSlide: Boolean, - isKeyboardVisible: Boolean, - stickerMenuHeight: Dp, - voiceRecorder: VoiceRecorderState, - isGifSearchFocused: Boolean, - showFullScreenEditor: Boolean, - currentMessageLength: Int, - maxMessageLength: Int, - isOverMessageLimit: Boolean, - isVideoMessageMode: Boolean, - isSlowModeActive: Boolean, - slowModeRemainingSeconds: Int, - replyMarkup: ReplyMarkupModel?, - showSendOptionsSheet: Boolean, - stickerRepository: StickerRepository, - isTablet: Boolean = false, - onCancelEdit: () -> Unit, - onCancelReply: () -> Unit, - onCancelMedia: () -> Unit, - onCancelDocuments: () -> Unit, - onAddMedia: () -> Unit, - onAddDocuments: () -> Unit, - onMediaOrderChange: (List) -> Unit, - onDocumentOrderChange: (List) -> Unit, - onMediaClick: (String) -> Unit, - onPasteImages: (List) -> Unit, - onMentionClick: (UserModel) -> Unit, - onMentionQueryClear: () -> Unit, - onInlineResultClick: (String) -> Unit, - onInlineSwitchPmClick: (String) -> Unit, - onLoadMoreInlineResults: (String) -> Unit, - onCommandClick: (String) -> Unit, - onAttachClick: () -> Unit, - onStickerMenuToggle: () -> Unit, - onShowBotCommands: () -> Unit, - onOpenMiniApp: (String, String) -> Unit, - onInputFocus: () -> Unit, - onOpenFullScreenEditor: () -> Unit, - onOpenScheduledMessages: () -> Unit, - onSendWithOptions: (MessageSendOptions) -> Unit, - onShowSendOptionsMenu: () -> Unit, - onSendAsDocument: () -> Unit, - onCameraClick: () -> Unit, - onVideoModeToggle: () -> Unit, - onVoiceStart: () -> Unit, - onVoiceStop: (Boolean) -> Unit, - onVoiceLock: () -> Unit, - onSendSilent: () -> Unit, - onScheduleMessage: () -> Unit, - onOpenScheduledMessagesFromPopup: () -> Unit, - onDismissSendOptions: () -> Unit, - onStickerClick: (String) -> Unit, - onGifClick: (GifModel) -> Unit, - onGifSearchFocusedChange: (Boolean) -> Unit, - onReplyMarkupButtonClick: (KeyboardButtonModel) -> Unit -) { - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp, - shape = if (isTablet) { - RoundedCornerShape(16.dp) - } else { - RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .windowInsetsPadding( - if (isStickerMenuVisible) WindowInsets(0, 0, 0, 0) - else WindowInsets.navigationBars - ) - .animateContentSize() - ) { - InputPreviewSection( - editingMessage = editingMessage, - replyMessage = replyMessage, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, - onCancelEdit = onCancelEdit, - onCancelReply = onCancelReply, - onCancelMedia = onCancelMedia, - onCancelDocuments = onCancelDocuments, - onAddMedia = onAddMedia, - onAddDocuments = onAddDocuments, - onMediaOrderChange = onMediaOrderChange, - onDocumentOrderChange = onDocumentOrderChange, - onMediaClick = onMediaClick - ) - - AnimatedVisibility( - visible = mentionSuggestions.isNotEmpty(), - enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() - ) { - MentionSuggestions( - suggestions = mentionSuggestions, - onMentionClick = { - onMentionClick(it) - onMentionQueryClear() - } - ) - } - - AnimatedVisibility( - visible = filteredCommands.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - BotCommandSuggestions( - commands = filteredCommands, - onCommandClick = onCommandClick, - modifier = Modifier.fillMaxWidth() - ) - } - - AnimatedVisibility( - visible = currentInlineBotUsername != null || isInlineBotLoading, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - InlineBotResults( - inlineBotResults = inlineBotResults, - isInlineMode = currentInlineBotUsername != null, - isLoading = isInlineBotLoading, - onResultClick = onInlineResultClick, - onSwitchPmClick = onInlineSwitchPmClick, - onLoadMore = onLoadMoreInlineResults - ) - } - - AnimatedVisibility( - visible = !isGifSearchFocused, - enter = expandVertically(animationSpec = tween(200)), - exit = shrinkVertically(animationSpec = tween(200)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .weight(1f) - .padding(start = if (voiceRecorder.isRecording) 0.dp else 4.dp) - ) { - AnimatedContent( - targetState = voiceRecorder.isRecording, - transitionSpec = { (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) }, - label = "InputContent" - ) { isRecording -> - if (isRecording) { - RecordingUI( - voiceRecorderState = voiceRecorder, - onStop = { onVoiceStop(false) }, - onCancel = { onVoiceStop(true) }, - modifier = Modifier.fillMaxWidth() - ) - } else { - InputTextFieldContainer( - textValue = textValue, - onValueChange = { - onTextValueChange( - mergeInputTextValuePreservingAnnotations( - textValue, - it - ) - ) - }, - onRichTextValueChange = onTextValueChange, - isBot = isBot, - botMenuButton = botMenuButton, - botCommands = botCommands, - canSendStickers = canSendStickers, - canWriteText = canWriteText, - canShowBotActions = canShowBotActions, - isStickerMenuVisible = isStickerMenuVisible, - editingMessage = editingMessage, - canOpenAttachSheet = canOpenAttachSheet, - onStickerMenuToggle = onStickerMenuToggle, - onAttachClick = onAttachClick, - onShowBotCommands = onShowBotCommands, - onOpenMiniApp = onOpenMiniApp, - knownCustomEmojis = knownCustomEmojis, - emojiFontFamily = emojiFontFamily, - focusRequester = focusRequester, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, - canPasteMediaFromClipboard = canPasteMediaFromClipboard, - onPasteImages = onPasteImages, - onFocus = onInputFocus, - onOpenFullScreenEditor = onOpenFullScreenEditor, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - - if (!voiceRecorder.isLocked) { - if (scheduledMessagesCount > 0) { - IconButton(onClick = onOpenScheduledMessages) { - Icon( - imageVector = Icons.Default.Schedule, - contentDescription = stringResource(R.string.action_scheduled_messages), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - Box(contentAlignment = Alignment.CenterEnd) { - InputBarSendButton( - textValue = textValue, - editingMessage = editingMessage, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, - isOverCharLimit = isOverMessageLimit, - canWriteText = canWriteText, - canSendAttachments = canSendAttachments, - canSendVoice = canSendVoice, - canSendVideoNotes = canSendVideoNotes, - isVideoMessageMode = isVideoMessageMode, - isSlowModeActive = isSlowModeActive, - slowModeRemainingSeconds = slowModeRemainingSeconds, - onSendWithOptions = onSendWithOptions, - onShowSendOptionsMenu = onShowSendOptionsMenu, - onCameraClick = onCameraClick, - onVideoModeToggle = onVideoModeToggle, - onVoiceStart = onVoiceStart, - onVoiceStop = onVoiceStop, - onVoiceLock = onVoiceLock - ) - - SendOptionsPopup( - expanded = showSendOptionsSheet, - scheduledMessagesCount = scheduledMessagesCount, - showSendAsDocument = pendingMediaPaths.isNotEmpty(), - onDismiss = onDismissSendOptions, - onSendAsDocument = onSendAsDocument, - onSendSilent = onSendSilent, - onScheduleMessage = onScheduleMessage, - onOpenScheduledMessages = onOpenScheduledMessagesFromPopup - ) - } - } - } - } - - AnimatedVisibility( - visible = !voiceRecorder.isRecording && !showFullScreenEditor && currentMessageLength > 1000, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Text( - text = stringResource(R.string.message_length_counter, currentMessageLength, maxMessageLength), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - textAlign = TextAlign.End, - style = MaterialTheme.typography.labelSmall, - color = if (isOverMessageLimit) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - AnimatedVisibility( - visible = replyMarkup is ReplyMarkupModel.ShowKeyboard && textValue.text.isEmpty() && !isStickerMenuVisible && !isKeyboardVisible, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - val markup = replyMarkup as? ReplyMarkupModel.ShowKeyboard ?: return@AnimatedVisibility - KeyboardMarkupView( - markup = markup, - onButtonClick = onReplyMarkupButtonClick, - onOpenMiniApp = onOpenMiniApp - ) - } - - AnimatedVisibility( - visible = isStickerMenuVisible, - enter = slideInVertically( - animationSpec = tween(220), - initialOffsetY = { it }) + fadeIn(animationSpec = tween(170)), - exit = if (closeStickerMenuWithoutSlide) { - fadeOut(animationSpec = tween(90)) - } else { - slideOutVertically( - animationSpec = tween(170), - targetOffsetY = { it }) + fadeOut(animationSpec = tween(120)) - } - ) { - StickerEmojiMenu( - onStickerSelected = onStickerClick, - onEmojiSelected = { emoji, sticker -> - onTextValueChange( - insertEmojiAtSelection( - value = textValue, - emoji = emoji, - sticker = sticker, - knownCustomEmojis = knownCustomEmojis - ) - ) - }, - onGifSelected = onGifClick, - onSearchFocused = onGifSearchFocusedChange, - panelHeight = stickerMenuHeight, - canSendStickers = canSendStickers, - stickerRepository = stickerRepository - ) - } - } - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt index f76b04b2..0b9774c0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats +package org.monogram.presentation.features.chats.list import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.StateFlow diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index 5d18d0a4..f07e5db6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList +package org.monogram.presentation.features.chats.list import android.util.Log import androidx.activity.compose.BackHandler @@ -121,18 +121,18 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled -import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.chatList.components.AccountMenu -import org.monogram.presentation.features.chats.chatList.components.ArchiveHeaderCard -import org.monogram.presentation.features.chats.chatList.components.ChatListItem -import org.monogram.presentation.features.chats.chatList.components.ChatListShimmer -import org.monogram.presentation.features.chats.chatList.components.ChatListTopBar -import org.monogram.presentation.features.chats.chatList.components.EmptyStateView -import org.monogram.presentation.features.chats.chatList.components.FolderTabs -import org.monogram.presentation.features.chats.chatList.components.MessageSearchItem -import org.monogram.presentation.features.chats.chatList.components.PermissionRequestSheet -import org.monogram.presentation.features.chats.chatList.components.SelectionTopBar -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily +import org.monogram.presentation.features.chats.list.ChatListComponent +import org.monogram.presentation.features.chats.list.components.AccountMenu +import org.monogram.presentation.features.chats.list.components.ArchiveHeaderCard +import org.monogram.presentation.features.chats.list.components.ChatListItem +import org.monogram.presentation.features.chats.list.components.ChatListShimmer +import org.monogram.presentation.features.chats.list.components.ChatListTopBar +import org.monogram.presentation.features.chats.list.components.EmptyStateView +import org.monogram.presentation.features.chats.list.components.FolderTabs +import org.monogram.presentation.features.chats.list.components.MessageSearchItem +import org.monogram.presentation.features.chats.list.components.PermissionRequestSheet +import org.monogram.presentation.features.chats.list.components.SelectionTopBar +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.instantview.InstantViewer import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid import org.monogram.presentation.features.webapp.MiniAppViewer diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt index 7a15de0b..56af0733 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats +package org.monogram.presentation.features.chats.list import com.arkivanov.mvikotlin.core.store.Store diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStoreFactory.kt similarity index 94% rename from presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStoreFactory.kt index 746b892b..1f410f86 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStoreFactory.kt @@ -1,12 +1,12 @@ -package org.monogram.presentation.features.chats +package org.monogram.presentation.features.chats.list import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import org.monogram.presentation.features.chats.ChatListStore.Intent -import org.monogram.presentation.features.chats.ChatListStore.Label -import org.monogram.presentation.features.chats.chatList.DefaultChatListComponent +import org.monogram.presentation.features.chats.list.ChatListStore.Intent +import org.monogram.presentation.features.chats.list.ChatListStore.Label +import org.monogram.presentation.features.chats.list.DefaultChatListComponent class ChatListStoreFactory( private val storeFactory: StoreFactory, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 19f56e3a..eba4ef88 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList +package org.monogram.presentation.features.chats.list import android.util.Log import com.arkivanov.decompose.value.Value @@ -35,9 +35,9 @@ import org.monogram.presentation.BuildConfig import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope -import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.ChatListStore -import org.monogram.presentation.features.chats.ChatListStoreFactory +import org.monogram.presentation.features.chats.list.ChatListComponent +import org.monogram.presentation.features.chats.list.ChatListStore +import org.monogram.presentation.features.chats.list.ChatListStoreFactory import org.monogram.presentation.root.AppComponentContext class DefaultChatListComponent( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/AccountMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/AccountMenu.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt index af1c12f3..e1986294 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/AccountMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ArchiveHeaderCard.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ArchiveHeaderCard.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ArchiveHeaderCard.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ArchiveHeaderCard.kt index 0a140f74..0ddb36b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ArchiveHeaderCard.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ArchiveHeaderCard.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/Avatar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/Avatar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt index 9cd404c9..621ca694 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/Avatar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,7 +22,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import org.monogram.presentation.core.util.generateColorFromHash -import org.monogram.presentation.features.chats.currentChat.components.AvatarPlayer +import org.monogram.presentation.core.media.AvatarPlayer import java.io.File @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatCreationCommon.kt similarity index 67% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatCreationCommon.kt index 48161788..ba81299b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatCreationCommon.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -39,99 +39,6 @@ import androidx.compose.ui.unit.sp import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -@Composable -fun SectionHeader(text: String, modifier: Modifier = Modifier) { - Text( - text = text, - modifier = modifier - .fillMaxWidth() - .padding(start = 12.dp, bottom = 8.dp, top = 16.dp), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) -} - -@Composable -fun SettingsTextField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - icon: ImageVector, - position: ItemPosition, - modifier: Modifier = Modifier, - enabled: Boolean = true, - singleLine: Boolean = false, - minLines: Int = 1, - maxLines: Int = Int.MAX_VALUE, - itemSpacing: Dp = 2.dp, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - trailingIcon: @Composable (() -> Unit)? = null -) { - val cornerRadius = 24.dp - val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = cornerRadius, - topEnd = cornerRadius, - bottomStart = 4.dp, - bottomEnd = 4.dp - ) - - ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = cornerRadius, - bottomEnd = cornerRadius, - topStart = 4.dp, - topEnd = 4.dp - ) - - ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) - } - - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = shape, - modifier = modifier.fillMaxWidth() - ) { - TextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text(placeholder) }, - modifier = Modifier.fillMaxWidth(), - enabled = enabled, - singleLine = singleLine, - minLines = minLines, - maxLines = maxLines, - leadingIcon = { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - }, - trailingIcon = trailingIcon, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledLeadingIconColor = MaterialTheme.colorScheme.primary, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) - } - if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE && itemSpacing > 0.dp) { - Spacer(Modifier.height(itemSpacing)) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun AutoDeleteSelectorSheet( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListItem.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListItem.kt index 51e4baa5..60b2110c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListItem.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -69,9 +69,9 @@ import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.TypingDots import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.toShortRelativeDate -import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle -import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent +import org.monogram.presentation.features.chats.conversation.ui.message.addEmojiStyle +import org.monogram.presentation.features.chats.conversation.ui.message.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageInlineContent import org.monogram.presentation.features.stickers.ui.view.StickerImage @OptIn(ExperimentalFoundationApi::class) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListShimmer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListShimmer.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListShimmer.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListShimmer.kt index 01259c58..75e22b34 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListShimmer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListShimmer.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListTopBar.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListTopBar.kt index 046d7f75..d57e4b8e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/ChatListTopBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/EmptyStateView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/EmptyStateView.kt similarity index 96% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/EmptyStateView.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/EmptyStateView.kt index e111248a..9c57bc97 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/EmptyStateView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/EmptyStateView.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/FolderTabs.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/FolderTabs.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/FolderTabs.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/FolderTabs.kt index 9a128faa..1925cc4f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/FolderTabs.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/FolderTabs.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background @@ -30,8 +30,8 @@ import org.koin.compose.koinInject import org.monogram.domain.models.FolderModel import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences -import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily +import org.monogram.presentation.features.chats.conversation.ui.message.addEmojiStyle +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.settings.folders.getFolderIcon @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/MessageSearchItem.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/MessageSearchItem.kt index 78a4978f..e1af1f0d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/MessageSearchItem.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewChannelContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewChannelContent.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewChannelContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewChannelContent.kt index 113bf1ea..abaa8da7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewChannelContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewChannelContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -25,7 +25,9 @@ import androidx.compose.ui.unit.sp import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem +import org.monogram.presentation.core.ui.SettingsTextField @Composable fun NewChannelContent( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewGroupContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewGroupContent.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewGroupContent.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewGroupContent.kt index 5cac73fc..20292c99 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/NewGroupContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/NewGroupContent.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -25,7 +25,9 @@ import androidx.compose.ui.unit.sp import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem +import org.monogram.presentation.core.ui.SettingsTextField @Composable fun NewGroupContent( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/PermissionRequestSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/PermissionRequestSheet.kt similarity index 99% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/PermissionRequestSheet.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/PermissionRequestSheet.kt index 25fa78a8..46dfeca7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/PermissionRequestSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/PermissionRequestSheet.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import android.Manifest import android.content.Intent diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/SelectionTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/SelectionTopBar.kt similarity index 98% rename from presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/SelectionTopBar.kt rename to presentation/src/main/java/org/monogram/presentation/features/chats/list/components/SelectionTopBar.kt index f2f406dd..0a2101d6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/SelectionTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/SelectionTopBar.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.chatList.components +package org.monogram.presentation.features.chats.list.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.VolumeOff diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt index 206b6b09..ffcdf383 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt @@ -109,11 +109,11 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile -import org.monogram.presentation.features.chats.chatList.components.SectionHeader -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog -import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog -import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.core.ui.SectionHeader +import org.monogram.presentation.core.ui.SettingsTextField +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ScheduleDatePickerDialog +import org.monogram.presentation.features.chats.conversation.ui.inputbar.ScheduleTimePickerDialog +import org.monogram.presentation.features.chats.conversation.ui.inputbar.buildScheduledDateEpochSeconds import java.text.DateFormat import java.util.Calendar import java.util.Date diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt index 9fdec772..3f1f01b4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt @@ -145,7 +145,7 @@ import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.chats.normalizeUrl +import org.monogram.presentation.features.chats.conversation.ui.message.normalizeUrl import org.monogram.presentation.features.instantview.components.AsyncImageWithDownload import org.monogram.presentation.features.instantview.components.AsyncVideoWithDownload import org.monogram.presentation.features.instantview.components.LocalFileRepository diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt index 2bae21e4..c412cbaa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt @@ -46,9 +46,9 @@ import kotlinx.coroutines.withTimeoutOrNull import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.webapp.PageBlockCaption import org.monogram.domain.models.webapp.RichText -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType -import org.monogram.presentation.features.chats.currentChat.components.chats.normalizeUrl +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType +import org.monogram.presentation.features.chats.conversation.ui.message.normalizeUrl import org.monogram.presentation.features.stickers.ui.view.shimmerEffect @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 64b1d87a..d1858d37 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -74,7 +74,7 @@ import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.features.profile.components.LocationViewer import org.monogram.presentation.features.profile.components.ProfileHeaderTransformed import org.monogram.presentation.features.profile.components.ProfileInfoSection diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/AdminManageContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/AdminManageContent.kt index 85115794..00d6eb24 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/AdminManageContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/AdminManageContent.kt @@ -22,7 +22,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.repository.ChatMemberStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt index 1614847b..8550158e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt @@ -32,7 +32,7 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.util.FileUtils -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt index 6ded266c..5a37a84b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt @@ -45,8 +45,8 @@ import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.features.profile.ProfileComponent import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.io.File diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt index 32eea554..e878146c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt @@ -115,7 +115,7 @@ import org.monogram.domain.models.StatisticsType import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.coRunCatching -import org.monogram.presentation.features.chats.chatList.components.SectionHeader +import org.monogram.presentation.core.ui.SectionHeader import java.text.SimpleDateFormat import java.util.Date import java.util.Locale diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt index 31e18932..fbbaa7dc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt @@ -28,8 +28,8 @@ import org.monogram.domain.models.MessageSenderModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SectionHeader -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SectionHeader +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.features.profile.logs.components.DateHeader import org.monogram.presentation.features.profile.logs.components.FilterChipCompact import org.monogram.presentation.features.profile.logs.components.LogBubble diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt index a9f23c03..3b9c6b01 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt @@ -30,9 +30,9 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText -import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji -import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent +import org.monogram.presentation.features.chats.conversation.ui.message.MessageText +import org.monogram.presentation.features.chats.conversation.ui.message.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageInlineContent import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import java.io.File diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/EmojisGrid.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/EmojisGrid.kt index 8eb52de0..630202bb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/EmojisGrid.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/EmojisGrid.kt @@ -78,8 +78,8 @@ import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences -import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily +import org.monogram.presentation.features.chats.conversation.ui.StickerSetSheet +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.stickers.ui.view.LocalIsScrolling import org.monogram.presentation.features.stickers.ui.view.StickerItem diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/GifsGrid.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/GifsGrid.kt index 773452a4..f5284e0f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/GifsGrid.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/GifsGrid.kt @@ -68,8 +68,8 @@ import org.koin.compose.koinInject import org.monogram.domain.models.GifModel import org.monogram.domain.repository.GifRepository import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer -import org.monogram.presentation.features.chats.currentChat.components.VideoType +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.features.stickers.ui.view.shimmerEffect @androidx.annotation.OptIn(UnstableApi::class) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index 1f9b58b2..6dcb4efd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -136,8 +136,8 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.DateFormatManager -import org.monogram.presentation.features.chats.currentChat.chatContent.DeleteMessagesSheet -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily +import org.monogram.presentation.features.chats.conversation.ui.content.DeleteMessagesSheet +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.text.SimpleDateFormat import java.util.Date diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickersGrid.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickersGrid.kt index 1e8ff98f..daddf992 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickersGrid.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickersGrid.kt @@ -70,7 +70,7 @@ import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R -import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet +import org.monogram.presentation.features.chats.conversation.ui.StickerSetSheet import org.monogram.presentation.features.stickers.ui.view.LocalIsScrolling import org.monogram.presentation.features.stickers.ui.view.StickerItem import org.monogram.presentation.features.stickers.ui.view.StickerSkeleton diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt index 9e102cba..7d18da30 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt @@ -1,8 +1,25 @@ package org.monogram.presentation.features.viewers +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.features.viewers.components.ImageOverlay +import org.monogram.presentation.features.viewers.components.ImagePage +@OptIn(ExperimentalFoundationApi::class) @Composable fun ImageViewer( images: List, @@ -21,20 +38,85 @@ fun ImageViewer( downloadUtils: IDownloadUtils, showImageNumber: Boolean = true ) { - MediaViewer( - mediaItems = images, - startIndex = startIndex, - onDismiss = onDismiss, - autoDownload = autoDownload, - onPageChanged = onPageChanged, - onForward = onForward, - onDelete = onDelete, - onCopyLink = onCopyLink, - onCopyText = onCopyText, - captions = captions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, - downloadUtils = downloadUtils, - showImageNumber = showImageNumber + require(images.isNotEmpty()) { "images can't be empty" } + + val resolvedIndex = startIndex.coerceIn(0, images.lastIndex.coerceAtLeast(0)) + val pagerState = rememberPagerState( + initialPage = resolvedIndex, + pageCount = { images.size } ) + val scope = rememberCoroutineScope() + val hostState = rememberFullscreenViewerHostState() + + var showControls by remember { mutableStateOf(true) } + var showSettingsMenu by remember { mutableStateOf(false) } + + LaunchedEffect(pagerState.currentPage) { + onPageChanged?.invoke(pagerState.currentPage) + hostState.zoomState.resetInstant(scope) + hostState.rootState.resetInstant(scope) + showSettingsMenu = false + } + + FullscreenViewerHost( + onDismiss = onDismiss, + showControls = showControls, + showSettingsMenu = showSettingsMenu, + onCloseSettingsMenu = { showSettingsMenu = false }, + hostState = hostState + ) { + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + key = { page -> + val path = images.getOrNull(page).orEmpty() + "image_page_${path}_$page" + }, + pageSize = PageSize.Fill, + beyondViewportPageCount = 0, + userScrollEnabled = zoomState.scale.value == 1f && rootState.offsetY.value == 0f + ) { page -> + val path = images.getOrNull(page) ?: return@HorizontalPager + + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + ) { + ImagePage( + path = path, + isDownloading = imageDownloadingStates.getOrNull(page) == true, + downloadProgress = imageDownloadProgressStates.getOrNull(page) ?: 0f, + zoomState = zoomState, + rootState = rootState, + screenHeightPx = screenHeightPx, + dismissDistancePx = dismissDistancePx, + dismissVelocityThreshold = dismissVelocityThreshold, + onDismiss = onDismiss, + showControls = showControls, + onToggleControls = { showControls = !showControls }, + pageIndex = page, + pagerIndex = pagerState.currentPage + ) + } + } + + ImageOverlay( + showControls = showControls, + rootState = rootState, + pagerState = pagerState, + mediaItems = images, + captions = captions, + showImageNumber = showImageNumber, + onDismiss = onDismiss, + showSettingsMenu = showSettingsMenu, + onToggleSettings = { showSettingsMenu = !showSettingsMenu }, + downloadUtils = downloadUtils, + onForward = onForward, + onDelete = onDelete, + onCopyLink = onCopyLink, + onCopyText = onCopyText + ) + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt index b9d7a048..715e9245 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt @@ -1,19 +1,16 @@ package org.monogram.presentation.features.viewers -import android.util.Log import androidx.activity.compose.BackHandler -import androidx.annotation.OptIn import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PageSize -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -28,89 +25,74 @@ import androidx.media3.common.util.UnstableApi import kotlinx.coroutines.launch import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.getMimeType -import org.monogram.presentation.features.viewers.components.* +import org.monogram.presentation.features.viewers.components.DismissRootState +import org.monogram.presentation.features.viewers.components.ZoomState +import org.monogram.presentation.features.viewers.components.findActivity +import org.monogram.presentation.features.viewers.components.rememberDismissRootState +import org.monogram.presentation.features.viewers.components.rememberZoomState + +internal data class FullscreenViewerHostState( + val rootState: DismissRootState, + val zoomState: ZoomState, + val screenHeightPx: Float, + val dismissDistancePx: Float, + val dismissVelocityThreshold: Float +) -private const val TAG = "MediaViewer" - -@OptIn(ExperimentalFoundationApi::class, UnstableApi::class) @Composable -fun MediaViewer( - mediaItems: List, - startIndex: Int = 0, - onDismiss: () -> Unit, - autoDownload: Boolean = true, - onPageChanged: ((Int) -> Unit)? = null, - onForward: (String) -> Unit = {}, - onDelete: ((String) -> Unit)? = null, - onCopyLink: ((String) -> Unit)? = null, - onCopyText: ((String) -> Unit)? = null, - onSaveGif: ((String) -> Unit)? = null, - captions: List = emptyList(), - fileIds: List = emptyList(), - imageDownloadingStates: List = emptyList(), - imageDownloadProgressStates: List = emptyList(), - supportsStreaming: Boolean = false, - downloadUtils: IDownloadUtils, - showImageNumber: Boolean = true, - isGesturesEnabled: Boolean = true, - isDoubleTapSeekEnabled: Boolean = true, - seekDuration: Int = 10, - isZoomEnabled: Boolean = true, - isAlwaysVideo: Boolean = false -) { - require(mediaItems.isNotEmpty()) { "mediaItems can't be empty" } - - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = startIndex, - pageCount = { mediaItems.size } - ) - +internal fun rememberFullscreenViewerHostState(): FullscreenViewerHostState { val rootState = rememberDismissRootState() val zoomState = rememberZoomState() + val containerSize = LocalWindowInfo.current.containerSize + val density = LocalDensity.current - var showControls by remember { mutableStateOf(true) } - var showSettingsMenu by remember { mutableStateOf(false) } - var currentVideoInPipMode by remember { mutableStateOf(false) } + return remember(rootState, zoomState, containerSize, density) { + FullscreenViewerHostState( + rootState = rootState, + zoomState = zoomState, + screenHeightPx = containerSize.height.toFloat(), + dismissDistancePx = with(density) { 160.dp.toPx() }, + dismissVelocityThreshold = with(density) { 1000.dp.toPx() } + ) + } +} +@Composable +internal fun FullscreenViewerHost( + onDismiss: () -> Unit, + showControls: Boolean, + showSettingsMenu: Boolean = false, + isInPictureInPicture: Boolean = false, + onCloseSettingsMenu: (() -> Unit)? = null, + hostState: FullscreenViewerHostState = rememberFullscreenViewerHostState(), + content: @Composable FullscreenViewerHostState.() -> Unit +) { val context = LocalContext.current - - val containerSize = LocalWindowInfo.current.containerSize - val density = LocalDensity.current - val screenHeightPx = containerSize.height.toFloat() - val dismissDistancePx = with(density) { 160.dp.toPx() } - val dismissVelocityThreshold = with(density) { 1000.dp.toPx() } + val currentOnCloseSettingsMenu = onCloseSettingsMenu LaunchedEffect(Unit) { - Log.d(TAG, "Opened with ${mediaItems.size} items, startIndex=$startIndex") launch { - rootState.scale.animateTo(1f, spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium)) + hostState.rootState.scale.animateTo( + 1f, + spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessMedium) + ) } launch { - rootState.backgroundAlpha.animateTo(1f, tween(150)) + hostState.rootState.backgroundAlpha.animateTo(1f, tween(150)) } } - LaunchedEffect(pagerState.currentPage) { - Log.d(TAG, "Page changed to ${pagerState.currentPage}") - onPageChanged?.invoke(pagerState.currentPage) - zoomState.resetInstant(scope) - rootState.resetInstant(scope) - showSettingsMenu = false - currentVideoInPipMode = false - } - - LaunchedEffect(showControls, currentVideoInPipMode) { + LaunchedEffect(showControls, isInPictureInPicture) { if (!showControls) { - showSettingsMenu = false + currentOnCloseSettingsMenu?.invoke() } - val activity = context.findActivity() - activity?.let { + context.findActivity()?.let { val insetsController = WindowCompat.getInsetsController(it.window, it.window.decorView) - insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - if (showControls && !currentVideoInPipMode) { + if (showControls && !isInPictureInPicture) { insetsController.show(WindowInsetsCompat.Type.systemBars()) } else { insetsController.hide(WindowInsetsCompat.Type.systemBars()) @@ -128,118 +110,106 @@ fun MediaViewer( } BackHandler { - Log.d(TAG, "BackHandler: showSettingsMenu=$showSettingsMenu, currentVideoInPipMode=$currentVideoInPipMode") if (showSettingsMenu) { - showSettingsMenu = false + currentOnCloseSettingsMenu?.invoke() + } else if (isInPictureInPicture) { + context.findActivity()?.finishAndRemoveTask() } else { - if (currentVideoInPipMode) { - context.findActivity()?.finishAndRemoveTask() - } else { - onDismiss() - } + onDismiss() } } Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = rootState.backgroundAlpha.value)) + .background(Color.Black.copy(alpha = hostState.rootState.backgroundAlpha.value)) .graphicsLayer { - translationY = rootState.offsetY.value - scaleX = rootState.scale.value - scaleY = rootState.scale.value + translationY = hostState.rootState.offsetY.value + scaleX = hostState.rootState.scale.value + scaleY = hostState.rootState.scale.value } ) { - HorizontalPager( - state = pagerState, - key = { page -> "media_page_${page}" }, - pageSize = PageSize.Fill, - pageSpacing = 0.dp, - beyondViewportPageCount = 0, - userScrollEnabled = zoomState.scale.value == 1f && rootState.offsetY.value == 0f - ) { page -> - val path = mediaItems.getOrNull(page) ?: return@HorizontalPager - val mimeType = getMimeType(path) - val isVideo = (isAlwaysVideo && path.isNotBlank()) || isVideoPath(path, mimeType) - - if (isVideo) { - VideoPage( - path = path, - fileId = fileIds.getOrNull(page) ?: 0, - caption = captions.getOrNull(page), - supportsStreaming = supportsStreaming, - downloadUtils = downloadUtils, - onDismiss = onDismiss, - showControls = showControls, - onToggleControls = { showControls = !showControls }, - onForward = onForward, - onDelete = onDelete, - onCopyLink = onCopyLink, - onCopyText = onCopyText, - onSaveGif = onSaveGif, - showSettingsMenu = showSettingsMenu, - onToggleSettings = { showSettingsMenu = !showSettingsMenu }, - isGesturesEnabled = isGesturesEnabled, - isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, - seekDuration = seekDuration, - isZoomEnabled = isZoomEnabled, - isActive = pagerState.currentPage == page, - onCurrentVideoPipModeChanged = { inPip -> - if (pagerState.currentPage == page) { - currentVideoInPipMode = inPip - } - }, - zoomState = zoomState, - rootState = rootState, - screenHeightPx = screenHeightPx, - dismissDistancePx = dismissDistancePx, - dismissVelocityThreshold = dismissVelocityThreshold - ) - } else { - ImagePage( - path = path, - isDownloading = imageDownloadingStates.getOrNull(page) == true, - downloadProgress = imageDownloadProgressStates.getOrNull(page) ?: 0f, - zoomState = zoomState, - rootState = rootState, - screenHeightPx = screenHeightPx, - dismissDistancePx = dismissDistancePx, - dismissVelocityThreshold = dismissVelocityThreshold, - onDismiss = onDismiss, - showControls = showControls, - onToggleControls = { showControls = !showControls }, - pageIndex = page, - pagerIndex = pagerState.currentPage - ) - } - } + hostState.content() + } +} - val currentPath = mediaItems.getOrNull(pagerState.currentPage) ?: "" - val currentMimeType = getMimeType(currentPath) - val isCurrentVideo = (isAlwaysVideo && currentPath.isNotBlank()) || isVideoPath(currentPath, currentMimeType) - - if (!isCurrentVideo) { - ImageOverlay( - showControls = showControls, - rootState = rootState, - pagerState = pagerState, - mediaItems = mediaItems, - captions = captions, - showImageNumber = showImageNumber, - onDismiss = onDismiss, - showSettingsMenu = showSettingsMenu, - onToggleSettings = { showSettingsMenu = !showSettingsMenu }, - downloadUtils = downloadUtils, - onForward = onForward, - onDelete = onDelete, - onCopyLink = onCopyLink, - onCopyText = onCopyText - ) - } +@OptIn(UnstableApi::class) +@Composable +fun MediaViewer( + mediaItems: List, + startIndex: Int = 0, + onDismiss: () -> Unit, + autoDownload: Boolean = true, + onPageChanged: ((Int) -> Unit)? = null, + onForward: (String) -> Unit = {}, + onDelete: ((String) -> Unit)? = null, + onCopyLink: ((String) -> Unit)? = null, + onCopyText: ((String) -> Unit)? = null, + onSaveGif: ((String) -> Unit)? = null, + captions: List = emptyList(), + fileIds: List = emptyList(), + imageDownloadingStates: List = emptyList(), + imageDownloadProgressStates: List = emptyList(), + supportsStreaming: Boolean = false, + downloadUtils: IDownloadUtils, + showImageNumber: Boolean = true, + isGesturesEnabled: Boolean = true, + isDoubleTapSeekEnabled: Boolean = true, + seekDuration: Int = 10, + isZoomEnabled: Boolean = true, + isAlwaysVideo: Boolean = false +) { + require(mediaItems.isNotEmpty()) { "mediaItems can't be empty" } + + val resolvedIndex = startIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)) + val currentPath = mediaItems[resolvedIndex] + val currentMimeType = getMimeType(currentPath) + val shouldRenderSingleVideo = + mediaItems.size == 1 && ((isAlwaysVideo && currentPath.isNotBlank()) || isVideoPath( + currentPath, + currentMimeType + )) + + if (shouldRenderSingleVideo) { + VideoViewer( + path = currentPath, + onDismiss = onDismiss, + onForward = onForward, + onDelete = onDelete, + onCopyLink = onCopyLink, + onCopyText = onCopyText, + onSaveGif = onSaveGif, + caption = captions.getOrNull(resolvedIndex), + fileId = fileIds.getOrNull(resolvedIndex) ?: 0, + supportsStreaming = supportsStreaming, + downloadUtils = downloadUtils, + isGesturesEnabled = isGesturesEnabled, + isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, + seekDuration = seekDuration, + isZoomEnabled = isZoomEnabled + ) + return } + + ImageViewer( + images = mediaItems, + startIndex = resolvedIndex, + onDismiss = onDismiss, + autoDownload = autoDownload, + onPageChanged = onPageChanged, + onForward = onForward, + onDelete = onDelete, + onCopyLink = onCopyLink, + onCopyText = onCopyText, + captions = captions, + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = downloadUtils, + showImageNumber = showImageNumber + ) } -private fun isVideoPath(path: String, mimeType: String?): Boolean { +internal fun isVideoPath(path: String, mimeType: String?): Boolean { if (path.isBlank()) return false if (mimeType?.startsWith("image/") == true) return false diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/VideoViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/VideoViewer.kt index d7c3196d..64065d62 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/VideoViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/VideoViewer.kt @@ -1,10 +1,18 @@ package org.monogram.presentation.features.viewers -import android.util.Log +import androidx.annotation.OptIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.media3.common.util.UnstableApi import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.features.viewers.components.VideoPage +@OptIn(UnstableApi::class) @Composable fun VideoViewer( path: String, @@ -36,29 +44,55 @@ fun VideoViewer( return } - Log.d("VideoViewer", "Composing VideoViewer for path=$effectivePath, fileId=$fileId") + val scope = rememberCoroutineScope() + val hostState = rememberFullscreenViewerHostState() - val mediaItems = remember(effectivePath) { listOf(effectivePath) } - val captions = remember(caption) { listOf(caption) } - val fileIds = remember(fileId) { listOf(fileId) } + var showControls by remember { mutableStateOf(true) } + var showSettingsMenu by remember { mutableStateOf(false) } + var currentVideoInPipMode by remember { mutableStateOf(false) } - MediaViewer( - mediaItems = mediaItems, - startIndex = 0, + LaunchedEffect(effectivePath, fileId, supportsStreaming) { + hostState.zoomState.resetInstant(scope) + hostState.rootState.resetInstant(scope) + showSettingsMenu = false + currentVideoInPipMode = false + } + + FullscreenViewerHost( onDismiss = onDismiss, - onForward = onForward, - onDelete = onDelete, - onCopyLink = onCopyLink, - onCopyText = onCopyText, - onSaveGif = onSaveGif, - captions = captions, - fileIds = fileIds, - supportsStreaming = supportsStreaming, - downloadUtils = downloadUtils, - isGesturesEnabled = isGesturesEnabled, - isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, - seekDuration = seekDuration, - isZoomEnabled = isZoomEnabled, - isAlwaysVideo = true - ) + showControls = showControls, + showSettingsMenu = showSettingsMenu, + isInPictureInPicture = currentVideoInPipMode, + onCloseSettingsMenu = { showSettingsMenu = false }, + hostState = hostState + ) { + VideoPage( + path = effectivePath, + fileId = fileId, + caption = caption, + supportsStreaming = supportsStreaming, + downloadUtils = downloadUtils, + onDismiss = onDismiss, + showControls = showControls, + onToggleControls = { showControls = !showControls }, + onForward = onForward, + onDelete = onDelete, + onCopyLink = onCopyLink, + onCopyText = onCopyText, + onSaveGif = onSaveGif, + showSettingsMenu = showSettingsMenu, + onToggleSettings = { showSettingsMenu = !showSettingsMenu }, + isGesturesEnabled = isGesturesEnabled, + isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, + seekDuration = seekDuration, + isZoomEnabled = isZoomEnabled, + isActive = true, + onCurrentVideoPipModeChanged = { currentVideoInPipMode = it }, + zoomState = zoomState, + rootState = rootState, + screenHeightPx = screenHeightPx, + dismissDistancePx = dismissDistancePx, + dismissVelocityThreshold = dismissVelocityThreshold + ) + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt index 3599dfa8..97473d72 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt @@ -103,6 +103,7 @@ fun ImagePage( pagerIndex: Int ) { val scope = rememberCoroutineScope() + Box( modifier = Modifier .fillMaxSize() diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt index f6b7679f..7a3d2133 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt @@ -294,7 +294,6 @@ fun VideoPage( exoPlayer.addListener(listener) lifecycleOwner.lifecycle.addObserver(observer) onDispose { - Log.d(TAG, "Disposing ExoPlayer for $path") lifecycleOwner.lifecycle.removeObserver(observer) exoPlayer.removeListener(listener) exoPlayer.release() diff --git a/presentation/src/main/java/org/monogram/presentation/features/webview/components/FindInPageBar.kt b/presentation/src/main/java/org/monogram/presentation/features/webview/components/FindInPageBar.kt index 9136dfde..e7f42647 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webview/components/FindInPageBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webview/components/FindInPageBar.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index a29f85ac..6bfba3db 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -58,10 +58,10 @@ import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.features.auth.DefaultAuthComponent -import org.monogram.presentation.features.chats.chatList.DefaultChatListComponent -import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool -import org.monogram.presentation.features.chats.newChat.DefaultNewChatComponent +import org.monogram.presentation.features.chats.list.DefaultChatListComponent +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.core.media.VideoPlayerPool +import org.monogram.presentation.features.chats.creation.DefaultNewChatComponent import org.monogram.presentation.features.profile.DefaultProfileComponent import org.monogram.presentation.features.profile.admin.DefaultAdminManageComponent import org.monogram.presentation.features.profile.admin.DefaultChatEditComponent diff --git a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt index 6b01b196..5bc92ad8 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt @@ -10,10 +10,10 @@ import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.auth.AuthComponent -import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool -import org.monogram.presentation.features.chats.newChat.NewChatComponent +import org.monogram.presentation.features.chats.list.ChatListComponent +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.core.media.VideoPlayerPool +import org.monogram.presentation.features.chats.creation.NewChatComponent import org.monogram.presentation.settings.folders.FoldersComponent import org.monogram.presentation.features.profile.ProfileComponent import org.monogram.presentation.features.profile.admin.AdminManageComponent diff --git a/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockContent.kt index 80b72fd5..d6cf0143 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockContent.kt @@ -28,7 +28,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ChatModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index abcd9045..356c7c9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -139,7 +139,7 @@ import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile import org.monogram.presentation.core.util.EmojiStyle import org.monogram.presentation.core.util.NightMode -import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily +import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.settings.chatSettings.components.ChatListPreview import org.monogram.presentation.settings.chatSettings.components.ChatSettingsPreview import org.monogram.presentation.settings.chatSettings.components.WallpaperItem diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt index 0df8f764..578502c5 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt @@ -36,7 +36,7 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.util.NightMode import org.monogram.presentation.core.util.coRunCatching -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import java.util.* private enum class PaletteMode { LIGHT, DARK } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/ChatSettingsPreview.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/ChatSettingsPreview.kt index 4d15617e..e6d95eeb 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/ChatSettingsPreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/ChatSettingsPreview.kt @@ -1,6 +1,13 @@ package org.monogram.presentation.settings.chatSettings.components -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape @@ -19,10 +26,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import org.monogram.domain.models.* +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel +import org.monogram.domain.models.WallpaperModel import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.MessageAppearanceConfig +import org.monogram.presentation.features.chats.conversation.ui.MessageBubbleContainer +import org.monogram.presentation.features.chats.conversation.ui.MessageRowBehaviorConfig +import org.monogram.presentation.features.chats.conversation.ui.buildSenderGrouping +import org.monogram.presentation.features.chats.conversation.ui.content.GroupedMessageItem import java.io.File @Composable @@ -217,18 +233,33 @@ fun ChatSettingsPreview( MessageBubbleContainer( msg = msg, - olderMsg = olderMsg, newerMsg = newerMsg, - isGroup = true, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = true, - autoDownloadWifi = true, - autoDownloadRoaming = false, - autoDownloadFiles = false, - autoplayGifs = true, - autoplayVideos = true, + appearance = MessageAppearanceConfig( + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = 200f, + autoDownloadMobile = true, + autoDownloadWifi = true, + autoDownloadRoaming = false, + autoDownloadFiles = false, + autoplayGifs = true, + autoplayVideos = true + ), + behavior = MessageRowBehaviorConfig( + isGroup = true, + isChannel = false, + isTopicClosed = false, + canReply = false, + swipeEnabled = false, + isSelectionMode = false, + isAnyViewerOpen = false + ), + senderGrouping = buildSenderGrouping( + item = GroupedMessageItem.Single(msg), + olderMsg = olderMsg, + newerMsg = newerMsg + ), onPhotoClick = onPhotoClick, onReplyClick = onReplyClick, toProfile = toProfile, diff --git a/presentation/src/main/java/org/monogram/presentation/settings/folders/FolderDialog.kt b/presentation/src/main/java/org/monogram/presentation/settings/folders/FolderDialog.kt index 255f3bba..770826f6 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/folders/FolderDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/folders/FolderDialog.kt @@ -26,8 +26,8 @@ import org.monogram.domain.models.ChatModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SectionHeader -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SectionHeader +import org.monogram.presentation.core.ui.SettingsTextField @OptIn(ExperimentalMaterial3Api::class) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt index f70807c8..5f244add 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt @@ -50,8 +50,8 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.util.FileUtils -import org.monogram.presentation.features.chats.chatList.components.SectionHeader -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SectionHeader +import org.monogram.presentation.core.ui.SettingsTextField import java.util.* private const val MAP_STYLE = "https://tiles.openfreemap.org/styles/bright" diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt index c0a32140..127db9a5 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt @@ -58,7 +58,7 @@ import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.proxy.MtprotoSecretNormalizer import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow @OptIn(ExperimentalMaterial3Api::class) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt b/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt index 4b62ab37..80d6450d 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/storage/CacheController.kt @@ -2,7 +2,7 @@ package org.monogram.presentation.settings.storage import android.content.Context import coil3.imageLoader -import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache +import org.monogram.presentation.core.media.ExoPlayerCache import java.io.File class CacheController(val context: Context, val exoPlayerCache: ExoPlayerCache) { diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index a7ef509f..2c9b60f8 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -158,6 +158,12 @@ Показать аккаунты Поиск сообщений… + Все авторы + Все даты + С + По + + 30д Очистить Без звука Подтверждённый аккаунт diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 773f15da..352ffd67 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -180,6 +180,16 @@ Search messages... + Searching... + %1$d / %2$d + Show all + Hide results + All senders + All dates + From + To + 7d + 30d Clear Muted Verified diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControlsTest.kt similarity index 93% rename from presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt rename to presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControlsTest.kt index 04df4f1c..71479424 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/components/TransformControlsTest.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components +package org.monogram.presentation.features.chats.conversation.editor.photo.components import org.junit.Assert.assertEquals import org.junit.Test diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometryTest.kt similarity index 98% rename from presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt rename to presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometryTest.kt index 1aa60c9f..bf96b436 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/editor/photo/crop/CropGeometryTest.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.crop +package org.monogram.presentation.features.chats.conversation.editor.photo.crop import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect