From 33b8b69eeb32899050e5d6dab83682aaa103d601 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sat, 4 Apr 2026 17:03:08 +0300 Subject: [PATCH 01/53] feat(chats): fast reply gesture --- .../components/FastReplyIndicator.kt | 69 +++++++ .../components/MessageBubbleContainer.kt | 171 ++++++++++++------ 2 files changed, 180 insertions(+), 60 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt 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/currentChat/components/FastReplyIndicator.kt new file mode 100644 index 00000000..549768d7 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt @@ -0,0 +1,69 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun FastReplyIndicator( + modifier: Modifier = Modifier, + dragOffsetX: Animatable, + isOutgoing: Boolean, + maxWidth: Dp, + fastReplyTriggerThreshold: Float +) { + val iconScale by animateFloatAsState( + targetValue = if (dragOffsetX.value < 0f) 1f else 0.5f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + ) + + val iconAlpha by animateFloatAsState( + targetValue = (dragOffsetX.value / fastReplyTriggerThreshold).coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 150) + ) + + val iconSize = 36.dp + + Box( + modifier = modifier + .offset(x = if (isOutgoing) iconSize else maxWidth) + .size(iconSize) + .graphicsLayer { + translationX = if (isOutgoing) (-dragOffsetX.value - iconSize.toPx()) * 0.5f else 0f + scaleX = iconScale + scaleY = iconScale + alpha = iconAlpha + } + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + tint = Color.White, + modifier = Modifier.fillMaxSize() + ) + } +} \ No newline at end of file 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 index e03b6b36..547c1d34 100644 --- 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 @@ -2,8 +2,11 @@ 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.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,10 +21,12 @@ 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 kotlinx.coroutines.launch import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -76,6 +81,8 @@ fun MessageBubbleContainer( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val fastReplyTriggerThreshold = -120f + val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -114,15 +121,43 @@ fun MessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val scope = rememberCoroutineScope() + 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) + } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + change.consume() + scope.launch { + dragOffsetX.snapTo((dragOffsetX.value + dragAmount).coerceIn(-200f, 0f)) + } + }, + onDragEnd = { + if (dragOffsetX.value < fastReplyTriggerThreshold && canReply) { + onReplySwipe(msg) + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + ) + } .pointerInput(Unit) { detectTapGestures( onTap = { offset -> + if (dragOffsetX.value < 0f) { + return@detectTapGestures + } val clickPos = outerColumnPosition + offset val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { @@ -130,6 +165,9 @@ fun MessageBubbleContainer( } }, onLongPress = { offset -> + if (dragOffsetX.value < 0f) { + return@detectTapGestures + } val clickPos = outerColumnPosition + offset val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { @@ -152,70 +190,83 @@ fun MessageBubbleContainer( toProfile = toProfile ) - 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 + Box( + modifier = Modifier.wrapContentSize() ) { - 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, - bubblePosition = bubblePosition, - bubbleSize = bubbleSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) + 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, + bubblePosition = bubblePosition, + bubbleSize = bubbleSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) - MessageReplyMarkup( - msg = msg, - onReplyMarkupButtonClick = onReplyMarkupButtonClick - ) + MessageReplyMarkup( + msg = msg, + onReplyMarkupButtonClick = onReplyMarkupButtonClick + ) - MessageViaBotAttribution( - msg = msg, + MessageViaBotAttribution( + msg = msg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + ) + } + + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth, + fastReplyTriggerThreshold = fastReplyTriggerThreshold ) } } From a73d370941b4ed34bcb92a183bb7035d64400b88 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sat, 4 Apr 2026 21:11:29 +0300 Subject: [PATCH 02/53] improve fast reply indicator design --- .../components/FastReplyIndicator.kt | 55 ++++++++++--------- .../components/MessageBubbleContainer.kt | 9 +-- 2 files changed, 33 insertions(+), 31 deletions(-) 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/currentChat/components/FastReplyIndicator.kt index 549768d7..6ee227ce 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/currentChat/components/FastReplyIndicator.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -18,8 +17,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -28,42 +27,44 @@ import androidx.compose.ui.unit.dp fun FastReplyIndicator( modifier: Modifier = Modifier, dragOffsetX: Animatable, - isOutgoing: Boolean, + isOutgoing: Boolean = false, maxWidth: Dp, + fadeInThreshold: Float, fastReplyTriggerThreshold: Float ) { val iconScale by animateFloatAsState( - targetValue = if (dragOffsetX.value < 0f) 1f else 0.5f, + targetValue = if (dragOffsetX.value - fadeInThreshold < 0f) 1f else 0.5f, animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) ) val iconAlpha by animateFloatAsState( - targetValue = (dragOffsetX.value / fastReplyTriggerThreshold).coerceIn(0f, 1f), + targetValue = ((dragOffsetX.value - fadeInThreshold) / fastReplyTriggerThreshold).coerceIn(0f, 1f), animationSpec = tween(durationMillis = 150) ) - val iconSize = 36.dp - - Box( - modifier = modifier - .offset(x = if (isOutgoing) iconSize else maxWidth) - .size(iconSize) - .graphicsLayer { - translationX = if (isOutgoing) (-dragOffsetX.value - iconSize.toPx()) * 0.5f else 0f - scaleX = iconScale - scaleY = iconScale - alpha = iconAlpha - } - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape + if (dragOffsetX.value < fadeInThreshold) { + Box( + modifier = modifier + .offset(x = if (isOutgoing) -fadeInThreshold.dp else maxWidth) + .size(30.dp) + .graphicsLayer { + translationX = if (isOutgoing) (-dragOffsetX.value + fadeInThreshold) * 0.5f else -fadeInThreshold + scaleX = iconScale + scaleY = iconScale + alpha = iconAlpha + } + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) ) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Reply, - contentDescription = null, - tint = Color.White, - modifier = Modifier.fillMaxSize() - ) + } } } \ No newline at end of file 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 index 547c1d34..aa78b13c 100644 --- 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 @@ -81,7 +81,8 @@ fun MessageBubbleContainer( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val fastReplyTriggerThreshold = -120f + val fadeInThreshold = -36f + val fastReplyTriggerThreshold = -120f + fadeInThreshold val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp @@ -124,7 +125,6 @@ fun MessageBubbleContainer( val scope = rememberCoroutineScope() val dragOffsetX = remember { Animatable(0f) } - Column( modifier = Modifier .fillMaxWidth() @@ -139,7 +139,7 @@ fun MessageBubbleContainer( onHorizontalDrag = { change, dragAmount -> change.consume() scope.launch { - dragOffsetX.snapTo((dragOffsetX.value + dragAmount).coerceIn(-200f, 0f)) + dragOffsetX.snapTo((dragOffsetX.value + dragAmount).coerceIn(-200f + fadeInThreshold, 0f)) } }, onDragEnd = { @@ -266,7 +266,8 @@ fun MessageBubbleContainer( dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, maxWidth = maxWidth, - fastReplyTriggerThreshold = fastReplyTriggerThreshold + fadeInThreshold = fadeInThreshold, + fastReplyTriggerThreshold = fastReplyTriggerThreshold, ) } } From 3baed7d1000c9f7bf61354967db7e750f58ae819 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sun, 5 Apr 2026 15:04:35 +0300 Subject: [PATCH 03/53] feat: fast reply for album messages + fix bugs with pointer input --- .../components/AlbumMessageBubbleContainer.kt | 264 +++++++++++------- .../components/MessageBubbleContainer.kt | 62 ++-- 2 files changed, 211 insertions(+), 115 deletions(-) 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/currentChat/components/AlbumMessageBubbleContainer.kt index 91555392..89fd2f4f 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/currentChat/components/AlbumMessageBubbleContainer.kt @@ -1,6 +1,10 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -10,13 +14,18 @@ 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.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange 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.launch import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar @@ -65,6 +74,9 @@ fun AlbumMessageBubbleContainer( ) { if (messages.isEmpty()) return + val fadeInThreshold = -36f + val fastReplyTriggerThreshold = -120f + fadeInThreshold + val firstMsg = messages.first() val lastMsg = messages.last() val isOutgoing = firstMsg.isOutgoing @@ -97,11 +109,59 @@ fun AlbumMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val scope = rememberCoroutineScope() + val dragOffsetX = remember { Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing, bottom = 2.dp) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48f) { + isDragging = true + } else if (totalDragX > 48f) { + break + } + } + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) + } + } + } + + if (isDragging) { + if (dragOffsetX.value < fastReplyTriggerThreshold) { + onReplySwipe(messages.first()) + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + } + } .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -137,109 +197,123 @@ fun AlbumMessageBubbleContainer( Spacer(modifier = Modifier.width(8.dp)) } - Column( - modifier = Modifier - .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) - .widthIn(max = maxWidth) - .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + Box( + modifier = Modifier.wrapContentSize() + ) { + Column( + modifier = Modifier + .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) + .widthIn(max = maxWidth) + .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + } } + ) { + if (isGroup && !isOutgoing && !isChannel) { + Text( + text = firstMsg.senderName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) + ) } - ) { - if (isGroup && !isOutgoing && !isChannel) { - Text( - text = firstMsg.senderName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) - ) - } - if (isChannel) { - ChannelAlbumMessageBubble( - messages = messages, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - fontSize = fontSize, - bubbleRadius = bubbleRadius, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } else { - ChatAlbumMessageBubble( - messages = messages, - isOutgoing = isOutgoing, - isGroup = isGroup, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - toProfile = toProfile, - modifier = Modifier, - fontSize = fontSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + if (isChannel) { + ChannelAlbumMessageBubble( + messages = messages, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + fontSize = fontSize, + bubbleRadius = bubbleRadius, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } else { + ChatAlbumMessageBubble( + messages = messages, + isOutgoing = isOutgoing, + isGroup = isGroup, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + toProfile = toProfile, + modifier = Modifier, + fontSize = fontSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - lastMsg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + lastMsg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + ) + } + + MessageViaBotAttribution( + msg = lastMsg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) ) } - MessageViaBotAttribution( - msg = lastMsg, + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth, + fadeInThreshold = fadeInThreshold, + fastReplyTriggerThreshold = fastReplyTriggerThreshold, ) } } 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 index aa78b13c..8546d684 100644 --- 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 @@ -6,7 +6,8 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -17,7 +18,10 @@ 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.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration @@ -131,33 +135,54 @@ fun MessageBubbleContainer( .background(animatedColor.value, RoundedCornerShape(12.dp)) .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) - .offset { - IntOffset(dragOffsetX.value.toInt(), 0) - } - .pointerInput(Unit) { - detectHorizontalDragGestures( - onHorizontalDrag = { change, dragAmount -> - change.consume() - scope.launch { - dragOffsetX.snapTo((dragOffsetX.value + dragAmount).coerceIn(-200f + fadeInThreshold, 0f)) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48f) { + isDragging = true + } else if (totalDragX > 48f) { + break + } } - }, - onDragEnd = { - if (dragOffsetX.value < fastReplyTriggerThreshold && canReply) { + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) + } + } + } + + if (isDragging) { + if (dragOffsetX.value < fastReplyTriggerThreshold) { onReplySwipe(msg) } scope.launch { dragOffsetX.animateTo(0f, spring()) } } - ) + } } .pointerInput(Unit) { detectTapGestures( onTap = { offset -> - if (dragOffsetX.value < 0f) { - return@detectTapGestures - } val clickPos = outerColumnPosition + offset val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { @@ -165,9 +190,6 @@ fun MessageBubbleContainer( } }, onLongPress = { offset -> - if (dragOffsetX.value < 0f) { - return@detectTapGestures - } val clickPos = outerColumnPosition + offset val bubbleRect = Rect(bubblePosition, bubbleSize.toSize()) if (!bubbleRect.contains(clickPos)) { From 1371efe33ebaf1e42d28a041326031ac3aab97df Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sun, 5 Apr 2026 15:48:35 +0300 Subject: [PATCH 04/53] feat(channels): fast reply gesture --- .../chatContent/ChatContentList.kt | 5 +- .../channels/ChannelMessageBubbleContainer.kt | 570 ++++++++++-------- 2 files changed, 326 insertions(+), 249 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 97585320..b78cc1c7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -581,6 +581,8 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, + canReply = state.canWrite && !isSelectionMode, + onReplySwipe = { component.onReplyMessage(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, downloadUtils = downloadUtils, @@ -864,7 +866,8 @@ private fun RootMessageSection( onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, onClosePoll = { component.onClosePoll(it) }, - toProfile = toProfile, swipeEnabled = false, + toProfile = toProfile, + swipeEnabled = false, onViaBotClick = onViaBotClick, onInstantViewClick = { component.onOpenInstantView(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt index 1c742a7d..d5b118a0 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -2,8 +2,11 @@ package org.monogram.presentation.features.chats.currentChat.components.channels import android.content.res.Configuration import androidx.compose.animation.Animatable +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,19 +17,25 @@ 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.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange 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 kotlinx.coroutines.launch 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.* @Composable @@ -70,9 +79,14 @@ fun ChannelMessageBubbleContainer( showComments: Boolean = true, toProfile: (Long) -> Unit = {}, onViaBotClick: (String) -> Unit = {}, + canReply: Boolean = true, + onReplySwipe: (MessageModel) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, ) { + val fadeInThreshold = -36f + val fastReplyTriggerThreshold = -120f + fadeInThreshold + val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -104,12 +118,60 @@ fun ChannelMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val scope = rememberCoroutineScope() + val dragOffsetX = remember { androidx.compose.animation.core.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) } + .pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48f) { + isDragging = true + } else if (totalDragX > 48f) { + break + } + } + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) + } + } + } + + if (isDragging) { + if (dragOffsetX.value < fastReplyTriggerThreshold) { + onReplySwipe(msg) + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + } + } .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -134,268 +196,280 @@ fun ChannelMessageBubbleContainer( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Bottom ) { - Column( - modifier = Modifier - .padding(horizontal = 8.dp) - .widthIn(max = maxWidth) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - } + Box( + modifier = Modifier.wrapContentSize() ) { - when (val content = msg.content) { - is MessageContent.Text -> { - ChannelTextMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - 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) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth() - ) - } + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .widthIn(max = maxWidth) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(msg.id, bubblePosition, bubbleSize) + } + } + ) { + when (val content = msg.content) { + is MessageContent.Text -> { + ChannelTextMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + 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) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth() + ) + } - is MessageContent.Photo -> { - ChannelPhotoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - 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) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils - ) - } + is MessageContent.Photo -> { + ChannelPhotoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + 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) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils + ) + } - is MessageContent.Video -> { - ChannelVideoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - 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) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Video -> { + ChannelVideoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + 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) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Document -> { - DocumentMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onDocumentClick = onDocumentClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Document -> { + DocumentMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onDocumentClick = onDocumentClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Audio -> { - AudioMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Audio -> { + AudioMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Gif -> { - ChannelGifMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - 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) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Gif -> { + ChannelGifMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + 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) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Sticker -> { - StickerMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - stickerSize = stickerSize, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onStickerClick = { onStickerClick(content.setId) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - toProfile = toProfile - ) + is MessageContent.Sticker -> { + StickerMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + stickerSize = stickerSize, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onStickerClick = { onStickerClick(content.setId) }, + onLongClick = { + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + (bubbleSize.toSize() / 2f).toOffset() + ) + }, + toProfile = toProfile + ) + } + + is MessageContent.Poll -> { + ChannelPollMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + onOptionClick = { onPollOptionClick(msg.id, it) }, + onRetractVote = { onRetractVote(msg.id) }, + onShowVoters = { onShowVoters(msg.id, it) }, + onClosePoll = { onClosePoll(msg.id) }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile + ) + } + + else -> {} } - is MessageContent.Poll -> { - ChannelPollMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - onOptionClick = { onPollOptionClick(msg.id, it) }, - onRetractVote = { onRetractVote(msg.id) }, - onShowVoters = { onShowVoters(msg.id, it) }, - onClosePoll = { onClosePoll(msg.id) }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile + msg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } ) } - else -> {} - } - - msg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } + MessageViaBotAttribution( + msg = msg, + isOutgoing = msg.isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(Alignment.Start) ) } - MessageViaBotAttribution( - msg = msg, - isOutgoing = msg.isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(Alignment.Start) + FastReplyIndicator( + modifier = Modifier.align(Alignment.CenterStart), + dragOffsetX = dragOffsetX, + maxWidth = maxWidth, + fadeInThreshold = fadeInThreshold, + fastReplyTriggerThreshold = fastReplyTriggerThreshold, ) } } From 1d85dec5a3180739f5b45c930c801b30377d1f29 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sun, 5 Apr 2026 21:08:46 +0300 Subject: [PATCH 05/53] refactor --- .../currentChat/components => core/ui}/FastReplyIndicator.kt | 2 +- .../chats/currentChat/components/AlbumMessageBubbleContainer.kt | 1 + .../chats/currentChat/components/MessageBubbleContainer.kt | 1 + .../components/channels/ChannelMessageBubbleContainer.kt | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) rename presentation/src/main/java/org/monogram/presentation/{features/chats/currentChat/components => core/ui}/FastReplyIndicator.kt (97%) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt similarity index 97% rename from presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt rename to presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt index 6ee227ce..9b32eb94 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.chats.currentChat.components +package org.monogram.presentation.core.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D 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/currentChat/components/AlbumMessageBubbleContainer.kt index 89fd2f4f..21815806 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/currentChat/components/AlbumMessageBubbleContainer.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.ui.FastReplyIndicator 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 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 index 8546d684..e2699c6f 100644 --- 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 @@ -35,6 +35,7 @@ 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.ui.FastReplyIndicator 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.* 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt index d5b118a0..ea751d34 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -35,7 +35,7 @@ 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.core.ui.FastReplyIndicator import org.monogram.presentation.features.chats.currentChat.components.chats.* @Composable From e74b31ca4b6fa47b08a72495738b23953f317087 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Sun, 5 Apr 2026 22:31:44 +0300 Subject: [PATCH 06/53] refactor fast reply --- .../core/ui/FastReplyIndicator.kt | 70 --------- .../components/AlbumMessageBubbleContainer.kt | 65 +-------- .../components/FastReplyIndicator.kt | 135 ++++++++++++++++++ .../components/MessageBubbleContainer.kt | 67 ++------- .../channels/ChannelMessageBubbleContainer.kt | 67 ++------- 5 files changed, 159 insertions(+), 245 deletions(-) delete mode 100644 presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt deleted file mode 100644 index 9b32eb94..00000000 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/FastReplyIndicator.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.monogram.presentation.core.ui - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Composable -fun FastReplyIndicator( - modifier: Modifier = Modifier, - dragOffsetX: Animatable, - isOutgoing: Boolean = false, - maxWidth: Dp, - fadeInThreshold: Float, - fastReplyTriggerThreshold: Float -) { - val iconScale by animateFloatAsState( - targetValue = if (dragOffsetX.value - fadeInThreshold < 0f) 1f else 0.5f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) - ) - - val iconAlpha by animateFloatAsState( - targetValue = ((dragOffsetX.value - fadeInThreshold) / fastReplyTriggerThreshold).coerceIn(0f, 1f), - animationSpec = tween(durationMillis = 150) - ) - - if (dragOffsetX.value < fadeInThreshold) { - Box( - modifier = modifier - .offset(x = if (isOutgoing) -fadeInThreshold.dp else maxWidth) - .size(30.dp) - .graphicsLayer { - translationX = if (isOutgoing) (-dragOffsetX.value + fadeInThreshold) * 0.5f else -fadeInThreshold - scaleX = iconScale - scaleY = iconScale - alpha = iconAlpha - } - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Reply, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - } - } -} \ No newline at end of file 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/currentChat/components/AlbumMessageBubbleContainer.kt index 21815806..a2a0da73 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/currentChat/components/AlbumMessageBubbleContainer.kt @@ -2,9 +2,6 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.spring -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -14,10 +11,7 @@ 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.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration @@ -25,11 +19,9 @@ 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.launch import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar -import org.monogram.presentation.core.ui.FastReplyIndicator 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 @@ -75,9 +67,6 @@ fun AlbumMessageBubbleContainer( ) { if (messages.isEmpty()) return - val fadeInThreshold = -36f - val fastReplyTriggerThreshold = -120f + fadeInThreshold - val firstMsg = messages.first() val lastMsg = messages.last() val isOutgoing = firstMsg.isOutgoing @@ -110,7 +99,6 @@ fun AlbumMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - val scope = rememberCoroutineScope() val dragOffsetX = remember { Animatable(0f) } Column( @@ -119,50 +107,13 @@ fun AlbumMessageBubbleContainer( .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing, bottom = 2.dp) .offset { IntOffset(dragOffsetX.value.toInt(), 0) } - .pointerInput(canReply) { - if (!canReply) return@pointerInput - - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - var isDragging = false - var totalDragX = 0f - - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Main) - val change = event.changes.firstOrNull { it.id == down.id } ?: break - - if (change.changedToUp()) break - - val deltaX = change.positionChange().x - totalDragX += deltaX - - if (!isDragging) { - if (totalDragX < -48f) { - isDragging = true - } else if (totalDragX > 48f) { - break - } - } - - if (isDragging) { - change.consume() - val newOffset = dragOffsetX.value + deltaX - scope.launch { - dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) - } - } - } - - if (isDragging) { - if (dragOffsetX.value < fastReplyTriggerThreshold) { - onReplySwipe(messages.first()) - } - scope.launch { - dragOffsetX.animateTo(0f, spring()) - } - } - } - } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(messages.first()) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -313,8 +264,6 @@ fun AlbumMessageBubbleContainer( dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, maxWidth = maxWidth, - fadeInThreshold = fadeInThreshold, - fastReplyTriggerThreshold = fastReplyTriggerThreshold, ) } } 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/currentChat/components/FastReplyIndicator.kt new file mode 100644 index 00000000..2c7cf991 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt @@ -0,0 +1,135 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +const val REPLY_TRIGGER_FRACTION = 0.4f +const val MAX_SWIPE_FRACTION = 0.7f + +@Composable +fun FastReplyIndicator( + modifier: Modifier = Modifier, + dragOffsetX: Animatable, + isOutgoing: Boolean = false, + maxWidth: Dp, +) { + val triggerDistance = maxWidth.value * REPLY_TRIGGER_FRACTION + val dragged = (-dragOffsetX.value).coerceAtLeast(0f) + val progress = ((dragged - 48.dp.value) / (triggerDistance - 48.dp.value)) + .coerceIn(0f, 1f) + + val iconAlpha by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 150) + ) + val iconScale by animateFloatAsState( + targetValue = lerp(0.5f, 1f, progress), + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + ) + + if (dragged > 48.dp.value) { + Box( + modifier = modifier + .offset(x = if (isOutgoing) (-38).dp else maxWidth) // todo: fix this shit + .size(30.dp) + .graphicsLayer { + translationX = if (isOutgoing) dragged * 0.5f else -dragged // todo: fix this shit too + scaleX = iconScale + scaleY = iconScale + alpha = iconAlpha + } + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +fun Modifier.fastReplyPointer( + canReply: Boolean, + dragOffsetX: Animatable, + scope: CoroutineScope, + onReplySwipe: () -> Unit, + maxWidth: Float +): Modifier = pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (down.pressed) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48.dp.toPx()) { + isDragging = true + } else if (totalDragX > 48.dp.toPx()) { + break + } + } + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-(maxWidth * MAX_SWIPE_FRACTION), 0f)) + } + } + } + + if (isDragging) { + if (-dragOffsetX.value >= maxWidth * REPLY_TRIGGER_FRACTION) { + onReplySwipe() + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + } +} \ No newline at end of file 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 index e2699c6f..fa6ef60d 100644 --- 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 @@ -3,11 +3,8 @@ 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.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,10 +15,7 @@ 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.PointerEventPass -import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration @@ -30,12 +24,10 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import kotlinx.coroutines.delay -import kotlinx.coroutines.launch 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.ui.FastReplyIndicator 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.* @@ -86,9 +78,6 @@ fun MessageBubbleContainer( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val fadeInThreshold = -36f - val fastReplyTriggerThreshold = -120f + fadeInThreshold - val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -127,7 +116,6 @@ fun MessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - val scope = rememberCoroutineScope() val dragOffsetX = remember { Animatable(0f) } Column( @@ -137,50 +125,13 @@ fun MessageBubbleContainer( .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) .offset { IntOffset(dragOffsetX.value.toInt(), 0) } - .pointerInput(canReply) { - if (!canReply) return@pointerInput - - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - var isDragging = false - var totalDragX = 0f - - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Main) - val change = event.changes.firstOrNull { it.id == down.id } ?: break - - if (change.changedToUp()) break - - val deltaX = change.positionChange().x - totalDragX += deltaX - - if (!isDragging) { - if (totalDragX < -48f) { - isDragging = true - } else if (totalDragX > 48f) { - break - } - } - - if (isDragging) { - change.consume() - val newOffset = dragOffsetX.value + deltaX - scope.launch { - dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) - } - } - } - - if (isDragging) { - if (dragOffsetX.value < fastReplyTriggerThreshold) { - onReplySwipe(msg) - } - scope.launch { - dragOffsetX.animateTo(0f, spring()) - } - } - } - } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -288,9 +239,7 @@ fun MessageBubbleContainer( .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - maxWidth = maxWidth, - fadeInThreshold = fadeInThreshold, - fastReplyTriggerThreshold = fastReplyTriggerThreshold, + maxWidth = maxWidth ) } } 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt index ea751d34..d95c191f 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -2,11 +2,8 @@ package org.monogram.presentation.features.chats.currentChat.components.channels import android.content.res.Configuration import androidx.compose.animation.Animatable -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -17,10 +14,7 @@ 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.PointerEventPass -import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration @@ -29,14 +23,14 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import kotlinx.coroutines.delay -import kotlinx.coroutines.launch 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.core.ui.FastReplyIndicator +import org.monogram.presentation.features.chats.currentChat.components.FastReplyIndicator import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.fastReplyPointer @Composable fun ChannelMessageBubbleContainer( @@ -84,9 +78,6 @@ fun ChannelMessageBubbleContainer( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false, ) { - val fadeInThreshold = -36f - val fastReplyTriggerThreshold = -120f + fadeInThreshold - val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -118,7 +109,6 @@ fun ChannelMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - val scope = rememberCoroutineScope() val dragOffsetX = remember { androidx.compose.animation.core.Animatable(0f) } Column( @@ -128,50 +118,13 @@ fun ChannelMessageBubbleContainer( .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) .offset { IntOffset(dragOffsetX.value.toInt(), 0) } - .pointerInput(canReply) { - if (!canReply) return@pointerInput - - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - var isDragging = false - var totalDragX = 0f - - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Main) - val change = event.changes.firstOrNull { it.id == down.id } ?: break - - if (change.changedToUp()) break - - val deltaX = change.positionChange().x - totalDragX += deltaX - - if (!isDragging) { - if (totalDragX < -48f) { - isDragging = true - } else if (totalDragX > 48f) { - break - } - } - - if (isDragging) { - change.consume() - val newOffset = dragOffsetX.value + deltaX - scope.launch { - dragOffsetX.snapTo(newOffset.coerceIn(-200f + fadeInThreshold, 0f)) - } - } - } - - if (isDragging) { - if (dragOffsetX.value < fastReplyTriggerThreshold) { - onReplySwipe(msg) - } - scope.launch { - dragOffsetX.animateTo(0f, spring()) - } - } - } - } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -468,8 +421,6 @@ fun ChannelMessageBubbleContainer( modifier = Modifier.align(Alignment.CenterStart), dragOffsetX = dragOffsetX, maxWidth = maxWidth, - fadeInThreshold = fadeInThreshold, - fastReplyTriggerThreshold = fastReplyTriggerThreshold, ) } } From 4adc908d844fd59ff9f4474c1876eea5f042fd38 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Mon, 6 Apr 2026 08:02:08 +0300 Subject: [PATCH 07/53] fix reply icon offset --- .../currentChat/components/FastReplyIndicator.kt | 13 ++++++++++--- .../channels/ChannelMessageBubbleContainer.kt | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) 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/currentChat/components/FastReplyIndicator.kt index 2c7cf991..9cf04035 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/currentChat/components/FastReplyIndicator.kt @@ -32,14 +32,16 @@ import androidx.compose.ui.util.lerp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -const val REPLY_TRIGGER_FRACTION = 0.4f +const val REPLY_TRIGGER_FRACTION = 0.35f const val MAX_SWIPE_FRACTION = 0.7f +const val ICON_OFFSET_FRACTION = 0.1f @Composable fun FastReplyIndicator( modifier: Modifier = Modifier, dragOffsetX: Animatable, isOutgoing: Boolean = false, + inverseOffset: Boolean = false, maxWidth: Dp, ) { val triggerDistance = maxWidth.value * REPLY_TRIGGER_FRACTION @@ -55,14 +57,19 @@ fun FastReplyIndicator( targetValue = lerp(0.5f, 1f, progress), animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) ) + val iconOffset = maxWidth * ICON_OFFSET_FRACTION if (dragged > 48.dp.value) { Box( modifier = modifier - .offset(x = if (isOutgoing) (-38).dp else maxWidth) // todo: fix this shit + .offset(x = if (isOutgoing) iconOffset else maxWidth) .size(30.dp) .graphicsLayer { - translationX = if (isOutgoing) dragged * 0.5f else -dragged // todo: fix this shit too + translationX = when { + isOutgoing -> (-dragOffsetX.value - iconOffset.value) * 0.5f + inverseOffset -> -iconOffset.value + else -> iconOffset.value + } scaleX = iconScale scaleY = iconScale alpha = iconAlpha 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt index d95c191f..79bae263 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/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -420,6 +420,7 @@ fun ChannelMessageBubbleContainer( FastReplyIndicator( modifier = Modifier.align(Alignment.CenterStart), dragOffsetX = dragOffsetX, + inverseOffset = isLandscape, maxWidth = maxWidth, ) } From 3669df4c3ce08c0260cf2cbb3a3489bca6287568 Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 08:07:23 +0300 Subject: [PATCH 08/53] disable fast reply in pinned messages list --- .../features/chats/currentChat/ChatContent.kt | 20 ------------------- .../pins/PinnedMessagesListSheet.kt | 4 ++++ 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index dc40d055..3116be69 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -444,26 +444,6 @@ fun ChatContent( .background(MaterialTheme.colorScheme.background) .onGloballyPositioned { containerSize = it.size } ) { - /*if (isDragToBackEnabled && !isTablet && !isCustomBackHandlingEnabled && dragOffsetX.value > 0 && previousChild != null) { - Box( - modifier = Modifier.fillMaxSize() - ) { - renderChild(previousChild) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Color.Black.copy( - alpha = 0.3f * (1f - (dragOffsetX.value / containerSize.width.toFloat()).coerceIn( - 0f, - 1f - )) - ) - ) - ) - } - }*/ - Box( modifier = Modifier .fillMaxSize() 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/currentChat/components/pins/PinnedMessagesListSheet.kt index d7d4dd4f..446302b8 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -128,6 +128,7 @@ fun PinnedMessagesListSheet( letterSpacing = state.letterSpacing, bubbleRadius = state.bubbleRadius, stickerSize = state.stickerSize, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -145,6 +146,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } @@ -172,6 +174,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -189,6 +192,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } From d86dd9a4b9e2d0faf4e6fcef94cf9b903667d3cc Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 10:07:21 +0300 Subject: [PATCH 09/53] disable fast reply for closed topics --- .../chats/currentChat/chatContent/ChatContentList.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 14861e44..d22cdcfe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -494,6 +494,7 @@ private fun MessageBubbleSwitcher( isAnyViewerOpen: Boolean = false ) { val isChannel = state.isChannel && state.currentTopicId == null + val isTopicClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed?: false when (item) { is GroupedMessageItem.Single -> { @@ -696,7 +697,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && !isTopicClosed, onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -764,7 +765,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && !isTopicClosed, onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, From 3295104a9baaa8e02288369c28803e30378fb7a8 Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 11:46:06 +0300 Subject: [PATCH 10/53] fix: fast reply in closed topics for admins --- .../features/chats/currentChat/chatContent/ChatContentList.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index d22cdcfe..09182a0c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -697,7 +697,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && !isTopicClosed, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -765,7 +765,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && !isTopicClosed, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, From d8fe47ef7376086fb2670dbaf94cf4ced8007d54 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Tue, 7 Apr 2026 13:37:03 +0300 Subject: [PATCH 11/53] fix compile --- .../chats/currentChat/components/pins/PinnedMessagesListSheet.kt | 1 - 1 file changed, 1 deletion(-) 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/currentChat/components/pins/PinnedMessagesListSheet.kt index 446302b8..804f9a1a 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -128,7 +128,6 @@ fun PinnedMessagesListSheet( letterSpacing = state.letterSpacing, bubbleRadius = state.bubbleRadius, stickerSize = state.stickerSize, - canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { From caeabab577fcc41d3781318100463759be85703d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:16:53 +0300 Subject: [PATCH 12/53] Fix TDLib update latency by removing channel relay --- .../java/org/monogram/data/di/TdLibClient.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index 14966f84..d4f3fba7 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -1,9 +1,13 @@ package org.monogram.data.di import android.util.Log -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine import org.drinkless.tdlib.Client import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.TdLibException @@ -13,8 +17,11 @@ import kotlin.coroutines.resume internal class TdLibClient { private val TAG = "TdLibClient" private val globalRetryAfterUntilMs = AtomicLong(0L) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val updateChannel = Channel(Channel.UNLIMITED) + private val _updates = MutableSharedFlow( + replay = 10, + extraBufferCapacity = 1000, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() @@ -28,9 +35,7 @@ internal class TdLibClient { } } - val updates = updateChannel - .receiveAsFlow() - .shareIn(scope, SharingStarted.Eagerly, replay = 10) + val updates: SharedFlow = _updates private val client = Client.create( { result -> @@ -38,7 +43,7 @@ internal class TdLibClient { if (result is TdApi.UpdateAuthorizationState) { _isAuthenticated.value = result.authorizationState is TdApi.AuthorizationStateReady } - updateChannel.trySend(result) + _updates.tryEmit(result) } }, { error -> From dbf36b2f8a43a579c307a4bd9958ea8b880c1548 Mon Sep 17 00:00:00 2001 From: kaajjo Date: Sat, 4 Apr 2026 18:23:04 +0300 Subject: [PATCH 13/53] fix(auth): handle unknown country codes in phone input (#167) Changes: - Reset selectedCountry to null if the entered code doesn't match any known country - Show a placeholder state: "Unknown country" + help icon when no country is found - Add nice little animation on country icon and name change https://github.com/user-attachments/assets/691cebd4-674c-4fe8-a92e-424a2ac99fbb Fixes #55 --- .../auth/components/PhoneInputScreen.kt | 168 +++++++++++++++--- .../src/main/res/values-ru-rRU/string.xml | 1 + .../src/main/res/values-sk/string.xml | 1 + .../src/main/res/values-uk/string.xml | 1 + .../src/main/res/values-zh-rCN/string.xml | 1 + presentation/src/main/res/values/string.xml | 1 + 6 files changed, 146 insertions(+), 27 deletions(-) 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 050bab70..8fcd30bd 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 @@ -1,11 +1,22 @@ package org.monogram.presentation.features.auth.components import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring 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.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -27,6 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -34,6 +46,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.HelpOutline import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Phone import androidx.compose.material.icons.filled.Search @@ -69,6 +82,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange @@ -77,6 +91,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.presentation.R @@ -98,7 +113,18 @@ fun PhoneInputScreen( onConfirm: (String) -> Unit, isSubmitting: Boolean ) { - val countries = remember { CountryManager.getCountries() } + val isPreview = LocalInspectionMode.current + + val countries = remember { + if (isPreview) { + listOf( + Country(name = "United States", code = "1", iso = "US", flagEmoji = "🇺🇸"), + ) + } else { + CountryManager.getCountries() + } + } + val defaultCountry = remember { val currentIso = Locale.getDefault().country countries.find { it.iso == currentIso } ?: countries.find { it.code == "380" } @@ -106,8 +132,18 @@ fun PhoneInputScreen( } var phoneBody by remember { mutableStateOf("") } - var selectedCountry by remember { mutableStateOf(defaultCountry) } - var codeInput by remember { mutableStateOf(selectedCountry.code) } + var selectedCountry by remember { mutableStateOf(defaultCountry) } + var codeInput by remember { mutableStateOf(defaultCountry.code) } + + var codeFieldValue by remember { + mutableStateOf( + TextFieldValue( + text = defaultCountry.code, + selection = TextRange(defaultCountry.code.length) + ) + ) + } + var phoneFieldValue by remember { mutableStateOf(TextFieldValue()) } var showCountryPicker by remember { mutableStateOf(false) } var activeField by remember { mutableStateOf(ActiveField.PHONE) } @@ -162,7 +198,10 @@ fun PhoneInputScreen( { country -> selectedCountry = country codeInput = country.code + codeFieldValue = + TextFieldValue(text = country.code, selection = TextRange(country.code.length)) phoneBody = "" + phoneFieldValue = TextFieldValue() phoneDisplay = "" activeField = ActiveField.PHONE closeCountryPicker() @@ -170,8 +209,9 @@ fun PhoneInputScreen( } val fullNumber = "+$codeInput$phoneBody" - val isFormValid = remember(fullNumber, selectedCountry.iso) { - codeInput.isNotEmpty() && CountryManager.isValidPhoneNumber(fullNumber, selectedCountry.iso) + val isFormValid = remember(fullNumber, selectedCountry?.iso) { + val iso = selectedCountry?.iso + codeInput.isNotEmpty() && iso != null && CountryManager.isValidPhoneNumber(fullNumber, iso) } val content: @Composable () -> Unit = { @@ -238,25 +278,89 @@ fun PhoneInputScreen( } ) { Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), verticalAlignment = Alignment.CenterVertically ) { - CountryFlag( - iso = selectedCountry.iso, - size = 40.dp - ) + + AnimatedContent( + targetState = selectedCountry, + transitionSpec = { + (fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn( + initialScale = 0.6f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + ).togetherWith( + fadeOut(animationSpec = tween(90)) + + scaleOut(targetScale = 0.8f) + ) + }, + label = "CountryIconAnimation" + ) { targetCountry -> + if (targetCountry != null) { + CountryFlag( + iso = targetCountry.iso, + size = 40.dp + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = "Unknown country", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { - Text( - text = selectedCountry.name, - style = MaterialTheme.typography.titleMedium, - ) + AnimatedContent( + targetState = selectedCountry?.name, + transitionSpec = { + if (targetState != null) { + (slideInVertically { height -> height / 2 } + fadeIn(tween(250))) togetherWith + (slideOutVertically { height -> -height / 2 } + fadeOut( + tween(150) + )) + } else { + (slideInVertically { height -> -height / 2 } + fadeIn(tween(250))) togetherWith + (slideOutVertically { height -> height / 2 } + fadeOut( + tween( + 150 + ) + )) + }.using(SizeTransform(clip = false)) + }, + label = "CountryTextAnimation" + ) { countryName -> + Text( + text = countryName ?: stringResource(R.string.unknown_country), + style = MaterialTheme.typography.titleMedium, + color = if (countryName != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( text = stringResource(R.string.country_label), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } + Icon( Icons.Default.ArrowDropDown, null, @@ -268,16 +372,6 @@ fun PhoneInputScreen( Spacer(modifier = Modifier.height(12.dp)) Box { - var codeFieldValue by remember(selectedCountry) { - mutableStateOf( - TextFieldValue( - text = selectedCountry.code, - selection = TextRange(selectedCountry.code.length) - ) - ) - } - var phoneFieldValue by remember { mutableStateOf(TextFieldValue()) } - val currentValue = if (activeField == ActiveField.CODE) codeFieldValue else phoneFieldValue @@ -290,13 +384,21 @@ fun PhoneInputScreen( if (digits.length <= 4) { codeInput = digits codeFieldValue = newValue.copy(text = digits) - countries.find { it.code == digits }?.let { selectedCountry = it } + + val newCountry = countries.find { it.code == digits } + selectedCountry = newCountry + + phoneDisplay = newCountry?.let { + CountryManager.formatPartialPhoneNumber(it.iso, phoneBody) + } ?: phoneBody } } else { if (digits.length <= 15) { phoneBody = digits - phoneDisplay = - CountryManager.formatPartialPhoneNumber(selectedCountry.iso, digits) + phoneDisplay = selectedCountry?.let { + CountryManager.formatPartialPhoneNumber(it.iso, digits) + } ?: digits + phoneFieldValue = newValue.copy(text = digits) } } @@ -566,4 +668,16 @@ fun PhoneInputScreen( } } } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PhoneInputScreenPreview() { + MaterialTheme { + PhoneInputScreen( + onConfirm = { }, + isSubmitting = false + ) + } } \ No newline at end of file diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index f7e0eee3..63acaae6 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -78,6 +78,7 @@ 000 00 00 Продолжить Выберите страну + Неизвестная страна Поиск страны или кода… Код подтверждения был отправлен через Telegram на другое Ваше устройство. Мы отправили СМС с кодом подтверждения на Ваш номер. diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index bf71550e..bb0e5c29 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -80,6 +80,7 @@ 000 00 00 Pokračovať Vybrať krajinu + Neznáma krajina Hľadať krajinu alebo kód... Kód sme odoslali do aplikácie Telegram na vašom inom zariadení. diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index f50a02d0..d7c37908 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -78,6 +78,7 @@ 000 00 00 Продовжити Виберіть країну + Невідома країна Пошук країни або коду… Код підтвердження надіслано через Telegram на інший Ваш пристрій. Ми надіслали СМС із кодом підтвердження на Ваш номер. diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index dbd36041..61a1e6da 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -78,6 +78,7 @@ 000 00 00 继续 选择国家 + 未知国家 搜索国家或代码… 我们已通过 Telegram 发送验证码到您的其他设备。 我们已通过短信发送验证码。 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index ed69de16..cf71caef 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -78,6 +78,7 @@ 000 00 00 Continue Select Country + Unknown country Search country or code... We\'ve sent the code to the Telegram app on your other device. From 76cc2c264795b14f91c0a3d29b16f32db1acdf99 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:02:35 +0300 Subject: [PATCH 14/53] fix #78 --- .../java/org/monogram/data/chats/ChatCache.kt | 27 ++++++ .../monogram/data/chats/ChatListManager.kt | 37 ++++---- .../monogram/data/chats/ChatTypingManager.kt | 8 +- .../repository/ChatsListRepositoryImpl.kt | 94 ++++++++++++++++--- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt index dcfc4031..356a3549 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -12,6 +12,9 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { val authoritativeActiveListChatIds = ConcurrentHashMap.newKeySet() val protectedPinnedChatIds = ConcurrentHashMap.newKeySet() val onlineMemberCount = ConcurrentHashMap() + val userIdToChatId = ConcurrentHashMap() + val supergroupIdToChatId = ConcurrentHashMap() + val basicGroupIdToChatId = ConcurrentHashMap() // Messages: ChatId -> (MessageId -> Message) private val messages = ConcurrentHashMap>() @@ -56,6 +59,7 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { existing.photo = chat.photo } existing.permissions = chat.permissions + existing.type = chat.type existing.lastMessage = chat.lastMessage val newPositions = chat.positions.toMutableList() @@ -94,8 +98,28 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { existing.lastReadInboxMessageId = chat.lastReadInboxMessageId existing.lastReadOutboxMessageId = chat.lastReadOutboxMessageId } + indexChatByType(existing) } else { allChats[chat.id] = chat + indexChatByType(chat) + } + } + + private fun indexChatByType(chat: TdApi.Chat) { + when (val type = chat.type) { + is TdApi.ChatTypePrivate -> if (type.userId != 0L) { + userIdToChatId[type.userId] = chat.id + } + + is TdApi.ChatTypeSupergroup -> if (type.supergroupId != 0L) { + supergroupIdToChatId[type.supergroupId] = chat.id + } + + is TdApi.ChatTypeBasicGroup -> if (type.basicGroupId != 0L) { + basicGroupIdToChatId[type.basicGroupId] = chat.id + } + + else -> Unit } } @@ -255,6 +279,9 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { authoritativeActiveListChatIds.clear() protectedPinnedChatIds.clear() onlineMemberCount.clear() + userIdToChatId.clear() + supergroupIdToChatId.clear() + basicGroupIdToChatId.clear() messages.clear() usersCache.clear() userFullInfoCache.clear() diff --git a/data/src/main/java/org/monogram/data/chats/ChatListManager.kt b/data/src/main/java/org/monogram/data/chats/ChatListManager.kt index 77389483..191951fc 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatListManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatListManager.kt @@ -7,7 +7,7 @@ class ChatListManager( private val cache: ChatCache, private val onChatNeeded: (Long) -> Unit ) { - private val tag = "PinnedDiag" + private val tag = "ChatListDiag" fun rebuildChatList( limit: Int = Int.MAX_VALUE, @@ -47,6 +47,10 @@ class ChatListManager( fun mapEntry(chatId: Long, position: TdApi.ChatPosition): org.monogram.domain.models.ChatModel? { val chat = cache.allChats[chatId] if (chat == null) { + Log.w( + tag, + "rebuild missing chat chatId=$chatId order=${position.order} pinned=${position.isPinned} active=${cache.activeListPositions.size} chats=${cache.allChats.size}" + ) if (position.order != 0L) onChatNeeded(chatId) return null } @@ -66,10 +70,10 @@ class ChatListManager( } } - if (pinnedEntries.isNotEmpty()) { - Log.d( + if (missingPinnedIds.isNotEmpty()) { + Log.w( tag, - "rebuild pinned: total=${pinnedEntries.size} mapped=$pinnedMapped missing=${missingPinnedIds.size} missingIds=${ + "rebuild pinned missing: total=${pinnedEntries.size} mapped=$pinnedMapped missing=${missingPinnedIds.size} missingIds=${ missingPinnedIds.take( 10 ) @@ -141,7 +145,18 @@ class ChatListManager( ) } if (!shouldProtectPinned) { - if (cache.activeListPositions.remove(chatId) != null) activeListChanged = true + val removed = cache.activeListPositions.remove(chatId) != null + if (removed) { + Log.w( + tag, + "position removed updateChatPosition chatId=$chatId reason=order0 currentPinned=${currentPos?.isPinned} protected=${ + cache.protectedPinnedChatIds.contains( + chatId + ) + } authoritative=${cache.authoritativeActiveListChatIds.contains(chatId)} active=${cache.activeListPositions.size}" + ) + activeListChanged = true + } cache.authoritativeActiveListChatIds.remove(chatId) cache.protectedPinnedChatIds.remove(chatId) } @@ -181,17 +196,7 @@ class ChatListManager( } oldPos == null || oldPos.order != newPos.order || oldPos.isPinned != newPos.isPinned } else { - if (cache.protectedPinnedChatIds.contains(chatId)) { - Log.d(tag, "updateActivePositions skip remove protected pinned chatId=$chatId") - return false - } - if (cache.authoritativeActiveListChatIds.contains(chatId)) { - if (cache.activeListPositions[chatId]?.isPinned == true) { - Log.d(tag, "updateActivePositions skip remove authoritative pinned chatId=$chatId") - } - return false - } - cache.activeListPositions.remove(chatId) != null + false } } diff --git a/data/src/main/java/org/monogram/data/chats/ChatTypingManager.kt b/data/src/main/java/org/monogram/data/chats/ChatTypingManager.kt index c6265c3f..94aa739f 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatTypingManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatTypingManager.kt @@ -13,7 +13,7 @@ class ChatTypingManager( private val usersCache: Map, private val allChats: Map, private val stringProvider: StringProvider, - private val onUpdate: () -> Unit, + private val onUpdate: (Long) -> Unit, private val onUserNeeded: (Long) -> Unit ) { private val typingStates = ConcurrentHashMap>() @@ -28,7 +28,7 @@ class ChatTypingManager( if (action is TdApi.ChatActionCancel) { removeTypingUser(chatId, userId) - onUpdate() + onUpdate(chatId) return } @@ -57,9 +57,9 @@ class ChatTypingManager( chatJobs[userId] = scope.launch { delay(6000) removeTypingUser(chatId, userId) - onUpdate() + onUpdate(chatId) } - onUpdate() + onUpdate(chatId) } fun removeTypingUser(chatId: Long, userId: Long) { diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 333f3955..813eaa62 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -62,6 +62,7 @@ class ChatsListRepositoryImpl( ) : ChatsListRepository { private val TAG = "ChatsListRepo" + private val diagTag = "ChatListDiag" private val scope = scopeProvider.appScope private val fileManager = ChatFileManager( @@ -76,7 +77,7 @@ class ChatsListRepositoryImpl( usersCache = cache.usersCache, allChats = cache.allChats, stringProvider = stringProvider, - onUpdate = { triggerUpdate() }, + onUpdate = { chatId -> triggerUpdate(chatId) }, onUserNeeded = { userId -> fetchUser(userId) } ) private val listManager = ChatListManager(cache) { chatId -> @@ -225,6 +226,7 @@ class ChatsListRepositoryImpl( activeRequestId val folderIdAtStart = activeFolderId val limitAtStart = currentLimit.coerceAtMost(maxChatListLimit) + val previousList = lastList val newList = listManager.rebuildChatList(limitAtStart, emptyList()) { chat, order, isPinned -> val cached = modelCache[chat.id] @@ -242,6 +244,10 @@ class ChatsListRepositoryImpl( } if (folderIdAtStart != activeFolderId) { + Log.d( + diagTag, + "rebuild skipped folder switched from=$folderIdAtStart to=$activeFolderId limit=$limitAtStart" + ) return@coRunCatching } @@ -255,7 +261,7 @@ class ChatsListRepositoryImpl( val pinnedInList = newList.asSequence().filter { it.isPinned }.map { it.id }.toSet() if (pinnedInPositions.size != pinnedInList.size) { Log.w( - "PinnedDiag", + diagTag, "emit mismatch folder=$folderIdAtStart pinnedPositions=${pinnedInPositions.size} pinnedList=${pinnedInList.size} missingInList=${ (pinnedInPositions - pinnedInList).take( 10 @@ -263,6 +269,19 @@ class ChatsListRepositoryImpl( }" ) } + val prevSize = previousList?.size ?: 0 + val newSize = newList.size + val positionsSize = cache.activeListPositions.size + val invalidatedSize = invalidatedModels.size + if (previousList != null && newSize < prevSize) { + val previousIds = previousList.asSequence().map { it.id }.toHashSet() + val newIds = newList.asSequence().map { it.id }.toHashSet() + val lostIds = (previousIds - newIds).take(20) + Log.w( + diagTag, + "emit shrunk folder=$folderIdAtStart prev=$prevSize new=$newSize positions=$positionsSize limit=$limitAtStart invalidated=$invalidatedSize lost=$lostIds" + ) + } _chatListFlow.value = newList _folderChatsFlow.tryEmit(FolderChatsUpdate(folderIdAtStart, newList)) lastList = newList @@ -404,45 +423,56 @@ class ChatsListRepositoryImpl( } is TdApi.UpdateUserStatus -> { cache.updateUser(update.userId) { it.status = update.status } - triggerUpdate() + cache.userIdToChatId[update.userId]?.let { chatId -> + triggerUpdate(chatId) + } } is TdApi.UpdateUser -> { cache.putUser(update.user) if (update.user.id == myUserId) myUserId = update.user.id - triggerUpdate() + val privateChatId = cache.userIdToChatId[update.user.id] + if (privateChatId != null) { + triggerUpdate(privateChatId) + } refreshActiveForumTopics() } is TdApi.UpdateSupergroup -> { cache.putSupergroup(update.supergroup) saveChatsBySupergroupId(update.supergroup.id) - triggerUpdate() + cache.supergroupIdToChatId[update.supergroup.id]?.let { chatId -> + triggerUpdate(chatId) + } } is TdApi.UpdateBasicGroup -> { cache.putBasicGroup(update.basicGroup) saveChatsByBasicGroupId(update.basicGroup.id) - triggerUpdate() + cache.basicGroupIdToChatId[update.basicGroup.id]?.let { chatId -> + triggerUpdate(chatId) + } } is TdApi.UpdateSupergroupFullInfo -> { cache.putSupergroupFullInfo(update.supergroupId, update.supergroupFullInfo) + val chatId = cache.supergroupIdToChatId[update.supergroupId] scope.launch(dispatchers.io) { - val chatId = - cache.allChats.values.find { (it.type as? TdApi.ChatTypeSupergroup)?.supergroupId == update.supergroupId }?.id if (chatId != null) { chatLocalDataSource.insertChatFullInfo(update.supergroupFullInfo.toEntity(chatId)) } } - triggerUpdate() + if (chatId != null) { + triggerUpdate(chatId) + } } is TdApi.UpdateBasicGroupFullInfo -> { cache.putBasicGroupFullInfo(update.basicGroupId, update.basicGroupFullInfo) + val chatId = cache.basicGroupIdToChatId[update.basicGroupId] scope.launch(dispatchers.io) { - val chatId = - cache.allChats.values.find { (it.type as? TdApi.ChatTypeBasicGroup)?.basicGroupId == update.basicGroupId }?.id if (chatId != null) { chatLocalDataSource.insertChatFullInfo(update.basicGroupFullInfo.toEntity(chatId)) } } - triggerUpdate() + if (chatId != null) { + triggerUpdate(chatId) + } } is TdApi.UpdateSecretChat -> { cache.putSecretChat(update.secretChat); triggerUpdate() @@ -689,6 +719,15 @@ class ChatsListRepositoryImpl( } private fun updateActiveListPositionsFromCache() { + val before = cache.activeListPositions.size + val savedAuthoritative = HashMap() + cache.authoritativeActiveListChatIds.forEach { chatId -> + val currentPos = cache.activeListPositions[chatId] ?: return@forEach + if (currentPos.order != 0L && listManager.isSameChatList(currentPos.list, activeChatList)) { + savedAuthoritative[chatId] = currentPos + } + } + cache.activeListPositions.clear() cache.authoritativeActiveListChatIds.clear() cache.protectedPinnedChatIds.clear() @@ -702,6 +741,32 @@ class ChatsListRepositoryImpl( } } } + + var restoredAuthoritative = 0 + savedAuthoritative.forEach { (chatId, position) -> + if (cache.activeListPositions.putIfAbsent(chatId, position) == null) { + restoredAuthoritative += 1 + } + cache.authoritativeActiveListChatIds.add(chatId) + if (position.isPinned) { + cache.protectedPinnedChatIds.add(chatId) + } + } + + val after = cache.activeListPositions.size + if (restoredAuthoritative > 0) { + Log.w( + diagTag, + "positions rebuild restored authoritative folder=$activeFolderId restored=$restoredAuthoritative" + ) + } + if (after != before) { + val level = if (after < before) "shrunk" else "expanded" + Log.w( + diagTag, + "positions rebuild $level folder=$activeFolderId before=$before after=$after chats=${cache.allChats.size}" + ) + } } override fun refresh() { @@ -995,7 +1060,10 @@ class ChatsListRepositoryImpl( coRunCatching { val user = gateway.execute(TdApi.GetUser(userId)) cache.putUser(user) - triggerUpdate() + val privateChatId = cache.userIdToChatId[user.id] + if (privateChatId != null) { + triggerUpdate(privateChatId) + } } cache.pendingUsers.remove(userId) } From 4bf4799b6d3da93a31d1f912588769e0b1867cb5 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:12:10 +0300 Subject: [PATCH 15/53] fix button size --- .../org/monogram/presentation/core/ui/ConfirmationSheet.kt | 2 +- .../org/monogram/presentation/core/ui/ExpressiveDefaults.kt | 4 ++-- .../presentation/features/chats/currentChat/ChatContent.kt | 2 +- .../settings/notifications/NotificationsContent.kt | 2 +- .../monogram/presentation/settings/premium/PremiumContent.kt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/ConfirmationSheet.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/ConfirmationSheet.kt index 59344cf0..7bd3475c 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/ConfirmationSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/ConfirmationSheet.kt @@ -40,7 +40,7 @@ fun ConfirmationSheet( onDismiss: () -> Unit, isDestructive: Boolean = true ) { - val buttonHeight = ButtonDefaults.LargeContainerHeight + val buttonHeight = ButtonDefaults.MediumContainerHeight val buttonShapes = ExpressiveDefaults.buttonShapesFor(buttonHeight) ModalBottomSheet( diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/ExpressiveDefaults.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/ExpressiveDefaults.kt index 3dbb48d3..73565295 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/ExpressiveDefaults.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/ExpressiveDefaults.kt @@ -12,11 +12,11 @@ import androidx.compose.ui.unit.Dp object ExpressiveDefaults { @Composable fun largeButtonShapes(): ButtonShapes = - ButtonDefaults.shapesFor(ButtonDefaults.LargeContainerHeight) + ButtonDefaults.shapesFor(ButtonDefaults.MediumContainerHeight) @Composable fun extraLargeButtonShapes(): ButtonShapes = - ButtonDefaults.shapesFor(ButtonDefaults.ExtraLargeContainerHeight) + ButtonDefaults.shapesFor(ButtonDefaults.LargeContainerHeight) @Composable fun buttonShapesFor(height: Dp): ButtonShapes = diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 91476f44..1942406b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -649,7 +649,7 @@ fun ChatContent( shapes = ExpressiveDefaults.largeButtonShapes(), modifier = Modifier .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight) + .height(ButtonDefaults.MediumContainerHeight) ) { Text( text = stringResource(R.string.action_join), diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt index 72e0cf11..504732d4 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt @@ -433,7 +433,7 @@ private fun NotificationOptionSheet( shapes = ExpressiveDefaults.largeButtonShapes(), modifier = Modifier .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight) + .height(ButtonDefaults.MediumContainerHeight) ) { Text(stringResource(R.string.cancel_button), fontSize = 16.sp, fontWeight = FontWeight.Bold) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt index 177204e6..156f6a40 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt @@ -97,7 +97,7 @@ fun PremiumContent(component: PremiumComponent) { modifier = Modifier .fillMaxWidth() .padding(16.dp) - .height(ButtonDefaults.LargeContainerHeight), + .height(ButtonDefaults.MediumContainerHeight), colors = ButtonDefaults.buttonColors( containerColor = Color(0xFFAF52DE) ) From ac715cc89e6d12c92f460883b72c627696ec24bc Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:17:19 +0300 Subject: [PATCH 16/53] fix #80 --- .../components/chats/MessageReactionsView.kt | 13 ++++++---- .../chats/currentChat/impl/MessageLoading.kt | 24 ++++++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) 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/currentChat/components/chats/MessageReactionsView.kt index bc3b270f..f048c9a4 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/currentChat/components/chats/MessageReactionsView.kt @@ -61,6 +61,7 @@ fun MessageReactionsView( } } } + val seenReactionKeys = remember { mutableStateMapOf() } AnimatedVisibility( visible = reactions.isNotEmpty(), @@ -74,12 +75,16 @@ fun MessageReactionsView( verticalArrangement = Arrangement.spacedBy(6.dp) ) { reactions.forEachIndexed { index, reaction -> - key(reaction.emoji ?: reaction.customEmojiId) { - var isVisible by remember { mutableStateOf(false) } + val reactionKey = reaction.emoji ?: reaction.customEmojiId ?: "unknown_$index" + key(reactionKey) { + var isVisible by remember { mutableStateOf(seenReactionKeys[reactionKey] == true) } LaunchedEffect(Unit) { - delay(index * 35L) - isVisible = true + if (!isVisible) { + delay(index * 35L) + isVisible = true + } + seenReactionKeys[reactionKey] = true } AnimatedVisibility( 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/currentChat/impl/MessageLoading.kt index 01961d8f..3ecbd2ef 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/currentChat/impl/MessageLoading.kt @@ -52,7 +52,8 @@ private fun mergeSenderVisuals(previous: MessageModel, incoming: MessageModel): senderAvatar = mergedAvatar, senderPersonalAvatar = mergedPersonalAvatar, senderCustomTitle = incoming.senderCustomTitle ?: previous.senderCustomTitle, - senderStatusEmojiPath = incoming.senderStatusEmojiPath ?: previous.senderStatusEmojiPath + senderStatusEmojiPath = incoming.senderStatusEmojiPath ?: previous.senderStatusEmojiPath, + reactions = incoming.reactions.ifEmpty { previous.reactions } ) } @@ -126,6 +127,13 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( } else { state.messages } + val existingReactionsById = if (replace) { + state.messages + .filter { it.reactions.isNotEmpty() } + .associate { it.id to it.reactions } + } else { + emptyMap() + } val isComments = state.rootMessage != null @@ -136,8 +144,18 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( filteredNewMessages.forEach { msg -> val previous = messageMap[msg.id] val mergedMessage = if (previous != null) mergeSenderVisuals(previous, msg) else msg - val old = messageMap.put(msg.id, mergedMessage) - if (old != mergedMessage) { + val restoredMessage = if (mergedMessage.reactions.isEmpty()) { + val previousReactions = existingReactionsById[msg.id] + if (!previousReactions.isNullOrEmpty()) { + mergedMessage.copy(reactions = previousReactions) + } else { + mergedMessage + } + } else { + mergedMessage + } + val old = messageMap.put(msg.id, restoredMessage) + if (old != restoredMessage) { hasChanges = true } } From 9f3ec7083bbe9e42fc998d5084b7e82373a963e9 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Apr 2026 21:40:45 +0200 Subject: [PATCH 17/53] Add build instructions for libvpx (#173) For a first time installation on develop, recurse submodules is necessary, on top of building libvpx. Otherwise, building the app will fail. Add a paragraph to address this --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a0964b8..72a785eb 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Follow these steps to set up the project locally. ### 1. Clone the Repository ```bash -git clone https://github.com/monogram-android/monogram.git +git clone --recurse-submodules https://github.com/monogram-android/monogram.git cd monogram ``` @@ -105,7 +105,15 @@ API_HASH=your_api_hash_here 10. Click **Update** next to the FCM credentials section. 11. Upload the service account JSON on the page that opens. -### 4. Build and Run +### 4. First Time Setup: Building libvpx + +The animations require libvpx to be compiled. This has to be done before starting a Gradle build or it will cause build failures. + +1. Change your working directory to `presentation/src/main/cpp` +2. In `build.sh`, add your `ANDROID_NDK_HOME` +3. Run `build.sh` and wait for it to finish + +### 5. Build and Run 1. Open the project in **Android Studio**. 2. Increase the IDE indexing limits so `TdApi.java` (the TDLib wrapper) is indexed correctly. In **Android Studio** or **IntelliJ IDEA**, open **Help → Edit Custom Properties...**, paste the lines below, and restart the IDE if prompted: From 6857299d3620cd702e345e7bff07bc2b62f542a0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:12:10 +0300 Subject: [PATCH 18/53] cleanup video/photo menu, fixed statusbar showing in video/photo --- .../features/viewers/MediaViewer.kt | 30 +++++++++ .../components/ImageViewerComponents.kt | 2 +- .../components/VideoViewerComponents.kt | 61 ++++++++----------- .../viewers/components/ViewerComponents.kt | 43 +++++++++---- .../src/main/res/values-ru-rRU/string.xml | 12 ++-- .../src/main/res/values-sk/string.xml | 2 +- .../src/main/res/values-uk/string.xml | 10 +-- 7 files changed, 101 insertions(+), 59 deletions(-) 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 d85964d9..69718d5e 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 @@ -21,6 +21,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.media3.common.util.UnstableApi import kotlinx.coroutines.launch import org.monogram.presentation.core.util.IDownloadUtils @@ -97,6 +100,33 @@ fun MediaViewer( currentVideoInPipMode = false } + LaunchedEffect(showControls, currentVideoInPipMode) { + if (!showControls) { + showSettingsMenu = false + } + + val activity = context.findActivity() + activity?.let { + val insetsController = WindowCompat.getInsetsController(it.window, it.window.decorView) + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + if (showControls && !currentVideoInPipMode) { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } else { + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + } + } + + DisposableEffect(context) { + onDispose { + context.findActivity()?.let { + WindowCompat.getInsetsController(it.window, it.window.decorView) + .show(WindowInsetsCompat.Type.systemBars()) + } + } + } + BackHandler { Log.d(TAG, "BackHandler: showSettingsMenu=$showSettingsMenu, currentVideoInPipMode=$currentVideoInPipMode") if (showSettingsMenu) { 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 1d55ea11..b9670402 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 @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.viewers.components 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 ee8eb75d..3e5eeb82 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 @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.viewers.components @@ -27,7 +27,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -40,6 +43,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.extractor.DefaultExtractorsFactory @@ -60,7 +64,7 @@ import kotlin.math.max private const val TAG = "VideoPage" @OptIn(UnstableApi::class) -@kotlin.OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun VideoPage( path: String, @@ -140,7 +144,10 @@ fun VideoPage( .setConstantBitrateSeekingEnabled(true) .setMp4ExtractorFlags(Mp4Extractor.FLAG_WORKAROUND_IGNORE_EDIT_LISTS) - val playerBuilder = ExoPlayer.Builder(context) + val renderersFactory = DefaultRenderersFactory(context) + .setEnableDecoderFallback(true) + + val playerBuilder = ExoPlayer.Builder(context, renderersFactory) .setAudioAttributes(audioAttributes, true) val dataSourceFactory = if (supportsStreaming && fileId != 0) { @@ -415,7 +422,13 @@ fun VideoPage( onRewind = { exoPlayer.seekTo(max(0, exoPlayer.currentPosition - seekDurationMs)) }, - onSettingsToggle = currentOnToggleSettings + onSettingsToggle = currentOnToggleSettings, + onLockToggle = { + isLocked = true + if (showSettingsMenu) { + currentOnToggleSettings() + } + } ) AnimatedVisibility( @@ -453,10 +466,6 @@ fun VideoPage( onMuteToggle = { isMuted = !isMuted }, - onLockToggle = { - isLocked = true - currentOnToggleSettings() - }, onRotationToggle = { val activity = context.findActivity() activity?.requestedOrientation = @@ -515,7 +524,7 @@ fun VideoPage( } } -@kotlin.OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun VideoPlayerControls( visible: Boolean, @@ -532,7 +541,8 @@ fun VideoPlayerControls( onBack: () -> Unit, onForward: () -> Unit, onRewind: () -> Unit, - onSettingsToggle: () -> Unit + onSettingsToggle: () -> Unit, + onLockToggle: () -> Unit ) { var isDragging by remember { mutableStateOf(false) } var sliderPosition by remember { mutableFloatStateOf(0f) } @@ -553,7 +563,12 @@ fun VideoPlayerControls( exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), modifier = Modifier.align(Alignment.TopCenter) ) { - ViewerTopBar(onBack = onBack, onActionClick = onSettingsToggle, isActionActive = isSettingsOpen) + ViewerTopBar( + onBack = onBack, + onActionClick = onSettingsToggle, + isActionActive = isSettingsOpen, + onLockClick = onLockToggle + ) } AnimatedVisibility( @@ -701,7 +716,6 @@ fun VideoSettingsMenu( onRepeatToggle: () -> Unit, onResizeToggle: () -> Unit, onMuteToggle: () -> Unit, - onLockToggle: () -> Unit, onRotationToggle: () -> Unit, onEnterPip: () -> Unit, onDownload: () -> Unit, @@ -729,16 +743,6 @@ fun VideoSettingsMenu( when (screen) { SettingsScreen.MAIN -> { Column(modifier = Modifier.padding(vertical = 4.dp)) { - Text( - text = stringResource(R.string.settings_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - ) MenuOptionRow( icon = Icons.Rounded.Speed, title = stringResource(R.string.settings_playback_speed), @@ -772,10 +776,6 @@ fun VideoSettingsMenu( trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight ) } - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - ) MenuToggleRow( icon = if (repeatMode == Player.REPEAT_MODE_ONE) Icons.Rounded.RepeatOne else Icons.Rounded.Repeat, title = stringResource(R.string.settings_loop_video), @@ -807,13 +807,6 @@ fun VideoSettingsMenu( onClick = onCopyLink ) MenuOptionRow(icon = Icons.AutoMirrored.Rounded.Forward, title = stringResource(R.string.action_forward), onClick = onForward) - MenuOptionRow( - icon = Icons.Rounded.Lock, - title = stringResource(R.string.settings_lock_controls), - onClick = onLockToggle, - iconTint = MaterialTheme.colorScheme.primary, - textColor = MaterialTheme.colorScheme.primary - ) if (onDelete != null) { HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt index 1683ac58..4e1da8cd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material3.* @@ -36,7 +37,8 @@ fun ViewerTopBar( onBack: () -> Unit, onActionClick: () -> Unit, modifier: Modifier = Modifier, - isActionActive: Boolean = false + isActionActive: Boolean = false, + onLockClick: (() -> Unit)? = null ) { Box( modifier = modifier @@ -67,17 +69,34 @@ fun ViewerTopBar( ) } - IconButton( - onClick = onActionClick, - colors = IconButtonDefaults.iconButtonColors( - containerColor = if (isActionActive) Color.White.copy(alpha = 0.2f) else Color.Transparent, - contentColor = Color.White - ) - ) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.viewer_options) - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (onLockClick != null) { + IconButton( + onClick = onLockClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.White.copy(alpha = 0.16f), + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.Rounded.LockOpen, + contentDescription = stringResource(R.string.settings_lock_controls) + ) + } + } + + IconButton( + onClick = onActionClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isActionActive) Color.White.copy(alpha = 0.2f) else Color.Transparent, + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.viewer_options) + ) + } } } } diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 63acaae6..61f22d30 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -1331,7 +1331,7 @@ Загружается оригинал... - Загрузить + Скачать Скачать видео Копировать фото Копировать текст @@ -1348,10 +1348,10 @@ Настройки - Скорость воспроизведения - Режим масштабирования - Вписать - Заполнить + Скорость + Масштаб + По размеру + На весь экран Поворот экрана Картинка в картинке Скриншот @@ -1366,7 +1366,7 @@ Обычная - Играть + Воспроизвести Пауза Назад Вперёд diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index bb0e5c29..092a7498 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -1415,7 +1415,7 @@ Rýchlosť prehrávania Režim mierky Prispôsobiť - Priblížiť + Vyplniť Otočiť obrazovku Obraz v obraze Snímka obrazovky diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index d7c37908..612da2f9 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -1348,10 +1348,10 @@ Налаштування - Швидкість відтворення - Режим масштабування - Вписати - Заповнити + Швидкість + Масштаб + За розміром + На весь екран Поворот екрана Картинка в картинці Скріншот @@ -1366,7 +1366,7 @@ Звичайна - Грати + Відтворити Пауза Назад Вперед From 809f141c4868bbd4430e2454bdb1b1f4d15c0614 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:15:41 +0300 Subject: [PATCH 19/53] Add libvpx build steps and submodule clone --- README_ES.md | 12 ++++++++++-- README_KOR.md | 12 ++++++++++-- README_RU.md | 12 ++++++++++-- README_UR.md | 12 ++++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/README_ES.md b/README_ES.md index f270bb35..83438a51 100644 --- a/README_ES.md +++ b/README_ES.md @@ -82,7 +82,7 @@ Sigue estos pasos para configurar el proyecto localmente. ### 1. Clona el Repositorio ```bash -git clone https://github.com/monogram-android/monogram.git +git clone --recurse-submodules https://github.com/monogram-android/monogram.git cd monogram ``` @@ -124,7 +124,15 @@ API_HASH=your_api_hash_here 10. Clickea en **Update** después de la sección FCM credentials. 11. Sube el service account JSON en la página que se abre. -### 4. Compilar y Ejecutar +### 4. Primera Configuración: Compilar libvpx + +Las animaciones requieren que libvpx esté compilado. Esto debe hacerse antes de iniciar una compilación de Gradle; de lo contrario, la compilación fallará. + +1. Cambia tu directorio de trabajo a `presentation/src/main/cpp`. +2. En `build.sh`, añade tu `ANDROID_NDK_HOME`. +3. Ejecuta `build.sh` y espera a que termine. + +### 5. Compilar y Ejecutar 1. Abre el proyecto en **Android Studio**. 2. Aumenta los límites de indexado del IDE para que `TdApi.java` (el wrapper de diff --git a/README_KOR.md b/README_KOR.md index 06d55c43..81ffe7d6 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -71,7 +71,7 @@ ### 1. 저장소 클론 ```bash -git clone https://github.com/monogram-android/monogram.git +git clone --recurse-submodules https://github.com/monogram-android/monogram.git cd monogram ``` @@ -104,7 +104,15 @@ API_HASH=your_api_hash_here 10. FCM 자격 증명 섹션 옆의 **Update**를 클릭합니다. 11. 열린 페이지에서 다운로드한 서비스 계정 JSON 파일을 업로드합니다. -### 4. 빌드 및 실행 +### 4. 최초 설정: libvpx 빌드 + +애니메이션을 사용하려면 libvpx를 먼저 컴파일해야 합니다. Gradle 빌드를 시작하기 전에 이 작업을 하지 않으면 빌드가 실패할 수 있습니다. + +1. 작업 디렉터리를 `presentation/src/main/cpp`로 이동합니다. +2. `build.sh`에 `ANDROID_NDK_HOME`을 추가합니다. +3. `build.sh`를 실행하고 완료될 때까지 기다립니다. + +### 5. 빌드 및 실행 1. **Android Studio**에서 프로젝트를 엽니다. 2. `TdApi.java`(TDLib 래퍼)가 올바르게 인덱싱되도록 IDE 인덱싱 제한을 늘립니다. **Android Studio** 또는 **IntelliJ IDEA**에서 **Help → Edit Custom Properties...**를 열고 아래 줄을 붙여넣은 후, 메시지가 나타나면 IDE를 다시 시작합니다: diff --git a/README_RU.md b/README_RU.md index 8063af22..fad2d805 100644 --- a/README_RU.md +++ b/README_RU.md @@ -72,7 +72,7 @@ ### 1. Клонирование репозитория ```bash -git clone https://github.com/monogram-android/monogram.git +git clone --recurse-submodules https://github.com/monogram-android/monogram.git cd monogram ``` @@ -105,7 +105,15 @@ API_HASH=your_api_hash_here 10. Нажмите **Update** рядом с разделом FCM credentials. 11. Загрузите JSON сервисного аккаунта на открывшейся странице. -### 4. Сборка и запуск +### 4. Первичная настройка: сборка libvpx + +Для анимаций требуется собрать libvpx. Это нужно сделать до запуска сборки Gradle, иначе сборка завершится с ошибками. + +1. Перейдите в директорию `presentation/src/main/cpp` +2. В `build.sh` укажите ваш `ANDROID_NDK_HOME` +3. Запустите `build.sh` и дождитесь завершения + +### 5. Сборка и запуск 1. Откройте проект в **Android Studio**. 2. Увеличьте лимиты индексации IDE, чтобы `TdApi.java` (обёртка над TDLib) корректно индексировался. В **Android Studio** или **IntelliJ IDEA** откройте **Help → Edit Custom Properties...**, вставьте строки ниже и при необходимости перезапустите IDE: diff --git a/README_UR.md b/README_UR.md index ecadc282..a2a98785 100644 --- a/README_UR.md +++ b/README_UR.md @@ -72,7 +72,7 @@ ### 1. ریپوزٹری کلون کریں ```bash -git clone https://github.com/monogram-android/monogram.git +git clone --recurse-submodules https://github.com/monogram-android/monogram.git cd monogram ``` @@ -105,7 +105,15 @@ API_HASH=your_api_hash_here 10. FCM اسناد والے سیکشن کے آگے **Update** پر کلک کریں۔ 11. کھلنے والے پیج پر سروس اکاؤنٹ JSON اپ لوڈ کریں۔ -### 4. بلڈ اور رن +### 4. پہلی مرتبہ سیٹ اپ: libvpx کی تعمیر + +اینیمیشنز کے لیے libvpx کو کمپائل کرنا ضروری ہے۔ یہ کام Gradle بلڈ شروع کرنے سے پہلے کرنا ہوگا، ورنہ بلڈ ناکام ہو سکتی ہے۔ + +1. اپنی ورکنگ ڈائرکٹری `presentation/src/main/cpp` پر لے جائیں۔ +2. `build.sh` میں اپنا `ANDROID_NDK_HOME` شامل کریں۔ +3. `build.sh` چلائیں اور مکمل ہونے تک انتظار کریں۔ + +### 5. بلڈ اور رن 1. **Android Studio** میں پروجیکٹ کھولیں۔ 2. IDE کی انڈیکسنگ کی حد میں اضافہ کریں تاکہ `TdApi.java` (TDLib ریپر) صحیح طرح انڈیکس ہو سکے۔ **Android Studio** یا **IntelliJ IDEA** میں، **Help → Edit Custom Properties...** کھولیں، نیچے دی گئی لائنز پیسٹ کریں، اور اگر کہا جائے تو IDE کو ری اسٹارٹ کریں: From 31b8f7f47038565257d870a15b70f7330f5318ef Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:36:22 +0300 Subject: [PATCH 20/53] Show expanded input actions on tablets --- .../components/inputbar/InputTextFieldContainer.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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/currentChat/components/inputbar/InputTextFieldContainer.kt index 764e1547..2fbab909 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/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -18,6 +18,7 @@ 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.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue @@ -54,6 +55,8 @@ fun InputTextFieldContainer( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { + val isTablet = LocalConfiguration.current.screenWidthDp >= 600 + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) @@ -99,7 +102,7 @@ fun InputTextFieldContainer( if (canWriteText) { AnimatedVisibility( - visible = textValue.text.contains('\n') || textValue.text.length > 150, + visible = isTablet || textValue.text.contains('\n') || textValue.text.length > 150, enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End) ) { From 93ca211e9ed39b6fe8b70b960d57c06cfbd36309 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:55:53 +0300 Subject: [PATCH 21/53] docs(readme): add independent client note across all README translations --- README.md | 1 + README_ES.md | 2 ++ README_KOR.md | 1 + README_RU.md | 1 + README_UR.md | 1 + 5 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 72a785eb..5944817d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Support the project on [**Boosty**](https://boosty.to/monogram). ## Key Features +- **Independent Client** — Not a fork of Telegram for Android. MonoGram is built entirely from scratch as a standalone project. - **Material Design 3** — A beautiful, adaptive UI that looks great on phones, tablets, and foldables. - **Secure** — Built-in biometric locking and encrypted local storage. - **Media Rich** — High-performance media playback with ExoPlayer and Coil 3. diff --git a/README_ES.md b/README_ES.md index 83438a51..107687b7 100644 --- a/README_ES.md +++ b/README_ES.md @@ -54,6 +54,8 @@ Ayuda al proyecto en [**Boosty**](https://boosty.to/monogram). ## Características Clave +- **Cliente Independiente** — No es un fork de Telegram para Android. MonoGram + está construido completamente desde cero como un proyecto independiente. - **Material Design 3** — Una bonita y adaptativa UI que se ve grandiosa en celulares, tablets y plegables. - **Seguro** — Almacenamiento local encriptado y bloqueo biométrico incluido. diff --git a/README_KOR.md b/README_KOR.md index 81ffe7d6..bc0d3411 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -49,6 +49,7 @@ ## 주요 기능 +- **독립 클라이언트** — Telegram for Android의 포크가 아닙니다. MonoGram은 독립 프로젝트로서 처음부터 완전히 새롭게 구축되었습니다. - **Material Design 3** — 스마트폰, 태블릿, 폴더블 기기에서 모두 멋지게 보이는 아름답고 적응형인 UI입니다. - **보안** — 생체 인식 잠금 및 암호화된 로컬 저장소가 내장되어 있습니다. - **풍부한 미디어** — ExoPlayer와 Coil 3를 사용한 고성능 미디어 재생을 지원합니다. diff --git a/README_RU.md b/README_RU.md index fad2d805..772f13b1 100644 --- a/README_RU.md +++ b/README_RU.md @@ -50,6 +50,7 @@ ## Ключевые особенности +- **Независимый клиент** — Не форк Telegram для Android. MonoGram написан полностью с нуля как самостоятельный проект. - **Material Design 3** — Красивый, адаптивный интерфейс, который отлично смотрится на телефонах, планшетах и складных устройствах. - **Безопасность** — Встроенная биометрическая блокировка и зашифрованное локальное хранилище. - **Мультимедиа** — Высокопроизводительное воспроизведение медиа с ExoPlayer и Coil 3. diff --git a/README_UR.md b/README_UR.md index a2a98785..9b0ddee8 100644 --- a/README_UR.md +++ b/README_UR.md @@ -50,6 +50,7 @@ ## اہم خصوصیات +- **آزاد کلائنٹ (Independent Client)** — یہ Telegram for Android کا فورک نہیں ہے۔ MonoGram ایک مکمل طور پر نئے سرے سے بنایا گیا آزاد پروجیکٹ ہے۔ - **Material Design 3** — ایک خوبصورت، موافق (adaptive) UI جو فونز، ٹیبلیٹس، اور فولڈ ایبلز پر بہترین نظر آتا ہے۔ - **محفوظ (Secure)** — بائیو میٹرک لاکنگ اور انکرپٹڈ لوکل اسٹوریج شامل ہے۔ - **میڈیا سے بھرپور (Media Rich)** — ExoPlayer اور Coil 3 کے ساتھ اعلیٰ کارکردگی والا میڈیا پلے بیک۔ From a63211966e3e6d10a9171ad87f1f59eaabe38dd2 Mon Sep 17 00:00:00 2001 From: Artem Zhiganov Date: Mon, 6 Apr 2026 00:38:31 +0700 Subject: [PATCH 22/53] feature: quote blocks (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавил поддержку отображения цитат в сообщениях (поддерживаются как обычные, так и спойлеры) (частичная реализация #107) - Создание цитат пока не поддерживается вставленный файл Signed-off-by: Artem Zhiganov --- .../remote/TdMessageRemoteDataSource.kt | 2 + .../org/monogram/data/mapper/ChatMapper.kt | 4 +- .../org/monogram/data/mapper/MessageMapper.kt | 4 +- .../data/repository/MessageRepositoryImpl.kt | 2 + .../monogram/domain/models/MessageModel.kt | 4 +- .../presentation/core/ui/spacer/Spacer.kt | 13 ++ .../components/chats/MessageText.kt | 47 ++++-- .../components/chats/QuoteBlock.kt | 150 ++++++++++++++++++ .../components/chats/TextBlocks.kt | 40 +++++ .../components/chats/TextMessageBubble.kt | 1 + .../components/chats/model/Mappers.kt | 22 +++ 11 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/QuoteBlock.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextBlocks.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt 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 f56802c3..5c71f698 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 @@ -806,6 +806,8 @@ class TdMessageRemoteDataSource( is MessageEntityType.Strikethrough -> TdApi.TextEntityTypeStrikethrough() is MessageEntityType.Spoiler -> TdApi.TextEntityTypeSpoiler() is MessageEntityType.Code -> TdApi.TextEntityTypeCode() + is MessageEntityType.BlockQuote -> TdApi.TextEntityTypeBlockQuote() + is MessageEntityType.BlockQuoteExpandable -> TdApi.TextEntityTypeExpandableBlockQuote() is MessageEntityType.Pre -> { if (value.language.isBlank()) TdApi.TextEntityTypePre() else TdApi.TextEntityTypePreCode(value.language) diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 9563c6a9..5d7dbe27 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -532,7 +532,9 @@ class ChatMapper(private val stringProvider: StringProvider) { is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji((entity.type as TdApi.TextEntityTypeCustomEmoji).customEmojiId) - else -> MessageEntityType.Other + is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote + is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable + else -> MessageEntityType.Other(entity.type.javaClass.simpleName) } ) } diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 855710b5..0b9ef94c 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -675,6 +675,8 @@ class MessageMapper( is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber + is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote + is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable is TdApi.TextEntityTypeCustomEmoji -> { val emojiId = entityType.customEmojiId val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) } @@ -686,7 +688,7 @@ class MessageMapper( MessageEntityType.CustomEmoji(emojiId, path) } - else -> MessageEntityType.Other + else -> MessageEntityType.Other(entityType.javaClass.simpleName) } MessageEntity(entity.offset, entity.length, type) } 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 cdb2a2e6..10e2905d 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -621,6 +621,8 @@ class MessageRepositoryImpl( is MessageEntityType.Strikethrough -> TdApi.TextEntityTypeStrikethrough() is MessageEntityType.Spoiler -> TdApi.TextEntityTypeSpoiler() is MessageEntityType.Code -> TdApi.TextEntityTypeCode() + is MessageEntityType.BlockQuote -> TdApi.TextEntityTypeBlockQuote() + is MessageEntityType.BlockQuoteExpandable -> TdApi.TextEntityTypeExpandableBlockQuote() is MessageEntityType.Pre -> if (value.language.isBlank()) TdApi.TextEntityTypePre() else TdApi.TextEntityTypePreCode( value.language ) diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index 11bf6fbf..f506629b 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -561,6 +561,8 @@ sealed interface MessageEntityType { object Underline : MessageEntityType object Strikethrough : MessageEntityType object Spoiler : MessageEntityType + object BlockQuote : MessageEntityType + object BlockQuoteExpandable: MessageEntityType object Code : MessageEntityType data class Pre(val language: String = "") : MessageEntityType data class TextUrl(val url: String) : MessageEntityType @@ -573,7 +575,7 @@ sealed interface MessageEntityType { object PhoneNumber : MessageEntityType object BankCardNumber : MessageEntityType data class CustomEmoji(val emojiId: Long, val path: String? = null) : MessageEntityType - object Other : MessageEntityType + data class Other(val srcEntity: String) : MessageEntityType } data class MessageReactionModel( diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/spacer/Spacer.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/spacer/Spacer.kt index 5ba5fdce..68b8aa95 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/spacer/Spacer.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/spacer/Spacer.kt @@ -1,5 +1,7 @@ package org.monogram.presentation.core.ui.spacer +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width @@ -28,4 +30,15 @@ fun WidthSpacer(widthDp: Dp) { @Composable fun HeightSpacer(heightDp: Dp) { Spacer(modifier = Modifier.height(heightDp)) +} + +/** + * Simple weight spacer + * + * @param weight weight in float valur + **/ +@NonRestartableComposable +@Composable +fun ColumnScope.WeightSpacer(weight: Float) { + Spacer(modifier = Modifier.weight(weight)) } \ No newline at end of file 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/currentChat/components/chats/MessageText.kt index fe75ca59..ef908270 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/currentChat/components/chats/MessageText.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import android.content.ClipData import android.os.Build +import android.util.Log import android.widget.Toast import androidx.compose.animation.core.withInfiniteAnimationFrameMillis import androidx.compose.foundation.gestures.detectTapGestures @@ -27,7 +28,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import org.monogram.domain.models.MessageEntity -import org.monogram.domain.models.MessageEntityType +import org.monogram.presentation.features.chats.currentChat.components.chats.model.isBlockElement @Composable fun MessageText( @@ -48,7 +49,7 @@ fun MessageText( val linkHandler = LocalLinkHandler.current val blockEntities = entities - .filter { it.type is MessageEntityType.Pre } + .filter { it.type.isBlockElement() } .sortedBy { it.offset } Column(modifier = modifier) { @@ -91,13 +92,10 @@ fun MessageText( } } - val codeType = entity.type as MessageEntityType.Pre - val codeRawText = text.text.substring(entity.offset, entity.offset + entity.length) - - CodeBlock( - text = codeRawText, - language = codeType.language, - isOutgoing = isOutgoing + TextBlocks( + text = text.text, + entity = entity, + isOutgoing = isOutgoing, ) lastOffset = entity.offset + entity.length @@ -166,7 +164,8 @@ private fun DefaultTextRender( modifier = Modifier .drawBehind { layoutResult.value?.let { result -> - val unrevealedSpoilers = text.getStringAnnotations("SPOILER_UNREVEALED", 0, text.length) + val unrevealedSpoilers = + text.getStringAnnotations("SPOILER_UNREVEALED", 0, text.length) unrevealedSpoilers.forEach { spoilerAnnotation -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { drawSpoilerEffectApi33( @@ -195,16 +194,25 @@ private fun DefaultTextRender( var consumed = false layoutResult.value?.let { result -> val rawPosition = result.getOffsetForPosition(offset) - val position = if (text.isNotEmpty()) rawPosition.coerceIn(0, text.length - 1) else 0 + val position = if (text.isNotEmpty()) rawPosition.coerceIn( + 0, + text.length - 1 + ) else 0 val annotations = buildList { - addAll(text.getStringAnnotations(position, (position + 1).coerceAtMost(text.length))) + addAll( + text.getStringAnnotations( + position, + (position + 1).coerceAtMost(text.length) + ) + ) if (position > 0) { addAll(text.getStringAnnotations(position - 1, position)) } } - val annotation = annotations.firstOrNull { it.tag.startsWith("SPOILER") } - ?: annotations.firstOrNull() + val annotation = + annotations.firstOrNull { it.tag.startsWith("SPOILER") } + ?: annotations.firstOrNull() annotation?.let { when (annotation.tag) { @@ -223,9 +231,16 @@ private fun DefaultTextRender( "COPY" -> { localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(annotation.item)) + ClipData.newPlainText( + "", + AnnotatedString(annotation.item) + ) ) - Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + "Copied to clipboard", + Toast.LENGTH_SHORT + ).show() consumed = true } 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/currentChat/components/chats/QuoteBlock.kt new file mode 100644 index 00000000..0f82876f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/QuoteBlock.kt @@ -0,0 +1,150 @@ +package org.monogram.presentation.features.chats.currentChat.components.chats + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.monogram.presentation.core.ui.spacer.WeightSpacer + +/** + * Quote block + * + * @param text quote text + * @param isOutgoing marks if message sent by me + * @param expandable true, if quote supports expanding + **/ +@Composable +internal fun QuoteBlock( + text: String, + isOutgoing: Boolean, + expandable: Boolean, +) { + var isCollapsed by remember(expandable, text) { mutableStateOf(true) } + val background = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f) + } + val bottomIcon = if (isCollapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp + + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .animateContentSize() + .clickable( + interactionSource = null, + enabled = expandable, + indication = ripple() + ) { + isCollapsed = !isCollapsed + } + .background( + color = background, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer( + Modifier + .width(2.dp) + .padding(vertical = 2.dp) + .fillMaxHeight() + .background( + color = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(4.dp) + ) + ) + + Text( + text = text, + maxLines = if (expandable && isCollapsed) 3 else Int.MAX_VALUE, + modifier = Modifier + .weight(1f, fill = false), + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium + ) + + Column { + Icon( + imageVector = Icons.Default.FormatQuote, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + WeightSpacer(1f) + + if (expandable) { + Icon( + imageVector = bottomIcon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun QuoteBlockPreview() { + MaterialTheme { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + QuoteBlock( + text = "Ваше мнение, конечно, очень важно, но, не очень-то и нужно...", + isOutgoing = true, + expandable = false + ) + + QuoteBlock( + text = "Ваше мнение, конечно, очень важно, но, не очень-то и нужно...", + isOutgoing = false, + expandable = false + ) + + QuoteBlock( + text = "Ваше мнение, конечно, очень важно, но, не очень-то и нужно...".repeat(4), + isOutgoing = true, + expandable = true + ) + + QuoteBlock( + text = "Ваше мнение, конечно, очень важно, но, не очень-то и нужно...".repeat(4), + isOutgoing = false, + expandable = true + ) + } + } +} \ No newline at end of file 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/currentChat/components/chats/TextBlocks.kt new file mode 100644 index 00000000..3c6746ac --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextBlocks.kt @@ -0,0 +1,40 @@ +package org.monogram.presentation.features.chats.currentChat.components.chats + +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 + +@Composable +internal fun TextBlocks( + text: String, + entity: MessageEntity, + isOutgoing: Boolean, +) { + when (val type = entity.type) { + is MessageEntityType.Pre -> { + CodeBlock( + text = text blockFor entity, + language = type.language, + isOutgoing = isOutgoing + ) + } + is MessageEntityType.BlockQuote -> { + QuoteBlock( + text = text blockFor entity, + isOutgoing = isOutgoing, + expandable = false, + ) + } + is MessageEntityType.BlockQuoteExpandable -> { + QuoteBlock( + text = text blockFor entity, + isOutgoing = isOutgoing, + expandable = true, + ) + } + else -> { + /***/ + } + } +} \ No newline at end of file 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/currentChat/components/chats/TextMessageBubble.kt index 1c8afada..9b3bee5d 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/currentChat/components/chats/TextMessageBubble.kt @@ -176,6 +176,7 @@ fun TextMessageBubble( ) Spacer(modifier = Modifier.width(4.dp)) } + Text( text = formatTime(msg.date), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), 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/currentChat/components/chats/model/Mappers.kt new file mode 100644 index 00000000..c9a4ed52 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt @@ -0,0 +1,22 @@ +package org.monogram.presentation.features.chats.currentChat.components.chats.model + +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType + +/** + * Gets text part for current [MessageEntity] + **/ +internal infix fun String.blockFor(entity: MessageEntity): String = + this.substring(entity.offset, entity.offset + entity.length) + +/** + * Checks if [MessageEntityType] is block element + **/ +internal fun MessageEntityType.isBlockElement(): Boolean { + return when (this) { + is MessageEntityType.Pre, + is MessageEntityType.BlockQuote, + is MessageEntityType.BlockQuoteExpandable -> true + else -> false + } +} \ No newline at end of file From 5d81999822722b0f41ac80a3f88a80b0b6922abe Mon Sep 17 00:00:00 2001 From: keimoger Date: Mon, 6 Apr 2026 01:09:26 +0400 Subject: [PATCH 23/53] Added Armenian translation (#186) --- app/src/main/res/values-hy/strings.xml | 39 + .../src/main/res/values-hy/string.xml | 1749 +++++++++++++++++ 2 files changed, 1788 insertions(+) create mode 100644 app/src/main/res/values-hy/strings.xml create mode 100644 presentation/src/main/res/values-hy/string.xml diff --git a/app/src/main/res/values-hy/strings.xml b/app/src/main/res/values-hy/strings.xml new file mode 100644 index 00000000..60da70cd --- /dev/null +++ b/app/src/main/res/values-hy/strings.xml @@ -0,0 +1,39 @@ + + Բացել MonoGram-ը + Մուտք գործեք կենսաչափական տվյալներով + Օգտագործել գաղտնագիրը + Մուտքագրեք գաղտնագիրը + Ձեր հաղորդագրությունները պաշտպանված են + Սխալ գաղտնագիր + Կենսաչափական հաստատում + + Պրոքսիի տվյալները + Ավելացնել և միանալ այս պրոքսի սերվերին + Սերվեր + Պորտ + Տեսակ + Անհայտ + Չեղարկել + Միանալ + + Միանալ + Ալիք + Խումբ + + %d անդամ + %d անդամ + + + Ընտրեք կարգավորումը + Ընտրեք չատը՝ զրույցը սկսելու համար + + Սխալների մատյան (Log) + Պատճենված է + Տարածել սխալի մատյանը + Տարածել + Պատճենել + Վերագործարկել հավելվածը + Սխալի մանրամասները + Ինչ-որ բան այն չէ: Խնդրում ենք տարածել կամ պատճենել սխալի մատյանը՝ ծրագրավորողներին խնդրի մասին հայտնելու համար: + + \ No newline at end of file diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml new file mode 100644 index 00000000..d1c30d94 --- /dev/null +++ b/presentation/src/main/res/values-hy/string.xml @@ -0,0 +1,1749 @@ + + + Հաստատում + Իսկապե՞ս ցանկանում եք լիազորել այս սարքը: + Այո, մուտք գործել + Չեղարկել + + Սկանավորել QR-ը + Սարքեր + Կցել սարք + + Այս սարքը + Մուտքի հարցումներ + Ակտիվ սեանսներ + + Հետ գնալ + Փակել սկաները + QR սկաների պատկերակ + + Միացում Telegram-ին… + + Ծրագրի մասին + Հետ + MonoGram + Տարբերակ %1$s + Օգտագործման պայմաններ + Կարդացել մեր պայմանները + Բաց կոդով լիցենզիաներ + MonoGram-ում օգտագործվող ծրագրակազմը + GitHub + Դիտել ելակետային կոդը + TDLib-ի տարբերակը + %1$s (%2$s) + Համայնք + Telegram չատ + Միացեք մեր համայնքին՝ քննարկելու և օգնություն ստանալու համար + Telegram ալիք + Հետևեք վերջին նորություններին և հայտարարություններին + Աջակցել MonoGram-ին + Աջակցեք նախագծին՝ այն զարգացնելու համար + Հեղինակներ + Ծրագրավորող + Պատկերակի & լոգոյի դիզայներ + MonoGram-ը Telegram-ի ոչ պաշտոնական հաճախորդ է՝ կառուցված Material Design 3 ոճով: + © 2026 MonoGram + + Ստուգել թարմացումները + Ստուգվում է… + Առկա է թարմացում. %1$s + Դուք օգտագործում եք վերջին տարբերակը + Թարմացումը պատրաստ է + Թարմացմանը սխալ է + Սեղմեք՝ նոր տարբերակը ստուգելու համար + Միացում սերվերին + Հասանելի է նոր տարբերակ ներբեռնման համար + Դուք օգտագործում եք ամենանոր տարբերակը + Սեղմեք՝ թարմացումը տեղադրելու համար + Թարմացումը ներբեռնվում է… + %1$d%% + Ինչ նորույթներ կան %1$s տարբերակում + Ներբեռնել թարմացումը + Չեղարկել + Բեռնվում է… + + Ձեր հեռախոսը + Հաստատում + Գաղտնաբառ + Պրոքսիի կարգավորումներ + Ձեր հեռախոսահամարը + Խնդրում ենք հաստատել երկրի կոդը և մուտքագրել հեռախոսահամարը: + Երկիր + Կոդ + Հեռախոսահամար + 00 000 000 + Շարունակել + Ընտրեք երկիրը + Որոնել երկիրը կամ կոդը… + Մենք ուղարկել ենք կոդը Ձեր մյուս սարքի Telegram հավելվածին: + + Մենք ուղարկել ենք կոդը SMS-ով: + Մենք զանգահարում ենք Ձեզ՝ կոդը հայտնելու համար: + Մենք ուղարկել ենք կոդը %1$s էլ. հասցեին: + Մենք ուղարկել ենք հաստատման կոդը: + Հաստատել + Կրկին ուղարկել %1$s-ից + Ուղարկել SMS-ով + Ստանալ զանգով + Կրկին ուղարկել կոդը + Սխա՞լ համար է: + Երկփուլային հաստատում + Ձեր հաշիվը պաշտպանված է հավելյալ գաղտնաբառով: + Գաղտնաբառ + Բացել + Տեղադրել + Մուտքի սխալ + Փակել + + Խնդրում ենք սպասել + Շա՞տ երկար տևեց + Վերակայել միացումը + + Վերահասցեագրել… + Ընտրված է %1$d չատ + Ուղարկել + Արխիվացված չատեր + Նոր չատ + Վերջինները + Մաքրել ամբողջը + Չատեր և կոնտակտներ + Գլոբալ որոնում + Հաղորդագրություններ + Ցույց տալ ավելին + Որոնել չատերում… + Ցանցի սպասում… + Միացում… + Թարմացում… + Միացում պրոքսիին… + Պրոքսին միացված է + Պրոքսի + Ֆորում + Սպոյլեր + Սևագիր: + Դեռ հրապարակումներ չկան + Դեռ հաղորդագրություններ չկան + Ամրացված + Նշումներ + Թաքցված հիմնական ցուցակից + Դեր չատեր չկան + Սկսեք նոր զրույց + Մինի հավելված + + MonoGram Dev + Ավելացնել հաշիվ + Մուտք գործել այլ հաշիվ + Իմ պրոֆիլը + Դիտել պրոֆիլը + Պահպանված հաղորդագրություններ + Ամպային պահոց + Կարգավորումներ + Հավելվածի կարգավորումներ + Առկա՜ է թարմացում + Հասանելի է նոր %1$s տարբերակը + Թարմացումը ներբեռնվում է… %1$d%% + Թարմացումը պատրաստ է տեղադրման + Օգնություն և հետադարձ կապ + Հաճախ տրվող հարցեր և աջակցություն + Գաղտնիության քաղաքականություն + MonoGram Dev Android-ի համար v%1$s + Անհայտ օգտատեր + Տեղեկություն չկա + Ցույց տալ հաշիվները + + Որոնել հաղորդագրությունները… + Մաքրել + Առանց ձայնի + Վերիֆիկացված + Հովանավոր + Միացնել ձայնը + Անջատել ձայնը + Ֆիլտրել գովազդը + Ավելացնել ալիքը սպիտակ ցուցակում + Պատճենել հղումը + Մաքրել պատմությունը + Ջնջել չատը + Բողոքել + ՄԻԱՆԱԼ + Իջնել ներքև + Այս թեման փակ է + Կցել + + Ջնջե՞լ հաղորդագրությունը + Ջնջե՞լ %1$d հաղորդագրությունները + Իսկապե՞ս ցանկանում եք ջնջել այս հաղորդագրությունը: + Իսկապե՞ս ցանկանում եք ջնջել այս հաղորդագրությունները: + Ջնջել բոլորի համար + Ջնջել ինձ համար + + Ինչու՞ եք բողոքում + Ձեր բողոքը անանուն է: Մենք կվերանայենք չատի պատմությունը՝ անվտանգությունն ապահովելու համար: + Բողոքի մանրամասներ + Նկարագրեք խնդիրը… + Ուղարկել բողոքը + Սպամ + Անցանկալի առևտրային բովանդակություն կամ խարդախություն + Բռնություն + Սպառնալիքներ կամ բռնության քարոզ + Պոռնոգրաֆիա + Անվայել բովանդակություն կամ հայհոյանք + Երեխաների պաշտպանություն + Երեխաներին վնաս հասցնող բովանդակություն + Հեղինակային իրավունք + Ուրիշի մտավոր սեփականության օգտագործում + Կեղծ հաշիվ + Ուրիշի կամ բոտի անունից ներկայանալը + Արգելված դեղամիջոցներ + Արգելված նյութերի վաճառքի կամ օգտագործման քարոզ + Գաղտնիության խախտում + Անձնական կոնտակտային տվյալների կամ հասցեների հրապարակում + Անհամապատասխան վայր + Բովանդակություն, որը կապ չունի տվյալ վայրի հետ + Այլ + Մեր կանոնները խախտող այլ խնդիր + + Սահմանափակել օգտատիրոջը + Ուղարկել հաղորդագրություններ + Ուղարկել մեդիա + Ուղարկել կպչուկ և GIF-եր + Ուղարկել հարցումներ + Կցել հղումներ + Ամրացնել հաղորդագրություններ + Փոխել չատի տվյալները + Սահմանափակել մինչև + Ընդմիշտ + Ընտրել ամսաթիվը + Ընտրել ժամը + Սահմանափակել + + Պատասխանել + Պատճենել + Ամրացնել + Ապաամրացնել + Վերահասցեագրել + Ընտրել + Ավելին + Ջնջել + Դիտել մեկնաբանությունները + Պահպանել ներբեռնումներում + Cocoon + Ամփոփում + Թարգմանել + Ստեղծվել է Telegram Cocoon-ի միջոցով + Վերականգնել բնօրինակ տեքստը + Սահմանափակել օգտատիրոջը + Խմբագրված + Կարդացված + Դիտումներ + + Անհայտ + %1$d անդամ + %1$s, %2$d առցանց + Դեռ հասանելի չէ + Որոնումը դեռ հասանելի չէ + Տարածելը դեռ հասանելի չէ + Արգելափակումը դեռ հասանելի չէ + Ջնջելը դեռ հասանելի չէ + Վիճակագրություն + Եկամուտ + Տարածել + Խմբագրել + Արգելափակել օգտատիրոջը + Դուրս գալ + Բացել + Հաղորդագրություն + Միանալ + Բողոքել + QR կոդ + Ավելացնել + Անձնական նկար + Այս նկարը տեսանելի է միայն Ձեզ + Բացել մինի հավելվածը + Գործարկել բոտի վեբ հավելվածը + Ընդունել TOS-ը + Վերանայել և ընդունել բոտի օգտագործման պայմանները + Բոտի թույլտվությունները + Կառավարել այս բոտի թույլտվությունները + Օգտանուն + Հղում + Հրավերի հղում + Բոտի տվյալները + Նկարագրություն + Իմ մասին + Ծննդյան ամսաթիվ + Գտնվելու վայրը + Աշխատանքային ժամեր + Վերջին գործողությունները + Դիտել չատի իրադարձությունների մատյանը + Դիտել չատի մանրամասն վիճակագրությունը + Դիտել չատի եկամուտների վիճակագրությունը + Ավելացրել է Ձեզ կոնտակտներում + Չի ավելացրել Ձեզ կոնտակտներում + Պահպանված է Ձեր կոնտակտներում + Պահպանված չէ Ձեր կոնտակտներում + Պրոֆիլի սթորիներ + Դուք կարող եք սթորիներ հրապարակել Ձեր պրոֆիլում + Չատի պաստառ + Դուք կարող եք սահմանել անհատական պաստառներ + Ձայնային և տեսահաղորդագրություններ + Ձայնային և տեսահաղորդագրությունների ուղարկումը սահմանափակված է + Դանդաղ ռեժիմ + Անդամները կարող են ուղարկել մեկ հաղորդագրություն ամեն %1$s-ը մեկ + Պաշտպանված բովանդակություն + Վերահասցեագրումն ու պահպանումը սահմանափակված են + Անանուն վերահասցեագրում + Վերահասցեագրված հաղորդագրությունները թաքցնում են պրոֆիլի հղումը + Չատի վիճակագրություն + %1$d ադմին + %1$d սահմանափակված + %1$d արգելափակված + Տեղեկություն + Ծանուցումներ + Հաղորդագրությունների ինքնաջնջում + Կարգավորումներ + Պահպանել + Սպամ + Բռնություն + Պոռնոգրաֆիա + Մանկական բռնություն + Հեղինակային իրավունք + Այլ + Փակել + Գործարկելով այս մինի հավելվածը՝ Դուք համաձայնում եք Օգտագործման պայմաններին և Գաղտնիության քաղաքականությանը: Բոտը կկարողանա մուտք գործել Ձեր պրոֆիլի հիմնական տվյալներին: + + Ընդունել և գործարկել + + QR կոդ + Տարածել + MonoGram-ը անվճար և բաց կոդով նախագիծ է: Ձեր աջակցությունը օգնում է մեզ պահպանել այն և զարգացնել նոր հնարավորություններ: + + Հովանավորի նշանը հասանելի է Advanced մակարդակի աջակցության կամ համարժեք նվիրատվության դեպքում (սկսած 150 ռուբլուց կամ մոտ 2 դոլարից): + + Աջակցել Boosty-ում + Գուցե ուշ + Խմբագրել պրոֆիլը + Երկար սեղմեք՝ թաքցնելու համար + Պահեք՝ տեսնելու, սեղմեք՝ պատճենելու համար + Ձեր ID-ն + Փոխեք Ձեր անունը, տվյալները և նկարը + Միացնել t.me հղումները + Բացել Telegram հղումները հենց հավելվածում + Ընդհանուր + Չատի կարգավորումներ + Թեմաներ, տեքստի չափ, տեսանյուտի փլեյեր + Գաղտնիություն և անվտանգություն + Գաղտնագիր, ակտիվ սեանսներ, գաղտնիություն + Ծանուցումներ և ձայներ + Հաղորդագրություններ, խմբեր, զանգեր + Տվյալներ և պահոց + Ցանցի օգտագործում, ինքնաներբեռնում + Էներգախնայողություն + Մարտկոցի օգտագործման կարգավորումներ + Չատի թղթապանակներ + Կազմակերպեք Ձեր չատերը + Կպչուկներ և էմոջիներ + Կառավարել սթիքերների և էմոջիների հավաքածուները + Կցված սարքեր + Լեզու + English + Պրոքսիի կարգավորումներ + MTProto, SOCKS5, HTTP + Telegram Premium + Բացահայտեք բացառիկ հնարավորություններ + Օգնեք մեզ զարգացնել նախագիծը + Աջակցում է MonoGram-ին + Այս օգտատերը աջակցում է նախագծին և օգնում մեզ կատարելագործվել + MonoGram-ի տարբերակը և տվյալները + Debug + Կարգաբերման տարբերակներ + Ցույց տալ հովանավորի պատուհանը + Բացել հովանավորների մասին տեղեկությունը + Ստիպողաբար համաժամեցնել հովանավորներին + Այժմ ներբեռնել հովանավորների ID-ները ալիքից + Դուրս գալ + Անջատվել հաշվից + + Օգտանուններ + Կրկին փորձել %1$d վրկ-ից + Ակտիվ օգտանուններ + Անջատված օգտանուններ + Հավաքածուի օգտանուններ + Հաստատել + Լավ + Ընտրել սկզբի ժամը + Ընտրել ավարտի ժամը + Աշխատանքային ժամեր + Աշխատանքային օրեր + Ժամանակահատված + Սկսած + Մինչև + Բիզնեսի գտնվելու վայրը + Հասցե + Հաստատել գտնվելու վայրը + Խմբագրել պրոֆիլը + Անուն (պարտադիր) + Ազգանուն (ոչ պարտադիր) + Իմ մասին + Ցանկացած մանրամասն, օրինակ՝ տարիքը, մասնագիտությունը կամ քաղաքը: Օրինակ՝ 23 տարեկան դիզայներ Երևանից: + Օգտանուն + Դուք կարող եք ընտրել օգտանուն Telegram-ում: Եթե ընտրեք, մարդիկ կկարողանան գտնել Ձեզ այդ անունով և կապվել Ձեզ հետ՝ առանց Ձեր հեռախոսահամարը իմանալու: + Ձեր ծննդյան օրը + Ծննդյան օր + Telegram Business + Կցված ալիքի ID + Բիզնեսի նկարագրություն + Բիզնեսի հասցե + Գեոդիրք + Աշխատանքային ժամեր + Որպես Premium օգտատեր, Դուք կարող եք կցել ալիք և ավելացնել բիզնես տվյալներ Ձեր պրոֆիլում: + Սահմանված չէ + (%1$d օր) + Երկ + Երք + Չորք + Հնգ + Ուրբ + Շբտ + Կիր + + Գաղտնիություն և անվտանգություն + Գաղտնիություն + Արգելափակված օգտատերեր + %1$d օգտատեր + Ոչ ոք + Հեռախոսահամար + Վերջին անգամ տեսած + Պրոֆիլի նկարներ + Վերահասցեագրված հաղորդագրություններ + Զանգեր + Խմբեր և ալիքներ + Անվտանգություն + Գաղտնագրով կողպում + Միացված + Անջատված + Բացել կենսաչափական տվյալներով + Օգտագործել մատնահետք կամ դեմքի ճանաչում + Ակտիվ սեանսներ + Կառավարեք Ձեր միացված սարքերը + Զգայուն բովանդակություն + Անջատել ֆիլտրումը + Ցուցադրել զգայուն բովանդակությունը բոլոր սարքերում: + Լրացուցիչ + Ջնջել իմ հաշիվը + Եթե բացակայում եմ %1$s + Ջնջել հաշիվը հիմա + Ընդմիշտ ջնջել հաշիվը և բոլոր տվյալները + Ինքնաջնջվել, եթե անակտիվ եմ… + Ջնջել հաշիվը + Իսկապե՞ս ցանկանում եք ջնջել Ձեր հաշիվը: Այս գործողությունը վերջնական է և չի կարող չեղարկվել: + Բոլորը + Իմ կոնտակտները + Ոչ ոք + 1 ամիս + 3 ամիս + 6 ամիս + 1 տարի + 18 ամիս + 2 տարի + %1$d ամիս + %1$d օր + Օգտատիրոջ հարցումով + + Պրոքսիի կարգավորումներ + Թարմացնել պինգերը + Ավելացնել պրոքսի + Միացում + Խելացի փոխանջատում + Ավտոմատ օգտագործել ամենաարագ պրոքսին + Նախապատվությունը տալ IPv6-ին + Օգտագործել IPv6, եթե հասանելի է + Անջատել պրոքսին + Միացված է ուղղակիորեն + Անցնել ուղիղ միացման + Telega Proxy + Telega Proxy-ն մեղադրվել է թրաֆիկի գաղտնալսման մեջ: MTProto-ն պաշտպանում է հաղորդագրությունները, բայց օգտագործեք այս պրոքսին Ձեր ռիսկով: Մանրամասն՝ t.me/telegaru + Միացնել Telega Proxy-ն + Ավտոմատ ընտրել լավագույնը + Թարմացնել ցուցակը + Ներբեռնել համայնքի թարմ պրոքսիները + Ձեր պրոքսիները + Մաքրել անջատվածները + Հեռացնել բոլորը + Ջնջել անջատված պրոքսիները + Սա կհեռացնի բոլոր անցանց պրոքսիները: Շարունակե՞լ: + Ջնջել բոլոր պրոքսիները + Սա կհեռացնի բոլոր կարգավորված պրոքսիները: Շարունակե՞լ: + Պրոքսիներ չկան + Ջնջել պրոքսին + Իսկապե՞ս ցանկանում եք ջնջել %1$s պրոքսին: + Նոր պրոքսի + Խմբագրել պրոքսին + Սերվերի հասցեն + Պորտ + Secret (Hex) + Օգտանուն (ոչ պարտադիր) + Գաղտնաբառ (ոչ պարտադիր) + Պահպանել փոփոխությունները + Թեստ + Թեստի արդյունքը + Ջնջել + Ստուգվում է… + Անցանց + %1$d մվ + + Ձեր օգտանունները + Այլ տարբերակներ + + Ծանուցումներ և ձայներ + Հաղորդագրությունների ծանուցումներ + Անձնական չատեր + Խմբեր + Ալիքներ + Ծանուցումների կարգավորումներ + Վիբրացիա + Առաջնահերթություն + Կրկնել ծանուցումները + Ցույց տալ միայն ուղարկողին + Թաքցնել հաղորդագրության բովանդակությունը ծանուցումներում + Push ծառայություն + Push մատակարար + Keep-Alive ծառայություն + Թույլ տալ հավելվածին աշխատել հետին պլանում՝ ծանուցումներ ստանալու համար + Թաքցնել հետին պլանի ծանուցումը + Թաքցնել ծառայության ծանուցումը գործարկվելուց հետո: Կարող է հանգեցնել ծառայության անջատմանը համակարգի կողմից + Ծանուցումներ հավելվածի ներսում + Ձայներ հավելվածի ներսում + Վիբրացիա հավելվածի ներսում + Նախադիտում հավելվածի ներսում + Իրադարձություններ + Կոնտակտը միացավ Telegram-ին + Ամրացված հաղորդագրություններ + Վերակայել բոլոր ծանուցումները + Չեղարկել ծանուցումների բոլոր անհատական կարգավորումները + Վիբրացիայի տեսակը + Ծանուցման առաջնահերթությունը + %1$s, %2$d բացառություն + Կանխադրված + Կարճ + Երկար + Անջատված + Ցածր + Կանխադրված + Բարձր + Երբեք + Ամեն %1$d րոպեն մեկ + Ամեն 1 ժամը մեկ + Ամեն %1$d ժամը մեկ + Firebase Cloud Messaging + GMS-less (Հետին պլանի ծառայություն) + + Չատի կարգավորումներ + Արտաքին տեսք + Տեքստի չափը + Տառերի հեռավորությունը + Հաղորդագրության կլորացումը + Վերակայել + Չատի պաստառ + Վերակայել պաստառը + Էմոջիների ոճը + Թեմա + Գիշերային ռեժիմ + Համակարգային + Լուսավոր + Մութ + Ըստ գրաֆիկի + Ավտոմատ + Ընթացիկ վիճակը + Օգտագործվում է՝ %1$s + Պայծառության շեմը՝ %1$d%% + Անցնել մութ թեմային, երբ էկրանի պայծառությունը այս մակարդակից ցածր է: + Դինամիկ գույներ + Դինամիկ գույներ + Օգտագործել համակարգի գույները թեմայի համար + Տվյալներ և պահոց + Սեղմել նկարները + Նվազեցնել նկարի չափը ուղարկելուց առաջ + Սեղմել վիդեոները + Նվազեցնել վիդեոյի չափը ուղարկելուց առաջ + Վիդեո փլեյեր + Միացնել ժեստերը + Սահեցրեք՝ ձայնը և պայծառությունը կառավարելու համար + Կրկնակի հպում՝ հետ/առաջ տալու համար + Կրկնակի հպեք վիդեոյի եզրերին + Հետ/առաջ տալու տևողությունը + Միացնել մեծացումը + Մեծացնել վիդեոն մատներով + Չատերի ցուցակ + Ամրացնել արխիվացված չատերը + Պահել արխիվացված չատերը ցուցակի սկզբում + Միշտ ցույց տալ ամրացված արխիվը + Արխիվը տեսանելի կլինի նույնիսկ թերթելիս + Ցույց տալ հղումների նախադիտումը + Ցուցադրել հղումների բովանդակությունը հաղորդագրություններում + Քաշել՝ հետ գնալու համար + Սահեցրեք ձախ եզրից՝ հետ գնալու համար + Երկտողանի + Եռատողանի + Ցույց տալ նկարները + Ցուցադրել պրոֆիլի նկարները չատերի ցուցակում + Էքսպերիմենտալ + AdBlock ալիքների համար + Թաքցնել հովանավորվող գրառումները ալիքներում + Վերջին մեդիա ֆայլերը + Մաքրել վերջին սթիքերները + Հեռացնել վերջերս օգտագործված բոլոր սթիքերները + Մաքրել վերջին էմոջիները + Հեռացնել վերջերս օգտագործված բոլոր էմոջիները + Հեռացնել էմոջի փաթեթը + Հեռացնե՞լ %1$s փաթեթը: + Սա կջնջի ներբեռնված էմոջիները Ձեր սարքից: + Խմբագրել թեման + Ընտրված է + Apple + Twitter + Windows + Catmoji + Noto + Համակարգային + + Տվյալներ և պահոց + Սկավառակի և ցանցի օգտագործում + Պահոցի օգտագործում + Կառավարեք Ձեր տեղական քեշը + Ցանցի օգտագործում + Դիտել ուղարկված և ստացված տվյալները + Մեդիա ֆայլերի ավտոմատ ներբեռնում + Բջջային ինտերնետով + Wi-Fi-ով + Ռոումինգում + Ֆայլերի ավտոմատ ներբեռնում + Ավտոմատ ներբեռնել ստացված ֆայլերը + Սթիքերների ավտոմատ ներբեռնում + Ավտոմատ ներբեռնել սթիքերները + Տեսահաղորդագրությունների ավտոմատ ներբեռնում + Ավտոմատ ներբեռնել տեսահաղորդագրությունները + Ավտոմատ նվագարկում + GIF-եր + Ավտոմատ նվագարկել GIF-երը + Վիդեոներ + Ավտոմատ նվագարկել վիդեոները + Միացված է + Անջատված է + + Էներգախնայողություն + Մարտկոց + Էներգախնայողության ռեժիմ + Նվազեցնում է ակտիվությունը և անիմացիաները՝ մարտկոցը խնայելու համար + Օպտիմալացնել մարտկոցի օգտագործումը + Սահմանափակել հետին պլանի աշխատանքը + Wake Lock + Թույլ չտալ պրոցեսորին քնել: Անջատեք՝ մարտկոցը խնայելու համար + Անիմացիաներ + Չատի անիմացիաներ + Անջատել անիմացիաները՝ մարտկոցը խնայելու համար + Հետին պլան + Անջատումը կխնայի էներգիան, բայց կարող է ուշացնել ծանուցումները + + Սթիքերներ և էմոջիներ + Սթիքերներ + Էմոջի + Վերջին սթիքերները + Սթիքերների հավաքածուներ + Արխիվացված սթիքերներ + Ավելացնել սեփական սթիքերները + Ստեղծեք Ձեր հավաքածուները @Stickers բոտի միջոցով + Տեղադրված սթիքերներ չկան + \"%1$s\"-ի համար սթիքերներ չեն գտնվել + Վերջին էմոջիները + Էմոջի փաթեթներ + Արխիվացված էմոջիներ + Ավելացնել սեփական էմոջիները + Ստեղծեք Ձեր էմոջի փաթեթները @Stickers բոտի միջոցով + Մաքրել վերջին էմոջիները + Հեռացնել վերջերս օգտագործված բոլոր էմոջիները + Տեղադրված էմոջի փաթեթներ չկան + \"%1$s\"-ի համար էմոջիներ չեն գտնվել + Որոնել փաթեթներ + Որոնել + + %1$d սթիքեր + %1$d սթիքեր + + + %1$d էմոջի + %1$d էմոջի + + Դիմակներ + Անհատական էմոջիներ + Պաշտոնական + Հղումը պատճենված է + + Ցանցի օգտագործում + Վերակայել վիճակագրությունը + Ցանցի վիճակագրություն + Հետևեք Ձեր ծախսած ինտերնետին: + Վիճակագրությունը անջատված է + Միացրեք վերևի կոճակով՝ Ձեր ծախսած տվյալները տեսնելու համար: + Ընդհանուր օգտագործում + Ուղարկված + Ստացված + Ամփոփում + Հավելվածի օգտագործում + Տվյալներ չկան + Վիճակագրություն չկա + Բջջային + Wi-Fi + Ռոումինգ + Այլ + + Պահոցի օգտագործում + Մաքրել ամբողջ քեշը • %1$s + Սա ներառում է նկարները, վիդեոները և այլ ֆայլերը: + Քեշի սահմանաչափ + Քեշի ավտոմատ մաքրում + Պահոցի օպտիմալացում + Պահոցի ավտոմատ օպտիմալացում հետին պլանում + Մանրամասն օգտագործում + Պահոցը մաքուր է + Քեշավորված ֆայլեր չեն գտնվել: + Մաքրել քեշը + Իսկապե՞ս ցանկանում եք մաքրել \"%1$s\"-ի քեշը: Սա կազատի %2$s: + Մաքրել ամբողջ քեշը + Սա կջնջի բոլոր չատերի մեդիա ֆայլերը սարքից: Համաձա՞յն եք: + Անսահմանափակ + Երբեք + Ամեն օր + Ամեն շաբաթ + Ամեն ամիս + Ընդհանուր օգտագործված + %1$d ֆայլ + %1$d ֆայլ + + Ո՞վ կարող է ինձ ավելացնել %1$s-ում: + Ո՞վ կարող է ինձ զանգահարել: + Ո՞վ կարող է տեսնել իմ վերջին անգամ տեսած լինելը: + Ո՞վ կարող է տեսնել իմ պրոֆիլի նկարները: + Ո՞վ կարող է տեսնել իմ տվյալները (bio): + Ո՞վ կարող է հղում ավելացնել իմ հաշվին՝ հաղորդագրությունները վերահասցեագրելիս: + Ո՞վ կարող է ինձ ավելացնել խմբերում և ալիքներում: + Ո՞վ կարող է տեսնել իմ հեռախոսահամարը: + Ո՞վ կարող է ինձ գտնել համարով: + Օգտատերերը, ովքեր ունեն Ձեր համարը իրենց կոնտակտներում, կտեսնեն այն Telegram-ում միայն եթե վերևի կարգավորումը թույլ է տալիս: + Ավելացնել բացառություններ + Միշտ թույլատրել + Երբեք չթույլատրել + %1$d օգտատեր/չատ + (ջնջված) + Չատի անդամներ + Որոնում հեռախոսահամարով + Արգելափակված օգտատերեր չկան + Արգելափակված օգտատերերը չեն կարողանա կապվել Ձեզ հետ և չեն տեսնի Ձեր վերջին ակտիվությունը: + Ապաարգելափակել + Արգելափակել օգտատիրոջը + Որոնել օգտատերերին + Օգտատերեր չեն գտնվել + + Փոխել գաղտնագիրը + Սահմանել գաղտնագիր + Հավելվածը պաշտպանված է գաղտնագրով: Մուտքագրեք նորը՝ այն փոխելու համար: + Մուտքագրեք 4-նիշանոց գաղտնագիր՝ հավելվածը կողպելու համար: + Գաղտնագիր + Ընթացիկ գաղտնագիրը + Պահպանել գաղտնագիրը + Անջատել գաղտնագիրը + Հաստատել գաղտնագիրը + Մուտքագրեք ընթացիկ գաղտնագիրը՝ փոփոխություն կատարելու համար: + Սխալ գաղտնագիր: + + Ավելացնել բանալի բառ + Միացնել AdBlock-ը + Սպիտակ ցուցակի ալիքներ + %1$d թույլատրված ալիք + Բեռնել հիմնական բառերը + Ներմուծել հաճախ հանդիպող գովազդային բառերը + Պատճենել բոլոր բառերը + Պատճենել ցուցակը clipboard-ում + Մաքրել բոլոր բառերը + Ջնջել բոլոր բանալի բառերը ցուցակից + Գրառումները թաքցնելու բառերը + Բանալի բառեր չկան + Սեղմեք + կոճակը՝ ֆիլտրման համար բառեր ավելացնելու համար + Ավելացնել բանալի բառեր + Մուտքագրեք բառերը՝ բաժանված ստորակետերով: + օր.՝ #promo, գովազդ, реклама + Ավելացնել ցուցակում + Այս ալիքների գրառումները չեն ֆիլտրվի + Սպիտակ ցուցակում ալիքներ չկան + Հեռացնել + + վերջերս + հենց նոր + + տեսնվել է %1$d րոպե առաջ + տեսնվել է %1$d րոպե առաջ + + տեսնվել է ժամը %1$s-ին + տեսնվել է երեկ ժամը %1$s-ին + տեսնվել է %1$s + վերջին շաբաթվա ընթացքում + վերջին ամսվա ընթացքում + շատ վաղուց + առցանց + անցանց + բոտ + Օգտանուն + %1$d սթիքեր + Արխիվացված + Ավելացնել + Ապաարխիվացնել + Արխիվացնել + Հեռացնել հավաքածուն + + Նոր հաղորդագրություն + Ավելացնել անդամներ + Նոր խումբ + Նոր ալիք + %1$d կոնտակտ + %1$d / 200000 + %1$d ընտրված է + Որոնել կոնտակտները… + Կոնտակտներ չեն գտնվել + \"%1$s\"-ի համար արդյունքներ չկան + Դասավորված ըստ ակտիվության + Նոր խումբ + Նոր ալիք + Աջակցություն + Ալիքները նախատեսված են Ձեր հաղորդագրությունները լայն լսարանին հասցնելու համար: + Ալիքի մանրամասները + Ալիքի անվանումը + Նկարագրություն (ոչ պարտադիր) + Հաղորդագրությունների ինքնաջնջում + Անջատված + 1 օր + 2 օր + 3 օր + 4 օր + 5 օր + 6 օր + 1 շաբաթ + 2 շաբաթ + 3 շաբաթ + 1 ամիս + 2 ամիս + 3 ամիս + 4 ամիս + 5 ամիս + 6 ամիս + 1 տարի + Ցանկացած անձ կարող է միանալ ալիքին, եթե ունի հղումը: + Մուտքագրեք անվանումը և ավելացրեք նկար: + Խմբի մանրամասները + Խմբի անվանումը + Խնդրում ենք մուտքագրել խմբի անվանումը + Խնդրում ենք մուտքագրել ալիքի անվանումը + Բացել պրոֆիլը + Խմբագրել անունը + Հեռացնել կոնտակտը + Խմբագրել կոնտակտը + Անուն + Ազգանուն + Հեռացնե՞լ կոնտակտը + Իսկապե՞ս ցանկանում եք հեռացնել %1$s-ին կոնտակտներից: + Ավտոմատ ջնջել այս խմբում ուղարկված հաղորդագրությունները որոշակի ժամանակ անց: + Ավելացնել նկար + Փոխել նկարը + + Պահանջվում են թույլտվություններ + Լիարժեք աշխատանքի համար MonoGram-ին անհրաժեշտ են հետևյալ թույլտվությունները: + Ծանուցումներ + Ստանալ ծանուցումներ նոր հաղորդագրությունների մասին + Թույլատրել + Հեռախոսի կարգավիճակ + Կառավարել սարքի վիճակը ավելի լավ աշխատանքի համար + Մարտկոցի օպտիմալացում + Ապահովել կայուն աշխատանք հետին պլանում + Անջատել + Տեսախցիկ + Նկարել և ուղարկել տեսահաղորդագրություններ + Միկրոֆոն + Ձայնագրել ձայնային և տեսահաղորդագրություններ + Տեղադրություն + Տարածել Ձեր գտնվելու վայրը և տեսնել մոտակա օգտատերերին + + Մաքրել ընտրությունը + Ամրացնել + + Բոտի հրամաններ + Ընտրեք հրամանը՝ բոտին ուղարկելու համար + + Չատերի ցուցակ + Konata Izumi + Ես կարճահասակ չեմ, ես ուղղակի խտացված հրաշք եմ: 🍫 Իսկ դեռ որոշել եմ դառնալ քնելու պրոֆեսիոնալ 😴 + 12:45 + Kagami Hiiragi + Չմոռանաս տնայինի մասին: Վաղը առավոտյան պետք է հանձնենք ու բավականին դժվար է: + 11:20 + + Նախադիտում + Ես + Ես կարճահասակ չեմ, ես ուղղակի խտացված հրաշք եմ: 🍫\nԻսկ դեռ որոշել եմ դառնալ քնելու պրոֆեսիոնալ 😴 + Դա ասում ես ամեն անգամ, երբ ձեռքդ չի հասնում վերևի դարակին… 🙄\nՏես սա: + Հերիք է բացահայտես ինձ: 😤✨\nԿօգտագործեմ իմ գաղտնի զենքը՝ 100%% մաքուր ծուլություն: + Դա շատ արդյունավետ է: 😵‍💫 + Այսօր + Konata + + %1$d բաժանորդ + Թեմա + + Ջնջել ձայնագրությունը + < Սահեցրեք՝ չեղարկելու համար + Ուղարկել ձայնագրությունը + Կողպել ձայնագրությունը + Սահեցրեք վերև + + Ավելացնել նկարագրություն… + Հաղորդագրություն + Հաղորդագրություն ուղարկելը թույլատրված չէ + + Նկար + Վիդեո + Սթիքեր + Ձայնային հաղորդագրություն + Տեսահաղորդագրություն + GIF + Տեղադրություն + Հաղորդագրություն + + Չեղարկել պատասխանը + Խմբագրել հաղորդագրությունը + Չեղարկել խմբագրումը + Ուղարկել %1$d ֆայլ + Ուղարկել մեդիա + Չեղարկել + Պատճենել + Տեղադրել + Կտրել + Ընտրել ամբողջը + Կիրառել + Պատրաստ է + Թարմացնել + Լիաէկրան խմբագրիչ + Խմբագրիչ + Ուղարկել առանց ձայնի + Պլանավորել հաղորդագրությունը + Պլանավորված հաղորդագրություններ + Պլանավորված (%1$d) + Պլանավորված հաղորդագրություններ չկան + Ընդհանուր պլանավորված՝ %1$d + Հաջորդ ուղարկումը՝ %1$s + Խմբագրելի՝ %1$d + ID: %1$d + Խմբագրել + Ուղարկել հիմա + Ջնջել + %1$d նիշ • %2$d ֆորմատավորում + %1$d ֆորմատավորման բլոկ + Ընտրեք տեքստը՝ ձևավորում կիրառելու համար + %1$d/%2$d + + Թավ + Շեղ + Ընդգծված + Վրագծված + Սպոյլեր + Կոդ + Մոնոբացատ + Հղում + Նշում + Էմոջի + Մաքրել + Ավելացնել հղում + URL + Կոդի լեզուն + Լեզուն (օր.՝ kotlin) + + Հրամաններ + + գրում է + տեսահաղորդագրություն է ձայնագրում + ձայնային հաղորդագրություն է ձայնագրում + նկար է ուղարկում + վիդեո է ուղարկում + ֆայլ է ուղարկում + սթիքեր է ընտրում + խաղում է + Ինչ-որ մեկը + և + և ևս %d հոգի + գրում են + + %d հոգի գրում է + %d հոգի գրում են + + + Նկարներ + Վիդեոներ + Փաստաթղթեր + Սթիքերներ + Երաժշտություն + Ձայնային հաղորդագրություններ + Տեսահաղորդագրություններ + Այլ ֆայլեր + Այլ / Քեշ + Չատ %d + + Զանգեր + + Նոր հաղորդագրությունների սպասում + Կանգնեցնել + Հետին պլանի ծառայություն + Ծանուցում հետին պլանում աշխատող հավելվածի մասին + + 📷 Նկար + 📹 Վիդեո + 🎤 Ձայնային հաղորդագրություն + 🧩 Սթիքեր + 📎 Փաստաթուղթ + 🎵 Աուդիո + GIF + 🎬 Տեսահաղորդագրություն + 👤 Կոնտակտ + 📊 Հարցում + 📍 Տեղադրություն + 📞 Զանգ + 🎮 Խաղ + 💳 Հաշիվ + 📚 Սթորի + 📌 Ամրացված հաղորդագրություն + Հաղորդագրություն + Բոտ + Առցանց + Անցանց + Հենց նոր + Տեսնվել է %d րոպե առաջ + Տեսնվել է %d րոպե առաջ + Տեսնվել է %s-ին + Տեսնվել է երեկ %s-ին + Տեսնվել է %s + Վերջերս + Վերջին շաբաթվա ընթացքում + Վերջին ամսվա ընթացքում + + Ամրացված հաղորդագրություններ + Ամրացված հաղորդագրություն + Ցույց տալ բոլոր ամրացվածները + Ապաամրացնել + Փակել + + %d հաղորդագրություն + %d հաղորդագրություն + + + Տեսահաղորդագրություն + GIF + Փաստաթուղթ + Հարցում: %s + Սթիքեր %s + + Դիտել + Կտրել + Ֆիլտրեր + Նկարել + Տեքստ + Ռետին + + Բնօրինակ + Սև ու սպիտակ + Սեպիա + Վինտաժ + Սառը + Տաք + Պոլարոիդ + Ինվերսիա + + Պահպանել + Չեղարկել + Հետ տալ + Առաջ տալ + Փակել + Վերակայել + Պտտել ձախ + Պտտել աջ + Ավելացնել տեքստ + Խմբագրել տեքստը + Կիրառել + Ջնջել + + Չափը + Մասշտաբ + Գրեք ինչ-որ բան… + Ընտրեք գործիքը՝ խմբագրելու համար + + Չեղարկե՞լ փոփոխությունները + Դուք ունեք չպահպանված փոփոխություններ: Իսկապե՞ս ցանկանում եք չեղարկել: + Չեղարկել + + Կիրառել + Պատրաստ է + Չեղարկել + Ցածր + + Դիտել + Կտրել + Ֆիլտրեր + Տեքստ + Սեղմել + + Միացնել ձայնը + Անջատել ձայնը + Ավելացնել տեքստ + Վիդեոյի որակը + Մոտավոր բիթրեյթը՝ %1$d կբ/վ + + Բնօրինակ + Սև ու սպիտակ + Սեպիա + Վինտաժ + Սառը + Տաք + Պոլարոիդ + Ինվերսիա + + Ֆայլը չի գտնվել + Վիդեո ֆայլը բացակայում է + Չեղարկե՞լ փոփոխությունները + Դուք ունեք չպահպանված փոփոխություններ: + Չեղարկել + + Բեռնվում է… + Վեբ դիտում + Փակել + Ավելի շատ տարբերակներ + + Տարբերակներ + Հետ + Առաջ + Թարմացնել + Գործողություններ + Կարգավորումներ + Պատճենել + Հղումը պատճենված է + Տարածել + Տարածել հղումը + Բրաուզեր + Գտնել էջում + Համակարգչային տարբերակ + Անջատել գովազդը + Տեքստի չափը՝ %1$d%% + + Ապահով + Ոչ ապահով + Անվտանգության տվյալներ + Ոչ ապահով միացում + Այս կայքի հետ միացումը վերծանված է և ապահով: + Այս կայքի հետ միացումը ապահով չէ: Խորհուրդ չի տրվում մուտքագրել անձնական տվյալներ: + Տրված է + Տրողը + Վավեր է մինչև + Անհայտ + + Գտնել էջում… + Նախորդը + Հաջորդը + Փակել որոնումը + + Քննարկում + Ալիք + Բացել քարտեզը + Ուղղություն + Նավիգացիա՝ + Բացել՝ + Բրաուզեր / Այլ + Մեդիա + Անդամներ + Ֆայլեր + Աուդիո + Ձայնային + Հղումներ + GIF-եր + Անդամներ չեն գտնվել + Մեդիա չի գտնվել + Աուդիո չի գտնվել + Ձայնային հաղորդագրություններ չկան + Ֆայլեր չկան + Հղումներ չկան + GIF-եր չկան + ԲՈՏ + Փակ է + ID + + Վիճակագրության վերլուծություն… + Ամփոփում + Անդամներ + Հաղորդագրություններ + Դիտողներ + Ակտիվ ուղարկողներ + Անդամների աճը + Նոր անդամներ + Հաղորդագրությունների բովանդակությունը + Գործողություններ + Ակտիվությունն ըստ օրերի + Ակտիվությունն ըստ շաբաթների + Ամենաակտիվ ժամերը + Դիտումների աղբյուրները + Նոր անդամների աղբյուրները + Լեզուներ + Ամենաակտիվ ուղարկողները + հաղորդ. + Միջին նիշերը՝ %1$d + Ամենաակտիվ ադմինները + գործող. + Ջնջել՝ %1$d | Արգելափ.՝ %2$d + Ամենաակտիվ հրավիրողները + հրավերներ + Ավելացված անդամներ + Բաժանորդներ + Ծանուցումները միացված են + Միջին դիտումները + Միջին տարածումները + Միջին ռեակցիաները + Աճ + Նոր բաժանորդներ + Դիտումներն ըստ ժամերի + Հաղորդագրության փոխազդեցությունները + Instant View-ի փոխազդեցությունները + Հաղորդագրության ռեակցիաները + Վերջին փոխազդեցությունները + Հաղորդագրություն + Սթորի + Գրառման ID + Ցույց տալ քիչ + Ցույց տալ բոլորը (%1$d) + Եկամուտ + Հասանելի մնացորդ + Ընդհանուր մնացորդ + Փոխարժեք + Եկամտի աճ + Ժամային եկամուտ + Վիճակագրությունը բեռնված է + Մեծացնել + Գրաֆիկի կառուցում… + Փոփոխություն չկա + նախորդի համեմատ + Անհայտ վիճակագրություն + Տվյալների դաս՝ %1$s + + Հետ + Տարբերակներ + 10 վայրկյան հետ + 10 վայրկյան առաջ + -%1$d վրկ + +%1$d վրկ + Նախադիտում %1$d + %1$d / %2$d + Բեռնվում է բնօրինակը… + + Ներբեռնել + Ներբեռնել վիդեոն + Պատճենել նկարը + Պատճենել տեքստը + Պատճենել հղումը + Պատճենել հղումը ժամանակով + Վերահասցեագրել + Պահպանել GIF-երում + Պահպանել պատկերասրահում + Պատճենել + Վերսկսել + Դադար + Նվագարկել + Բացել + + Կարգավորումներ + Նվագարկման արագությունը + Մասշտաբի ռեժիմ + Տեղավորել + Մեծացնել + Պտտել էկրանը + «Նկարը նկարում» + Սքրինշոթ + Կրկնել վիդեոն + Անջատել ձայնը + Ենթագրեր + Կողպել կառավարումը + Որակ + Վիդեոյի որակը + Ավտո + Բարձր որակ + Սովորական + + Նվագարկել + Դադար + Հետ տալ + Առաջ տալ + + Սթիքերներ + Էմոջիներ + GIF-եր + + Վերջին սթիքերները + Սթիքերներ + Որոնել սթիքերները + + Վերջին էմոջիները + Ստանդարտ էմոջիներ + Անհատական էմոջիներ + Էմոջիներ + Որոնել էմոջիները + + Վերջին և պահպանված GIF-երը + GIF-եր չեն գտնվել + Որոնել GIF-եր + + Հետ + Մաքրել + Վերջինները + + Պահպանված է պատկերասրահում + Տարածել QR-ը + Ուղարկման սխալ՝ %1$s + + Խմբագրել ալիքը + Խմբագրել խումբը + Ալիքի անվանումը + Խմբի անվանումը + Նկարագրություն + Կարգավորումներ + Հանրային ալիք + Հանրային խումբ + Ավտոմատ թարգմանություն + Թեմաներ + Կառավարում + Ջնջել ալիքը + Ջնջել խումբը + + Որոնում… + Ադմինիստրատորներ + Բաժանորդներ + Անդամներ + Սև ցուցակ + Արդյունքներ չկան + Անդամներ դեռ չկան + + Ադմինի իրավունքները + Անհատական կարգավիճակ + Այս անվանումը տեսանելի կլինի բոլոր անդամներին + Ի՞նչ կարող է անել այս ադմինը + Կառավարել չատը + Գրել հաղորդագրություններ + Խմբագրել հաղորդագրությունները + Ջնջել հաղորդագրությունները + Սահմանափակել անդամներին + Հրավիրել օգտատերերի + Կառավարել թեմաները + Կառավարել տեսաչատերը + Հրապարակել սթորիներ + Խմբագրել սթորիները + Ջնջել սթորիները + Ավելացնել նոր ադմիններ + Մնալ անանուն + + Թույլտվություններ + Ի՞նչ կարող են անել անդամները + Ավելացնել անդամներ + + Հետ + Պահպանել + Մաքրել + Որոնել + Ավելացնել + Խմբագրել + Օգտանուն + Ֆիլտրեր + + Վերջին գործողությունները + Բեռնվել է %d իրադարձություն + Գործողություններ չեն գտնվել + Փորձեք փոխել ֆիլտրերը + Դեռ հասանելի չէ + Օգտատիրոջ ID-ն պատճենված է + + Ֆիլտրել գործողությունները + Վերակայել + Կիրառել + Գործողության տեսակները + Ըստ օգտատերերի + Որոնում… + Օգտատերեր չեն գտնվել + \"%s\"-ի համար արդյունքներ չկան + + Խմբագրումներ + Ջնջումներ + Ամրացումներ + Միացումներ + Դուրս գալը + Հրավերներ + Նշանակումներ + Սահմանափակումներ + Տեղեկություն + Կարգավորումներ + Հղումներ + Վիդեո + + խմբագրել է հաղորդագրությունը + ջնջել է հաղորդագրությունը + ամրացրել է հաղորդագրությունը + ապաամրացրել է հաղորդագրությունը + միացել է չատին + դուրս է եկել չատից + հրավիրել է %s-ին + փոխել է %s-ի թույլտվությունները + փոխել է %s-ի սահմանափակումները + փոխել է չատի անվանումը՝ \"%s\" + փոխել է չատի նկարագրությունը + փոխել է օգտանունը՝ @%s + փոխել է չատի նկարը + խմբագրել է հրավերի հղումը + չեղարկել է հրավերի հղումը + ջնջել է հրավերի հղումը + սկսել է տեսաչատ + ավարտել է տեսաչատը + կատարել է գործողություն՝ %s + + Բնօրինակ հաղորդագրություն: + Նոր հաղորդագրություն: + Ջնջված հաղորդագրություն: + Ամրացված հաղորդագրություն: + Ապաամրացված հաղորդագրություն: + Մինչև՝ %s + Սահմանափակված է ընդմիշտ + Հին + Նոր + Չատի հին նկարը + Չատի նոր նկարը + Սկսած + Մինչև + Թույլտվության փոփոխություններ: + Ընթացիկ թույլտվություններ: + + Հաղորդագրություններ + Մեդիա + Սթիքերներ + Հղումներ + Հարցումներ + Հրավիրել + Ամրացնել + Տեղեկություն + + Ադմին + Սեփականատեր + Սահմանափակված + Արգելափակված + Անդամ + + Նկար + Վիդեո + GIF + Սթիքեր + Փաստաթուղթ + Աուդիո + Ձայնային հաղորդագրություն + Տեսահաղորդագրություն + Կոնտակտ + Հարցում + Տեղադրություն + Վայր + Չաջակցվող հաղորդագրություն + + HH:mm + %1$02d:%2$02d + + %1$.1fՀ + %1$.1fՄ + + Թողնել մեկնաբանություն + %1$d մեկնաբանություն + %1$.1fՀ մեկնաբանություն + %1$.1fՄ մեկնաբանություն + + Վերջնական արդյունքներ + Անանուն + Հանրային + Վիկտորինա + Հարցում + • Բազմակի ընտրություն + Չեղարկել ձայնը + Փակել հարցումը + + %d ձայն + %d ձայն + + + Ավելին + Քվեարկել + Բացատրություն + + Քվեարկողներ + Քվեարկողներ դեռ չկան + Փակել + + Նկար + Վիդեո + Սթիքեր + Ձայնային հաղորդագրություն + Տեսահաղորդագրություն + GIF + Հաղորդագրություն + + Չատի թղթապանակներ + Հետ + Նոր թղթապանակ + Ստեղծել + Խմբագրել թղթապանակը + Պահպանել + Կանխադրված + Անհատական + Ջնջել + Ստեղծեք թղթապանակներ տարբեր չատերի համար և արագ անցեք դրանց միջև: + Թղթապանակներ չկան + Սեղմեք + կոճակը՝ ստեղծելու համար + Բոլոր չատերը + %1$d չատ + Բարձրացնել + Իջեցնել + Թղթապանակի անվանումը + Ընտրել պատկերակը + Ներառված չատերը + Որոնել չատերը… + Չեղարկել + + Telegram Premium + Հետ + Բաժանորդագրվել %s/ամիս + Բացահայտեք բացառիկ հնարավորություններ + + Կրկնակի սահմանաչափեր + Մինչև %1$d ալիք, %2$d թղթապանակ, %3$d ամրացված չատ և այլն: + Ձայնի վերածում տեքստի + Կարդացեք ցանկացած ձայնային հաղորդագրության տեքստային տարբերակը: + Ավելի արագ ներբեռնում + Մեդիա ֆայլերի ներբեռնման առավելագույն արագություն: + Իրական ժամանակի թարգմանություն + Թարգմանեք ամբողջական չատերը մեկ հպումով: + Անիմացիոն էմոջիներ + Օգտագործեք հարյուրավոր անիմացիոն էմոջիներ Ձեր հաղորդագրություններում: + Չատերի առաջադեմ կառավարում + Գործիքներ՝ կանխադրված թղթապանակ սահմանելու և նոր չատերը ավտոմատ արխիվացնելու համար: + Առանց գովազդի + Հանրային ալիքների գովազդները Ձեզ այլևս չեն ցուցադրվի: + Անսահմանափակ ռեակցիաներ + Արձագանքեք հազարավոր էմոջիներով՝ մինչև 3 ռեակցիա մեկ հաղորդագրության համար: + Premium նշան + Հատուկ նշան Ձեր անվան կողքին: + Էմոջի կարգավիճակներ + Ընտրեք հազարավոր էմոջիներից՝ Ձեր անվան կողքին ցուցադրելու համար: + Premium պատկերակներ + Ընտրեք հավելվածի տարբեր պատկերակներից: + + Վերաբեռնել + Պատճենել հղումը + Բացել բրաուզերում + Ավելացնել հիմնական էկրանին + + Փակե՞լ մինի հավելվածը + Դուք ունեք չպահպանված փոփոխություններ: Իսկապե՞ս ցանկանում եք փակել: + Փակել + Չեղարկել + Թույլտվության հարցում + Թույլատրել + Մերժել + Բոտի թույլտվությունները + Օգտագործման պայմաններ + Գործարկելով այս մինի հավելվածը՝ Դուք համաձայնում եք օգտագործման պայմաններին: + Ընդունել և գործարկել + + Որոնել հոդվածում… + Հետ + Մաքրել + Ակնթարթային դիտում + Ավելին + %d դիտում + Պատճենել հղումը + Բացել բրաուզերում + Որոնել + Տեքստի չափը + Դիտել վիդեոն + Նվագարկել անիմացիան + Աուդիո + Անհայտ կատարող + Նվագարկել + Բացել + Փոքրացնել + Մեծացնել + Քարտեզ՝ %1$s, %2$s + + Տարածել պրոֆիլը + Տարածել Ձեր Monogram պրոֆիլի հղումը + Պատճենել հղումը + Պատճենել Ձեր Monogram պրոֆիլի հղումը clipboard-ում + + Թեմայի խմբագրիչ + Ներկապնակի ռեժիմ + Ընտրեք, թե որ ներկապնակն եք խմբագրում: + Լուսավոր + Մութ + Խմբագրվում է՝ %1$s ներկապնակը + Այժմ ակտիվ է՝ %1$s + Շեշտադրում (Accent) + Շեշտադրումը թարմացնում է միայն %1$s ներկապնակը: + Hex շեշտադրում + Կիրառել + Չեղարկել + Ընտրել + Պահպանել + Բեռնել + Երկուսն էլ + Hex + monogram-theme.json + + Թեմայի ֆայլը պահպանված է + Պահպանումը ձախողվեց + Թեման բեռնված է + Անվավեր ֆայլ + Բեռնումը ձախողվեց + + Թեմայի աղբյուրը + Ընտրեք հավելվածի գույների աղբյուրը: Custom-ը Ձեր ներկապնակներն են, Monet-ը՝ Android-ի դինամիկ գույները: + Անհատական թեմա + Օգտագործեք Ձեր սեփական գույները և նախնական կարգավորումները: + Monet + Օգտագործել Material You գույները՝ հիմնված պաստառի վրա (Android 12+): + AMOLED մութ + Օգտագործել բացարձակ սև գույն OLED էկրանների համար: + + Նախնական թեմաներ + Յուրաքանչյուր թեմա ունի լուսավոր և մութ տարբերակներ: + Գույների ձեռքով ընտրություն (%1$s) + %1$s նախադիտում + Ամփոփիչ տեքստ + Գործողություն + Ընտրել %1$s + Երանգ %1$d° + Հագեցվածություն %1$d%% + Պայծառություն %1$d%% + Թափանցիկություն %1$d%% + + Հիմնական (Primary) + Երկրորդային + Երրորդային + Ֆոն + Մակերես (Surface) + Հիմնական կոնտեյներ + Երկրորդային կոնտեյներ + Երրորդային կոնտեյներ + Մակերեսի տարբերակ + Եզրագիծ + + L P + L C + L BG + D P + D C + D BG + + Կապույտ + Կանաչ + Նարնջագույն + Վարդագույն + Ինդիգո + Ցիան + + Դասական + Հավասարակշռված կապույտ՝ հստակ ընթեռնելիությամբ: + + Անտառ + Բնական կանաչ երանգներ՝ հանգիստ հակադրությամբ: + + Օվկիանոս + Սառը կապույտ-ցիան գրադիենտներ: + + Մայրամուտ + Տաք նարնջագույն և մարջանագույն երանգներ: + + Գրաֆիտ + Չեզոք մոխրագույն բազա՝ կապույտ-մոխրագույն շեշտադրումներով: + + Անանուխ + Թարմ անանուխի և փիրուզագույնի համադրություն: + + Ռուբին + Մուգ կարմիր շեշտադրումներ: + + Լավանդա-մոխրագույն + Մեղմ մանուշակագույն-մոխրագույն գունապնակ: + + Ավազ + Բեժ և սաթագույն երանգներ՝ տեսողության համար հարմարավետ: + + Արկտիկա + Սառցե կապույտ և սպիտակ՝ բարձր հստակությամբ: + + Զմրուխտ + Վառ կանաչ գունապնակ՝ ժամանակակից հակադրությամբ: + + Պղինձ + Տաք պղնձագույն թեմա: + + Սակուրա + Մեղմ վարդագույն երանգներ: + + Nord + Հյուսիսային սառը կապույտներ՝ պրոֆեսիոնալ տեսքով: + + Տեսախցիկի և միկրոֆոնի թույլտվությունները պարտադիր են + Մշակվում է… + Մշակման սխալ՝ %1$s + Փակել ձայնագրիչը + Փոխել տեսախցիկը + Ավարտել ձայնագրությունը + REC + ՊԱՏՐԱՍՏ Է + Ժամանակ՝ %1$s + Մասշտաբ՝ %1$.1fx (տիրույթ՝ %2$.1fx - %3$.1fx) + Չեղարկել + + Մաքրե՞լ պատմությունը + Իսկապե՞ս ցանկանում եք մաքրել չատի պատմությունը: Այս գործողությունը հնարավոր չէ չեղարկել: + Մաքրել պատմությունը + Ջնջե՞լ չատը + Իսկապե՞ս ցանկանում եք ջնջել այս չատը: + Ջնջել չատը + Դուրս գա՞լ չատից + Իսկապե՞ս ցանկանում եք դուրս գալ այս չատից: + Դուրս գալ + Ջնջե՞լ ալիքը + Ջնջե՞լ խումբը + Իսկապե՞ս ցանկանում եք ջնջել այս ալիքը: Բոլոր հաղորդագրությունները կջնջվեն: + Իսկապե՞ս ցանկանում եք ջնջել այս խումբը: Բոլոր հաղորդագրությունները կջնջվեն: + Ջնջե՞լ %1$d չատերը + Իսկապե՞ս ցանկանում եք ջնջել ընտրված չատերը: + Ջնջել չատերը + Արգելափակե՞լ օգտատիրոջը + Իսկապե՞ս ցանկանում եք արգելափակել այս օգտատիրոջը: Նա այլևս չի կարողանա Ձեզ հաղորդագրություն գրել: + Արգելափակել + Ապաարգելափակե՞լ օգտատիրոջը + Իսկապե՞ս ցանկանում եք ապաարգելափակել այս օգտատիրոջը: + Ապաամրացնե՞լ հաղորդագրությունը + Իսկապե՞ս ցանկանում եք ապաամրացնել այս հաղորդագրությունը: + Ապաամրացնել + Իսկապե՞ս ցանկանում եք մաքրել վերջին սթիքերները: + Մաքրել սթիքերները + Իսկապե՞ս ցանկանում եք մաքրել վերջին էմոջիները: + Մաքրել էմոջիները + Ավելացնել կոնտակտներում + Հեռացնել կոնտակտներից + Նշել որպես կարդացված + Նշել որպես չկարդացված + Խմբագրել + Վերադասավորել + Ջնջել + \ No newline at end of file From 66d8d6dc69ff6bc68812388d985f991daab307c5 Mon Sep 17 00:00:00 2001 From: CJ-1347 Date: Sun, 5 Apr 2026 17:09:57 -0400 Subject: [PATCH 24/53] Added Spanish translation (#156) --- app/src/main/res/values-es/strings.xml | 44 + .../src/main/res/values-es/string.xml | 1918 +++++++++++++++++ 2 files changed, 1962 insertions(+) create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 presentation/src/main/res/values-es/string.xml diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..62586cc7 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,44 @@ + + + Desbloquear MonoGram + Inicia sesión usando tus credenciales biométricas + Usa contraseña + Ingresa Contraseña + Tus Mensajes están protegidos + Contraseña Invalida + Desbloqueo Biométrico + + + Detalles de Proxy + Añadir y conectar a este servidor proxy + Servidor + Puerto + Tipo + Desconocido + Cancelar + Conectar + + + Unirse + Canal + Grupo + + %d member + %d members + + + + Seleccionar un ajuste + Selecciona un chat para comenzar + + + Registro de Cierres + Copiado al portapapeles + Compartir Registro de Cierre + Compartir + Copiar + Reiniciar app + Detalles + Algo salio mal, por favor copia o comparte los registros para reportar este problema a los desarrolladores. + + diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml new file mode 100644 index 00000000..95f55f71 --- /dev/null +++ b/presentation/src/main/res/values-es/string.xml @@ -0,0 +1,1918 @@ + + + Confirmación + ¿Realmente quieres autorizar este dispositivo? + Sí, inicia sesión + Cancelar + + Escanear QR + Dispositivos + Vincular un dispositivo + + Este dispositivo + Solicitudes de inicio de sesión + Sesiones activas + + Navegar hacia atrás + Cerrar escáner + Icono del escáner QR + + + Conectando a Telegram… + + + + Acerca de + Atrás + MonoGram + Versión %1$s + Términos de Servicio + Lee nuestros términos y condiciones + Licencias de Código Abierto + Software utilizado en MonoGram + GitHub + Ver código fuente + Versión de TDLib + %1$s (%2$s) + Comunidad + Chat de Telegram + Únete a nuestra comunidad para discutir funciones y obtener ayuda + Canal de Telegram + Mantente actualizado con las últimas noticias y anuncios + Apoyar MonoGram + Apoya el desarrollo y ayúdanos a mantener el proyecto vivo + Mantenedores + Desarrollador + Diseñador de Iconos y Logo + MonoGram es un cliente de Telegram no oficial creado con Material Design 3 + © 2026 MonoGram + + + Buscar Actualizaciones + Buscando... + Actualización Disponible: %1$s + Estás actualizado + Actualización Lista + Error de Actualización + Toca para buscar una nueva versión + Conectando al servidor + Nueva versión disponible para descargar + Estás usando la última versión + Toca para instalar la actualización + Descargando Actualización... + %1$d%% + Novedades en %1$s + Descargar Actualización + Cancelar + Cargando... + + + Tu Teléfono + Verificación + Contraseña + Configuración de Proxy + Tu Número de Teléfono + Por favor, confirma tu código de país e ingresa tu número de teléfono. + País + Código + Número de Teléfono + 000 00 00 + Continuar + Seleccionar País + Buscar país o código... + Hemos enviado el código a la aplicación de Telegram en tu otro dispositivo. + + Hemos enviado el código por SMS. + Te estamos llamando con el código. + Hemos enviado el código a %1$s. + Hemos enviado el código de verificación. + Confirmar + Reenviar código en %1$s + Reenviar por SMS + Reenviar por Llamada + Reenviar código + ¿Número incorrecto? + Verificación de Dos Pasos + Tu cuenta está protegida con una contraseña adicional. + Contraseña + Desbloquear + Pegar + Error de Autenticación + Descartar + + + Por favor, espera un momento + ¿Está tardando demasiado? + Restablecer Conexión + + + + Reenviar a... + %1$d chats seleccionados + Enviar + Chats Archivados + Nuevo Chat + Reciente + Limpiar Todo + Chats y contactos + Búsqueda global + Mensajes + Mostrar más + Buscar conversaciones… + Esperando red… + Conectando… + Actualizando… + Conectando a proxy… + Proxy habilitado + Proxy + Foro + Spoiler + Borrador: + Sin publicaciones aún + Sin mensajes aún + Fijado + Menciones + Oculto de la lista principal + Sin chats aún + Iniciar una nueva conversación + Mini App + + + MonoGram Dev + Añadir Cuenta + Inicia sesión en otra cuenta + Mi Perfil + Ver tu perfil + Mensajes Guardados + Almacenamiento en la nube + Configuración + Configuración de la aplicación + Actualización Disponible + Nueva versión %1$s disponible + Descargando actualización... %1$d%% + Actualización lista para instalar + Ayuda y Comentarios + Preguntas frecuentes y soporte + Política de Privacidad + MonoGram Dev para Android v%1$s + Usuario Desconocido + Sin información + Mostrar cuentas + + + Buscar mensajes... + Limpiar + Silenciado + Verificado + Patrocinador + Desmutear + Mutear + Filtrar Anuncios + Lista Blanca del Canal + Copiar Enlace + Limpiar Historial + Eliminar Chat + Reportar + UNIRSE + Desplazarse hacia abajo + Este tema está cerrado + Adjuntar + + + ¿Eliminar mensaje? + ¿Eliminar %1$d mensajes? + ¿Estás seguro de que quieres eliminar este mensaje? + ¿Estás seguro de que quieres eliminar estos mensajes? + Eliminar para todos + Eliminar para mí + + ¿Por qué estás reportando esto? + Tu reporte es anónimo. Revisaremos el historial del chat para garantizar la seguridad. + Detalles del reporte + Describe el problema… + Enviar Reporte + Spam + Contenido comercial no deseado o estafas + Violencia + Amenazas o glorificación de la violencia + Pornografía + Contenido inapropiado o lenguaje explícito + Seguridad infantil + Contenido que implica daño a menores + Derechos de Autor + Usar propiedad intelectual de otro + Suplantación de Identidad + Pretender ser otra persona o un bot + Drogas Ilegales + Promover la venta o uso de sustancias prohibidas + Violación de Privacidad + Compartir información de contacto privada o direcciones + Ubicación Irrelevante + Contenido no relevante para este lugar específico + Otro + Algo más que viola nuestros términos + + Restringir Usuario + Enviar Mensajes + Enviar Multimedia + Enviar Stickers y GIFs + Enviar Encuestas + Incrustar Enlaces + Fijar Mensajes + Cambiar Información del Chat + Restringir hasta + Para Siempre + Seleccionar Fecha + Seleccionar Hora + Restringir + + Responder + Copiar + Fijar + Desfijar + Reenviar + Seleccionar + Más + Eliminar + Ver Comentarios + Guardar en Descargas + Cocoon + Resumen + Traducir + Generado con Telegram Cocoon + Restaurar Texto Original + Restringir Usuario + Editado + Leído + Vistas + + + + Desconocido + %1$d miembros + %1$s, %2$d en línea + No implementado + Búsqueda no implementada + Compartir no implementado + Bloquear no implementado + Eliminar no implementado + Estadísticas + Ingresos + Compartir + Editar + Bloquear Usuario + Salir + Abrir + Mensaje + Unirse + Reportar + Código QR + Añadir + Foto Personal + Esta foto solo es visible para ti + Abrir Mini App + Lanzar aplicación web del bot + Aceptar TOS + Revisar y aceptar términos de servicio del bot + Permisos del Bot + Gestionar permisos para este bot + Nombre de Usuario + Enlace + Enlace de Invitación + Información del Bot + Descripción + Biografía + Fecha de Nacimiento + Ubicación + Horario de Atención + Acciones Recientes + Ver registro de eventos del chat + Ver estadísticas detalladas del chat + Ver estadísticas de ingresos del chat + Te añadió a contactos + No te añadió a contactos + Guardado en tus contactos + No guardado en tus contactos + Historias de perfil + Puedes publicar historias desde tu perfil + Fondo de chat + Puedes establecer fondos personalizados + Notas de voz y video + El envío de notas de voz y video está restringido + Modo Lento + Los miembros pueden enviar un mensaje cada %1$s + Contenido Protegido + El reenvío y guardado están restringidos + Reenviós Privados + Los mensajes reenviados ocultan el enlace de perfil + Estadísticas del Chat + %1$d administradores + %1$d restringidos + %1$d baneados + Información + Notificaciones + Eliminar mensajes automáticamente + Configuración + Guardar + Spam + Violencia + Pornografía + Abuso Infantil + Derechos de Autor + Otro + Cerrar + Al lanzar esta Mini App, aceptas los Términos de Servicio y Política de Privacidad. El bot podrá acceder a tu información básica de perfil. + + Aceptar y Lanzar + + + Código QR + Compartir + MonoGram es un proyecto gratuito y de código abierto. Tu apoyo nos ayuda a mantenerlo vivo y desarrollar nuevas funciones. + + La insignia de patrocinador con un corazón está disponible desde el nivel de soporte Avanzado o una contribución equivalente de 150 RUB (aproximadamente $1.96). + + Apoyar en Boosty + Tal vez Después + Editar Perfil + Presiona largo para enmascarar + Mantén presionado para mostrar, toca para copiar + Tu ID + Cambia tu nombre, biografía y foto de perfil + Habilitar enlaces t.me + Abrir enlaces de Telegram en la aplicación + General + Configuración del Chat + Temas, tamaño de texto, reproductor de video + Privacidad y Seguridad + Código de acceso, sesiones activas, privacidad + Notificaciones y Sonidos + Mensajes, grupos, llamadas + Datos y Almacenamiento + Uso de red, descarga automática + Ahorro de Energía + Configuración de uso de batería + Carpetas de Chat + Organiza tus chats + Stickers y Emoji + Gestiona packs de stickers y packs de emoji + Dispositivos vinculados + Idioma + Inglés + Configuración de Proxy + MTProto, SOCKS5, HTTP + Telegram Premium + Desbloquea funciones exclusivas + Ayúdanos a desarrollar el proyecto + Apoya MonoGram + Este usuario apoya el proyecto y nos ayuda a seguir mejorando + Versión e información de MonoGram + Depuración + Opciones de depuración + Mostrar hoja de patrocinador + Abrir panel inferior de información de patrocinador + Forzar sincronización de patrocinador + Obtener IDs de patrocinador del canal ahora + Cerrar Sesión + Desconectar de la cuenta + + + + Nombres de Usuario + Reintentar en %1$ds + Nombres de Usuario Activos + Nombres de Usuario Deshabilitados + Nombres de Usuario Coleccionables + Listo + OK + Seleccionar Hora de Inicio + Seleccionar Hora de Fin + Horario de Trabajo + Días de Trabajo + Rango de Hora + De + Para + Ubicación del Negocio + Dirección + Confirmar Ubicación + Editar Perfil + Nombre (Requerido) + Apellido (Opcional) + Biografía + Cualquier detalle como edad, ocupación o ciudad. Ejemplo: Diseñador de 23 años de San Francisco. + + Nombre de Usuario + Puedes elegir un nombre de usuario en Telegram. Si lo haces, las personas podrán encontrarte por este nombre de usuario y contactarte sin necesidad de tu número de teléfono. + + Tu Cumpleaños + Cumpleaños + Telegram Business + ID del Canal Vinculado + Biografía Empresarial + Dirección del Negocio + Geo de Ubicación + Horario de Atención + Como usuario Premium, puedes añadir el enlace de un canal y establecer detalles empresariales en tu perfil + + No establecido + (%1$d días) + L + M + M + J + V + S + D + + + Privacidad y Seguridad + Privacidad + Usuarios Bloqueados + %1$d usuarios + Ninguno + Número de Teléfono + Última Vista y En Línea + Fotos de Perfil + Mensajes Reenviados + Llamadas + Grupos y Canales + Seguridad + Bloqueo por Código de Acceso + Activado + Desactivado + Desbloquear con Biometría + Usa huella dactilar o cara para desbloquear + Sesiones Activas + Gestiona tus dispositivos con sesión iniciada + Contenido Sensible + Deshabilitar filtrado + Mostrar contenido sensible en canales públicos en todos tus dispositivos. + Avanzado + Eliminar Mi Cuenta + Si estás inactivo por %1$s + Eliminar Cuenta Ahora + Elimina permanentemente tu cuenta y todos tus datos + Auto-destruirse si está Inactivo Por... + Eliminar Cuenta + ¿Estás seguro de que quieres eliminar tu cuenta? Esta acción es permanente y no se puede deshacer. + + Todos + Mis Contactos + Nadie + 1 mes + 3 meses + 6 meses + 1 año + 18 meses + 2 años + %1$d meses + %1$d días + El usuario solicitó la eliminación + + + Configuración de Proxy + Actualizar Pings + Añadir Proxy + Conexión + Cambio Inteligente + Usar automáticamente el proxy más rápido + Preferir IPv6 + Usar IPv6 cuando esté disponible + Deshabilitar Proxy + Conectado directamente + Cambiar a conexión directa + Telega Proxy + Se ha acusado a Telega Proxy de interceptar tráfico. MTProto protege los datos de los mensajes de la intercepción, pero usa este proxy bajo tu propio riesgo. Más info: t.me/te[...] + + Habilitar Telega Proxy + Auto-obtener y cambiar al mejor + Actualizar Lista + Obtener últimos proxys de la comunidad + Tus Proxys + Limpiar Sin Conexión + Eliminar Todo + Eliminar Proxys Sin Conexión + Esto eliminará todos los proxys actualmente sin conexión. ¿Continuar? + Eliminar Todos los Proxys + Esto eliminará todos los proxys configurados de la aplicación. ¿Continuar? + Sin proxys añadidos + Eliminar Proxy + ¿Estás seguro de que quieres eliminar el proxy %1$s? + Nuevo Proxy + Editar Proxy + Dirección del Servidor + Puerto + Secreto (Hex) + Nombre de Usuario (Opcional) + Contraseña (Opcional) + Guardar Cambios + Probar + Resultado de la Prueba + Eliminar + Verificando... + Sin Conexión + %1$dms + + + Tus Nombres de Usuario + Más opciones + + + Notificaciones y Sonidos + Notificaciones de Mensajes + Chats Privados + Grupos + Canales + Configuración de Notificaciones + Vibración + Prioridad + Repetir Notificaciones + Mostrar Solo Remitente + Ocultar contenido del mensaje en notificaciones + Servicio Push + Proveedor de Push + Servicio Keep-Alive + Mantener la aplicación ejecutándose en segundo plano para notificaciones confiables + + Ocultar Notificación en Primer Plano + Oculta la notificación del servicio después de iniciarse. Puede llevar a la terminación del servicio por el sistema + + Notificaciones en la Aplicación + Sonidos en la Aplicación + Vibración en la Aplicación + Vista Previa en la Aplicación + Eventos + Contacto Se Unió a Telegram + Mensajes Fijados + Restablecer Todas las Notificaciones + Deshacer toda configuración personalizada de notificaciones para todos tus contactos y grupos + + Patrón de Vibración + Prioridad de Notificación + %1$s, %2$d excepciones + Predeterminado + Corto + Largo + Deshabilitado + Bajo + Predeterminado + Alto + Nunca + Cada %1$d minutos + Cada 1 hora + Cada %1$d horas + Firebase Cloud Messaging + Sin GMS (Servicio de Fondo) + + + + Configuración del Chat + Apariencia + Tamaño de texto del mensaje + Espaciado de letras del mensaje + Redondeo de burbuja + Tamaño del sticker + Restablecer + Fondo de Pantalla del Chat + Restablecer Fondo de Pantalla + Estilo de Emoji + Tema + Modo Nocturno + Sistema + Claro + Oscuro + Programado + Automático + Comportamiento actual + Usando: %1$s + Umbral de brillo: %1$d%% + Cambiar a tema oscuro cuando el brillo de la pantalla esté por debajo de este nivel. + + Colores Dinámicos + Colores Dinámicos + Usar colores del sistema para el tema de la aplicación + Datos y Almacenamiento + Comprimir Fotos + Reducir tamaño de foto antes de enviar + Comprimir Videos + Reducir tamaño de video antes de enviar + Reproductor de Video + Habilitar Gestos + Deslizar para controlar volumen y brillo + Doble Toque para Buscar + Doble toque en los bordes del video para buscar + Duración de Búsqueda + Habilitar Zoom + Pellizcar para hacer zoom en el reproductor de video + Lista de Chat + Fijar Chats Archivados + Mantener chats archivados en la parte superior de la lista + Mostrar Siempre Archivo Fijado + Mantener archivo fijado visible incluso al desplazarse + Mostrar Vista Previa de Enlaces + Mostrar vistas previas de enlaces en mensajes + Arrastrar para Volver + Deslizar desde el borde izquierdo para volver + Dos Líneas + Tres Líneas + Mostrar Fotos + Mostrar fotos de perfil en la lista de chat + Experimental + AdBlock para Canales + Ocultar publicaciones patrocinadas en canales + Multimedia Reciente + Limpiar Stickers Recientes + Eliminar todos los stickers usados recientemente + Limpiar Emojis Recientes + Eliminar todos los emojis usados recientemente + Eliminar Pack de Emoji + ¿Eliminar Pack %1$s? + Esto eliminará el pack de emoji descargado de tu dispositivo. Puedes descargarlo de nuevo más tarde. + + Editar tema personalizado + Seleccionado + Apple + Twitter + Windows + Catmoji + Noto + Sistema + + + Datos y Almacenamiento + Uso de disco y red + Uso de Almacenamiento + Gestiona tu caché local + Uso de Red + Ver datos enviados y recibidos + Descarga automática de multimedia + Cuando uses datos móviles + Cuando esté conectado a Wi-Fi + Cuando esté en roaming + Descargar Archivos Automáticamente + Descargar automáticamente archivos entrantes + Descargar Stickers Automáticamente + Descargar automáticamente stickers + Descargar Notas de Video Automáticamente + Descargar automáticamente notas de video + Reproducción automática de multimedia + GIFs + Reproducir automáticamente GIFs en la lista de chat y chats + Videos + Reproducir automáticamente videos en chats + Habilitado + Deshabilitado + + + Ahorro de Energía + Batería + Modo de Ahorro de Energía + Reduce la actividad en segundo plano y animaciones para ahorrar batería + Optimizar Uso de Batería + Limitar agresivamente el trabajo en segundo plano y liberar wake locks + Wake Lock + Mantener CPU activa para tareas en segundo plano. Deshabilitar para ahorrar batería + Animaciones + Animaciones de Chat + Deshabilitar animaciones en chat para ahorrar batería + Segundo Plano + Deshabilitar esto reducirá el uso de energía pero puede retrasar notificaciones en segundo plano + + + + + Stickers y Emoji + Stickers + Emoji + Stickers Recientes + Packs de Stickers + Stickers Archivados + Añadir stickers propios + Crea tus propios packs de stickers usando el bot @Stickers + No hay packs de stickers instalados + No se encontraron stickers para \"%1$s\" + Emojis Recientes + Packs de Emoji + Emojis Archivados + Añadir emoji propios + Crea tus propios packs de emoji usando el bot @Stickers + Limpiar Emojis Recientes + Eliminar todos los emojis usados recientemente + No hay packs de emoji instalados + No se encontraron emojis para \"%1$s\" + Buscar packs + Buscar + + %1$d sticker + %1$d stickers + + + %1$d emoji + %1$d emojis + + Máscaras + Emojis Personalizados + Oficial + Enlace copiado al portapapeles + + + Uso de Red + Restablecer Estadísticas + Estadísticas de Red + Realiza un seguimiento de cuántos datos usas. Deshabilitar puede reducir el uso de espacio en disco. + + Estadísticas de Red Deshabilitadas + El seguimiento de uso de red está actualmente desactivado. Habilítalo usando el interruptor anterior para ver cuántos datos estás usando en redes móviles, Wi-Fi y roaming. + + Uso Total + Enviado + Recibido + Resumen + Uso de la Aplicación + No hay datos de uso registrados + No hay estadísticas disponibles + Móvil + Wi-Fi + Roaming + Otro + + + Uso de Almacenamiento + Limpiar Todo el Caché • %1$s + Incluye fotos, videos, documentos, stickers y GIFs de todos los chats. + + Límite de Caché + Limpiar Caché Automáticamente + Optimizador de Almacenamiento + Optimización de almacenamiento en segundo plano + Uso Detallado + Almacenamiento Limpio + No se encontraron archivos en caché. + Limpiar Caché + ¿Estás seguro de que quieres limpiar el caché para \"%1$s\"? Esto liberará %2$s. + + Limpiar Todo el Caché + Esto eliminará todos los archivos de caché de multimedia de todos los chats. ¿Estás seguro? + + Ilimitado + Nunca + Cada Día + Cada Semana + Cada Mes + Total Usado + %1$d archivos + %1$d archivos + + + ¿Quién puede añadirme a %1$s? + ¿Quién puede llamarme? + ¿Quién puede ver mi última hora vista? + ¿Quién puede ver mis fotos de perfil? + ¿Quién puede ver mi biografía? + ¿Quién puede añadir un enlace a mi cuenta al reenviar mis mensajes? + ¿Quién puede añadirme a grupos y canales? + ¿Quién puede ver mi número de teléfono? + ¿Quién puede encontrarme por mi número? + Los usuarios que añadan tu número a sus contactos lo verán en Telegram solo si están autorizados por la configuración anterior. + + Añadir excepciones + Permitir Siempre + Nunca Permitir + %1$d usuarios/chats + (eliminado) + Miembros del chat + Búsqueda por Número de Teléfono + No hay usuarios bloqueados + Los usuarios bloqueados no podrán contactarte y no verán tu hora de última vista. + + Desbloquear + Bloquear Usuario + Buscar usuarios + No se encontraron usuarios + + + Cambiar Código de Acceso + Establecer Código de Acceso + Tu aplicación está protegida actualmente con un código de acceso. Ingresa uno nuevo para cambiarlo. + + Ingresa un código de acceso de 4 dígitos para bloquear la aplicación y proteger tu privacidad. + Código de Acceso + Código de Acceso Actual + Guardar Código de Acceso + Desactivar Código de Acceso + Verificar Código de Acceso + Ingresa tu código de acceso actual antes de cambiarlo o desactivarlo. + Código de acceso incorrecto. + + + Añadir Palabra Clave + Habilitar AdBlock + Canales en Lista Blanca + %1$d canales permitidos + Cargar palabras clave base + Importar palabras clave de anuncios comunes desde activos + Copiar todas las palabras clave + Copiar lista actual al portapapeles + Limpiar todas las palabras clave + Eliminar todas las palabras clave de la lista + Palabras clave para ocultar publicaciones + No hay palabras clave añadidas + Toca el botón + para añadir palabras clave para filtrar + Añadir Palabras Clave + Ingresa palabras clave separadas por comas o nuevas líneas para filtrar publicaciones del canal. + + p. ej. #promo, publicidad, реклама + Añadir a la Lista + Las publicaciones de estos canales no serán filtradas + No hay canales en lista blanca + Eliminar + + + visto hace poco + visto hace un momento + + visto hace %1$d minuto + visto hace %1$d minutos + + visto a las %1$s + visto ayer a las %1$s + visto %1$s + visto hace una semana + visto hace un mes + visto hace mucho tiempo + en línea + desconectado + bot + Nombre de Usuario + %1$d stickers + Archivado + Añadir + Dearchivar + Archivar + Eliminar pack + + + Nuevo Mensaje + Añadir Miembros + Nuevo Grupo + Nuevo Canal + %1$d contactos + %1$d / 200000 + %1$d seleccionados + Buscar contactos... + No se encontraron contactos + Sin resultados para \"%1$s\" + Ordenado por última hora vista + Nuevo Grupo + Nuevo Canal + Soporte + Los canales son una herramienta para transmitir tus mensajes a audiencias ilimitadas. + Detalles del Canal + Nombre del Canal + Descripción (opcional) + Eliminar Mensajes Automáticamente + Desactivado + 1 día + 2 días + 3 días + 4 días + 5 días + 6 días + 1 semana + 2 semanas + 3 semanas + 1 mes + 2 meses + 3 meses + 4 meses + 5 meses + 6 meses + 1 año + Puedes proporcionar una descripción opcional para tu canal. Cualquier persona puede unirse a tu canal si tiene un enlace. + Proporciona un nombre y una foto opcional para tu nuevo grupo. + Detalles del Grupo + Nombre del Grupo + Por favor, ingresa un nombre de grupo + Por favor, ingresa un nombre de canal + Abrir Perfil + Editar Nombre + Eliminar Contacto + Editar Contacto + Nombre + Apellido + ¿Eliminar contacto? + ¿Estás seguro de que quieres eliminar %1$s de tus contactos? + Eliminar automáticamente nuevos mensajes enviados en este grupo después de un período de tiempo. + Añadir foto + Cambiar foto + + + Permisos Requeridos + Para proporcionar la mejor experiencia, MonoGram necesita los siguientes permisos. + Notificaciones + Recibe notificaciones sobre nuevos mensajes + Permitir + Estado del Teléfono + Gestionar estados del dispositivo para una mejor experiencia de usuario + Optimización de Batería + Garantizar operación confiable en segundo plano + Deshabilitar + Cámara + Tomar fotos y grabar mensajes de video + Micrófono + Grabar mensajes de voz y video + Ubicación + Comparte tu ubicación y ve usuarios cercanos + + + Limpiar selección + Fijar + + + Comandos del Bot + Selecciona un comando para enviar al bot + + + Lista de Chat + Konata Izumi + ¡No soy baja, solo tengo la impresionante concentración! 🍫 Además, he decidido convertirme en una profesional del sueño 😴 + 12:45 + Kagami Hiiragi + ¡No olvides la tarea! Se vence mañana por la mañana y es bastante difícil. + 11:20 + + + Vista Previa + Yo + ¡No soy baja, solo tengo la impresionante concentración! 🍫\nAdemás, he decidido convertirme en una profesional del sueño 😴 + Eso es lo que siempre dices cuando no puedes alcanzar el estante superior... 🙄\nMira esto: esto + ¡Deja de exponerme! 😤✨\nSolo usaré mi arma secreta: 💯 pura pereza + ¡Es súper efectivo! 😵‍💫 + Hoy + Konata + + %1$d suscriptores + Hilo + + + Eliminar Grabación + < Deslizar para cancelar + Enviar Grabación + Bloquear Grabación + Deslizar hacia arriba + + + Añadir un título… + Mensaje + No se permite enviar mensajes + + + Foto + Video + Sticker + Mensaje de voz + Mensaje de video + GIF + Ubicación + Mensaje + + + + + Cancelar respuesta + Editar mensaje + Cancelar edición + Adjuntar %1$d elementos + Enviar multimedia + Cancelar + Copiar + Pegar + Cortar + Seleccionar todo + Aplicar + Hecho + Actualizar + Editor a pantalla completa + Editor + Enviar silenciosamente + Programar mensaje + Mensajes programados + Mensajes programados (%1$d) + No hay mensajes programados + Total programado: %1$d + Próximo envío: %1$s + Editable ahora: %1$d + ID: %1$d + Editar + Enviar + Eliminar + Gestionar + Acceso limitado a fotos habilitado + Solo las fotos y vídeos seleccionados son visibles. + Adjuntos + Otras fuentes + Todos + Fotos + Vídeos + Todas las carpetas + Capturas de pantalla + %1$d seleccionados + Listo para adjuntar + Permitir acceso a multimedia + Concede acceso a fotos y vídeos para adjuntar archivos en el chat. + Conceder acceso + %1$d caracteres • %2$d bloques de formato + %1$d bloques de formato + Selecciona texto para aplicar formato enriquecido como en Telegram + %1$d/%2$d + Deshacer + Rehacer + Vista previa + Editar + Markdown: activado + Markdown: desactivado + A+ + A- + Fragmentos + Guardar como fragmento + Título del fragmento + Sin fragmentos todavía + Buscar + Reemplazar + Reemplazar todo + %1$d de %2$d coincidencias + Sin coincidencias + %1$d palabras + ~%1$d min de lectura + Borrador guardado automáticamente + Insertar + Anterior + Siguiente + + Negrita + Cursiva + Subrayado + Tachado + Spoiler + Código + Monoespaciado + Enlace + Mención + Emoji + Limpiar + Añadir enlace + URL + Lenguaje de código + Lenguaje (ej. kotlin) + + + + Comandos + + + está escribiendo + está grabando vídeo + está grabando nota de voz + está enviando foto + está enviando vídeo + está enviando archivo + está eligiendo un sticker + está jugando + Alguien + y + y %d más + están escribiendo + + %d persona está escribiendo + %d personas están escribiendo + + + + Fotos + Vídeos + Documentos + Stickers + Música + Mensajes de voz + Mensajes de vídeo + Otros archivos + Otros / Caché + Chat %d + + + Llamadas + + + Esperando nuevos mensajes + Detener + Servicio en segundo plano + Notificación sobre la aplicación en ejecución en segundo plano + + + 📷 Foto + 📹 Vídeo + 🎤 Mensaje de voz + 🧩 Sticker + 📎 Documento + 🎵 Audio + GIF + 🎬 Mensaje de vídeo + 👤 Contacto + 📊 Encuesta + 📍 Ubicación + 📞 Llamada + 🎮 Juego + 💳 Factura + 📚 Historia + 📌 Mensaje fijado + Mensaje + Bot + En línea + Desconectado + Visto hace poco + Visto hace %d minuto + Visto hace %d minutos + Visto a las %s + Visto ayer a las %s + Visto el %s + Visto recientemente + Visto hace una semana + Visto hace un mes + + + Mensajes fijados + Mensaje fijado + Mostrar todos fijados + Desfijar + Cerrar + + %d mensaje + %d mensajes + + + + Mensaje de vídeo + GIF + Documento + Encuesta: %s + Sticker %s + + + Ver + Recortar + Filtros + Dibujar + Texto + Borrador + + + Original + B&N + Sepia + Vintage + Frío + Cálido + Polaroid + Invertir + + + Guardar + Cancelar + Deshacer + Rehacer + Cerrar + Restablecer + Girar a la izquierda + Girar a la derecha + Añadir texto + Editar texto + Aplicar + Eliminar + + + Tamaño + Zoom + Escribe algo... + Selecciona una herramienta para empezar a editar + + + ¿Descartar cambios? + Tienes cambios sin guardar. ¿Estás seguro de que quieres descartarlos? + Descartar + + + Aplicar + Hecho + Cancelar + Bajo + + + Ver + Recortar + Filtros + Texto + Comprimir + + + Activar sonido + Silenciar + Añadir overlay de texto + Calidad de vídeo + Bitrate estimado: %1$d kbps + + + Original + B&N + Sepia + Vintage + Frío + Cálido + Polaroid + Invertir + + + Archivo no encontrado + Archivo de vídeo faltante + ¿Descartar cambios? + Tienes cambios sin guardar. ¿Estás seguro de que quieres descartarlos? + Descartar + + + Cargando… + Vista web + Cerrar + Más opciones + + + Opciones + Atrás + Adelante + Actualizar + Acciones + Configuración + Copiar + Enlace copiado + Compartir + Compartir enlace mediante + Navegador + Buscar + Sitio de escritorio + Bloquear anuncios + Tamaño de texto: %1$d%% + + + Seguro + No seguro + Información de seguridad + Conexión no segura + La conexión con este sitio está cifrada y es segura. + La conexión con este sitio no es segura. No deberías introducir información sensible (como contraseñas o tarjetas de crédito) ya que podría ser robada por atacantes. + Emitido para + Emitido por + Válido hasta + Desconocido + + + Buscar en la página… + Anterior + Siguiente + Cerrar búsqueda + + + Discusión + Canal + Abrir Mapas + Indicaciones + Navegar con + Abrir con + Navegador / Otro + Multimedia + Miembros + Archivos + Audio + Voz + Enlaces + GIFs + No se encontraron miembros + No se encontró multimedia + No se encontró audio + No se encontraron mensajes de voz + No se encontraron archivos + No se encontraron enlaces + No se encontraron GIFs + BOT + Cerrado + ID + + + Analizando estadísticas... + Resumen + Miembros + Mensajes + Visualizadores + Remitentes activos + Crecimiento de miembros + Nuevos miembros + Contenido de mensaje + Acciones + Actividad por día + Actividad por semana + Horas principales + Visualizaciones por fuente + Nuevos miembros por fuente + Idiomas + Remitentes principales + msgs + Promedio de caracteres: %1$d + Principales administradores + acciones + Elim: %1$d | Ban: %2$d + Principales invitadores + invitaciones + Miembros añadidos + Suscriptores + Notificaciones habilitadas + Visualizaciones promedio de msg + Comparticiones promedio de msg + Reacciones promedio + Crecimiento + Nuevos suscriptores + Visualizaciones por hora + Interacciones de mensajes + Interacciones de vista instantánea + Reacciones de mensajes + Interacciones recientes + Mensaje + Historia + ID de publicación + Mostrar menos + Mostrar todo (%1$d) + Ingresos + Saldo disponible + Saldo total + Tipo de cambio + Crecimiento de ingresos + Ingresos por hora + Información cargada + Ampliar + Renderizando gráfico... + Sin cambios + vs anterior + Tipo de estadística desconocido + Clase de datos: %1$s + + + Atrás + Opciones + Rebobinar 10 segundos + Adelantar 10 segundos + -%1$ds + +%1$ds + Miniatura %1$d + %1$d / %2$d + Cargando original... + + + Descargar + Descargar vídeo + Copiar imagen + Copiar texto + Copiar enlace + Copiar enlace con hora + Reenviar + Guardar en GIFs + Guardar en galería + Copiar al portapapeles + Reiniciar + Pausar + Reproducir + Desbloquear + + + Configuración + Velocidad de reproducción + Modo de escala + Ajustar + Zoom + Rotar pantalla + Imagen en imagen + Captura de pantalla + Reproducir vídeo en bucle + Silenciar audio + Subtítulos + Bloquear controles + Calidad + Calidad de vídeo + Automático + Alta resolución + Normal + + + Reproducir + Pausar + Rebobinar + Adelantar + + + Stickers + Emojis + GIFs + + + Stickers recientes + Stickers + Buscar stickers + + + Emojis recientes + Emojis estándar + Emojis personalizados + Emojis + Buscar emojis + + + GIFs recientes y guardados + No se encontraron GIFs + Buscar GIFs + + + Atrás + Limpiar + Reciente + + + Guardado en galería + Compartir código QR + Error al enviar: %1$s + + + Editar canal + Editar grupo + Nombre del canal + Nombre del grupo + Descripción + Configuración + Canal público + Grupo público + Traducción automática + Temas + Gestión + Eliminar canal + Eliminar grupo + + + Buscar... + Administradores + Suscriptores + Miembros + Lista negra + No se encontraron resultados + Sin miembros todavía + + + Derechos de administrador + Título personalizado + Este título será visible para todos los miembros del chat + ¿Qué puede hacer este administrador? + Gestionar chat + Publicar mensajes + Editar mensajes + Eliminar mensajes + Restringir miembros + Invitar usuarios + Gestionar temas + Gestionar videollamadas + Publicar historias + Editar historias + Eliminar historias + Añadir nuevos administradores + Permanecer anónimo + + + Permisos + ¿Qué pueden hacer los miembros de este grupo? + Añadir miembros + + + Atrás + Guardar + Limpiar + Buscar + Añadir + Editar + Usuario + Filtros + + + Acciones recientes + %d eventos cargados + No se encontraron acciones recientes + Intenta cambiar los filtros + No implementado + ID de usuario copiado + + + Filtrar acciones + Restablecer + Aplicar + Tipos de acciones + Por usuarios + Buscar… + No se encontraron usuarios + Sin resultados para \"%s\" + + + Ediciones + Eliminaciones + Fijaciones + Incorporaciones + Salidas + Invitaciones + Promociones + Restricciones + Información + Configuración + Enlaces + Vídeo + + + editó un mensaje + eliminó un mensaje + fijó un mensaje + desfijó un mensaje + se unió al chat + salió del chat + invitó a %s + cambió permisos para %s + cambió restricciones para %s + cambió el título del chat a \"%s\" + cambió la descripción del chat + cambió el usuario a @%s + cambió la foto del chat + editó el enlace de invitación + revocó el enlace de invitación + eliminó el enlace de invitación + inició una videollamada + terminó la videollamada + realizó una acción: %s + + + Mensaje original: + Nuevo mensaje: + Mensaje eliminado: + Mensaje fijado: + Mensaje desfijado: + Hasta: %s + Restringido permanentemente + Anterior + Nuevo + Foto del chat anterior + Nueva foto del chat + Desde + Hasta + Cambios de permisos: + Permisos actuales: + + + Mensajes + Multimedia + Stickers + Enlaces + Encuestas + Invitación + Fijación + Información + + Administrador + Propietario + Restringido + Baneado + Miembro + + + Foto + Vídeo + GIF + Sticker + Documento + Audio + Mensaje de voz + Mensaje de vídeo + Contacto + Encuesta + Ubicación + Lugar + Mensaje no soportado + + + HH:mm + %1$02d:%2$02d + + + %1$.1fK + %1$.1fM + + + Deja un comentario + %1$d comentarios + %1$.1fK comentarios + %1$.1fM comentarios + + + Resultados finales + Anónima + Pública + Cuestionario + Encuesta + • Opción múltiple + Retirar voto + Cerrar encuesta + + %d voto + %d votos + + + + Más + Votar + Explicación + + + Votantes de la encuesta + Sin votantes todavía + Cerrar + + + Foto + Vídeo + Sticker + Mensaje de voz + Mensaje de vídeo + GIF + Mensaje + + + Carpetas de chat + Atrás + Nueva carpeta + Crear + Editar carpeta + Guardar + Carpetas predeterminadas + Carpetas personalizadas + Eliminar + Crea carpetas para diferentes grupos de chats e intercambia entre ellas rápidamente. + Sin carpetas personalizadas + Toca el botón + para crear una + Todos los chats + %1$d chats + Subir + Bajar + Nombre de carpeta + Elegir icono + Chats incluidos + Buscar chats… + Cancelar + + + Telegram Premium + Atrás + Suscribirse por %s al mes + Desbloquear características exclusivas + + + Límites duplicados + Hasta %1$d canales, %2$d carpetas de chat, %3$d fijaciones, %4$d enlaces públicos y más. + Conversión de voz a texto + Lee la transcripción de cualquier mensaje de voz tocando el botón junto a él. + Velocidad de descarga más rápida + Sin más límites en la velocidad de descarga de multimedia y documentos. + Traducción en tiempo real + Traduce chats completos en tiempo real con un solo toque. + Emojis animados + Incluye emojis animados de cientos de paquetes en tus mensajes. + Gestión avanzada de chats + Herramientas para establecer carpeta predeterminada, archivar automáticamente y ocultar chats nuevos de no contactos. + Sin anuncios + Los canales públicos a veces muestran anuncios, pero ya no aparecerán para ti. + Reacciones ilimitadas + Reacciona con miles de emojis — usando hasta 3 por mensaje. + Insignia Premium + Una insignia especial junto a tu nombre mostrando que te suscribes a Telegram Premium. + Estados de Emoji + Elige de miles de emojis para mostrar junto a tu nombre. + Iconos de aplicación Premium + Elige de una selección de iconos de aplicación de Telegram para tu pantalla de inicio. + + + Recargar + Copiar enlace + Abrir en navegador + Añadir a pantalla de inicio + + + ¿Cerrar Mini App? + Tienes cambios sin guardar. ¿Estás seguro de que quieres cerrar? + Cerrar + Cancelar + Solicitud de permiso + Permitir + Denegar + Permisos del bot + Términos de servicio + Al lanzar esta Mini App, aceptas los Términos de servicio y la Política de privacidad. El bot podrá acceder a tu información de perfil básica. + Aceptar y lanzar + + + Buscar en el artículo… + Atrás + Limpiar + Vista instantánea + Más + %d visualizaciones + Copiar enlace + Abrir en navegador + Buscar + Tamaño de texto + Reproducir vídeo + Reproducir animación + Audio + Artista desconocido + Reproducir + Abrir + Contraer + Expandir + Mapa: %1$s, %2$s + + + Compartir perfil + Comparte tu enlace de perfil de Monogram + Copiar enlace + Copia tu enlace de perfil de Monogram al portapapeles + + + Editor de temas + Modo de paleta + Elige qué paleta estás editando. El editor se abre en la paleta actualmente activa en la aplicación. + Claro + Oscuro + Editando: paleta %1$s + Actualmente activa en la aplicación: %1$s + Acento + El acento actualiza solo la paleta %1$s. + Acento hexadecimal + Aplicar + Cancelar + Elegir + Guardar + Cargar + Ambos + Hexadecimal + monogram-theme.json + + Archivo de tema guardado + Guardado fallido + Tema cargado + Archivo inválido + Carga fallida + + Fuente de tema + Elige exactamente una fuente para tus colores de aplicación. Personalizado usa tus paletas editables, Monet usa color dinámico de Android. AMOLED solo afecta fondos oscuros. + Tema personalizado + Usa tus propias paletas claro/oscuro, acentos, preajustes y colores de rol manual. + Monet + Usa colores de Material You generados por el sistema basados en el fondo de pantalla (Android 12+). + AMOLED oscuro + Fuerza negro puro para superficies oscuras para reducir el brillo y ahorrar energía en pantallas OLED. + + Temas preestablecidos + Cada ajuste preestablecido tiene variantes listo claro y oscuro. Toca claro, oscuro o ambos para aplicar al instante. + Colores manuales (%1$s) + Vista previa %1$s + Texto de resumen + Acción + Elegir %1$s + Matiz %1$d° + Saturación %1$d%% + Brillo %1$d%% + Alfa %1$d%% + + Primario + Secundario + Terciario + Fondo + Superficie + Contenedor primario + Contenedor secundario + Contenedor terciario + Variante de superficie + Contorno + + L P + L C + L BG + D P + D C + D BG + + Azul + Verde + Naranja + Rosa + Índigo + Cian + + Clásico + Azul equilibrado con legibilidad clara y superficies neutrales. + + Bosque + Verdes naturales con contraste tranquilo y tonos de contenedor suave. + + Océano + Sensación de gradiente cian-azul frío con luz fresca y modo oscuro profundo. + + Atardecer + Conjunto de acento naranja cálido y coral con claridad de primer plano alta. + + Grafito + Base en escala de grises neutral con acentos azul-gris y estructura fuerte. + + Menta + Combinación fresca de menta y verde azulado con contenedores suaves. + + Rubí + Acento rojo profundo con contenedores neutrales para fuerte enfoque de acción. + + Gris lavanda + Esquema gris violeta silenciado con saturación controlada y contraste limpio. + + Arena + Paleta beige y ámbar ajustada para ruido visual muy suave. + + Ártico + Sensación blanca azul helada con legibilidad alta y límites nítidos. + + Esmeralda + Paleta verde vibrante con superficies limpias y contraste moderno. + + Cobre + Tema cobre-naranja cálido que mantiene el texto claro en ambos modos. + + Sakura + Tonos rosa-magenta suaves con contenedores suaves y destacados fuertes. + + Nord + Azules nórdicos fríos con equilibrio visual tranquilo y profesional. + + + Se requieren permisos de cámara y micrófono + Procesando... + Error de procesamiento: %1$s + Cerrar grabadora + Cambiar cámara + Terminar grabación + REC + LISTO + Tiempo: %1$s + Zoom: %1$.1fx (rango %2$.1fx - %3$.1fx) + Cancelar + + ¿Limpiar historial? + ¿Estás seguro de que quieres limpiar el historial del chat? Esta acción no se puede deshacer. + Limpiar historial + ¿Eliminar chat? + ¿Estás seguro de que quieres eliminar este chat? Esta acción no se puede deshacer. + Eliminar chat + ¿Salir del chat? + ¿Estás seguro de que quieres salir de este chat? + Salir + ¿Eliminar canal? + ¿Eliminar grupo? + ¿Estás seguro de que quieres eliminar este canal? Todos los mensajes y multimedia se perderán. + ¿Estás seguro de que quieres eliminar este grupo? Todos los mensajes y multimedia se perderán. + ¿Eliminar %1$d chats? + ¿Estás seguro de que quieres eliminar los chats seleccionados? + Eliminar chats + ¿Bloquear usuario? + ¿Estás seguro de que quieres bloquear a este usuario? Este usuario no podrá enviarte mensajes. + Bloquear + ¿Desbloquear usuario? + ¿Estás seguro de que quieres desbloquear a este usuario? Podrán enviarte mensajes de nuevo. + ¿Desfijar mensaje? + ¿Estás seguro de que quieres desfijar este mensaje? + Desfijar + ¿Estás seguro de que quieres limpiar los stickers recientes? + Limpiar stickers + ¿Estás seguro de que quieres limpiar los emojis recientes? + Limpiar emojis + Añadir a contactos + Eliminar de contactos + Marcar como leído + Marcar como no leído + Editar + Reordenar + Eliminar + Código de confirmación inválido + Contraseña inválida + Ocurrió un error inesperado + From ee5d7cebf697832d37a77986346b68a05f60363d Mon Sep 17 00:00:00 2001 From: Luiz Renato Date: Sun, 5 Apr 2026 18:10:47 -0300 Subject: [PATCH 25/53] Added Brazilian Portuguese translation (#183) --- app/src/main/res/values-pt-rBR/strings.xml | 44 + .../src/main/res/values-pt-rBR/string.xml | 1943 +++++++++++++++++ 2 files changed, 1987 insertions(+) create mode 100644 app/src/main/res/values-pt-rBR/strings.xml create mode 100644 presentation/src/main/res/values-pt-rBR/string.xml diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000..d2f1510a --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,44 @@ + + + Desbloquear MonoGram + Faça login usando sua credencial biométrica + Usar código de acesso + Digite o código de acesso + Suas mensagens estão protegidas + Código de acesso inválido + Desbloqueio biométrico + + + Detalhes do proxy + Adicionar e conectar a este servidor proxy + Servidor + Porta + Tipo + Desconhecido + Cancelar + Conectar + + + Entrar + Canal + Grupo + + %d membro + %d membros + + + + Selecione uma configuração + Selecione um chat para começar a enviar mensagens + + + Registro de falha + Copiado para a área de transferência + Compartilhar registro de falha + Compartilhar + Copiar + Reiniciar app + Detalhes do erro + Algo deu errado. Compartilhe ou copie os logs abaixo para relatar este problema aos desenvolvedores. + + diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml new file mode 100644 index 00000000..6cb413bd --- /dev/null +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -0,0 +1,1943 @@ + + + Confirmação + Você realmente quer autorizar este dispositivo? + Sim, entrar + Cancelar + + Escanear QR + Dispositivos + Vincular dispositivo + + Este dispositivo + Solicitações de login + Sessões ativas + + Voltar + Fechar scanner + Ícone do scanner QR + + + Conectando ao Telegram… + + + Sobre + Voltar + MonoGram + Versão %1$s + Termos de Serviço + Leia nossos termos e condições + Licenças de Código Aberto + Softwares usados no MonoGram + GitHub + Ver código-fonte + Versão da TDLib + %1$s (%2$s) + Comunidade + Chat no Telegram + Participe da comunidade para discutir recursos e receber ajuda + Canal no Telegram + Fique por dentro das novidades e anúncios + Apoie o MonoGram + Apoie o desenvolvimento e ajude a manter o projeto vivo + Mantenedores + Desenvolvedor + Designer de ícone e logotipo + O MonoGram é um cliente não oficial do Telegram construído com Material Design 3 + © 2026 MonoGram + + + Buscar atualizações + Verificando... + Atualização disponível: %1$s + Você está atualizado + Atualização pronta + Erro na atualização + Toque para buscar nova versão + Conectando ao servidor + Nova versão disponível para download + Você está usando a versão mais recente + Toque para instalar a atualização + Baixando atualização... + %1$d%% + Novidades no %1$s + Baixar atualização + Cancelar + Carregando... + + + Seu telefone + Verificação + Senha + Configurações de proxy + Seu número de telefone + Confirme o código do seu país e insira o número. + País + Código + Número de telefone + 000 00 00 + Continuar + Selecione o país + País desconhecido + Buscar país ou código... + Enviamos o código para o app Telegram no seu outro dispositivo. + + Enviamos o código por SMS. + Estamos ligando com o código. + Enviamos o código para %1$s. + Enviamos o código de verificação. + Confirmar + Reenviar código em %1$s + Reenviar por SMS + Reenviar por ligação + Reenviar código + Número errado? + Verificação em duas etapas + Sua conta está protegida com uma senha adicional. + Senha + Desbloquear + Colar + Erro de autenticação + Fechar + + + Aguarde um momento + Está demorando? + Reiniciar conexão + + + Encaminhar para... + %1$d conversas selecionadas + Enviar + Chats arquivados + Nova conversa + Recentes + Limpar tudo + Conversas e contatos + Busca global + Mensagens + Mostrar mais + Buscar conversas… + Aguardando a rede… + Conectando… + Atualizando… + Conectando ao proxy… + Proxy ativado + Proxy + Fórum + Spoiler + Rascunho: + Ainda não há publicações + Ainda não há mensagens + Fixados + Menções + Ocultos da lista principal + Ainda não há chats + Começar nova conversa + Mini App + + + MonoGram Dev + Adicionar conta + Entrar em outra conta + Meu perfil + Ver seu perfil + Mensagens salvas + Armazenamento em nuvem + Configurações + Preferências do app + Atualização disponível + Nova versão %1$s disponível + Baixando atualização... %1$d%% + Atualização pronta para instalar + Ajuda e feedback + FAQ e suporte + Política de privacidade + MonoGram Dev para Android v%1$s + Usuário desconhecido + Sem informações + Mostrar contas + + + Buscar mensagens... + Limpar + Silenciado + Verificado + Patrocinador + Reativar áudio + Silenciar + Filtrar anúncios + Colocar canal na lista branca + Copiar link + Limpar histórico + Excluir chat + Denunciar + ENTRAR + Ir para o fim + Este tópico está fechado + Anexar + + + Excluir mensagem? + Excluir %1$d mensagens? + Tem certeza de que deseja excluir esta mensagem? + Tem certeza de que deseja excluir essas mensagens? + Excluir para todos + Excluir para mim + + Por que você está denunciando isso? + Sua denúncia é anônima. Vamos revisar o histórico do chat para garantir a segurança. + Detalhes da denúncia + Descreva o problema… + Enviar denúncia + Spam + Conteúdo comercial indesejado ou golpes + Violência + Ameaças ou glorificação de violência + Pornografia + Mídia imprópria ou linguagem explícita + Segurança infantil + Conteúdo envolvendo danos a menores + Direitos autorais + Uso da propriedade intelectual de outra pessoa + Impersonação + Fingindo ser outra pessoa ou um bot + Drogas ilegais + Promoção da venda ou uso de substâncias proibidas + Violação de privacidade + Compartilhamento de contatos privados ou endereços + Localização irrelevante + Conteúdo não relacionado a este lugar específico + Outro + Algo mais que viole nossos termos + + Restringir usuário + Enviar mensagens + Enviar mídia + Enviar figurinhas e GIFs + Enviar enquetes + Incorporar links + Fixar mensagens + Alterar informações do chat + Restringir até + Para sempre + Selecionar data + Selecionar horário + Restringir + + Responder + Copiar + Fixar + Desafixar + Encaminhar + Selecionar + Mais + Excluir + Ver comentários + Salvar em Downloads + Cocoon + Resumo + Traduzir + Gerado com o Telegram Cocoon + Restaurar texto original + Restringir usuário + Editado + Lido + Visualizações + + + Desconhecido + %1$d membros + %1$s, %2$d online + Não implementado + Busca não implementada + Compartilhamento não implementado + Bloqueio não implementado + Exclusão não implementada + Estatísticas + Receitas + Compartilhar + Editar + Bloquear usuário + Sair + Abrir + Mensagem + Entrar + Denunciar + QR Code + Adicionar + Foto pessoal + Essa foto só é visível para você + Abrir Mini App + Iniciar o aplicativo web do bot + Aceitar termos + Leia e aceite os termos de serviço do bot + Permissões do bot + Gerencie permissões deste bot + Nome de usuário + Link + Link de convite + Informações do bot + Descrição + Biografia + Data de nascimento + Localização + Horário de funcionamento + Ações recentes + Ver log de eventos do chat + Ver estatísticas detalhadas do chat + Ver estatísticas de receita do chat + Adicionou você aos contatos + Não adicionou você aos contatos + Salvo nos seus contatos + Não salvo nos seus contatos + Stories do perfil + Você pode publicar stories no seu perfil + Plano de fundo do chat + Você pode definir fundos personalizados + Notas de voz e vídeo + Envio de notas de voz e vídeo está restrito + Modo lento + Os membros podem enviar uma mensagem a cada %1$s + Conteúdo protegido + Encaminhamentos e salvamentos são restritos + Encaminhamentos privados + Mensagens encaminhadas ocultam o link do perfil + Estatísticas do chat + %1$d administradores + %1$d restringidos + %1$d banidos + Informações + Notificações + Mensagens com exclusão automática + Configurações + Salvar + Spam + Violência + Pornografia + Abuso infantil + Direitos autorais + Outro + Fechar + Ao iniciar este Mini App, você concorda com os Termos de Serviço e a Política de Privacidade. O bot poderá acessar suas informações básicas de perfil. + + Aceitar e iniciar + + + QR Code + Compartilhar + O MonoGram é um projeto gratuito e de código aberto. Seu apoio nos ajuda a mantê-lo vivo e desenvolver novos recursos. + + O emblema de patrocinador com coração está disponível a partir do nível de suporte Avançado ou uma contribuição equivalente a 150 RUB (cerca de US$ 1,96). + + Apoiar no Boosty + Talvez depois + Editar perfil + Pressione e segure para ocultar + Segure para mostrar, toque para copiar + Seu ID + Altere seu nome, bio e foto de perfil + Ativar links t.me + Abra links do Telegram no aplicativo + Geral + Configurações de chat + Temas, tamanho do texto, player de vídeo + Privacidade e segurança + Código, sessões ativas, privacidade + Notificações e sons + Mensagens, grupos, chamadas + Dados e armazenamento + Uso de rede, download automático + Economia de energia + Configurações de consumo da bateria + Pastas de chat + Organize seus chats + Figurinhas e emoji + Gerencie pacotes de figurinhas e emoji + Dispositivos vinculados + Idioma + Inglês + Configurações de proxy + MTProto, SOCKS5, HTTP + Telegram Premium + Desbloqueie recursos exclusivos + Ajude a desenvolver o projeto + Apoia o MonoGram + Este usuário apoia o projeto e nos ajuda a melhorar + Versão e informações do MonoGram + Depuração + Opções de depuração + Mostrar ficha do patrocinador + Abrir ficha com informações do patrocinador + Forçar sincronização de patrocinadores + Buscar IDs de patrocinadores do canal agora + Sair + Desconectar da conta + + + Nomes de usuário + Tente novamente em %1$ds + Nomes de usuário ativos + Nomes de usuário desativados + Nomes de usuário colecionáveis + Concluído + OK + Selecionar horário inicial + Selecionar horário final + Horário de trabalho + Dias úteis + Intervalo de tempo + De + Até + Local do negócio + Endereço + Confirmar localização + Editar perfil + Nome (obrigatório) + Sobrenome (opcional) + Bio + Any details such as age, occupation or city. Example: 23 y.o. designer from San + Francisco. + + Nome de usuário + Você pode escolher um nome de usuário no Telegram. Assim, as pessoas poderão encontrá-lo por ele e falar com você sem precisar do número de telefone. + + Seu aniversário + Aniversário + Telegram Business + ID do canal vinculado + Biografia comercial + Endereço comercial + Localização geográfica + Horário de funcionamento + Como usuário Premium, você pode adicionar seu link a um canal e incluir detalhes comerciais no perfil. + + Não definido + (%1$d dias) + Seg + Ter + Qua + Qui + Sex + Sáb + Dom + + + Privacidade e segurança + Privacidade + Usuários bloqueados + %1$d usuários + Nenhum + Número de telefone + Visto por último e online + Fotos de perfil + Mensagens encaminhadas + Chamadas + Grupos e canais + Segurança + Bloqueio por código + Ligado + Desligado + Desbloquear com biometria + Use impressão digital ou rosto para desbloquear + Sessões ativas + Gerencie os dispositivos conectados + Conteúdo sensível + Desativar filtragem + Exibir mídia sensível em canais públicos em todos os seus dispositivos. + Avançado + Excluir minha conta + Se ausente por %1$s + Excluir conta agora + Eliminar permanentemente sua conta e todos os dados + Autodestruir se ficar inativo por... + Excluir conta + Tem certeza de que deseja excluir sua conta? Esta ação é permanente e não pode ser desfeita. + + Todo mundo + Meus contatos + Ninguém + 1 mês + 3 meses + 6 meses + 1 ano + 18 meses + 2 anos + %1$d meses + %1$d dias + Usuário solicitou exclusão + + + Configurações de proxy + Atualizar pings + Adicionar proxy + Conexão + Troca inteligente + Use automaticamente o proxy mais rápido + Preferir IPv6 + Use IPv6 quando disponível + Desativar proxy + Conectado diretamente + Alternar para conexão direta + Proxy Telega + O Telega Proxy foi acusado de interceptar tráfego. O MTProto protege os dados das mensagens, mas use este proxy por sua conta e risco. Saiba mais: t.me/telegaru + + Ativar Telega Proxy + Buscar automaticamente e alternar para o melhor + Atualizar lista + Buscar os proxies comunitários mais recentes + Seus proxies + Limpar offline + Remover tudo + Excluir proxies offline + Isso removerá todos os proxies offline atuais. Deseja continuar? + Excluir todos os proxies + Isso removerá todos os proxies configurados no app. Deseja continuar? + Nenhum proxy adicionado + Excluir proxy + Tem certeza de que deseja excluir o proxy %1$s? + Novo proxy + Editar proxy + Endereço do servidor + Porta + Segredo (Hex) + Nome de usuário (opcional) + Senha (opcional) + Salvar alterações + Testar + Resultado do teste + Excluir + Verificando... + Offline + %1$dms + + + Seus nomes de usuário + Mais opções + + + Notificações e sons + Notificações de mensagens + Conversas privadas + Grupos + Canais + Configurações de notificações + Vibração + Prioridade + Repetir notificações + Apenas remetente + Ocultar conteúdo da mensagem nas notificações + Serviço de push + Provedor de push + Serviço keep-alive + Mantenha o app rodando em segundo plano para notificações confiáveis + + Ocultar notificação em primeiro plano + Oculte a notificação do serviço após iniciá-lo. Isso pode levar à + finalização do serviço pelo sistema + + Notificações no app + Sons no app + Vibrar no app + Pré-visualização no app + Eventos + Contato entrou no Telegram + Mensagens fixadas + Redefinir todas as notificações + Desfazer todas as configurações personalizadas de notificação para seus contatos e grupos + + + Padrão de vibração + Prioridade da notificação + %1$s, %2$d exceções + Padrão + Curta + Longa + Desativada + Baixa + Padrão + Alta + Nunca + A cada %1$d minutos + A cada 1 hora + A cada %1$d horas + Firebase Cloud Messaging + Sem GMS (Serviço em segundo plano) + + + Configurações de chat + Aparência + Tamanho do texto + Espaçamento entre letras + Arredondamento das bolhas + Tamanho das figurinhas + Redefinir + Papel de parede do chat + Redefinir papel de parede + Estilo de emoji + Tema + Modo noturno + Sistema + Claro + Escuro + Agendado + Automático + Comportamento atual + Usando: %1$s + Limiar de brilho: %1$d%% + Ative o tema escuro quando o brilho da tela estiver abaixo deste nível. + + Cores dinâmicas + Cores dinâmicas + Use as cores do sistema no tema do app + Dados e armazenamento + Comprimir fotos + Reduza o tamanho da foto antes de enviar + Comprimir vídeos + Reduza o tamanho do vídeo antes de enviar + Player de vídeo + Ativar gestos + Deslize para controlar volume e brilho + Toque duplo para avançar + Toque duas vezes nas bordas do vídeo para pular + Duração do pulo + Ativar zoom + Aperte para ampliar no player de vídeo + Lista de chats + Fixar chats arquivados + Mantenha chats arquivados no topo da lista + Sempre mostrar arquivo fixado + Mantenha o arquivo fixado visível mesmo ao rolar + Mostrar prévias de links + Exiba prévias de links nas mensagens + Arrastar para voltar + Deslize da borda esquerda para retornar + Duas linhas + Três linhas + Mostrar fotos + Exibir fotos de perfil na lista de chats + Experimental + AdBlock para canais + Oculte publicações patrocinadas em canais + Mídia recente + Limpar figurinhas recentes + Remover todas as figurinhas usadas recentemente + Limpar emojis recentes + Remover todos os emojis usados recentemente + Remover pacote de emojis + Remover o pacote %1$s? + Isso excluirá o pacote de emojis baixado do dispositivo. Você pode baixá-lo novamente depois. + + Editar tema personalizado + Selecionado + Apple + Twitter + Windows + Catmoji + Noto + Sistema + + + Dados e armazenamento + Uso de disco e rede + Uso de armazenamento + Gerencie seu cache local + Uso de rede + Ver dados enviados e recebidos + Download automático de mídia + Ao usar dados móveis + Quando conectado ao Wi-Fi + Ao usar roaming + Baixar arquivos automaticamente + Baixar arquivos recebidos automaticamente + Baixar figurinhas automaticamente + Baixar figurinhas automaticamente + Baixar notas de vídeo automaticamente + Baixar notas de vídeo automaticamente + Reprodução automática + GIFs + Reproduzir GIFs automaticamente na lista de chats e nos chats + Vídeos + Reproduzir vídeos automaticamente nos chats + Ativado + Desativado + + + Economia de energia + Bateria + Modo economia de energia + Reduz atividades em segundo plano e animações para economizar bateria + Otimizar uso da bateria + Limitar fortemente o trabalho em segundo plano e liberar wake locks + Wake lock + Mantenha a CPU acordada para tarefas em segundo plano. Desative para economizar bateria + Animações + Animações do chat + Desative animações no chat para economizar bateria + Plano de fundo + Desativar isso reduzirá o consumo de energia, mas pode atrasar as notificações em segundo plano + + + + + Figurinhas e emoji + Figurinhas + Emoji + Figurinhas recentes + Pacotes de figurinhas + Figurinhas arquivadas + Adicionar figurinhas próprias + Crie seus próprios pacotes usando o bot @Stickers + Nenhum pacote de figurinhas instalado + Nenhuma figurinha encontrada para \"%1$s\" + Emojis recentes + Pacotes de emoji + Emojis arquivados + Adicionar emoji próprio + Crie seus próprios pacotes de emoji usando o bot @Stickers + Limpar emojis recentes + Remover todos os emojis usados recentemente + Nenhum pacote de emoji instalado + Nenhum emoji encontrado para \"%1$s\" + Buscar pacotes + Buscar + + %1$d figurinha + %1$d figurinhas + + + %1$d emoji + %1$d emojis + + Máscaras + Emojis personalizados + Oficial + Link copiado para a área de transferência + + + Uso de rede + Redefinir estatísticas + Estatísticas de rede + Acompanhe quanto dados você gasta. Desativar pode reduzir o uso de espaço em disco. + + Estatísticas de rede desativadas + O monitoramento de uso de rede está desativado no momento. Ative a chave acima para ver quanto dados você usa no celular, Wi-Fi e roaming. + + Uso total + Enviado + Recebido + Visão geral + Uso do app + Nenhum dado de uso registrado + Nenhuma estatística disponível + Celular + Wi-Fi + Roaming + Outros + + + Uso de armazenamento + Limpar todo o cache • %1$s + Inclui fotos, vídeos, documentos, figurinhas e GIFs de todos os chats. + + Limite do cache + Limpar cache automaticamente + Otimização de armazenamento + Otimização de armazenamento em segundo plano + Uso detalhado + Armazenamento limpo + Nenhum arquivo em cache encontrado. + Limpar cache + Tem certeza de que deseja limpar o cache de \"%1$s\"? Isso liberará + %2$s. + + Limpar todo o cache + Isso removerá todos os arquivos de mídia em cache de todos os chats. Tem certeza? + + Ilimitado + Nunca + Todos os dias + Toda semana + Todo mês + Total usado + %1$d arquivos + %1$d arquivos + + + Quem pode me adicionar a %1$s? + Quem pode me ligar? + Quem pode ver meu status de visto por último? + Quem pode ver minhas fotos de perfil? + Quem pode ver minha bio? + Quem pode adicionar um link para minha conta ao encaminhar minhas mensagens? + Quem pode me adicionar a grupos e canais? + Quem pode ver meu número de telefone? + Quem pode me encontrar pelo número? + Usuários que adicionarem seu número aos contatos verão no Telegram + apenas se a configuração acima permitir. + + Adicionar exceções + Sempre permitir + Nunca permitir + %1$d usuários/chats + (excluído) + Membros do chat + Busca por número de telefone + Nenhum usuário bloqueado + Usuários bloqueados não poderão contatá-lo e não verão seu visto por último. + + Desbloquear + Bloquear usuário + Buscar usuários + Nenhum usuário encontrado + + + Alterar código + Definir código + O app já está protegido por um código. Digite um novo para alterá-lo. + Digite um código de 4 dígitos para bloquear o app e proteger sua privacidade. + Código + Código atual + Salvar código + Desativar código + Verificar código + Digite seu código atual antes de alterar ou desativar. + Código incorreto. + + + Adicionar palavra-chave + Ativar AdBlock + Canais permitidos + %1$d canais permitidos + Carregar palavras-chave base + Importar palavras-chave comuns de anúncios dos recursos + Copiar todas as palavras-chave + Copiar a lista atual para a área de transferência + Limpar todas as palavras-chave + Remover todas as palavras-chave da lista + Palavras-chave para ocultar publicações + Nenhuma palavra-chave adicionada + Toque no botão + para adicionar palavras-chave para filtrar + Adicionar palavras-chave + Digite palavras-chave separadas por vírgulas ou quebras de linha para filtrar publicações de canal. + + ex: #promo, ad, реклама + Adicionar à lista + Publicações desses canais não serão filtradas + Nenhum canal permitido + Remover + + + visto recentemente + visto agora mesmo + + visto há %1$d minuto + visto há %1$d minutos + + visto às %1$s + visto ontem às %1$s + visto em %1$s + visto na última semana + visto no último mês + visto há muito tempo + online + offline + bot + Nome de usuário + %1$d figurinhas + Arquivado + Adicionar + Desarquivar + Arquivar + Remover pacote + + + Nova mensagem + Adicionar membros + Novo grupo + Novo canal + %1$d contatos + %1$d / 200000 + %1$d selecionado(s) + Buscar contatos... + Nenhum contato encontrado + Nenhum resultado para "%1$s" + Ordenado pelo visto por último + Novo grupo + Novo canal + Suporte + Canais são uma ferramenta para transmitir suas mensagens a audiências ilimitadas. + Detalhes do canal + Nome do canal + Descrição (opcional) + Mensagens com exclusão automática + Desativado + 1 dia + 2 dias + 3 dias + 4 dias + 5 dias + 6 dias + 1 semana + 2 semanas + 3 semanas + 1 mês + 2 meses + 3 meses + 4 meses + 5 meses + 6 meses + 1 ano + Você pode fornecer uma descrição opcional para seu canal. Qualquer pessoa pode entrar se tiver um link. + Escolha um nome e uma foto opcional para o novo grupo. + Detalhes do grupo + Nome do grupo + Digite um nome para o grupo + Digite um nome para o canal + Abrir perfil + Editar nome + Remover contato + Editar contato + Nome + Sobrenome + Remover contato? + Tem certeza de que deseja remover %1$s dos seus contatos? + Excluir automaticamente novas mensagens enviadas neste grupo após um certo período. + Adicionar foto + Mudar foto + + + Permissões necessárias + Para oferecer a melhor experiência, o MonoGram precisa das permissões abaixo. + Notificações + Receba notificações sobre novas mensagens + Permitir + Estado do telefone + Gerencie estados do dispositivo para melhorar a experiência + Otimização da bateria + Garanta operação confiável em segundo plano + Desativar + Câmera + Tire fotos e grave mensagens de vídeo + Microfone + Grave mensagens de voz e vídeo + Localização + Compartilhe sua localização e veja usuários próximos + + + Limpar seleção + Fixar + + + Comandos do bot + Selecione um comando para enviar ao bot + + + Lista de chats + Konata Izumi + Não sou baixinha, apenas concentro toda a fofura! 🍫 Também decidi virar uma profissional do sono 😴 + 12:45 + Kagami Hiiragi + Não esqueça da lição! A entrega é amanhã de manhã e é bem difícil. + 11:20 + + + Pré-visualização + Eu + Não sou baixinha, só concentro todo o charme! 🍫\nAlém disso, decidi virar uma especialista no sono 😴 + É isso que você fala toda vez que não alcança a prateleira de cima... 🙄\nVeja só: isso + Pare de me expor! 😤✨\nVou usar minha arma secreta: 💯 pura preguiça + É super eficaz! 😵‍💫 + Hoje + Konata + + %1$d assinantes + Tópico + + + Excluir gravação + < Deslize para cancelar + Enviar gravação + Bloquear gravação + Deslize para cima + + + Adicionar legenda… + Mensagem + Enviar mensagens não é permitido + + + Foto + Vídeo + Figurinha + Mensagem de voz + Mensagem de vídeo + GIF + Localização + Mensagem + + + Cancelar resposta + Editar mensagem + Cancelar edição + Anexar %1$d itens + Enviar mídia + Cancelar + Copiar + Colar + Recortar + Selecionar tudo + Aplicar + Concluído + Atualizar + Editor em tela cheia + Editor + Enviar silenciosamente + Agendar mensagem + Mensagens agendadas + Mensagens agendadas (%1$d) + Ainda não há mensagens agendadas + Total agendado: %1$d + Próximo envio: %1$s + Editável agora: %1$d + ID: %1$d + Editar + Enviar + Excluir + Gerenciar + Acesso limitado a fotos habilitado + Apenas fotos e vídeos selecionados estão visíveis. + Anexos + Outras fontes + Todas + Fotos + Vídeos + Todas as pastas + Capturas de tela + %1$d selecionados + Pronto para anexar + Permitir acesso a mídias + Conceda acesso a fotos e vídeos para anexar arquivos no chat. + Conceder acesso + %1$d caracteres • %2$d blocos de formatação + %1$d blocos de formatação + Selecione o texto para aplicar formatação rica como no Telegram + %1$d/%2$d + Desfazer + Refazer + Pré-visualização + Editar + Markdown: ativado + Markdown: desativado + A+ + A- + Modelos + Salvar como snippet + Título do snippet + Nenhum snippet ainda + Buscar + Substituir + Substituir tudo + %1$d de %2$d correspondências + Nenhuma correspondência + %1$d palavras + ~%1$d min de leitura + Rascunho salvo automaticamente + IA + Editor de IA + Traduzir + Estilizar + Corrigir + Original + Resultado + Alterações + Aplicar resultado + Traduzir texto + Aplicar estilo + Corrigir texto + Idioma de destino + Selecione o idioma + Nenhum idioma encontrado + Formal + Curto + Tribal + Corp + Bíblico + Viking + Zen + Adicionar emojis + Processando... + Digite texto para usar a IA + Muitas solicitações de IA. O Telegram Premium pode ser necessário. + Falha no processamento da IA + Inserir + Anterior + Próximo + + Negrito + Itálico + Sublinhado + Tachado + Spoiler + Código + Monoespaçado + Link + Menção + Emoji + Limpar + Adicionar link + URL + Linguagem do código + Idioma (ex: kotlin) + + + Comandos + + + está digitando + está gravando vídeo + está gravando mensagem de voz + está enviando foto + está enviando vídeo + está enviando arquivo + está escolhendo uma figurinha + está jogando + Alguém + e + e mais %d + estão digitando + + %d pessoa está digitando + %d pessoas estão digitando + + + + Fotos + Vídeos + Documentos + Figurinhas + Músicas + Mensagens de voz + Mensagens de vídeo + Outros arquivos + Outros / Cache + Chat %d + + + Chamadas + + + Aguardando novas mensagens + Parar + Serviço em segundo plano + Notificação sobre o app rodando em segundo plano + + + 📷 Foto + 📹 Vídeo + 🎤 Mensagem de voz + 🧩 Figurinha + 📎 Documento + 🎵 Áudio + GIF + 🎬 Mensagem de vídeo + 👤 Contato + 📊 Enquete + 📍 Localização + 📞 Chamada + 🎮 Jogo + 💳 Fatura + 📚 História + 📌 Mensagem fixada + Mensagem + Bot + Online + Offline + Visto agora + Visto há %d minuto + Visto há %d minutos + Visto às %s + Visto ontem às %s + Visto em %s + Visto recentemente + Visto na última semana + Visto no último mês + + + Mensagens fixadas + Mensagem fixada + Mostrar todos fixados + Desafixar + Fechar + + %d mensagem + %d mensagens + + + + Mensagem de vídeo + GIF + Documento + Enquete: %s + Figurinha %s + + + Visualizar + Recortar + Filtros + Desenhar + Texto + Borracha + + + Original + P&B + Sépia + Vintage + Frio + Quente + Polaroid + Inverter + + + Salvar + Cancelar + Desfazer + Refazer + Fechar + Redefinir + Girar para a esquerda + Girar para a direita + Adicionar texto + Editar texto + Aplicar + Excluir + + + Tamanho + Zoom + Digite algo... + Selecione uma ferramenta para começar a editar + + + Descartar alterações? + Há alterações não salvas. Tem certeza de que deseja descartá-las? + Descartar + + + Aplicar + Concluído + Cancelar + Baixa + + + Visualizar + Cortar + Filtros + Texto + Comprimir + + + Ativar áudio + Silenciar + Adicionar texto + Qualidade do vídeo + Taxa de bits estimada: %1$d kbps + + + Original + P&B + Sépia + Vintage + Frio + Quente + Polaroid + Inverter + + + Arquivo não encontrado + Arquivo de vídeo ausente + Descartar alterações? + Há alterações não salvas. Tem certeza de que deseja descartá-las? + Descartar + + + Carregando… + Visualização web + Fechar + Mais opções + + + Opções + Voltar + Avançar + Atualizar + Ações + Configurações + Copiar + Link copiado + Compartilhar + Compartilhar link via + Abrir no navegador + Buscar + Site para desktop + Bloquear anúncios + Tamanho do texto: %1$d%% + + + Seguro + Inseguro + Informações de segurança + Conexão insegura + A conexão com este site está criptografada e segura. + A conexão com este site não é segura. Você não deve inserir dados sensíveis (como senhas ou cartões) pois podem ser roubados. + Emitido para + Emitido por + Válido até + Desconhecido + + + Buscar na página… + Anterior + Próximo + Fechar busca + + + Discussão + Canal + Abrir no Maps + Rotas + Navegar com + Abrir com + Navegador / Outro + Mídia + Membros + Arquivos + Áudio + Voz + Links + GIFs + Nenhum membro encontrado + Nenhuma mídia encontrada + Nenhum áudio encontrado + Nenhuma mensagem de voz encontrada + Nenhum arquivo encontrado + Nenhum link encontrado + Nenhum GIF encontrado + BOT + Fechado + ID + + + Analisando estatísticas... + Visão geral + Membros + Mensagens + Visualizadores + Remetentes ativos + Crescimento de membros + Novos membros + Conteúdo das mensagens + Ações + Atividade por dia + Atividade por semana + Horários com mais atividade + Visualizações por origem + Novos membros por origem + Idiomas + Principais remetentes + mens. + Média de caracteres: %1$d + Principais administradores + ações + Exc: %1$d | Ban: %2$d + Principais convidadores + convites + Membros adicionados + Assinantes + Notificações ativadas + Média de visualizações por mensagem + Média de compartilhamentos por mensagem + Média de reações + Crescimento + Novos assinantes + Visualizações por hora + Interações por mensagem + Interações do Instant View + Reações de mensagens + Interações recentes + Mensagem + História + ID da publicação + Mostrar menos + Mostrar tudo (%1$d) + Receita + Saldo disponível + Saldo total + Taxa de câmbio + Crescimento da receita + Receita por hora + Informações carregadas + Aproximar + Renderizando gráfico... + Sem alterações + vs anterior + Tipo de estatística desconhecido + Classe de dados: %1$s + + + Voltar + Opções + Retroceder 10 segundos + Avançar 10 segundos + -%1$ds + +%1$ds + Miniatura %1$d + %1$d / %2$d + Carregando original... + + + Baixar + Baixar vídeo + Copiar imagem + Copiar texto + Copiar link + Copiar link com hora + Encaminhar + Salvar em GIFs + Salvar na galeria + Copiar para a área de transferência + Reiniciar + Pausar + Reproduzir + Desbloquear + + + Configurações + Velocidade de reprodução + Modo de escala + Ajustar + Zoom + Rotacionar tela + Imagem em imagem + Captura de tela + Repetir vídeo + Silenciar áudio + Legendas + Bloquear controles + Qualidade + Qualidade do vídeo + Automática + Alta definição + Normal + + + Reproduzir + Pausar + Retroceder + Avançar + + + Figurinhas + Emojis + GIFs + + + Figurinhas recentes + Figurinhas + Buscar figurinhas + + + Emojis recentes + Emojis padrão + Emojis personalizados + Emojis + Buscar emojis + + + GIFs recentes e salvos + Nenhum GIF encontrado + Buscar GIFs + + + Voltar + Limpar + Recentes + + + Salvo na galeria + Compartilhar QR + Erro ao enviar: %1$s + + + Editar canal + Editar grupo + Nome do canal + Nome do grupo + Descrição + Configurações + Canal público + Grupo público + Tradução automática + Tópicos + Gerenciamento + Excluir canal + Excluir grupo + + + Buscar... + Administradores + Assinantes + Membros + Lista negra + Nenhum resultado encontrado + Ainda não há membros + + + Direitos de admin + Título personalizado + Este título será visível para todos os membros no chat + O que este admin pode fazer? + Gerenciar chat + Enviar mensagens + Editar mensagens + Excluir mensagens + Restringir membros + Convidar usuários + Gerenciar tópicos + Gerenciar videochats + Publicar stories + Editar stories + Excluir stories + Adicionar novos admins + Permanecer anônimo + + + Permissões + O que os membros deste grupo podem fazer? + Adicionar membros + + + Voltar + Salvar + Limpar + Buscar + Adicionar + Editar + Nome de usuário + Filtros + + + Ações recentes + %d eventos carregados + Nenhuma ação recente encontrada + Tente mudar os filtros + Não implementado + ID do usuário copiado + + + Filtrar ações + Redefinir + Aplicar + Tipos de ação + Por usuários + Buscar… + Nenhum usuário encontrado + Nenhum resultado para \"%s\" + + + Edições + Exclusões + Fixações + Entradas + Saídas + Convites + Promoções + Restrições + Informações + Configurações + Links + Vídeo + + + editou uma mensagem + excluiu uma mensagem + fixou uma mensagem + desfixou uma mensagem + entrou no chat + saiu do chat + convidou %s + alterou permissões de %s + alterou restrições de %s + mudou o título do chat para \"%s\" + alterou a descrição do chat + mudou o nome de usuário para @%s + alterou a foto do chat + editou o link de convite + revogou o link de convite + excluiu o link de convite + iniciou uma videochamada + encerrou a videochamada + realizou uma ação: %s + + + Mensagem original: + Nova mensagem: + Mensagem excluída: + Mensagem fixada: + Mensagem desfixada: + Até: %s + Restrito permanentemente + Antiga + Nova + Foto antiga do chat + Nova foto do chat + De + Para + Alterações de permissão: + Permissões atuais: + + + Mensagens + Mídia + Figurinhas + Links + Enquetes + Convidar + Fixar + Informações + + Admin + Proprietário + Restrito + Banido + Membro + + + Foto + Vídeo + GIF + Figurinha + Documento + Áudio + Mensagem de voz + Mensagem de vídeo + Contato + Enquete + Localização + Local + Mensagem não compatível + + + HH:mm + %1$02d:%2$02d + + + %1$.1fK + %1$.1fM + + + Deixar um comentário + %1$d comentários + %1$.1fK comentários + %1$.1fM comentários + + + Resultados finais + Anônimo + Público + Quiz + Enquete + • Escolha múltipla + Retirar voto + Encerrar enquete + + %d voto + %d votos + + + + Mais + Votar + Explicação + + + Participantes da enquete + Ainda não há votos + Fechar + + + Foto + Vídeo + Figurinha + Mensagem de voz + Mensagem de vídeo + GIF + Mensagem + + + Pastas de chat + Voltar + Nova pasta + Criar + Editar pasta + Salvar + Pastas padrão + Pastas personalizadas + Excluir + Crie pastas para diferentes grupos de chats e alterne rapidamente entre elas. + Nenhuma pasta personalizada + Toque no botão + para criar uma + Todos os chats + %1$d conversas + Mover para cima + Mover para baixo + Nome da pasta + Escolher ícone + Chats incluídos + Buscar chats… + Cancelar + + + Telegram Premium + Voltar + Assinar por %s por mês + Desbloquear recursos exclusivos + + + Limites dobrados + Até %1$d canais, %2$d pastas de chat, %3$d fixações, %4$d links públicos e mais. + Conversão de voz para texto + Leia a transcrição de qualquer mensagem de voz tocando no botão ao lado. + Velocidade de download maior + Sem limites na velocidade de download de mídias e documentos. + Tradução em tempo real + Traduza chats inteiros em tempo real com um único toque. + Emojis animados + Inclua emojis animados de centenas de pacotes nas suas mensagens. + Gerenciamento avançado de chats + Ferramentas para definir pasta padrão, arquivar automaticamente e ocultar novos chats de não contatos. + Sem anúncios + Canais públicos às vezes mostram anúncios, mas eles não aparecerão mais para você. + Reações infinitas + Reaja com milhares de emojis — usando até 3 por mensagem. + Distintivo Premium + Um distintivo especial ao lado do seu nome mostrando que você assina o Telegram Premium. + Status com emoji + Escolha entre milhares de emojis para mostrar ao lado do seu nome. + Ícones Premium + Escolha entre uma seleção de ícones do Telegram para a tela inicial. + + + Recarregar + Copiar link + Abrir no navegador + Adicionar à tela inicial + + + Fechar Mini App? + Você tem alterações não salvas. Tem certeza de que deseja fechar? + Fechar + Cancelar + Solicitação de permissão + Permitir + Negar + Permissões do bot + Termos de Serviço + Ao iniciar este Mini App, você concorda com os Termos de Serviço e a Política de Privacidade. O bot poderá acessar suas informações básicas de perfil. + Aceitar e iniciar + + + Buscar no artigo… + Voltar + Limpar + Visualização instantânea + Mais + %d visualizações + Copiar link + Abrir no navegador + Buscar + Tamanho do texto + Reproduzir vídeo + Reproduzir animação + Áudio + Artista desconhecido + Reproduzir + Abrir + Recolher + Expandir + Mapa: %1$s, %2$s + + + Compartilhar perfil + Compartilhe o link do seu perfil Monogram + Copiar link + Copie o link do seu perfil Monogram para a área de transferência + + + Editor de temas + Modo de paleta + Escolha qual paleta você está editando. O editor abre na paleta atualmente ativa no app. + Claro + Escuro + Editando: paleta %1$s + Atualmente ativo no app: %1$s + Acento + O acento atualiza apenas a paleta %1$s. + Acento em hex + Aplicar + Cancelar + Escolher + Salvar + Carregar + Ambos + Hex + monogram-theme.json + + Arquivo de tema salvo + Falha ao salvar + Tema carregado + Arquivo inválido + Falha ao carregar + + Fonte do tema + Escolha exatamente uma fonte para as cores do app. Personalizado usa suas paletas editáveis, Monet usa as cores dinâmicas do Android e AMOLED afeta apenas fundos escuros. + Tema personalizado + Use suas próprias paletas claro/escuro, acentos, predefinições e cores manuais para roles. + Monet + Use as cores geradas pelo Material You baseadas no papel de parede (Android 12+). + AMOLED escuro + Force fundo preto puro para reduzir brilho e economizar bateria em telas OLED. + + Temas predefinidos + Cada predefinição tem variantes claro e escuro prontas. Toque em Claro, Escuro ou Ambos para aplicar instantaneamente. + Cores manuais (%1$s) + Pré-visualização %1$s + Texto do resumo + Ação + Escolher %1$s + Matiz %1$d° + Saturação %1$d%% + Brilho %1$d%% + Alpha %1$d%% + + Primário + Secundário + Terciário + Fundo + Superfície + Contêiner primário + Contêiner secundário + Contêiner terciário + Variante da superfície + Contorno + + L P + L C + L BG + D P + D C + D BG + + Azul + Verde + Laranja + Rosa + Índigo + Ciano + + Clássico + Azul equilibrado com legibilidade clara e superfícies neutras. + + Floresta + Verdes naturais com contraste tranquilo e tons suaves para containers. + + Oceano + Gradiente ciano-azul frio com luz fresca e modo escuro profundo. + + Pôr do Sol + Acentos laranja e coral quentes com clareza alta no primeiro plano. + + Grafite + Base neutra em escala de cinza com acentos azul-cinza e estrutura forte. + + Menta + Combinação fresca de menta e teal com containers suaves. + + Rubi + Acento vermelho profundo com containers neutros para foco em ações. + + Lavender Gray + Esquema violeta-cinza suave com saturação controlada e contraste limpo. + + Areia + Paleta bege e âmbar ajustada para ruído visual muito suave. + + Ártico + Sensação azul-branco gelada com alta legibilidade e limites nítidos. + + Esmeralda + Paleta verde vibrante com superfícies limpas e contraste moderno. + + Cobre + Tema cobre-laranja quente que mantém o texto claro em ambos os modos. + + Sakura + Tons suaves de rosa-magenta com containers delicados e destaques fortes. + + Nord + Azuis frios do norte com equilíbrio visual calmo e profissional. + + Permissões de câmera e microfone são obrigatórias + Processando... + Erro de processamento: %1$s + Fechar gravador + Trocar câmera + Terminar gravação + REC + PRONTO + Tempo: %1$s + Zoom: %1$.1fx (intervalo %2$.1fx - %3$.1fx) + Cancelar + + Limpar histórico? + Tem certeza de que deseja limpar o histórico do chat? Esta ação não pode ser desfeita. + Limpar histórico + Excluir chat? + Tem certeza de que deseja excluir este chat? Esta ação não pode ser desfeita. + Excluir chat + Sair do chat? + Tem certeza de que deseja sair deste chat? + Sair + Excluir canal? + Excluir grupo? + Tem certeza de que deseja excluir este canal? Todas as mensagens e mídias serão perdidas. + Tem certeza de que deseja excluir este grupo? Todas as mensagens e mídias serão perdidas. + Excluir %1$d chats? + Tem certeza de que deseja excluir os chats selecionados? + Excluir chats + Bloquear usuário? + Tem certeza de que deseja bloquear este usuário? Ele não poderá enviar mensagens. + Bloquear + Desbloquear usuário? + Tem certeza de que deseja desbloquear este usuário? Ele poderá enviar mensagens + novamente. + + Desafixar mensagem? + Tem certeza de que deseja desafixar esta mensagem? + Desafixar + Tem certeza de que deseja limpar as figurinhas recentes? + Limpar figurinhas + Tem certeza de que deseja limpar os emojis recentes? + Limpar emojis + Adicionar aos contatos + Remover dos contatos + Marcar como lido + Marcar como não lido + Editar + Reordenar + Excluir + Código de confirmação inválido + Senha inválida + Ocorreu um erro inesperado + From 196b723ecfb53b8b91cf30aad57a2126803f57e7 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:14:58 +0300 Subject: [PATCH 26/53] Add new locales: hy, es, and pt-BR --- app/src/main/res/xml/locales_config.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 028e72f1..392ba817 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -5,4 +5,7 @@ - \ No newline at end of file + + + + From 04cc5a7ed83f9a35fb9523a70e644bbcb465f323 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:53:37 +0300 Subject: [PATCH 27/53] Complete rewrite and split of the repository module (#187) - [x] User Repository - [x] Message Repository - [x] Settings Repository - [x] Sticker Repository - [x] Chats List Repository - [x] Link Handler Repository --- .../data/chats/ChatPersistenceManager.kt | 140 +++ .../monogram/data/chats/ChatSearchManager.kt | 85 ++ .../monogram/data/chats/ChatUpdateHandler.kt | 267 +++++ .../monogram/data/chats/ForumTopicsManager.kt | 149 +++ .../cache/RoomStickerLocalDataSource.kt | 120 ++ .../cache/RoomUserLocalDataSource.kt | 69 +- .../cache/StickerLocalDataSource.kt | 30 + .../datasource/cache/UserLocalDataSource.kt | 7 +- .../datasource/remote/ChatRemoteSource.kt | 1 + .../datasource/remote/EmojiRemoteSource.kt | 10 + .../data/datasource/remote/GifRemoteSource.kt | 9 + .../datasource/remote/LinkRemoteDataSource.kt | 15 + .../remote/NominatimRemoteDataSource.kt | 63 + .../datasource/remote/StickerRemoteSource.kt | 8 - .../remote/TdChatRemoteDataSource.kt | 4 + .../datasource/remote/TdEmojiRemoteSource.kt | 68 ++ .../datasource/remote/TdGifRemoteSource.kt | 62 + .../remote/TdLinkRemoteDataSource.kt | 45 + .../remote/TdMessageRemoteDataSource.kt | 4 +- .../remote/TdStickerRemoteSource.kt | 102 +- .../monogram/data/di/TdNotificationManager.kt | 8 +- .../java/org/monogram/data/di/dataModule.kt | 208 +++- .../monogram/data/gateway/TdLibException.kt | 5 + .../data/infra/TdLibParametersProvider.kt | 37 + .../monogram/data/mapper/ChatEntityMapper.kt | 122 ++ .../org/monogram/data/mapper/LinkMapper.kt | 25 + .../org/monogram/data/mapper/MessageMapper.kt | 24 +- .../monogram/data/mapper/SettingsMapper.kt | 2 +- .../data/mapper/user/UserEntityMapper.kt | 63 + .../repository/AttachMenuBotRepositoryImpl.kt | 98 ++ .../data/repository/AuthRepositoryImpl.kt | 64 +- .../data/repository/BotRepositoryImpl.kt | 32 + .../data/repository/ChatInfoRepositoryImpl.kt | 119 ++ .../ChatStatisticsRepositoryImpl.kt | 35 + .../repository/ChatsListRepositoryImpl.kt | 942 +++++---------- .../data/repository/EmojiRepositoryImpl.kt | 80 ++ .../data/repository/GifRepositoryImpl.kt | 42 + .../repository/LinkHandlerRepositoryImpl.kt | 377 ++---- .../monogram/data/repository/LinkParser.kt | 123 ++ .../data/repository/LocationRepositoryImpl.kt | 86 +- .../NetworkStatisticsRepositoryImpl.kt | 31 + .../NotificationSettingsRepositoryImpl.kt | 112 ++ .../monogram/data/repository/ParsedLink.kt | 22 + .../data/repository/PremiumRepositoryImpl.kt | 30 + .../repository/ProfilePhotoRepositoryImpl.kt | 264 +++++ .../data/repository/SessionRepositoryImpl.kt | 23 + .../data/repository/SettingsRepositoryImpl.kt | 340 ------ .../data/repository/SponsorRepositoryImpl.kt | 12 + .../data/repository/StickerRepositoryImpl.kt | 413 ++----- .../data/repository/StorageRepositoryImpl.kt | 80 ++ .../UserProfileEditRepositoryImpl.kt | 61 + .../data/repository/UserRepositoryImpl.kt | 1044 ----------------- .../repository/WallpaperRepositoryImpl.kt | 90 ++ .../data/repository/user/UserMediaResolver.kt | 139 +++ .../repository/user/UserRepositoryImpl.kt | 318 +++++ .../repository/user/UserUpdateSynchronizer.kt | 76 ++ .../data/stickers/StickerFileManager.kt | 178 +++ .../repository/AttachMenuBotRepository.kt | 8 + .../domain/repository/BotRepository.kt | 9 + .../repository/ChatCreationRepository.kt | 15 + .../repository/ChatEventLogRepository.kt | 15 + .../domain/repository/ChatFolderRepository.kt | 27 + .../domain/repository/ChatInfoRepository.kt | 63 + .../domain/repository/ChatListRepository.kt | 16 + .../repository/ChatOperationsRepository.kt | 20 + .../domain/repository/ChatSearchRepository.kt | 22 + .../repository/ChatSettingsRepository.kt | 14 + .../repository/ChatStatisticsRepository.kt | 11 + .../domain/repository/ChatsListRepository.kt | 101 -- .../domain/repository/ConnectionStatus.kt | 9 + .../domain/repository/EmojiRepository.kt | 16 + .../domain/repository/FileRepository.kt | 24 + .../repository/ForumTopicsRepository.kt | 17 + .../domain/repository/GifRepository.kt | 11 + .../domain/repository/InlineBotRepository.kt | 30 + .../domain/repository/LocationRepository.kt | 1 - .../domain/repository/MessageAiRepository.kt | 22 + .../domain/repository/MessageRepository.kt | 94 +- .../repository/NetworkStatisticsRepository.kt | 10 + .../NotificationSettingsRepository.kt | 16 + .../domain/repository/PaymentRepository.kt | 11 + .../domain/repository/PremiumRepository.kt | 12 + .../repository/ProfilePhotoRepository.kt | 22 + .../domain/repository/SessionRepository.kt | 9 + .../domain/repository/SettingsRepository.kt | 54 - .../domain/repository/SponsorRepository.kt | 5 + .../domain/repository/StickerRepository.kt | 14 +- .../domain/repository/StorageRepository.kt | 12 + .../repository/UserProfileEditRepository.kt | 19 + .../domain/repository/UserRepository.kt | 102 +- .../domain/repository/WallpaperRepository.kt | 9 + .../domain/repository/WebAppRepository.kt | 17 + .../presentation/core/ui/SettingsItem.kt | 8 +- .../monogram/presentation/di/AppContainer.kt | 30 +- .../presentation/di/KoinAppContainer.kt | 30 +- .../chats/chatList/ChatListContent.kt | 3 +- .../chatList/DefaultChatListComponent.kt | 72 +- .../chats/currentChat/DefaultChatComponent.kt | 64 +- .../chatContent/ChatContentViewers.kt | 21 +- .../chatContent/ChatMessageOptionsMenu.kt | 14 +- .../inputbar/FullScreenEditorSheet.kt | 2 +- .../chats/currentChat/impl/BotOperations.kt | 16 +- .../chats/currentChat/impl/ChatInfo.kt | 22 +- .../chats/currentChat/impl/MessageActions.kt | 10 +- .../chats/currentChat/impl/MessageLoading.kt | 2 +- .../currentChat/impl/MessageOperations.kt | 2 +- .../chats/currentChat/impl/Preferences.kt | 2 +- .../chats/currentChat/impl/Stickers.kt | 2 +- .../chats/newChat/DefaultNewChatComponent.kt | 14 +- .../features/instantview/InstantViewer.kt | 4 +- .../components/InstantViewComponents.kt | 20 +- .../components/InstantViewUtils.kt | 6 +- .../profile/DefaultProfileComponent.kt | 59 +- .../features/profile/ProfileContent.kt | 63 +- .../admin/DefaultAdminManageComponent.kt | 14 +- .../profile/admin/DefaultChatEditComponent.kt | 32 +- .../admin/DefaultChatPermissionsComponent.kt | 10 +- .../admin/DefaultMemberListComponent.kt | 8 +- .../logs/DefaultProfileLogsComponent.kt | 4 +- .../profile/logs/ProfileLogsComponent.kt | 4 +- .../features/stickers/ui/menu/EmojisGrid.kt | 16 +- .../features/stickers/ui/menu/GifsGrid.kt | 16 +- .../stickers/ui/menu/MessageOptionsMenu.kt | 8 +- .../features/webapp/MiniAppState.kt | 12 +- .../features/webapp/MiniAppViewer.kt | 18 +- .../webapp/components/InvoiceDialog.kt | 18 +- .../presentation/root/DefaultRootComponent.kt | 6 +- .../presentation/root/StartupComponent.kt | 20 +- .../settings/adblock/AdBlockComponent.kt | 4 +- .../chatSettings/ChatSettingsComponent.kt | 17 +- .../dataStorage/DataStorageComponent.kt | 7 +- .../settings/debug/DefaultDebugComponent.kt | 4 +- .../settings/folders/FoldersComponent.kt | 22 +- .../networkUsage/NetworkUsageComponent.kt | 15 +- .../notifications/NotificationsComponent.kt | 26 +- .../notifications/NotificationsContent.kt | 2 +- .../NotificationsExceptionsContent.kt | 4 +- .../settings/premium/PremiumComponent.kt | 14 +- .../privacy/PrivacySettingComponent.kt | 4 +- .../profile/DefaultEditProfileComponent.kt | 38 +- .../settings/sessions/SessionsComponent.kt | 4 +- .../settings/DefaultSettingsComponent.kt | 12 +- .../settings/stickers/StickersComponent.kt | 4 +- .../settings/stickers/StickersContent.kt | 2 +- .../settings/storage/StorageUsageComponent.kt | 16 +- 145 files changed, 4956 insertions(+), 3731 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt create mode 100644 data/src/main/java/org/monogram/data/chats/ChatSearchManager.kt create mode 100644 data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt create mode 100644 data/src/main/java/org/monogram/data/chats/ForumTopicsManager.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/cache/RoomStickerLocalDataSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/cache/StickerLocalDataSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/EmojiRemoteSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/GifRemoteSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/LinkRemoteDataSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/NominatimRemoteDataSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/TdEmojiRemoteSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/TdGifRemoteSource.kt create mode 100644 data/src/main/java/org/monogram/data/datasource/remote/TdLinkRemoteDataSource.kt create mode 100644 data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/LinkMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ChatStatisticsRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/GifRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/LinkParser.kt create mode 100644 data/src/main/java/org/monogram/data/repository/NetworkStatisticsRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ParsedLink.kt create mode 100644 data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/SessionRepositoryImpl.kt delete mode 100644 data/src/main/java/org/monogram/data/repository/SettingsRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/UserProfileEditRepositoryImpl.kt delete mode 100644 data/src/main/java/org/monogram/data/repository/UserRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt create mode 100644 data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt create mode 100644 data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/AttachMenuBotRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/BotRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatCreationRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatEventLogRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatFolderRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatSearchRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatStatisticsRepository.kt delete mode 100644 domain/src/main/java/org/monogram/domain/repository/ChatsListRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ConnectionStatus.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/EmojiRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/FileRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ForumTopicsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/GifRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/InlineBotRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/MessageAiRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/NetworkStatisticsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/NotificationSettingsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/PaymentRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/SessionRepository.kt delete mode 100644 domain/src/main/java/org/monogram/domain/repository/SettingsRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/UserProfileEditRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/WebAppRepository.kt diff --git a/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt new file mode 100644 index 00000000..5955658f --- /dev/null +++ b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt @@ -0,0 +1,140 @@ +package org.monogram.data.chats + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.db.model.ChatEntity +import org.monogram.data.mapper.ChatMapper +import org.monogram.domain.models.ChatModel +import java.util.concurrent.ConcurrentHashMap + +class ChatPersistenceManager( + private val scope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val cache: ChatCache, + private val chatLocalDataSource: ChatLocalDataSource, + private val chatMapper: ChatMapper, + private val modelFactory: ChatModelFactory, + private val listManager: ChatListManager, + private val activeChatListProvider: () -> TdApi.ChatList +) { + private val lastSavedEntities = ConcurrentHashMap() + private val pendingSaveJobs = ConcurrentHashMap() + private val mainChatList = TdApi.ChatListMain() + + fun rememberSavedEntity(entity: ChatEntity) { + lastSavedEntities[entity.id] = entity + } + + fun scheduleChatSave(chatId: Long) { + val chat = cache.getChat(chatId) ?: return + + pendingSaveJobs[chatId]?.cancel() + pendingSaveJobs[chatId] = scope.launch(dispatchers.io) { + try { + delay(SINGLE_CHAT_SAVE_DEBOUNCE_MS) + val activeChatList = activeChatListProvider() + val position = resolvePersistPosition(chat, activeChatList) + val model = modelFactory.mapChatToModel( + chat = chat, + order = position?.order ?: 0L, + isPinned = position?.isPinned ?: false, + allowMediaDownloads = false + ) + var entity = chatMapper.mapToEntity(chat, model) + if (position != null && (position.order != entity.order || position.isPinned != entity.isPinned)) { + entity = entity.copy(order = position.order, isPinned = position.isPinned) + } + + val last = lastSavedEntities[chatId] + if (last == null || isEntityChanged(last, entity)) { + chatLocalDataSource.insertChat(entity) + lastSavedEntities[chatId] = entity + } + } finally { + pendingSaveJobs.remove(chatId) + } + } + } + + fun scheduleSavesBySupergroupId(supergroupId: Long) { + cache.allChats.values + .asSequence() + .filter { (it.type as? TdApi.ChatTypeSupergroup)?.supergroupId == supergroupId } + .map { it.id } + .forEach { scheduleChatSave(it) } + } + + fun scheduleSavesByBasicGroupId(basicGroupId: Long) { + cache.allChats.values + .asSequence() + .filter { (it.type as? TdApi.ChatTypeBasicGroup)?.basicGroupId == basicGroupId } + .map { it.id } + .forEach { scheduleChatSave(it) } + } + + suspend fun persistChatModels(models: List, activeChatList: TdApi.ChatList) { + val toSave = models + .map { model -> mapModelToEntity(model, activeChatList) } + .filter { entity -> + val last = lastSavedEntities[entity.id] + if (last == null || isEntityChanged(last, entity)) { + lastSavedEntities[entity.id] = entity + true + } else { + false + } + } + + if (toSave.isNotEmpty()) { + chatLocalDataSource.insertChats(toSave) + } + } + + fun clear() { + pendingSaveJobs.values.forEach { it.cancel() } + pendingSaveJobs.clear() + lastSavedEntities.clear() + } + + private fun mapModelToEntity(model: ChatModel, activeChatList: TdApi.ChatList): ChatEntity { + val chat = cache.getChat(model.id) + if (chat == null) { + return chatMapper.mapToEntity(model) + } + + val persistPosition = resolvePersistPosition(chat, activeChatList) + val mapped = chatMapper.mapToEntity(chat, model) + return if (persistPosition != null && + (persistPosition.order != mapped.order || persistPosition.isPinned != mapped.isPinned) + ) { + mapped.copy(order = persistPosition.order, isPinned = persistPosition.isPinned) + } else { + mapped + } + } + + private fun resolvePersistPosition(chat: TdApi.Chat, activeChatList: TdApi.ChatList): TdApi.ChatPosition? { + return chat.positions.find { pos -> + pos.order != 0L && listManager.isSameChatList(pos.list, mainChatList) + } + ?: chat.positions.find { pos -> + pos.order != 0L && listManager.isSameChatList(pos.list, activeChatList) + } + ?: chat.positions.firstOrNull { it.order != 0L } + } + + private fun isEntityChanged(old: ChatEntity, new: ChatEntity): Boolean { + return old.withoutCreatedAt() != new.withoutCreatedAt() + } + + private fun ChatEntity.withoutCreatedAt(): ChatEntity = copy(createdAt = 0L) + + companion object { + private const val SINGLE_CHAT_SAVE_DEBOUNCE_MS = 2000L + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ChatSearchManager.kt b/data/src/main/java/org/monogram/data/chats/ChatSearchManager.kt new file mode 100644 index 00000000..51c9fb5c --- /dev/null +++ b/data/src/main/java/org/monogram/data/chats/ChatSearchManager.kt @@ -0,0 +1,85 @@ +package org.monogram.data.chats + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.remote.ChatRemoteSource +import org.monogram.data.db.dao.SearchHistoryDao +import org.monogram.data.db.model.SearchHistoryEntity +import org.monogram.data.mapper.MessageMapper +import org.monogram.domain.models.ChatModel +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.SearchMessagesResult + +class ChatSearchManager( + private val chatRemoteSource: ChatRemoteSource, + private val messageMapper: MessageMapper, + private val cacheProvider: CacheProvider, + private val searchHistoryDao: SearchHistoryDao, + private val dispatchers: DispatcherProvider, + private val scope: CoroutineScope, + private val resolveChatById: suspend (Long) -> ChatModel? +) { + val searchHistory: Flow> = cacheProvider.searchHistory.map { ids -> + coroutineScope { + ids.map { id -> async { resolveChatById(id) } }.awaitAll().filterNotNull() + } + } + + init { + scope.launch(dispatchers.io) { + searchHistoryDao.getSearchHistory().collect { entities -> + cacheProvider.setSearchHistory(entities.map { it.chatId }) + } + } + } + + suspend fun searchChats(query: String): List { + if (query.isBlank()) return emptyList() + val result = chatRemoteSource.searchChats(query, 50) ?: return emptyList() + return coroutineScope { + result.chatIds.map { id -> async { resolveChatById(id) } }.awaitAll().filterNotNull() + } + } + + suspend fun searchPublicChats(query: String): List { + if (query.isBlank()) return emptyList() + val result = chatRemoteSource.searchPublicChats(query) ?: return emptyList() + return coroutineScope { + result.chatIds.map { id -> async { resolveChatById(id) } }.awaitAll().filterNotNull() + } + } + + suspend fun searchMessages(query: String, offset: String, limit: Int): SearchMessagesResult { + val result = chatRemoteSource.searchMessages(query, offset, limit) + ?: return SearchMessagesResult(emptyList(), "") + val models = coroutineScope { + result.messages.map { message -> + async { messageMapper.mapMessageToModel(message, isChatOpen = false) } + }.awaitAll() + } + return SearchMessagesResult(models, result.nextOffset) + } + + fun addSearchChatId(chatId: Long) { + cacheProvider.addSearchChatId(chatId) + scope.launch(dispatchers.io) { + searchHistoryDao.insertSearchChatId(SearchHistoryEntity(chatId)) + } + } + + fun removeSearchChatId(chatId: Long) { + cacheProvider.removeSearchChatId(chatId) + scope.launch(dispatchers.io) { + searchHistoryDao.deleteSearchChatId(chatId) + } + } + + fun clearSearchHistory() { + cacheProvider.clearSearchHistory() + scope.launch(dispatchers.io) { + searchHistoryDao.clearAll() + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt new file mode 100644 index 00000000..7876ccf0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt @@ -0,0 +1,267 @@ +package org.monogram.data.chats + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.mapper.user.toEntity + +class ChatUpdateHandler( + private val cache: ChatCache, + private val listManager: ChatListManager, + private val typingManager: ChatTypingManager, + private val fileManager: ChatFileManager, + private val folderManager: ChatFolderManager, + private val chatLocalDataSource: ChatLocalDataSource, + private val dispatchers: DispatcherProvider, + private val scope: CoroutineScope, + private val activeChatListProvider: () -> TdApi.ChatList, + private val myUserIdProvider: () -> Long, + private val onSaveChat: (Long) -> Unit, + private val onSaveChatsBySupergroupId: (Long) -> Unit, + private val onSaveChatsByBasicGroupId: (Long) -> Unit, + private val onTriggerUpdate: (Long?) -> Unit, + private val onRefreshChat: suspend (Long) -> Unit, + private val onRefreshForumTopics: () -> Unit, + private val onAuthorizationStateClosed: () -> Unit +) { + fun handle(update: TdApi.Update) { + when (update) { + is TdApi.UpdateNewChat -> { + cache.putChat(update.chat) + listManager.updateActiveListPositions(update.chat.id, update.chat.positions, activeChatListProvider()) + onSaveChat(update.chat.id) + onTriggerUpdate(update.chat.id) + } + + is TdApi.UpdateChatTitle -> { + cache.updateChat(update.chatId) { it.title = update.title } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatPhoto -> { + cache.updateChat(update.chatId) { it.photo = update.photo } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatEmojiStatus -> { + cache.updateChat(update.chatId) { it.emojiStatus = update.emojiStatus } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatDraftMessage -> { + cache.updateChat(update.chatId) { chat -> + chat.draftMessage = update.draftMessage + if (!update.positions.isNullOrEmpty()) { + chat.positions = update.positions + listManager.updateActiveListPositions(update.chatId, update.positions, activeChatListProvider()) + } + } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatPosition -> { + if (listManager.updateChatPositionInCache(update.chatId, update.position, activeChatListProvider())) { + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + } + + is TdApi.UpdateChatLastMessage -> { + cache.updateChat(update.chatId) { chat -> + chat.lastMessage = update.lastMessage + if (!update.positions.isNullOrEmpty()) { + chat.positions = update.positions + listManager.updateActiveListPositions(update.chatId, update.positions, activeChatListProvider()) + } + typingManager.clearTypingStatus(update.chatId) + } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatReadInbox -> { + cache.updateChat(update.chatId) { it.unreadCount = update.unreadCount } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatReadOutbox -> { + cache.updateChat(update.chatId) { it.lastReadOutboxMessageId = update.lastReadOutboxMessageId } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatUnreadMentionCount -> { + cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } + folderManager.handleUpdateChatUnreadCount(update.chatId, update.unreadMentionCount) + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatUnreadReactionCount -> { + cache.updateChat(update.chatId) { it.unreadReactionCount = update.unreadReactionCount } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateMessageMentionRead -> { + cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } + folderManager.handleUpdateChatUnreadCount(update.chatId, update.unreadMentionCount) + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateMessageReactions -> { + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateFile -> { + if (fileManager.handleFileUpdate(update.file)) { + val chatId = fileManager.getChatIdByPhotoId(update.file.id) + onTriggerUpdate(chatId) + onRefreshForumTopics() + } + } + + is TdApi.UpdateDeleteMessages -> { + if (update.isPermanent || update.fromCache) { + scope.launch { onRefreshChat(update.chatId) } + } + } + + is TdApi.UpdateChatFolders -> { + Log.d(TAG, "UpdateChatFolders received in update handler") + folderManager.handleChatFoldersUpdate(update) + onTriggerUpdate(null) + } + + is TdApi.UpdateUserStatus -> { + cache.updateUser(update.userId) { it.status = update.status } + cache.userIdToChatId[update.userId]?.let { chatId -> + onTriggerUpdate(chatId) + } + } + + is TdApi.UpdateUser -> { + cache.putUser(update.user) + val privateChatId = cache.userIdToChatId[update.user.id] + if (privateChatId != null) { + onTriggerUpdate(privateChatId) + } + onRefreshForumTopics() + } + + is TdApi.UpdateSupergroup -> { + cache.putSupergroup(update.supergroup) + onSaveChatsBySupergroupId(update.supergroup.id) + cache.supergroupIdToChatId[update.supergroup.id]?.let { chatId -> + onTriggerUpdate(chatId) + } + } + + is TdApi.UpdateBasicGroup -> { + cache.putBasicGroup(update.basicGroup) + onSaveChatsByBasicGroupId(update.basicGroup.id) + cache.basicGroupIdToChatId[update.basicGroup.id]?.let { chatId -> + onTriggerUpdate(chatId) + } + } + + is TdApi.UpdateSupergroupFullInfo -> { + cache.putSupergroupFullInfo(update.supergroupId, update.supergroupFullInfo) + val chatId = cache.supergroupIdToChatId[update.supergroupId] + scope.launch(dispatchers.io) { + if (chatId != null) { + chatLocalDataSource.insertChatFullInfo(update.supergroupFullInfo.toEntity(chatId)) + } + } + if (chatId != null) { + onTriggerUpdate(chatId) + } + } + + is TdApi.UpdateBasicGroupFullInfo -> { + cache.putBasicGroupFullInfo(update.basicGroupId, update.basicGroupFullInfo) + val chatId = cache.basicGroupIdToChatId[update.basicGroupId] + scope.launch(dispatchers.io) { + if (chatId != null) { + chatLocalDataSource.insertChatFullInfo(update.basicGroupFullInfo.toEntity(chatId)) + } + } + if (chatId != null) { + onTriggerUpdate(chatId) + } + } + + is TdApi.UpdateSecretChat -> { + cache.putSecretChat(update.secretChat) + onTriggerUpdate(null) + } + + is TdApi.UpdateChatAction -> typingManager.handleChatAction(update) + + is TdApi.UpdateChatNotificationSettings -> { + cache.updateChat(update.chatId) { it.notificationSettings = update.notificationSettings } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatViewAsTopics -> { + cache.updateChat(update.chatId) { it.viewAsTopics = update.viewAsTopics } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatIsTranslatable -> { + cache.updateChat(update.chatId) { it.isTranslatable = update.isTranslatable } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatPermissions -> { + cache.putChatPermissions(update.chatId, update.permissions) + cache.updateChat(update.chatId) { it.permissions = update.permissions } + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateChatMember -> { + val memberId = update.newChatMember.memberId + if (memberId is TdApi.MessageSenderUser && memberId.userId == myUserIdProvider()) { + cache.putMyChatMember(update.chatId, update.newChatMember) + onTriggerUpdate(update.chatId) + } + } + + is TdApi.UpdateChatOnlineMemberCount -> { + cache.putOnlineMemberCount(update.chatId, update.onlineMemberCount) + onSaveChat(update.chatId) + onTriggerUpdate(update.chatId) + } + + is TdApi.UpdateAuthorizationState -> { + Log.d(TAG, "UpdateAuthorizationState: ${update.authorizationState}") + if (update.authorizationState is TdApi.AuthorizationStateLoggingOut || + update.authorizationState is TdApi.AuthorizationStateClosed + ) { + cache.clearAll() + onAuthorizationStateClosed() + } + } + + else -> {} + } + } + + companion object { + private const val TAG = "ChatUpdateHandler" + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ForumTopicsManager.kt b/data/src/main/java/org/monogram/data/chats/ForumTopicsManager.kt new file mode 100644 index 00000000..e3905a8b --- /dev/null +++ b/data/src/main/java/org/monogram/data/chats/ForumTopicsManager.kt @@ -0,0 +1,149 @@ +package org.monogram.data.chats + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.remote.ChatRemoteSource +import org.monogram.data.db.model.TopicEntity +import org.monogram.data.mapper.ChatMapper +import org.monogram.domain.models.TopicModel + +class ForumTopicsManager( + private val chatRemoteSource: ChatRemoteSource, + private val chatMapper: ChatMapper, + private val cache: ChatCache, + private val fileManager: ChatFileManager, + private val chatLocalDataSource: ChatLocalDataSource, + private val dispatchers: DispatcherProvider, + private val scope: CoroutineScope, + private val fetchUser: (Long) -> Unit +) { + private val _forumTopicsFlow = MutableSharedFlow>>( + replay = 1, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val forumTopicsFlow: Flow>> = _forumTopicsFlow.asSharedFlow() + + private var activeForumChatId: Long? = null + + fun refreshActiveForumTopics() { + val chatId = activeForumChatId ?: return + scope.launch { getForumTopics(chatId) } + } + + suspend fun getForumTopics( + chatId: Long, + query: String = "", + offsetDate: Int = 0, + offsetMessageId: Long = 0, + offsetForumTopicId: Int = 0, + limit: Int = 20 + ): List { + activeForumChatId = chatId + val result = chatRemoteSource.getForumTopics( + chatId = chatId, + query = query, + offsetDate = offsetDate, + offsetMessageId = offsetMessageId, + offsetForumTopicId = offsetForumTopicId, + limit = limit + ) ?: return emptyList() + + val models = result.topics.map { topic -> + val (lastMessageText, entities, time) = chatMapper.formatMessageInfo(topic.lastMessage, null) { userId -> + cache.usersCache[userId]?.firstName ?: run { + fetchUser(userId) + null + } + } + + val emojiId = topic.info.icon.customEmojiId + var emojiPath: String? = null + if (emojiId != 0L) { + emojiPath = fileManager.getEmojiPath(emojiId) + if (emojiPath == null) { + fileManager.loadEmoji(emojiId) + } + } + + var senderName: String? = null + var senderAvatar: String? = null + when (val senderId = topic.lastMessage?.senderId) { + is TdApi.MessageSenderUser -> { + cache.usersCache[senderId.userId]?.let { user -> + senderName = user.firstName + user.profilePhoto?.small?.let { small -> + fileManager.registerTrackedFile(small.id) + senderAvatar = small.local.path.ifEmpty { fileManager.getFilePath(small.id) } + if (senderAvatar.isNullOrEmpty()) { + fileManager.downloadFile(small.id, 24, synchronous = false) + } + } + } ?: fetchUser(senderId.userId) + } + + is TdApi.MessageSenderChat -> { + cache.getChat(senderId.chatId)?.let { chat -> + senderName = chat.title + chat.photo?.small?.let { small -> + fileManager.registerTrackedFile(small.id) + senderAvatar = small.local.path.ifEmpty { fileManager.getFilePath(small.id) } + if (senderAvatar.isNullOrEmpty()) { + fileManager.downloadFile(small.id, 24, synchronous = false) + } + } + } + } + + else -> Unit + } + + TopicModel( + id = topic.info.forumTopicId, + name = topic.info.name, + iconCustomEmojiId = emojiId, + iconCustomEmojiPath = emojiPath, + iconColor = topic.info.icon.color, + isClosed = topic.info.isClosed, + isPinned = topic.isPinned, + unreadCount = topic.unreadCount, + lastMessageText = lastMessageText, + lastMessageEntities = entities, + lastMessageTime = time, + order = topic.order, + lastMessageSenderName = senderName, + lastMessageSenderAvatar = senderAvatar + ) + } + + scope.launch(dispatchers.io) { + chatLocalDataSource.insertTopics(result.topics.map { topic -> + val (text, _, time) = chatMapper.formatMessageInfo(topic.lastMessage, null) { null } + TopicEntity( + chatId = chatId, + id = topic.info.forumTopicId, + name = topic.info.name, + iconCustomEmojiId = topic.info.icon.customEmojiId, + iconColor = topic.info.icon.color, + isClosed = topic.info.isClosed, + isPinned = topic.isPinned, + unreadCount = topic.unreadCount, + lastMessageText = text, + lastMessageTime = time, + order = topic.order, + lastMessageSenderName = null + ) + }) + } + + _forumTopicsFlow.tryEmit(chatId to models) + return models + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomStickerLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomStickerLocalDataSource.kt new file mode 100644 index 00000000..b7cd8d62 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomStickerLocalDataSource.kt @@ -0,0 +1,120 @@ +package org.monogram.data.datasource.cache + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import org.monogram.data.db.dao.RecentEmojiDao +import org.monogram.data.db.dao.StickerPathDao +import org.monogram.data.db.dao.StickerSetDao +import org.monogram.data.db.model.RecentEmojiEntity +import org.monogram.data.db.model.StickerPathEntity +import org.monogram.data.db.model.StickerSetEntity +import org.monogram.domain.models.RecentEmojiModel +import org.monogram.domain.models.StickerSetModel + +class RoomStickerLocalDataSource( + private val stickerSetDao: StickerSetDao, + private val recentEmojiDao: RecentEmojiDao, + private val stickerPathDao: StickerPathDao +) : StickerLocalDataSource { + + override fun getInstalledStickerSetsByType(type: String): Flow> { + return stickerSetDao.getInstalledStickerSetsByType(type) + .map { entities -> entities.mapNotNull { it.toModel() } } + } + + override fun getArchivedStickerSetsByType(type: String): Flow> { + return stickerSetDao.getArchivedStickerSetsByType(type) + .map { entities -> entities.mapNotNull { it.toModel() } } + } + + override suspend fun getStickerSetById(id: Long): StickerSetModel? { + return stickerSetDao.getStickerSetById(id)?.toModel() + } + + override suspend fun getStickerSetByName(name: String): StickerSetModel? { + return stickerSetDao.getStickerSetByName(name)?.toModel() + } + + override suspend fun saveStickerSets( + sets: List, + type: String, + isInstalled: Boolean, + isArchived: Boolean + ) { + stickerSetDao.deleteStickerSets(type, isInstalled, isArchived) + val normalized = sets.map { it.copy(isInstalled = isInstalled, isArchived = isArchived) } + stickerSetDao.insertStickerSets(normalized.map { it.toEntity(type) }) + } + + override suspend fun insertStickerSet(set: StickerSetModel, type: String) { + stickerSetDao.insertStickerSet(set.toEntity(type)) + } + + override suspend fun clearStickerSets() { + stickerSetDao.clearAll() + } + + override suspend fun getPath(fileId: Long): String? { + return stickerPathDao.getPath(fileId) + } + + override suspend fun insertPath(fileId: Long, path: String) { + stickerPathDao.insertPath(StickerPathEntity(fileId, path)) + } + + override suspend fun deletePath(fileId: Long) { + stickerPathDao.deletePath(fileId) + } + + override suspend fun clearPaths() { + stickerPathDao.clearAll() + } + + override fun getRecentEmojis(): Flow> { + return recentEmojiDao.getRecentEmojis() + .map { entities -> entities.mapNotNull { it.toModel() } } + } + + override suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) { + recentEmojiDao.deleteRecentEmoji(recentEmoji.emoji, recentEmoji.sticker?.id) + recentEmojiDao.insertRecentEmoji( + RecentEmojiEntity( + emoji = recentEmoji.emoji, + stickerId = recentEmoji.sticker?.id, + data = Json.encodeToString(recentEmoji) + ) + ) + } + + override suspend fun clearRecentEmojis() { + recentEmojiDao.clearAll() + } + + private fun StickerSetModel.toEntity(type: String): StickerSetEntity { + return StickerSetEntity( + id = id, + name = name, + type = type, + isInstalled = isInstalled, + isArchived = isArchived, + data = Json.encodeToString(this) + ) + } + + private fun StickerSetEntity.toModel(): StickerSetModel? { + return try { + Json.decodeFromString(data) + } catch (_: Exception) { + null + } + } + + private fun RecentEmojiEntity.toModel(): RecentEmojiModel? { + return try { + Json.decodeFromString(data) + } catch (_: Exception) { + null + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt index bfea263a..2a684860 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt @@ -5,6 +5,7 @@ import org.monogram.data.db.dao.UserDao import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.db.model.UserEntity import org.monogram.data.db.model.UserFullInfoEntity +import org.monogram.data.mapper.user.extractPersonalAvatarPath import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi import java.util.concurrent.ConcurrentHashMap @@ -70,74 +71,18 @@ class RoomUserLocalDataSource( userFullInfoDao.deleteExpired(timestamp) } - suspend fun saveUser(user: UserEntity) = userDao.insertUser(user) - suspend fun loadUser(userId: Long): UserEntity? { + override suspend fun saveUser(user: UserEntity) = userDao.insertUser(user) + + override suspend fun loadUser(userId: Long): UserEntity? { val entity = userDao.getUser(userId) entity?.let { users[it.id] = it.toTdApi() } return entity } + suspend fun deleteUser(userId: Long) = userDao.deleteUser(userId) - suspend fun clearDatabase() { + + override suspend fun clearDatabase() { userDao.clearAll() userFullInfoDao.clearAll() } - - private fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { - val usernamesData = buildString { - append(usernames?.activeUsernames?.joinToString("|").orEmpty()) - append('\n') - append(usernames?.disabledUsernames?.joinToString("|").orEmpty()) - append('\n') - append(usernames?.editableUsername.orEmpty()) - append('\n') - append(usernames?.collectibleUsernames?.joinToString("|").orEmpty()) - } - - val statusType = when (status) { - is TdApi.UserStatusOnline -> "ONLINE" - is TdApi.UserStatusRecently -> "RECENTLY" - is TdApi.UserStatusLastWeek -> "LAST_WEEK" - is TdApi.UserStatusLastMonth -> "LAST_MONTH" - else -> "OFFLINE" - } - - val statusEmojiId = when (val type = emojiStatus?.type) { - is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId - is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L - } - - return UserEntity( - id = id, - firstName = firstName, - lastName = lastName.ifEmpty { null }, - phoneNumber = phoneNumber.ifEmpty { null }, - avatarPath = profilePhoto?.big?.local?.path?.ifEmpty { null } - ?: profilePhoto?.small?.local?.path?.ifEmpty { null }, - personalAvatarPath = personalAvatarPath, - isPremium = isPremium, - isVerified = verificationStatus?.isVerified ?: false, - isSupport = isSupport, - isContact = isContact, - isMutualContact = isMutualContact, - isCloseFriend = isCloseFriend, - haveAccess = haveAccess, - username = usernames?.activeUsernames?.firstOrNull(), - usernamesData = usernamesData, - statusType = statusType, - accentColorId = accentColorId, - profileAccentColorId = profileAccentColorId, - statusEmojiId = statusEmojiId, - languageCode = languageCode.ifEmpty { null }, - lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, - createdAt = System.currentTimeMillis() - ) - } - - private fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? { - val bestPhotoSize = personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull() - return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } - ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } - } } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/StickerLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/StickerLocalDataSource.kt new file mode 100644 index 00000000..d1997e30 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/cache/StickerLocalDataSource.kt @@ -0,0 +1,30 @@ +package org.monogram.data.datasource.cache + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.RecentEmojiModel +import org.monogram.domain.models.StickerSetModel + +interface StickerLocalDataSource { + fun getInstalledStickerSetsByType(type: String): Flow> + fun getArchivedStickerSetsByType(type: String): Flow> + suspend fun getStickerSetById(id: Long): StickerSetModel? + suspend fun getStickerSetByName(name: String): StickerSetModel? + suspend fun saveStickerSets( + sets: List, + type: String, + isInstalled: Boolean, + isArchived: Boolean + ) + + suspend fun insertStickerSet(set: StickerSetModel, type: String) + suspend fun clearStickerSets() + + suspend fun getPath(fileId: Long): String? + suspend fun insertPath(fileId: Long, path: String) + suspend fun deletePath(fileId: Long) + suspend fun clearPaths() + + fun getRecentEmojis(): Flow> + suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) + suspend fun clearRecentEmojis() +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt index f95dc07e..c831bfdc 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt @@ -1,6 +1,7 @@ package org.monogram.data.datasource.cache import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.UserEntity import org.monogram.data.db.model.UserFullInfoEntity interface UserLocalDataSource { @@ -14,4 +15,8 @@ interface UserLocalDataSource { suspend fun getFullInfoEntity(userId: Long): UserFullInfoEntity? suspend fun saveFullInfoEntity(info: UserFullInfoEntity) suspend fun deleteExpired(timestamp: Long) -} \ No newline at end of file + + suspend fun saveUser(user: UserEntity) {} + suspend fun loadUser(userId: Long): UserEntity? = null + suspend fun clearDatabase() {} +} diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt index 61e63423..22a8e83e 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt @@ -9,6 +9,7 @@ interface ChatRemoteSource { suspend fun searchPublicChats(query: String): TdApi.Chats? suspend fun searchMessages(query: String, offset: String, limit: Int): TdApi.FoundMessages? suspend fun getChat(chatId: Long): TdApi.Chat? + suspend fun getUser(userId: Long): TdApi.User? suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int): Long suspend fun createChannel(title: String, description: String, isMegagroup: Boolean, messageAutoDeleteTime: Int): Long suspend fun setChatPhoto(chatId: Long, photoPath: String) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/EmojiRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/EmojiRemoteSource.kt new file mode 100644 index 00000000..166d622e --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/EmojiRemoteSource.kt @@ -0,0 +1,10 @@ +package org.monogram.data.datasource.remote + +import org.monogram.domain.models.StickerModel + +interface EmojiRemoteSource { + suspend fun getEmojiCategories(): List + suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List + suspend fun searchEmojis(query: String): List + suspend fun searchCustomEmojis(query: String): List +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/GifRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/GifRemoteSource.kt new file mode 100644 index 00000000..eb44caf0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/GifRemoteSource.kt @@ -0,0 +1,9 @@ +package org.monogram.data.datasource.remote + +import org.monogram.domain.models.GifModel + +interface GifRemoteSource { + suspend fun getSavedGifs(): List + suspend fun addSavedGif(path: String) + suspend fun searchGifs(query: String): List +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/LinkRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/LinkRemoteDataSource.kt new file mode 100644 index 00000000..eb35b0bc --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/LinkRemoteDataSource.kt @@ -0,0 +1,15 @@ +package org.monogram.data.datasource.remote + +import org.drinkless.tdlib.TdApi + +interface LinkRemoteDataSource { + suspend fun getInternalLinkType(url: String): TdApi.InternalLinkType? + suspend fun getMessageLinkInfo(url: String): TdApi.MessageLinkInfo? + suspend fun searchPublicChat(username: String): TdApi.Chat? + suspend fun checkChatInviteLink(inviteLink: String): TdApi.ChatInviteLinkInfo? + suspend fun joinChatByInviteLink(inviteLink: String): TdApi.Chat? + suspend fun getMe(): TdApi.User? + suspend fun createPrivateChat(userId: Long): TdApi.Chat? + suspend fun searchUserByPhoneNumber(phoneNumber: String): TdApi.User? + suspend fun searchUserByToken(token: String): TdApi.User? +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/NominatimRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/NominatimRemoteDataSource.kt new file mode 100644 index 00000000..5c9a7cd7 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/NominatimRemoteDataSource.kt @@ -0,0 +1,63 @@ +package org.monogram.data.datasource.remote + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.monogram.domain.models.webapp.OSMReverseResponse +import java.net.HttpURLConnection +import java.net.URI + +class NominatimRemoteDataSource { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + + suspend fun reverseGeocode(lat: Double, lon: Double): OSMReverseResponse? { + val url = "$BASE_URL/reverse?format=jsonv2&lat=$lat&lon=$lon&addressdetails=1" + val responseText = makeHttpRequest(url) ?: return null + + return try { + json.decodeFromString(responseText) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse reverse geocode response", e) + null + } + } + + private suspend fun makeHttpRequest(urlString: String): String? = withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + val url = URI(urlString).toURL() + connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/json") + } + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + connection.inputStream.bufferedReader().use { it.readText() } + } else { + Log.e(TAG, "Nominatim response code=$responseCode url=$urlString") + null + } + } catch (e: Exception) { + Log.e(TAG, "Nominatim network request failed", e) + null + } finally { + connection?.disconnect() + } + } + + private companion object { + private const val TAG = "NominatimRemote" + private const val BASE_URL = "https://nominatim.openstreetmap.org" + private const val USER_AGENT = "MonoGram-Android-App/1.0" + private const val TIMEOUT_MS = 15_000 + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/StickerRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/StickerRemoteSource.kt index 1d8c466b..bcd27fe0 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/StickerRemoteSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/StickerRemoteSource.kt @@ -1,6 +1,5 @@ package org.monogram.data.datasource.remote -import org.monogram.domain.models.GifModel import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel import org.monogram.domain.models.StickerType @@ -14,14 +13,7 @@ interface StickerRemoteSource { suspend fun toggleStickerSetInstalled(setId: Long, isInstalled: Boolean) suspend fun toggleStickerSetArchived(setId: Long, isArchived: Boolean) suspend fun reorderStickerSets(type: StickerType, setIds: List) - suspend fun getEmojiCategories(): List - suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List - suspend fun searchEmojis(query: String): List - suspend fun searchCustomEmojis(query: String): List suspend fun searchStickers(query: String): List suspend fun searchStickerSets(query: String): List - suspend fun getSavedGifs(): List - suspend fun addSavedGif(path: String) - suspend fun searchGifs(query: String): List suspend fun clearRecentStickers() } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt index 811d5b45..218bac89 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt @@ -37,6 +37,10 @@ class TdChatRemoteSource( return coRunCatching { gateway.execute(TdApi.GetChat(chatId)) }.getOrNull() } + override suspend fun getUser(userId: Long): TdApi.User? { + return coRunCatching { gateway.execute(TdApi.GetUser(userId)) }.getOrNull() + } + override suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int): Long { return coRunCatching { gateway.execute(TdApi.CreateNewBasicGroupChat(userIds.toLongArray(), title, messageAutoDeleteTime)).chatId diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdEmojiRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdEmojiRemoteSource.kt new file mode 100644 index 00000000..2bda82bd --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdEmojiRemoteSource.kt @@ -0,0 +1,68 @@ +package org.monogram.data.datasource.remote + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.mapper.toDomain +import org.monogram.domain.models.StickerModel + +class TdEmojiRemoteSource( + private val gateway: TelegramGateway +) : EmojiRemoteSource { + override suspend fun getEmojiCategories(): List { + val types = listOf( + TdApi.EmojiCategoryTypeDefault(), + TdApi.EmojiCategoryTypeRegularStickers(), + TdApi.EmojiCategoryTypeEmojiStatus(), + TdApi.EmojiCategoryTypeChatPhoto() + ) + + return coroutineScope { + types + .map { type -> + async { + coRunCatching { + gateway.execute(TdApi.GetEmojiCategories(type)) + }.getOrNull() + } + } + .awaitAll() + .asSequence() + .filterNotNull() + .flatMap { it.categories.asSequence() } + .mapNotNull { it.source as? TdApi.EmojiCategorySourceSearch } + .flatMap { it.emojis.asSequence() } + .toList() + } + } + + override suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List { + return coRunCatching { + val result = gateway.execute(TdApi.GetMessageAvailableReactions(chatId, messageId, 32)) + buildSet { + result.topReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } + result.recentReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } + result.popularReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } + }.toList() + }.getOrDefault(emptyList()) + } + + override suspend fun searchEmojis(query: String): List { + return coRunCatching { + gateway.execute(TdApi.SearchEmojis(query, emptyArray())) + .emojiKeywords + .map { it.emoji } + }.getOrDefault(emptyList()) + } + + override suspend fun searchCustomEmojis(query: String): List { + return coRunCatching { + gateway.execute( + TdApi.SearchStickers(TdApi.StickerTypeCustomEmoji(), "", query, emptyArray(), 0, 100) + ).stickers.map { it.toDomain() } + }.getOrDefault(emptyList()) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdGifRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdGifRemoteSource.kt new file mode 100644 index 00000000..6695614f --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdGifRemoteSource.kt @@ -0,0 +1,62 @@ +package org.monogram.data.datasource.remote + +import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching +import org.monogram.data.gateway.TelegramGateway +import org.monogram.domain.models.GifModel + +class TdGifRemoteSource( + private val gateway: TelegramGateway +) : GifRemoteSource { + override suspend fun getSavedGifs(): List { + return coRunCatching { + val recentGifs = coRunCatching { + gateway.execute(TdApi.GetSavedAnimations()) + .animations + .map { animation -> + GifModel( + id = animation.animation.remote?.id?.takeIf { it.isNotEmpty() } + ?: animation.animation.id.toString(), + fileId = animation.animation.id.toLong(), + thumbFileId = animation.thumbnail?.file?.id?.toLong(), + width = animation.width, + height = animation.height + ) + } + }.getOrDefault(emptyList()) + + if (recentGifs.isNotEmpty()) { + return@coRunCatching recentGifs + } + + searchGifs("") + }.getOrDefault(emptyList()) + } + + override suspend fun addSavedGif(path: String) { + coRunCatching { + gateway.execute(TdApi.AddSavedAnimation(TdApi.InputFileLocal(path))) + } + } + + override suspend fun searchGifs(query: String): List { + return coRunCatching { + val chat = gateway.execute(TdApi.SearchPublicChat("gif")) + val type = chat.type as? TdApi.ChatTypePrivate ?: return@coRunCatching emptyList() + + gateway.execute(TdApi.GetInlineQueryResults(type.userId, chat.id, null, query, "")) + .results + .mapNotNull { item -> + if (item !is TdApi.InlineQueryResultAnimation) return@mapNotNull null + + GifModel( + id = item.id, + fileId = item.animation.animation.id.toLong(), + thumbFileId = item.animation.thumbnail?.file?.id?.toLong(), + width = item.animation.width, + height = item.animation.height + ) + } + }.getOrDefault(emptyList()) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdLinkRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdLinkRemoteDataSource.kt new file mode 100644 index 00000000..39c03df4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdLinkRemoteDataSource.kt @@ -0,0 +1,45 @@ +package org.monogram.data.datasource.remote + +import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching +import org.monogram.data.gateway.TelegramGateway + +class TdLinkRemoteDataSource( + private val gateway: TelegramGateway +) : LinkRemoteDataSource { + + override suspend fun getInternalLinkType(url: String): TdApi.InternalLinkType? = + coRunCatching { gateway.execute(TdApi.GetInternalLinkType(url)) }.getOrNull() + + override suspend fun getMessageLinkInfo(url: String): TdApi.MessageLinkInfo? = + coRunCatching { gateway.execute(TdApi.GetMessageLinkInfo(url)) }.getOrNull() + + override suspend fun searchPublicChat(username: String): TdApi.Chat? = + coRunCatching { gateway.execute(TdApi.SearchPublicChat(username)) }.getOrNull() + + override suspend fun checkChatInviteLink(inviteLink: String): TdApi.ChatInviteLinkInfo? = + coRunCatching { gateway.execute(TdApi.CheckChatInviteLink(inviteLink)) }.getOrNull() + + override suspend fun joinChatByInviteLink(inviteLink: String): TdApi.Chat? = + coRunCatching { gateway.execute(TdApi.JoinChatByInviteLink(inviteLink)) }.getOrNull() + + override suspend fun getMe(): TdApi.User? = + coRunCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() + + override suspend fun createPrivateChat(userId: Long): TdApi.Chat? { + if (userId == 0L) return null + return coRunCatching { gateway.execute(TdApi.CreatePrivateChat(userId, false)) }.getOrNull() + } + + override suspend fun searchUserByPhoneNumber(phoneNumber: String): TdApi.User? { + if (phoneNumber.isBlank()) return null + return coRunCatching { + gateway.execute(TdApi.SearchUserByPhoneNumber(phoneNumber, true)) + }.getOrNull() + } + + override suspend fun searchUserByToken(token: String): TdApi.User? { + if (token.isBlank()) return null + return coRunCatching { gateway.execute(TdApi.SearchUserByToken(token)) }.getOrNull() + } +} \ No newline at end of file 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 5c71f698..11632a7f 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 @@ -24,7 +24,7 @@ class TdMessageRemoteDataSource( private val gateway: TelegramGateway, private val messageMapper: MessageMapper, private val userRepository: UserRepository, - private val chatsListRepository: ChatsListRepository, + private val chatListRepository: ChatListRepository, private val cache: ChatCache, private val pollRepository: PollRepository, private val fileDownloadQueue: FileDownloadQueue, @@ -321,7 +321,7 @@ class TdMessageRemoteDataSource( if (cachedChat != null) { UserModel(id = cachedChat.id, firstName = cachedChat.title, lastName = "", username = null, avatarPath = cachedChat.photo?.small?.local?.path) } else { - val chat = chatsListRepository.getChatById(sender.chatId) + val chat = chatListRepository.getChatById(sender.chatId) if (chat != null) UserModel(id = chat.id, firstName = chat.title, lastName = "", username = null, avatarPath = chat.avatarPath) else null } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdStickerRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdStickerRemoteSource.kt index ab5c3697..ddf68eed 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdStickerRemoteSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdStickerRemoteSource.kt @@ -1,14 +1,10 @@ package org.monogram.data.datasource.remote -import org.monogram.data.core.coRunCatching -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.toApi import org.monogram.data.mapper.toDomain -import org.monogram.domain.models.GifModel import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel import org.monogram.domain.models.StickerType @@ -16,6 +12,7 @@ import org.monogram.domain.models.StickerType class TdStickerRemoteSource( private val gateway: TelegramGateway ) : StickerRemoteSource { + override suspend fun getInstalledStickerSets(type: StickerType): List { return coRunCatching { gateway.execute(TdApi.GetInstalledStickerSets(type.toApi())) @@ -66,53 +63,6 @@ class TdStickerRemoteSource( } } - override suspend fun getEmojiCategories(): List { - val types = listOf( - TdApi.EmojiCategoryTypeDefault(), - TdApi.EmojiCategoryTypeRegularStickers(), - TdApi.EmojiCategoryTypeEmojiStatus(), - TdApi.EmojiCategoryTypeChatPhoto() - ) - return coroutineScope { - types - .map { type -> async { coRunCatching { gateway.execute(TdApi.GetEmojiCategories(type)) }.getOrNull() } } - .awaitAll() - .asSequence() - .filterNotNull() - .flatMap { it.categories.asSequence() } - .mapNotNull { it.source as? TdApi.EmojiCategorySourceSearch } - .flatMap { it.emojis.asSequence() } - .toList() - } - } - - override suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List { - return coRunCatching { - val result = gateway.execute(TdApi.GetMessageAvailableReactions(chatId, messageId, 32)) - buildSet { - result.topReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } - result.recentReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } - result.popularReactions.forEach { (it.type as? TdApi.ReactionTypeEmoji)?.let { r -> add(r.emoji) } } - }.toList() - }.getOrDefault(emptyList()) - } - - override suspend fun searchEmojis(query: String): List { - return coRunCatching { - gateway.execute(TdApi.SearchEmojis(query, emptyArray())) - .emojiKeywords - .map { it.emoji } - }.getOrDefault(emptyList()) - } - - override suspend fun searchCustomEmojis(query: String): List { - return coRunCatching { - gateway.execute( - TdApi.SearchStickers(TdApi.StickerTypeCustomEmoji(), "", query, emptyArray(), 0, 100) - ).stickers.map { it.toDomain() } - }.getOrDefault(emptyList()) - } - override suspend fun searchStickers(query: String): List { return coRunCatching { gateway.execute( @@ -129,54 +79,6 @@ class TdStickerRemoteSource( }.getOrDefault(emptyList()) } - override suspend fun getSavedGifs(): List { - return coRunCatching { - val recentGifs = coRunCatching { - gateway.execute(TdApi.GetSavedAnimations()) - .animations - .map { animation -> - GifModel( - id = animation.animation.remote?.id?.takeIf { it.isNotEmpty() } ?: animation.animation.id.toString(), - fileId = animation.animation.id.toLong(), - thumbFileId = animation.thumbnail?.file?.id?.toLong(), - width = animation.width, - height = animation.height - ) - } - }.getOrDefault(emptyList()) - - when { - recentGifs.isNotEmpty() -> { - return@coRunCatching recentGifs - } - else -> return searchGifs("") - } - }.getOrDefault(emptyList()) - } - - override suspend fun addSavedGif(path: String) { - coRunCatching { gateway.execute(TdApi.AddSavedAnimation(TdApi.InputFileLocal(path))) } - } - - override suspend fun searchGifs(query: String): List { - return coRunCatching { - val chat = gateway.execute(TdApi.SearchPublicChat("gif")) - val type = chat.type as? TdApi.ChatTypePrivate ?: return emptyList() - gateway.execute(TdApi.GetInlineQueryResults(type.userId, chat.id, null, query, "")) - .results - .mapNotNull { item -> - if (item !is TdApi.InlineQueryResultAnimation) return@mapNotNull null - GifModel( - id = item.id, - fileId = item.animation.animation.id.toLong(), - thumbFileId = item.animation.thumbnail?.file?.id?.toLong(), - width = item.animation.width, - height = item.animation.height - ) - } - }.getOrDefault(emptyList()) - } - override suspend fun clearRecentStickers() { coRunCatching { gateway.execute(TdApi.ClearRecentStickers()) } } diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index 9cfd1ee2..31b46b38 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -32,9 +32,9 @@ import org.monogram.data.service.NotificationDismissReceiver import org.monogram.data.service.NotificationReadReceiver import org.monogram.data.service.NotificationReplyReceiver import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.domain.repository.PushProvider -import org.monogram.domain.repository.SettingsRepository -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope import java.util.concurrent.ConcurrentHashMap import kotlin.math.min @@ -42,7 +42,7 @@ class TdNotificationManager( private val context: Context, private val gateway: TelegramGateway, private val appPreferences: AppPreferencesProvider, - private val settingsRepository: SettingsRepository, + private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingDao: NotificationSettingDao, private val fileQueue: FileDownloadQueue ) { @@ -246,7 +246,7 @@ class TdNotificationManager( ) scopes.forEach { (key, scope) -> - val enabled = coRunCatching { settingsRepository.getNotificationSettings(scope) } + val enabled = coRunCatching { notificationSettingsRepository.getNotificationSettings(scope) } .getOrDefault(false) scopeNotificationsEnabled[key] = enabled diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 961f322d..ec4a9b83 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -29,6 +29,8 @@ import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.NetworkMapper import org.monogram.data.mapper.StorageMapper import org.monogram.data.repository.* +import org.monogram.data.repository.user.UserRepositoryImpl +import org.monogram.data.stickers.StickerFileManager import org.monogram.domain.repository.* val dataModule = module { @@ -39,6 +41,7 @@ val dataModule = module { single { DefaultDispatcherProvider() } single { DefaultScopeProvider(get()) } single { AndroidStringProvider(androidContext()) } + single { TdLibParametersProvider(androidContext()) } single(createdAtStart = true) { OfflineWarmup( scopeProvider = get(), @@ -85,6 +88,10 @@ val dataModule = module { ) } + single { + NominatimRemoteDataSource() + } + factory { PlayerDataSourceFactoryImpl( fileDataSource = get() @@ -93,7 +100,7 @@ val dataModule = module { single(createdAtStart = true) { AuthRepositoryImpl( - context = androidContext(), + parametersProvider = get(), remote = get(), updates = get(), scopeProvider = get() @@ -106,6 +113,12 @@ val dataModule = module { ) } + factory { + TdLinkRemoteDataSource( + gateway = get() + ) + } + // Database single { Room.databaseBuilder( @@ -153,6 +166,14 @@ val dataModule = module { ) } + single { + RoomStickerLocalDataSource( + stickerSetDao = get(), + recentEmojiDao = get(), + stickerPathDao = get() + ) + } + single { UserRepositoryImpl( remote = get(), @@ -164,6 +185,54 @@ val dataModule = module { gateway = get(), fileQueue = get(), keyValueDao = get(), + cacheProvider = get() + ) + } + + single { + UserProfileEditRepositoryImpl( + remote = get() + ) + } + + single { + ProfilePhotoRepositoryImpl( + remote = get(), + chatLocal = get(), + gateway = get(), + updates = get(), + fileQueue = get() + ) + } + + single { + ChatInfoRepositoryImpl( + remote = get(), + chatLocal = get(), + userRepository = get() + ) + } + + single { + PremiumRepositoryImpl( + remote = get() + ) + } + + single { + BotRepositoryImpl( + remote = get() + ) + } + + single { + ChatStatisticsRepositoryImpl( + remote = get() + ) + } + + single { + SponsorRepositoryImpl( sponsorSyncManager = get() ) } @@ -218,10 +287,11 @@ val dataModule = module { connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, gateway = get(), userRepository = get(), + chatInfoRepository = get(), customEmojiPaths = get().customEmojiPaths, fileIdToCustomEmojiId = get().fileIdToCustomEmojiId, fileApi = get(), - settingsRepository = get(), + appPreferences = get(), cache = get(), scopeProvider = get() ) @@ -239,12 +309,10 @@ val dataModule = module { ) } - single { + single { ChatsListRepositoryImpl( remoteDataSource = get(), - cacheDataSource = get(), chatRemoteSource = get(), - proxyRemoteSource = get(), updates = get(), appPreferences = get(), cacheProvider = get(), @@ -264,6 +332,13 @@ val dataModule = module { stringProvider = get() ) } + single { get() } + single { get() } + single { get() } + single { get() } + single { get() } + single { get() } + single { get() } factory { TdSettingsRemoteDataSource( @@ -276,24 +351,63 @@ val dataModule = module { InMemorySettingsCacheDataSource() } - single { - SettingsRepositoryImpl( + single { + NotificationSettingsRepositoryImpl( remote = get(), cache = get(), chatsRemote = get(), updates = get(), - appPreferences = get(), - cacheProvider = get(), scopeProvider = get(), - dispatchers = get(), - attachBotDao = get(), - keyValueDao = get(), + dispatchers = get() + ) + } + + single { + SessionRepositoryImpl( + remote = get() + ) + } + + single { + WallpaperRepositoryImpl( + remote = get(), + updates = get(), wallpaperDao = get(), + dispatchers = get(), + scopeProvider = get() + ) + } + + single { + StorageRepositoryImpl( + remote = get(), + cache = get(), + chatsRemote = get(), + dispatchers = get(), storageMapper = get(), - stringProvider = get(), + stringProvider = get() + ) + } + + single { + NetworkStatisticsRepositoryImpl( + remote = get(), networkMapper = get() ) } + + single { + AttachMenuBotRepositoryImpl( + remote = get(), + cache = get(), + cacheProvider = get(), + updates = get(), + dispatchers = get(), + attachBotDao = get(), + scopeProvider = get() + ) + } + single { PollRepositoryImpl() } @@ -303,7 +417,7 @@ val dataModule = module { gateway = get(), messageMapper = get(), userRepository = get(), - chatsListRepository = get(), + chatListRepository = get(), cache = get(), pollRepository = get(), fileDownloadQueue = get(), @@ -330,12 +444,31 @@ val dataModule = module { ) } + single { get() } + single { get() } + single { get() } + single { get() } + single { get() } + single { get() } + factory { TdStickerRemoteSource( gateway = get() ) } + factory { + TdGifRemoteSource( + gateway = get() + ) + } + + factory { + TdEmojiRemoteSource( + gateway = get() + ) + } + single { FileMessageRegistry() } @@ -359,19 +492,44 @@ val dataModule = module { ) } + single { + StickerFileManager( + localDataSource = get(), + fileQueue = get(), + fileUpdateHandler = get(), + dispatchers = get(), + scopeProvider = get() + ) + } + single { StickerRepositoryImpl( remote = get(), - fileQueue = get(), - fileUpdateHandler = get(), + fileManager = get(), updates = get(), cacheProvider = get(), dispatchers = get(), + localDataSource = get(), + scopeProvider = get() + ) + } + + single { + GifRepositoryImpl( + remote = get(), + cacheProvider = get(), + stickerFileManager = get() + ) + } + + single { + EmojiRepositoryImpl( + remote = get(), + localDataSource = get(), + cacheProvider = get(), + dispatchers = get(), context = androidContext(), - scopeProvider = get(), - stickerSetDao = get(), - recentEmojiDao = get(), - stickerPathDao = get() + scopeProvider = get() ) } @@ -389,8 +547,12 @@ val dataModule = module { ) } + single { + LinkParser() + } + single { - LinkHandlerRepositoryImpl(get(), get(), get(), get()) + LinkHandlerRepositoryImpl(get(), get(), get(), get(), get()) } single { @@ -417,7 +579,9 @@ val dataModule = module { } single { - LocationRepositoryImpl() + LocationRepositoryImpl( + remote = get() + ) } factory { diff --git a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt index 3370c219..3d18f839 100644 --- a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt +++ b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt @@ -3,3 +3,8 @@ package org.monogram.data.gateway import org.drinkless.tdlib.TdApi class TdLibException(val error: TdApi.Error) : Exception(error.message) + +fun Throwable.toUserMessage(defaultMessage: String = "Unknown error"): String { + val tdMessage = (this as? TdLibException)?.error?.message.orEmpty() + return tdMessage.ifEmpty { message ?: defaultMessage } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt b/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt new file mode 100644 index 00000000..9fceffe3 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/TdLibParametersProvider.kt @@ -0,0 +1,37 @@ +package org.monogram.data.infra + +import android.content.Context +import android.os.Build +import org.drinkless.tdlib.TdApi +import org.monogram.data.BuildConfig +import java.io.File +import java.util.* + +class TdLibParametersProvider( + private val context: Context +) { + fun create(): TdApi.SetTdlibParameters { + return TdApi.SetTdlibParameters().apply { + databaseDirectory = File(context.filesDir, "td-db").absolutePath + filesDirectory = File(context.filesDir, "td-files").absolutePath + databaseEncryptionKey = byteArrayOf() + apiId = BuildConfig.API_ID + apiHash = BuildConfig.API_HASH + systemLanguageCode = Locale.getDefault().language + deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}" + systemVersion = Build.VERSION.RELEASE + applicationVersion = resolveAppVersion() + useMessageDatabase = true + useFileDatabase = true + useChatInfoDatabase = true + } + } + + private fun resolveAppVersion(): String { + return try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0" + } catch (_: Exception) { + "1.0" + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt new file mode 100644 index 00000000..66cbe9d3 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt @@ -0,0 +1,122 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.ChatEntity + +fun TdApi.Chat.toEntity(): ChatEntity { + val isChannel = (type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false + val isArchived = positions.any { it.list is TdApi.ChatListArchive } + val permissions = permissions ?: TdApi.ChatPermissions() + val cachedCounts = parseCachedCounts(clientData) + val senderId = when (val sender = messageSenderId) { + is TdApi.MessageSenderUser -> sender.userId + is TdApi.MessageSenderChat -> sender.chatId + else -> null + } + val privateUserId = (type as? TdApi.ChatTypePrivate)?.userId ?: 0L + val basicGroupId = (type as? TdApi.ChatTypeBasicGroup)?.basicGroupId ?: 0L + val supergroupId = (type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: 0L + val secretChatId = (type as? TdApi.ChatTypeSecret)?.secretChatId ?: 0 + return ChatEntity( + id = id, + title = title, + unreadCount = unreadCount, + avatarPath = photo?.small?.local?.path, + lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "", + lastMessageTime = (lastMessage?.date?.toLong() ?: 0L).toString(), + lastMessageDate = lastMessage?.date ?: 0, + order = positions.firstOrNull()?.order ?: 0L, + isPinned = positions.firstOrNull()?.isPinned ?: false, + isMuted = notificationSettings.muteFor > 0, + isChannel = isChannel, + isGroup = type is TdApi.ChatTypeBasicGroup || (type is TdApi.ChatTypeSupergroup && !isChannel), + type = when (type) { + is TdApi.ChatTypePrivate -> "PRIVATE" + is TdApi.ChatTypeBasicGroup -> "BASIC_GROUP" + is TdApi.ChatTypeSupergroup -> "SUPERGROUP" + is TdApi.ChatTypeSecret -> "SECRET" + else -> "PRIVATE" + }, + privateUserId = privateUserId, + basicGroupId = basicGroupId, + supergroupId = supergroupId, + secretChatId = secretChatId, + positionsCache = encodePositions(positions), + isArchived = isArchived, + memberCount = cachedCounts.first, + onlineCount = cachedCounts.second, + unreadMentionCount = unreadMentionCount, + unreadReactionCount = unreadReactionCount, + isMarkedAsUnread = isMarkedAsUnread, + hasProtectedContent = hasProtectedContent, + isTranslatable = isTranslatable, + hasAutomaticTranslation = false, + messageAutoDeleteTime = messageAutoDeleteTime, + canBeDeletedOnlyForSelf = canBeDeletedOnlyForSelf, + canBeDeletedForAllUsers = canBeDeletedForAllUsers, + canBeReported = canBeReported, + lastReadInboxMessageId = lastReadInboxMessageId, + lastReadOutboxMessageId = lastReadOutboxMessageId, + lastMessageId = lastMessage?.id ?: 0L, + isLastMessageOutgoing = lastMessage?.isOutgoing ?: false, + replyMarkupMessageId = replyMarkupMessageId, + messageSenderId = senderId, + blockList = blockList != null, + emojiStatusId = (emojiStatus?.type as? TdApi.EmojiStatusTypeCustomEmoji)?.customEmojiId, + accentColorId = accentColorId, + profileAccentColorId = profileAccentColorId, + backgroundCustomEmojiId = backgroundCustomEmojiId, + photoId = photo?.small?.id ?: 0, + isSupergroup = type is TdApi.ChatTypeSupergroup, + isAdmin = false, + isOnline = false, + typingAction = null, + draftMessage = (draftMessage?.inputMessageText as? TdApi.InputMessageText)?.text?.text, + isVerified = false, + viewAsTopics = viewAsTopics, + isForum = false, + isBot = false, + isMember = true, + username = null, + description = null, + inviteLink = null, + permissionCanSendBasicMessages = permissions.canSendBasicMessages, + permissionCanSendAudios = permissions.canSendAudios, + permissionCanSendDocuments = permissions.canSendDocuments, + permissionCanSendPhotos = permissions.canSendPhotos, + permissionCanSendVideos = permissions.canSendVideos, + permissionCanSendVideoNotes = permissions.canSendVideoNotes, + permissionCanSendVoiceNotes = permissions.canSendVoiceNotes, + permissionCanSendPolls = permissions.canSendPolls, + permissionCanSendOtherMessages = permissions.canSendOtherMessages, + permissionCanAddLinkPreviews = permissions.canAddLinkPreviews, + permissionCanEditTag = permissions.canEditTag, + permissionCanChangeInfo = permissions.canChangeInfo, + permissionCanInviteUsers = permissions.canInviteUsers, + permissionCanPinMessages = permissions.canPinMessages, + permissionCanCreateTopics = permissions.canCreateTopics, + createdAt = System.currentTimeMillis() + ) +} + +private fun encodePositions(positions: Array): String? { + if (positions.isEmpty()) return null + val encoded = positions.mapNotNull { pos -> + if (pos.order == 0L) return@mapNotNull null + val pinned = if (pos.isPinned) 1 else 0 + when (val list = pos.list) { + is TdApi.ChatListMain -> "m:${pos.order}:$pinned" + is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" + is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" + else -> null + } + } + return if (encoded.isEmpty()) null else encoded.joinToString("|") +} + +private fun parseCachedCounts(clientData: String?): Pair { + if (clientData.isNullOrBlank()) return 0 to 0 + val memberCount = Regex("""mc:(\d+)""").find(clientData)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 0 + val onlineCount = Regex("""oc:(\d+)""").find(clientData)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 0 + return memberCount to onlineCount +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/LinkMapper.kt b/data/src/main/java/org/monogram/data/mapper/LinkMapper.kt new file mode 100644 index 00000000..51a7804c --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/LinkMapper.kt @@ -0,0 +1,25 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.repository.LinkAction + +fun TdApi.SettingsSection?.toLinkSettingsType(): LinkAction.SettingsType = when (this) { + is TdApi.SettingsSectionPrivacyAndSecurity -> LinkAction.SettingsType.PRIVACY + is TdApi.SettingsSectionDevices -> LinkAction.SettingsType.SESSIONS + is TdApi.SettingsSectionChatFolders -> LinkAction.SettingsType.FOLDERS + is TdApi.SettingsSectionAppearance, + is TdApi.SettingsSectionNotifications -> LinkAction.SettingsType.CHAT + + is TdApi.SettingsSectionDataAndStorage -> LinkAction.SettingsType.DATA_STORAGE + is TdApi.SettingsSectionPowerSaving -> LinkAction.SettingsType.POWER_SAVING + is TdApi.SettingsSectionPremium -> LinkAction.SettingsType.PREMIUM + else -> LinkAction.SettingsType.MAIN +} + +fun TdApi.ProxyType?.toLinkProxyTypeOrNull(): ProxyTypeModel? = when (this) { + is TdApi.ProxyTypeMtproto -> ProxyTypeModel.Mtproto(secret) + is TdApi.ProxyTypeSocks5 -> ProxyTypeModel.Socks5(username, password) + is TdApi.ProxyTypeHttp -> ProxyTypeModel.Http(username, password, httpOnly) + else -> null +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 0b9ef94c..4e0b209b 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -11,7 +11,8 @@ import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.domain.models.* -import org.monogram.domain.repository.SettingsRepository +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.UserRepository import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -20,10 +21,11 @@ class MessageMapper( private val connectivityManager: ConnectivityManager, private val gateway: TelegramGateway, private val userRepository: UserRepository, + private val chatInfoRepository: ChatInfoRepository, private val customEmojiPaths: ConcurrentHashMap, private val fileIdToCustomEmojiId: ConcurrentHashMap, private val fileApi: MessageFileApi, - private val settingsRepository: SettingsRepository, + private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, scopeProvider: ScopeProvider ) { @@ -88,10 +90,10 @@ class MessageMapper( private fun isNetworkAutoDownloadEnabled(): Boolean { return when (getCurrentNetworkType()) { - is TdApi.NetworkTypeWiFi -> settingsRepository.autoDownloadWifi.value - is TdApi.NetworkTypeMobile -> settingsRepository.autoDownloadMobile.value - is TdApi.NetworkTypeMobileRoaming -> settingsRepository.autoDownloadRoaming.value - else -> settingsRepository.autoDownloadWifi.value + is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value + is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value + is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value + else -> appPreferences.autoDownloadWifi.value } } @@ -313,7 +315,7 @@ class MessageMapper( senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL } } else { val member = try { - withTimeout(500) { userRepository.getChatMember(msg.chatId, senderId) } + withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) } } catch (e: Exception) { null } @@ -793,8 +795,8 @@ class MessageMapper( TdMessageRemoteDataSource.DownloadType.DEFAULT -> { if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload } - TdMessageRemoteDataSource.DownloadType.STICKER -> networkAutoDownload && settingsRepository.autoDownloadStickers.value - TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> networkAutoDownload && settingsRepository.autoDownloadVideoNotes.value + TdMessageRemoteDataSource.DownloadType.STICKER -> networkAutoDownload && appPreferences.autoDownloadStickers.value + TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> networkAutoDownload && appPreferences.autoDownloadVideoNotes.value else -> networkAutoDownload } @@ -1133,7 +1135,7 @@ class MessageMapper( val videoPath = videoFile.local.path.takeIf { isValidPath(it) } fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id) - if (videoPath == null && networkAutoDownload && settingsRepository.autoDownloadVideoNotes.value) { + if (videoPath == null && networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, 0, 0, false) } @@ -1175,7 +1177,7 @@ class MessageMapper( val path = stickerFile.local.path.takeIf { isValidPath(it) } fileApi.registerFileForMessage(stickerFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload && settingsRepository.autoDownloadStickers.value) { + if (path == null && networkAutoDownload && appPreferences.autoDownloadStickers.value) { fileApi.enqueueDownload(stickerFile.id, 1, TdMessageRemoteDataSource.DownloadType.STICKER, 0, 0, false) } diff --git a/data/src/main/java/org/monogram/data/mapper/SettingsMapper.kt b/data/src/main/java/org/monogram/data/mapper/SettingsMapper.kt index ef258966..60b03fbc 100644 --- a/data/src/main/java/org/monogram/data/mapper/SettingsMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/SettingsMapper.kt @@ -1,7 +1,7 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope fun TdNotificationScope.toApi(): TdApi.NotificationSettingsScope = when (this) { TdNotificationScope.PRIVATE_CHATS -> TdApi.NotificationSettingsScopePrivateChats() diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt new file mode 100644 index 00000000..d309e9ae --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt @@ -0,0 +1,63 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.UserEntity + +fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { + val usernamesData = buildString { + append(usernames?.activeUsernames?.joinToString("|").orEmpty()) + append('\n') + append(usernames?.disabledUsernames?.joinToString("|").orEmpty()) + append('\n') + append(usernames?.editableUsername.orEmpty()) + append('\n') + append(usernames?.collectibleUsernames?.joinToString("|").orEmpty()) + } + + val statusType = when (status) { + is TdApi.UserStatusOnline -> "ONLINE" + is TdApi.UserStatusRecently -> "RECENTLY" + is TdApi.UserStatusLastWeek -> "LAST_WEEK" + is TdApi.UserStatusLastMonth -> "LAST_MONTH" + else -> "OFFLINE" + } + + val statusEmojiId = when (val type = emojiStatus?.type) { + is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId + is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId + else -> 0L + } + + return UserEntity( + id = id, + firstName = firstName, + lastName = lastName.ifEmpty { null }, + phoneNumber = phoneNumber.ifEmpty { null }, + avatarPath = profilePhoto?.big?.local?.path?.ifEmpty { null } + ?: profilePhoto?.small?.local?.path?.ifEmpty { null }, + personalAvatarPath = personalAvatarPath, + isPremium = isPremium, + isVerified = verificationStatus?.isVerified ?: false, + isSupport = isSupport, + isContact = isContact, + isMutualContact = isMutualContact, + isCloseFriend = isCloseFriend, + haveAccess = haveAccess, + username = usernames?.activeUsernames?.firstOrNull(), + usernamesData = usernamesData, + statusType = statusType, + accentColorId = accentColorId, + profileAccentColorId = profileAccentColorId, + statusEmojiId = statusEmojiId, + languageCode = languageCode.ifEmpty { null }, + lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, + createdAt = System.currentTimeMillis() + ) +} + +fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? { + val bestPhotoSize = personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: personalPhoto?.sizes?.lastOrNull() + return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } + ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt new file mode 100644 index 00000000..ddd86da2 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt @@ -0,0 +1,98 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.monogram.core.DispatcherProvider +import org.monogram.core.ScopeProvider +import org.monogram.data.datasource.cache.SettingsCacheDataSource +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.db.dao.AttachBotDao +import org.monogram.data.db.model.AttachBotEntity +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.mapper.toDomain +import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.CacheProvider + +class AttachMenuBotRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val cache: SettingsCacheDataSource, + private val cacheProvider: CacheProvider, + private val updates: UpdateDispatcher, + private val dispatchers: DispatcherProvider, + private val attachBotDao: AttachBotDao, + scopeProvider: ScopeProvider +) : AttachMenuBotRepository { + + private val scope = scopeProvider.appScope + private val attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value) + + init { + scope.launch { + updates.attachmentMenuBots.collect { update -> + cache.putAttachMenuBots(update.bots) + val bots = update.bots.map { it.toDomain() } + attachMenuBots.value = bots + cacheProvider.setAttachBots(bots) + + saveAttachBotsToDb(bots) + + update.bots.forEach { bot -> + bot.androidSideMenuIcon?.let { icon -> + if (icon.local.path.isEmpty()) { + remote.downloadFile(icon.id, 1) + } + } + } + } + } + + scope.launch { + updates.file.collect { update -> + val currentBots = attachMenuBots.value + if (currentBots.any { it.icon?.icon?.id == update.file.id }) { + cache.getAttachMenuBots()?.let { bots -> + val domainBots = bots.map { it.toDomain() } + attachMenuBots.value = domainBots + cacheProvider.setAttachBots(domainBots) + saveAttachBotsToDb(domainBots) + } + } + } + } + + scope.launch { + attachBotDao.getAttachBots().collect { entities -> + val bots = entities.mapNotNull { + try { + Json.decodeFromString(it.data) + } catch (_: Exception) { + null + } + } + if (bots.isNotEmpty()) { + attachMenuBots.value = bots + cacheProvider.setAttachBots(bots) + } + } + } + } + + override fun getAttachMenuBots(): Flow> { + return attachMenuBots + } + + private suspend fun saveAttachBotsToDb(bots: List) { + withContext(dispatchers.io) { + attachBotDao.clearAll() + attachBotDao.insertAttachBots( + bots.map { + AttachBotEntity(it.botUserId, Json.encodeToString(it)) + } + ) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt index a0bce991..79b307c8 100644 --- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt @@ -1,29 +1,24 @@ package org.monogram.data.repository -import android.content.Context -import android.os.Build import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.ScopeProvider -import org.monogram.data.BuildConfig import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.AuthRemoteDataSource -import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.gateway.toUserMessage +import org.monogram.data.infra.TdLibParametersProvider import org.monogram.data.mapper.toDomain import org.monogram.domain.repository.AuthRepository import org.monogram.domain.repository.AuthStep -import java.io.File -import java.util.* class AuthRepositoryImpl( - private val context: Context, + private val parametersProvider: TdLibParametersProvider, private val remote: AuthRemoteDataSource, private val updates: UpdateDispatcher, scopeProvider: ScopeProvider ) : AuthRepository { - private val scope = scopeProvider.appScope private val _authState = MutableStateFlow(AuthStep.Loading) @@ -45,57 +40,34 @@ class AuthRepositoryImpl( } private suspend fun sendTdLibParameters() { - coRunCatching { - val parameters = TdApi.SetTdlibParameters().apply { - databaseDirectory = File(context.filesDir, "td-db").absolutePath - filesDirectory = File(context.filesDir, "td-files").absolutePath - databaseEncryptionKey = byteArrayOf() - apiId = BuildConfig.API_ID - apiHash = BuildConfig.API_HASH - systemLanguageCode = Locale.getDefault().language - deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}" - systemVersion = Build.VERSION.RELEASE - applicationVersion = try { - context.packageManager.getPackageInfo(context.packageName, 0).versionName - } catch (e: Exception) { - "1.0" - } - useMessageDatabase = true - useFileDatabase = true - useChatInfoDatabase = true - } - remote.setTdlibParameters(parameters) - }.onFailure { emitError(it) } + coRunCatching { remote.setTdlibParameters(parametersProvider.create()) } + .onFailure { emitError(it) } } - override fun sendPhone(phone: String) { + private fun launchAuthAction(action: suspend () -> Unit) { scope.launch { - coRunCatching { remote.setPhoneNumber(phone) } + coRunCatching { action() } .onFailure { emitError(it) } } } + override fun sendPhone(phone: String) { + launchAuthAction { remote.setPhoneNumber(phone) } + } + override fun resendCode() { - scope.launch { - coRunCatching { remote.resendCode() } - .onFailure { emitError(it) } - } + launchAuthAction { remote.resendCode() } } override fun sendCode(code: String) { - scope.launch { + launchAuthAction { val isEmail = (_authState.value as? AuthStep.InputCode)?.isEmailCode == true - coRunCatching { - if (isEmail) remote.checkEmailCode(code) else remote.setAuthCode(code) - }.onFailure { emitError(it) } + if (isEmail) remote.checkEmailCode(code) else remote.setAuthCode(code) } } override fun sendPassword(password: String) { - scope.launch { - coRunCatching { remote.checkPassword(password) } - .onFailure { emitError(it) } - } + launchAuthAction { remote.checkPassword(password) } } override fun reset() { @@ -103,10 +75,6 @@ class AuthRepositoryImpl( } private fun emitError(t: Throwable) { - val error = (t as? TdLibException)?.error - val errorMessage = error?.message ?: "" - - val message = errorMessage.ifEmpty { t.message ?: "Unknown error" } - _errors.tryEmit(message) + _errors.tryEmit(t.toUserMessage()) } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt new file mode 100644 index 00000000..a4661e4c --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt @@ -0,0 +1,32 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotInfoModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.repository.BotRepository + +class BotRepositoryImpl( + private val remote: UserRemoteDataSource +) : BotRepository { + + override suspend fun getBotCommands(botId: Long): List { + val fullInfo = remote.getBotFullInfo(botId) ?: return emptyList() + return fullInfo.botInfo?.commands?.map { + BotCommandModel(it.command, it.description) + } ?: emptyList() + } + + override suspend fun getBotInfo(botId: Long): BotInfoModel? { + val fullInfo = remote.getBotFullInfo(botId) ?: return null + val commands = fullInfo.botInfo?.commands?.map { + BotCommandModel(it.command, it.description) + } ?: emptyList() + val menuButton = when (val btn = fullInfo.botInfo?.menuButton) { + is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(btn.text, btn.url) + else -> BotMenuButtonModel.Default + } + return BotInfoModel(commands, menuButton) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt new file mode 100644 index 00000000..f2018f2b --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ChatInfoRepositoryImpl.kt @@ -0,0 +1,119 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.data.mapper.toEntity +import org.monogram.data.mapper.user.* +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.GroupMemberModel +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.domain.repository.ChatMembersFilter +import org.monogram.domain.repository.UserRepository + +class ChatInfoRepositoryImpl( + private val remote: UserRemoteDataSource, + private val chatLocal: ChatLocalDataSource, + private val userRepository: UserRepository +) : ChatInfoRepository { + + override suspend fun getChatFullInfo(chatId: Long): ChatFullInfoModel? { + if (chatId == 0L) return null + + val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } + ?: chatLocal.getChat(chatId)?.toTdApiChat() + + if (chat != null) { + val dbFullInfo = chatLocal.getChatFullInfo(chatId) + return when (val type = chat.type) { + is TdApi.ChatTypePrivate -> { + userRepository.resolveUserChatFullInfo(type.userId) ?: dbFullInfo?.toDomain() + } + + is TdApi.ChatTypeSupergroup -> { + val fullInfo = remote.getSupergroupFullInfo(type.supergroupId) + val supergroup = remote.getSupergroup(type.supergroupId) + fullInfo?.let { + chatLocal.insertChatFullInfo(it.toEntity(chatId)) + } + fullInfo?.mapSupergroupFullInfoToChat(supergroup) ?: dbFullInfo?.toDomain() + } + + is TdApi.ChatTypeBasicGroup -> { + val fullInfo = remote.getBasicGroupFullInfo(type.basicGroupId) + fullInfo?.let { + chatLocal.insertChatFullInfo(it.toEntity(chatId)) + } + fullInfo?.mapBasicGroupFullInfoToChat() ?: dbFullInfo?.toDomain() + } + + else -> dbFullInfo?.toDomain() + } + } + + return userRepository.resolveUserChatFullInfo(chatId) + } + + override suspend fun searchPublicChat(username: String): ChatModel? { + val chat = remote.searchPublicChat(username) ?: return null + chatLocal.insertChat(chat.toEntity()) + return chat.toDomain() + } + + override suspend fun getChatMembers( + chatId: Long, + offset: Int, + limit: Int, + filter: ChatMembersFilter + ): List { + val chat = remote.getChat(chatId) ?: return emptyList() + val members: List = when (val type = chat.type) { + is TdApi.ChatTypeSupergroup -> { + val tdFilter = filter.toApi() + remote.getSupergroupMembers(type.supergroupId, tdFilter, offset, limit) + ?.members?.toList() ?: emptyList() + } + + is TdApi.ChatTypeBasicGroup -> { + if (offset > 0) return emptyList() + val fullInfo = remote.getBasicGroupMembers(type.basicGroupId) ?: return emptyList() + fullInfo.members.filter { member -> + when (filter) { + is ChatMembersFilter.Administrators -> + member.status is TdApi.ChatMemberStatusAdministrator || + member.status is TdApi.ChatMemberStatusCreator + + else -> true + } + } + } + + else -> emptyList() + } + + return coroutineScope { + members.map { member -> + async { + val sender = member.memberId as? TdApi.MessageSenderUser ?: return@async null + val user = userRepository.getUser(sender.userId) ?: return@async null + member.toDomain(user) + } + }.awaitAll().filterNotNull() + } + } + + override suspend fun getChatMember(chatId: Long, userId: Long): GroupMemberModel? { + val member = remote.getChatMember(chatId, userId) ?: return null + val user = userRepository.getUser(userId) ?: return null + return member.toDomain(user) + } + + override suspend fun setChatMemberStatus(chatId: Long, userId: Long, status: ChatMemberStatus) { + remote.setChatMemberStatus(chatId, userId, status.toApi()) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/ChatStatisticsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatStatisticsRepositoryImpl.kt new file mode 100644 index 00000000..97905455 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ChatStatisticsRepositoryImpl.kt @@ -0,0 +1,35 @@ +package org.monogram.data.repository + +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.data.mapper.user.toDomain +import org.monogram.domain.models.ChatRevenueStatisticsModel +import org.monogram.domain.models.ChatStatisticsModel +import org.monogram.domain.models.StatisticsGraphModel +import org.monogram.domain.repository.ChatStatisticsRepository + +class ChatStatisticsRepositoryImpl( + private val remote: UserRemoteDataSource +) : ChatStatisticsRepository { + + override suspend fun getChatStatistics(chatId: Long, isDark: Boolean): ChatStatisticsModel? { + val stats = remote.getChatStatistics(chatId, isDark) ?: return null + return stats.toDomain() + } + + override suspend fun getChatRevenueStatistics( + chatId: Long, + isDark: Boolean + ): ChatRevenueStatisticsModel? { + val stats = remote.getChatRevenueStatistics(chatId, isDark) ?: return null + return stats.toDomain() + } + + override suspend fun loadStatisticsGraph( + chatId: Long, + token: String, + x: Long + ): StatisticsGraphModel? { + val graph = remote.getStatisticsGraph(chatId, token, x) ?: return null + return graph.toDomain() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 813eaa62..7c22ce6f 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -1,33 +1,30 @@ package org.monogram.data.repository import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.core.ScopeProvider import org.monogram.data.chats.* import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource -import org.monogram.data.datasource.cache.ChatsCacheDataSource import org.monogram.data.datasource.remote.ChatRemoteSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource -import org.monogram.data.datasource.remote.ProxyRemoteDataSource import org.monogram.data.db.dao.ChatFolderDao import org.monogram.data.db.dao.SearchHistoryDao import org.monogram.data.db.dao.UserFullInfoDao -import org.monogram.data.db.model.ChatEntity -import org.monogram.data.db.model.SearchHistoryEntity -import org.monogram.data.db.model.TopicEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.ConnectionManager import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.MessageMapper -import org.monogram.data.mapper.user.toEntity import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.FolderModel @@ -39,9 +36,7 @@ import java.util.concurrent.atomic.AtomicLong class ChatsListRepositoryImpl( private val remoteDataSource: ChatsRemoteDataSource, - private val cacheDataSource: ChatsCacheDataSource, private val chatRemoteSource: ChatRemoteSource, - private val proxyRemoteSource: ProxyRemoteDataSource, private val updates: UpdateDispatcher, private val appPreferences: AppPreferencesProvider, private val cacheProvider: CacheProvider, @@ -59,19 +54,54 @@ class ChatsListRepositoryImpl( private val userFullInfoDao: UserFullInfoDao, private val fileQueue: FileDownloadQueue, private val stringProvider: StringProvider -) : ChatsListRepository { +) : ChatListRepository, + ChatFolderRepository, + ChatOperationsRepository, + ChatSearchRepository, + ForumTopicsRepository, + ChatSettingsRepository, + ChatCreationRepository { - private val TAG = "ChatsListRepo" - private val diagTag = "ChatListDiag" private val scope = scopeProvider.appScope + private val _chatListFlow = MutableStateFlow>(emptyList()) + override val chatListFlow: StateFlow> = _chatListFlow.asStateFlow() + + private val _folderChatsFlow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val folderChatsFlow: Flow = _folderChatsFlow.asSharedFlow() + + private val _foldersFlow = MutableStateFlow(listOf(FolderModel(-1, ""))) + override val foldersFlow: StateFlow> = _foldersFlow.asStateFlow() + + private val _isLoadingFlow = MutableStateFlow(false) + override val isLoadingFlow: StateFlow = _isLoadingFlow.asStateFlow() + + private val _folderLoadingFlow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val folderLoadingFlow: Flow = _folderLoadingFlow.asSharedFlow() + + override val connectionStateFlow = connectionManager.connectionStateFlow + override val isArchivePinned = appPreferences.isArchivePinned + override val isArchiveAlwaysVisible = appPreferences.isArchiveAlwaysVisible + private val fileManager = ChatFileManager( gateway = gateway, dispatchers = dispatchers, scopeProvider = scopeProvider, fileQueue = fileQueue, - onUpdate = { triggerUpdate(); refreshActiveForumTopics() } + onUpdate = { + triggerUpdate() + refreshActiveForumTopics() + } ) + private val typingManager = ChatTypingManager( scope = scope, usersCache = cache.usersCache, @@ -80,11 +110,16 @@ class ChatsListRepositoryImpl( onUpdate = { chatId -> triggerUpdate(chatId) }, onUserNeeded = { userId -> fetchUser(userId) } ) + private val listManager = ChatListManager(cache) { chatId -> if (cache.pendingChats.add(chatId)) { - scope.launch { refreshChat(chatId); cache.pendingChats.remove(chatId) } + scope.launch { + refreshChat(chatId) + cache.pendingChats.remove(chatId) + } } } + private val modelFactory = ChatModelFactory( gateway = gateway, dispatchers = dispatchers, @@ -99,35 +134,16 @@ class ChatsListRepositoryImpl( fetchUser = { userId -> fetchUser(userId) } ) - private val _chatListFlow = MutableStateFlow>(emptyList()) - override val chatListFlow = _chatListFlow.asStateFlow() - - private val _folderChatsFlow = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val folderChatsFlow: Flow = _folderChatsFlow.asSharedFlow() - - private val _foldersFlow = MutableStateFlow(listOf(FolderModel(-1, ""))) - override val foldersFlow = _foldersFlow.asStateFlow() - - private val _isLoadingFlow = MutableStateFlow(false) - override val isLoadingFlow = _isLoadingFlow.asStateFlow() - - private val _folderLoadingFlow = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val folderLoadingFlow: Flow = _folderLoadingFlow.asSharedFlow() - - override val connectionStateFlow = connectionManager.connectionStateFlow - - private val _forumTopicsFlow = MutableSharedFlow>>( - replay = 1, extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST + private val persistenceManager = ChatPersistenceManager( + scope = scope, + dispatchers = dispatchers, + cache = cache, + chatLocalDataSource = chatLocalDataSource, + chatMapper = chatMapper, + modelFactory = modelFactory, + listManager = listManager, + activeChatListProvider = { activeChatList } ) - override val forumTopicsFlow = _forumTopicsFlow.asSharedFlow() private val folderManager = ChatFolderManager( gateway = gateway, @@ -137,38 +153,79 @@ class ChatsListRepositoryImpl( cacheProvider = cacheProvider, chatFolderDao = chatFolderDao ) - override val isArchivePinned = appPreferences.isArchivePinned - override val isArchiveAlwaysVisible = appPreferences.isArchiveAlwaysVisible - override val searchHistory: Flow> = cacheProvider.searchHistory.map { ids -> - coroutineScope { - ids.map { id -> async { getChatById(id) } }.awaitAll().filterNotNull() - } - } - private var activeForumChatId: Long? = null + private val forumTopicsManager = ForumTopicsManager( + chatRemoteSource = chatRemoteSource, + chatMapper = chatMapper, + cache = cache, + fileManager = fileManager, + chatLocalDataSource = chatLocalDataSource, + dispatchers = dispatchers, + scope = scope, + fetchUser = { userId -> fetchUser(userId) } + ) + override val forumTopicsFlow: Flow>> = forumTopicsManager.forumTopicsFlow + + private val searchManager = ChatSearchManager( + chatRemoteSource = chatRemoteSource, + messageMapper = messageMapper, + cacheProvider = cacheProvider, + searchHistoryDao = searchHistoryDao, + dispatchers = dispatchers, + scope = scope, + resolveChatById = { chatId -> getChatById(chatId) } + ) + override val searchHistory: Flow> = searchManager.searchHistory + private var myUserId: Long = 0L @Volatile private var activeFolderId: Int = -1 - @Volatile private var activeChatList: TdApi.ChatList = TdApi.ChatListMain() + + @Volatile + private var activeChatList: TdApi.ChatList = TdApi.ChatListMain() + @Volatile private var activeRequestId: Long = 0L + private val requestIdGenerator = AtomicLong(0L) private val cacheHydrated = CompletableDeferred() private val updateChannel = Channel(Channel.CONFLATED) private var pendingSelectFolderJob: Job? = null + private val maxChatListLimit = 10_000 private val initialChatListLimit = 50 private var currentLimit = initialChatListLimit - private val lastSavedEntities = ConcurrentHashMap() - private val pendingSaveJobs = ConcurrentHashMap() private val modelCache = ConcurrentHashMap() private val invalidatedModels = ConcurrentHashMap.newKeySet() private var lastList: List? = null private var lastListFolderId: Int = -1 - private val mainChatList = TdApi.ChatListMain() + private val updateHandler = ChatUpdateHandler( + cache = cache, + listManager = listManager, + typingManager = typingManager, + fileManager = fileManager, + folderManager = folderManager, + chatLocalDataSource = chatLocalDataSource, + dispatchers = dispatchers, + scope = scope, + activeChatListProvider = { activeChatList }, + myUserIdProvider = { myUserId }, + onSaveChat = { chatId -> persistenceManager.scheduleChatSave(chatId) }, + onSaveChatsBySupergroupId = { supergroupId -> persistenceManager.scheduleSavesBySupergroupId(supergroupId) }, + onSaveChatsByBasicGroupId = { basicGroupId -> persistenceManager.scheduleSavesByBasicGroupId(basicGroupId) }, + onTriggerUpdate = { chatId -> triggerUpdate(chatId) }, + onRefreshChat = { chatId -> refreshChat(chatId) }, + onRefreshForumTopics = { refreshActiveForumTopics() }, + onAuthorizationStateClosed = { + clearTransientState() + scope.launch(dispatchers.io) { + chatLocalDataSource.clearAll() + } + } + ) init { scope.launch(dispatchers.io) { @@ -181,13 +238,13 @@ class ChatsListRepositoryImpl( if (entities.isNotEmpty()) { entities.forEach { entity -> cache.putChatFromEntity(entity) - lastSavedEntities[entity.id] = entity + persistenceManager.rememberSavedEntity(entity) } updateActiveListPositionsFromCache() triggerUpdate() } - }.onFailure { e -> - Log.e(TAG, "Failed to hydrate chat cache", e) + }.onFailure { error -> + Log.e(TAG, "Failed to hydrate chat cache", error) } if (!cacheHydrated.isCompleted) { @@ -196,446 +253,83 @@ class ChatsListRepositoryImpl( } scope.launch(dispatchers.io) { - for (u in updateChannel) { + for (unit in updateChannel) { rebuildAndEmit() - delay(250) + delay(REBUILD_THROTTLE_MS) } } scope.launch { - updates.chatsListUpdates.collect { update -> handleUpdate(update) } + updates.chatsListUpdates.collect { update -> + updateHandler.handle(update) + } } scope.launch { updates.chatFolders.collect { update -> - Log.d(TAG, "UpdateChatFolders received via dedicated flow") folderManager.handleChatFoldersUpdate(update) triggerUpdate() } } - - scope.launch { - searchHistoryDao.getSearchHistory().collect { entities -> - cacheProvider.setSearchHistory(entities.map { it.chatId }) - } - } } private suspend fun rebuildAndEmit() { coRunCatching { - activeRequestId - val folderIdAtStart = activeFolderId - val limitAtStart = currentLimit.coerceAtMost(maxChatListLimit) - val previousList = lastList - - val newList = listManager.rebuildChatList(limitAtStart, emptyList()) { chat, order, isPinned -> - val cached = modelCache[chat.id] - if (cached != null && cached.order == order && cached.isPinned == isPinned && !invalidatedModels.contains( - chat.id - ) - ) { - cached - } else { - modelFactory.mapChatToModel(chat, order, isPinned).also { - modelCache[chat.id] = it - invalidatedModels.remove(chat.id) - } - } - } + val folderId = activeFolderId + val limit = currentLimit.coerceAtMost(maxChatListLimit) + val newList = rebuildChatModels(limit) - if (folderIdAtStart != activeFolderId) { - Log.d( - diagTag, - "rebuild skipped folder switched from=$folderIdAtStart to=$activeFolderId limit=$limitAtStart" - ) + if (folderId != activeFolderId) { return@coRunCatching } - val folderChanged = folderIdAtStart != lastListFolderId - if (folderChanged || newList != lastList) { - val pinnedInPositions = cache.activeListPositions.entries - .asSequence() - .filter { it.value.isPinned } - .map { it.key } - .toSet() - val pinnedInList = newList.asSequence().filter { it.isPinned }.map { it.id }.toSet() - if (pinnedInPositions.size != pinnedInList.size) { - Log.w( - diagTag, - "emit mismatch folder=$folderIdAtStart pinnedPositions=${pinnedInPositions.size} pinnedList=${pinnedInList.size} missingInList=${ - (pinnedInPositions - pinnedInList).take( - 10 - ) - }" - ) - } - val prevSize = previousList?.size ?: 0 - val newSize = newList.size - val positionsSize = cache.activeListPositions.size - val invalidatedSize = invalidatedModels.size - if (previousList != null && newSize < prevSize) { - val previousIds = previousList.asSequence().map { it.id }.toHashSet() - val newIds = newList.asSequence().map { it.id }.toHashSet() - val lostIds = (previousIds - newIds).take(20) - Log.w( - diagTag, - "emit shrunk folder=$folderIdAtStart prev=$prevSize new=$newSize positions=$positionsSize limit=$limitAtStart invalidated=$invalidatedSize lost=$lostIds" - ) - } - _chatListFlow.value = newList - _folderChatsFlow.tryEmit(FolderChatsUpdate(folderIdAtStart, newList)) - lastList = newList - lastListFolderId = folderIdAtStart - - val toSave = newList.map { model -> - val chat = cache.getChat(model.id) - if (chat != null) { - val persistPosition = resolvePersistPosition(chat) - val mapped = chatMapper.mapToEntity(chat, model) - if (persistPosition != null && - (persistPosition.order != mapped.order || persistPosition.isPinned != mapped.isPinned) - ) { - mapped.copy(order = persistPosition.order, isPinned = persistPosition.isPinned) - } else { - mapped - } - } - else chatMapper.mapToEntity(model) - } - .filter { entity -> - val last = lastSavedEntities[entity.id] - if (last == null || isEntityChanged(last, entity)) { - lastSavedEntities[entity.id] = entity - true - } else { - false - } - } - - if (toSave.isNotEmpty()) { - chatLocalDataSource.insertChats(toSave) - } + if (!shouldEmitList(folderId, newList)) { + return@coRunCatching } - }.onFailure { e -> - Log.e(TAG, "Error rebuilding chat list", e) + + emitListUpdate(folderId, newList) + persistenceManager.persistChatModels(newList, activeChatList) + }.onFailure { error -> + Log.e(TAG, "Error rebuilding chat list", error) } } - private fun handleUpdate(update: TdApi.Update) { - when (update) { - is TdApi.UpdateNewChat -> { - cache.putChat(update.chat) - listManager.updateActiveListPositions(update.chat.id, update.chat.positions, activeChatList) - saveChatToDb(update.chat.id) - triggerUpdate(update.chat.id) - } - is TdApi.UpdateChatTitle -> { - cache.updateChat(update.chatId) { it.title = update.title } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatPhoto -> { - cache.updateChat(update.chatId) { it.photo = update.photo } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatEmojiStatus -> { - cache.updateChat(update.chatId) { it.emojiStatus = update.emojiStatus } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatDraftMessage -> { - cache.updateChat(update.chatId) { chat -> - chat.draftMessage = update.draftMessage - if (!update.positions.isNullOrEmpty()) { - chat.positions = update.positions - listManager.updateActiveListPositions(update.chatId, update.positions, activeChatList) - } - } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatPosition -> { - if (listManager.updateChatPositionInCache(update.chatId, update.position, activeChatList)) { - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - } - is TdApi.UpdateChatLastMessage -> { - cache.updateChat(update.chatId) { chat -> - chat.lastMessage = update.lastMessage - if (!update.positions.isNullOrEmpty()) { - chat.positions = update.positions - listManager.updateActiveListPositions(update.chatId, update.positions, activeChatList) - } - typingManager.clearTypingStatus(update.chatId) - } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatReadInbox -> { - cache.updateChat(update.chatId) { it.unreadCount = update.unreadCount } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatReadOutbox -> { - cache.updateChat(update.chatId) { it.lastReadOutboxMessageId = update.lastReadOutboxMessageId } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatUnreadMentionCount -> { - cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } - folderManager.handleUpdateChatUnreadCount(update.chatId, update.unreadMentionCount) - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatUnreadReactionCount -> { - cache.updateChat(update.chatId) { it.unreadReactionCount = update.unreadReactionCount } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateMessageMentionRead -> { - cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } - folderManager.handleUpdateChatUnreadCount(update.chatId, update.unreadMentionCount) - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateMessageReactions -> { - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateFile -> { - if (fileManager.handleFileUpdate(update.file)) { - val chatId = fileManager.getChatIdByPhotoId(update.file.id) - triggerUpdate(chatId) - refreshActiveForumTopics() - } - } - is TdApi.UpdateDeleteMessages -> { - if (update.isPermanent || update.fromCache) { - scope.launch { refreshChat(update.chatId) } - } - } - is TdApi.UpdateChatFolders -> { - Log.d(TAG, "UpdateChatFolders received in handleUpdate") - folderManager.handleChatFoldersUpdate(update) - triggerUpdate() - } - is TdApi.UpdateUserStatus -> { - cache.updateUser(update.userId) { it.status = update.status } - cache.userIdToChatId[update.userId]?.let { chatId -> - triggerUpdate(chatId) - } - } - is TdApi.UpdateUser -> { - cache.putUser(update.user) - if (update.user.id == myUserId) myUserId = update.user.id - val privateChatId = cache.userIdToChatId[update.user.id] - if (privateChatId != null) { - triggerUpdate(privateChatId) - } - refreshActiveForumTopics() - } - is TdApi.UpdateSupergroup -> { - cache.putSupergroup(update.supergroup) - saveChatsBySupergroupId(update.supergroup.id) - cache.supergroupIdToChatId[update.supergroup.id]?.let { chatId -> - triggerUpdate(chatId) - } - } - is TdApi.UpdateBasicGroup -> { - cache.putBasicGroup(update.basicGroup) - saveChatsByBasicGroupId(update.basicGroup.id) - cache.basicGroupIdToChatId[update.basicGroup.id]?.let { chatId -> - triggerUpdate(chatId) - } - } - is TdApi.UpdateSupergroupFullInfo -> { - cache.putSupergroupFullInfo(update.supergroupId, update.supergroupFullInfo) - val chatId = cache.supergroupIdToChatId[update.supergroupId] - scope.launch(dispatchers.io) { - if (chatId != null) { - chatLocalDataSource.insertChatFullInfo(update.supergroupFullInfo.toEntity(chatId)) - } + private fun rebuildChatModels(limit: Int): List { + return listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> + val cached = modelCache[chat.id] + if (cached != null && + cached.order == order && + cached.isPinned == isPinned && + !invalidatedModels.contains(chat.id) + ) { + cached + } else { + modelFactory.mapChatToModel(chat, order, isPinned).also { mapped -> + modelCache[chat.id] = mapped + invalidatedModels.remove(chat.id) } - if (chatId != null) { - triggerUpdate(chatId) - } - } - is TdApi.UpdateBasicGroupFullInfo -> { - cache.putBasicGroupFullInfo(update.basicGroupId, update.basicGroupFullInfo) - val chatId = cache.basicGroupIdToChatId[update.basicGroupId] - scope.launch(dispatchers.io) { - if (chatId != null) { - chatLocalDataSource.insertChatFullInfo(update.basicGroupFullInfo.toEntity(chatId)) - } - } - if (chatId != null) { - triggerUpdate(chatId) - } - } - is TdApi.UpdateSecretChat -> { - cache.putSecretChat(update.secretChat); triggerUpdate() - } - is TdApi.UpdateChatAction -> typingManager.handleChatAction(update) - is TdApi.UpdateChatNotificationSettings -> { - cache.updateChat(update.chatId) { it.notificationSettings = update.notificationSettings } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) } - is TdApi.UpdateChatViewAsTopics -> { - cache.updateChat(update.chatId) { it.viewAsTopics = update.viewAsTopics } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatIsTranslatable -> { - cache.updateChat(update.chatId) { it.isTranslatable = update.isTranslatable } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatPermissions -> { - cache.putChatPermissions(update.chatId, update.permissions) - cache.updateChat(update.chatId) { it.permissions = update.permissions } - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateChatMember -> { - val memberId = update.newChatMember.memberId - if (memberId is TdApi.MessageSenderUser && memberId.userId == myUserId) { - cache.putMyChatMember(update.chatId, update.newChatMember) - triggerUpdate(update.chatId) - } - } - is TdApi.UpdateChatOnlineMemberCount -> { - cache.putOnlineMemberCount(update.chatId, update.onlineMemberCount) - saveChatToDb(update.chatId) - triggerUpdate(update.chatId) - } - is TdApi.UpdateAuthorizationState -> { - Log.d(TAG, "UpdateAuthorizationState: ${update.authorizationState}") - if (update.authorizationState is TdApi.AuthorizationStateLoggingOut || - update.authorizationState is TdApi.AuthorizationStateClosed - ) { - cache.clearAll() - modelCache.clear() - invalidatedModels.clear() - lastSavedEntities.clear() - scope.launch { chatLocalDataSource.clearAll() } - } - } - else -> {} } } - private fun saveChatToDb(chatId: Long) { - val chat = cache.getChat(chatId) ?: return - - pendingSaveJobs[chatId]?.cancel() - pendingSaveJobs[chatId] = scope.launch(dispatchers.io) { - delay(2000) - val position = resolvePersistPosition(chat) - - val model = modelFactory.mapChatToModel( - chat = chat, - order = position?.order ?: 0L, - isPinned = position?.isPinned ?: false, - allowMediaDownloads = false - ) - val entity = chatMapper.mapToEntity(chat, model) - - val last = lastSavedEntities[chatId] - if (last == null || isEntityChanged(last, entity)) { - chatLocalDataSource.insertChat(entity) - lastSavedEntities[chatId] = entity - } - pendingSaveJobs.remove(chatId) - } + private fun shouldEmitList(folderId: Int, newList: List): Boolean { + return folderId != lastListFolderId || newList != lastList } - private fun isEntityChanged(old: ChatEntity, new: ChatEntity): Boolean { - return old.title != new.title || - old.unreadCount != new.unreadCount || - old.unreadMentionCount != new.unreadMentionCount || - old.unreadReactionCount != new.unreadReactionCount || - old.avatarPath != new.avatarPath || - old.lastMessageText != new.lastMessageText || - old.lastMessageTime != new.lastMessageTime || - old.lastMessageDate != new.lastMessageDate || - old.order != new.order || - old.isPinned != new.isPinned || - old.isMuted != new.isMuted || - old.isChannel != new.isChannel || - old.isGroup != new.isGroup || - old.type != new.type || - old.privateUserId != new.privateUserId || - old.basicGroupId != new.basicGroupId || - old.supergroupId != new.supergroupId || - old.secretChatId != new.secretChatId || - old.positionsCache != new.positionsCache || - old.isArchived != new.isArchived || - old.memberCount != new.memberCount || - old.onlineCount != new.onlineCount || - old.isMarkedAsUnread != new.isMarkedAsUnread || - old.hasProtectedContent != new.hasProtectedContent || - old.isTranslatable != new.isTranslatable || - old.hasAutomaticTranslation != new.hasAutomaticTranslation || - old.messageAutoDeleteTime != new.messageAutoDeleteTime || - old.canBeDeletedOnlyForSelf != new.canBeDeletedOnlyForSelf || - old.canBeDeletedForAllUsers != new.canBeDeletedForAllUsers || - old.canBeReported != new.canBeReported || - old.lastReadInboxMessageId != new.lastReadInboxMessageId || - old.lastReadOutboxMessageId != new.lastReadOutboxMessageId || - old.lastMessageId != new.lastMessageId || - old.isLastMessageOutgoing != new.isLastMessageOutgoing || - old.replyMarkupMessageId != new.replyMarkupMessageId || - old.messageSenderId != new.messageSenderId || - old.blockList != new.blockList || - old.emojiStatusId != new.emojiStatusId || - old.accentColorId != new.accentColorId || - old.profileAccentColorId != new.profileAccentColorId || - old.backgroundCustomEmojiId != new.backgroundCustomEmojiId || - old.photoId != new.photoId || - old.isSupergroup != new.isSupergroup || - old.isAdmin != new.isAdmin || - old.isOnline != new.isOnline || - old.typingAction != new.typingAction || - old.draftMessage != new.draftMessage || - old.isVerified != new.isVerified || - old.isSponsor != new.isSponsor || - old.viewAsTopics != new.viewAsTopics || - old.isForum != new.isForum || - old.isBot != new.isBot || - old.isMember != new.isMember || - old.username != new.username || - old.description != new.description || - old.inviteLink != new.inviteLink || - old.permissionCanSendBasicMessages != new.permissionCanSendBasicMessages || - old.permissionCanSendAudios != new.permissionCanSendAudios || - old.permissionCanSendDocuments != new.permissionCanSendDocuments || - old.permissionCanSendPhotos != new.permissionCanSendPhotos || - old.permissionCanSendVideos != new.permissionCanSendVideos || - old.permissionCanSendVideoNotes != new.permissionCanSendVideoNotes || - old.permissionCanSendVoiceNotes != new.permissionCanSendVoiceNotes || - old.permissionCanSendPolls != new.permissionCanSendPolls || - old.permissionCanSendOtherMessages != new.permissionCanSendOtherMessages || - old.permissionCanAddLinkPreviews != new.permissionCanAddLinkPreviews || - old.permissionCanEditTag != new.permissionCanEditTag || - old.permissionCanChangeInfo != new.permissionCanChangeInfo || - old.permissionCanInviteUsers != new.permissionCanInviteUsers || - old.permissionCanPinMessages != new.permissionCanPinMessages || - old.permissionCanCreateTopics != new.permissionCanCreateTopics || - old.lastMessageContentType != new.lastMessageContentType || - old.lastMessageSenderName != new.lastMessageSenderName + private fun emitListUpdate(folderId: Int, newList: List) { + _chatListFlow.value = newList + _folderChatsFlow.tryEmit(FolderChatsUpdate(folderId, newList)) + lastList = newList + lastListFolderId = folderId } - private fun resolvePersistPosition(chat: TdApi.Chat): TdApi.ChatPosition? { - return chat.positions.find { pos -> - pos.order != 0L && listManager.isSameChatList(pos.list, mainChatList) - } - ?: chat.positions.find { pos -> - pos.order != 0L && listManager.isSameChatList(pos.list, activeChatList) - } - ?: chat.positions.firstOrNull { it.order != 0L } + private fun clearTransientState() { + modelCache.clear() + invalidatedModels.clear() + lastList = null + lastListFolderId = -1 + _chatListFlow.value = emptyList() + persistenceManager.clear() } private fun triggerUpdate(chatId: Long? = null) { @@ -658,8 +352,7 @@ class ChatsListRepositoryImpl( } private fun refreshActiveForumTopics() { - val chatId = activeForumChatId ?: return - scope.launch { getForumTopics(chatId) } + forumTopicsManager.refreshActiveForumTopics() } override fun retryConnection() { @@ -677,7 +370,6 @@ class ChatsListRepositoryImpl( } pendingSelectFolderJob = null - Log.d(TAG, "selectFolder: folderId=$folderId") val newList: TdApi.ChatList = when (folderId) { -1 -> TdApi.ChatListMain() -2 -> TdApi.ChatListArchive() @@ -687,31 +379,24 @@ class ChatsListRepositoryImpl( listManager.isSameChatList(newList, activeChatList) && activeRequestId != 0L ) { - Log.d(TAG, "selectFolder: already initialized for this folder, skipping") return } activeFolderId = folderId activeChatList = newList updateActiveListPositionsFromCache() - val cachedChatsCount = cache.activeListPositions.size + val initialLoadLimit = initialChatListLimit.coerceAtMost(maxChatListLimit) currentLimit = initialLoadLimit val requestId = requestIdGenerator.incrementAndGet() activeRequestId = requestId - Log.d( - TAG, - "selectFolder: cache positions=$cachedChatsCount, initialLoadLimit=$initialLoadLimit" - ) setLoadingState(folderId, requestId, true) triggerUpdate() scope.launch(dispatchers.io) { - Log.d(TAG, "selectFolder: calling loadChats for $newList with limit=$initialLoadLimit") chatRemoteSource.loadChats(newList, initialLoadLimit) setLoadingState(folderId, requestId, false) - Log.d(TAG, "selectFolder: loadChats completed") if (isRequestActive(folderId, requestId)) { triggerUpdate() } @@ -719,7 +404,6 @@ class ChatsListRepositoryImpl( } private fun updateActiveListPositionsFromCache() { - val before = cache.activeListPositions.size val savedAuthoritative = HashMap() cache.authoritativeActiveListChatIds.forEach { chatId -> val currentPos = cache.activeListPositions[chatId] ?: return@forEach @@ -732,41 +416,23 @@ class ChatsListRepositoryImpl( cache.authoritativeActiveListChatIds.clear() cache.protectedPinnedChatIds.clear() cache.allChats.values.forEach { chat -> - chat.positions.find { listManager.isSameChatList(it.list, activeChatList) }?.let { - if (it.order != 0L) { - cache.activeListPositions[chat.id] = it - if (it.isPinned) { + chat.positions.find { listManager.isSameChatList(it.list, activeChatList) }?.let { position -> + if (position.order != 0L) { + cache.activeListPositions[chat.id] = position + if (position.isPinned) { cache.protectedPinnedChatIds.add(chat.id) } } } } - var restoredAuthoritative = 0 savedAuthoritative.forEach { (chatId, position) -> - if (cache.activeListPositions.putIfAbsent(chatId, position) == null) { - restoredAuthoritative += 1 - } + cache.activeListPositions.putIfAbsent(chatId, position) cache.authoritativeActiveListChatIds.add(chatId) if (position.isPinned) { cache.protectedPinnedChatIds.add(chatId) } } - - val after = cache.activeListPositions.size - if (restoredAuthoritative > 0) { - Log.w( - diagTag, - "positions rebuild restored authoritative folder=$activeFolderId restored=$restoredAuthoritative" - ) - } - if (after != before) { - val level = if (after < before) "shrunk" else "expanded" - Log.w( - diagTag, - "positions rebuild $level folder=$activeFolderId before=$before after=$after chats=${cache.allChats.size}" - ) - } } override fun refresh() { @@ -797,7 +463,6 @@ class ChatsListRepositoryImpl( override fun loadNextChunk(limit: Int) { if (!cacheHydrated.isCompleted) return if (_isLoadingFlow.value || currentLimit >= maxChatListLimit) return - Log.d(TAG, "loadNextChunk: limit=$limit") val folderId = activeFolderId val requestId = activeRequestId @@ -830,14 +495,15 @@ class ChatsListRepositoryImpl( val chatObj = cache.getChat(chatId) ?: chatLocalDataSource.getChat(chatId)?.let { entity -> cache.putChatFromEntity(entity) - lastSavedEntities[entity.id] = entity + persistenceManager.rememberSavedEntity(entity) cache.getChat(chatId) } - ?: remoteDataSource.getChat(chatId)?.also { - cache.putChat(it) - listManager.updateActiveListPositions(it.id, it.positions, activeChatList) - saveChatToDb(it.id) - } ?: return null + ?: remoteDataSource.getChat(chatId)?.also { chat -> + cache.putChat(chat) + listManager.updateActiveListPositions(chat.id, chat.positions, activeChatList) + persistenceManager.scheduleChatSave(chat.id) + } + ?: return null val position = chatObj.positions.find { listManager.isSameChatList(it.list, activeChatList) } return coRunCatching { @@ -846,36 +512,32 @@ class ChatsListRepositoryImpl( } override suspend fun searchChats(query: String): List { - if (query.isBlank()) return emptyList() - val result = chatRemoteSource.searchChats(query, 50) ?: return emptyList() - return coroutineScope { - result.chatIds.map { id -> async { getChatById(id) } }.awaitAll().filterNotNull() - } + return searchManager.searchChats(query) } override suspend fun searchPublicChats(query: String): List { - if (query.isBlank()) return emptyList() - val result = chatRemoteSource.searchPublicChats(query) ?: return emptyList() - return coroutineScope { - result.chatIds.map { id -> async { getChatById(id) } }.awaitAll().filterNotNull() - } + return searchManager.searchPublicChats(query) } override suspend fun searchMessages(query: String, offset: String, limit: Int): SearchMessagesResult { - val result = chatRemoteSource.searchMessages(query, offset, limit) ?: return SearchMessagesResult(emptyList(), "") - val models = coroutineScope { - result.messages.map { msg -> async { messageMapper.mapMessageToModel(msg, isChatOpen = false) } }.awaitAll() - } - return SearchMessagesResult(models, result.nextOffset) + return searchManager.searchMessages(query, offset, limit) } override fun toggleMuteChats(chatIds: Set, mute: Boolean) { val muteFor = if (mute) Int.MAX_VALUE else 0 - chatIds.forEach { chatId -> scope.launch(dispatchers.io) { chatRemoteSource.muteChat(chatId, muteFor) } } + chatIds.forEach { chatId -> + scope.launch(dispatchers.io) { + chatRemoteSource.muteChat(chatId, muteFor) + } + } } override fun toggleArchiveChats(chatIds: Set, archive: Boolean) { - chatIds.forEach { chatId -> scope.launch(dispatchers.io) { chatRemoteSource.archiveChat(chatId, archive) } } + chatIds.forEach { chatId -> + scope.launch(dispatchers.io) { + chatRemoteSource.archiveChat(chatId, archive) + } + } } override fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) { @@ -884,6 +546,7 @@ class ChatsListRepositoryImpl( -2 -> TdApi.ChatListArchive() else -> TdApi.ChatListFolder(folderId) } + chatIds.forEach { chatId -> scope.launch(dispatchers.io) { chatRemoteSource.toggleChatIsPinned(chatList, chatId, pin) @@ -900,144 +563,134 @@ class ChatsListRepositoryImpl( } override fun deleteChats(chatIds: Set) { - chatIds.forEach { chatId -> scope.launch(dispatchers.io) { chatRemoteSource.deleteChat(chatId) } } + chatIds.forEach { chatId -> + scope.launch(dispatchers.io) { + chatRemoteSource.deleteChat(chatId) + } + } } override fun leaveChat(chatId: Long) { - scope.launch(dispatchers.io) { chatRemoteSource.leaveChat(chatId) } + scope.launch(dispatchers.io) { + chatRemoteSource.leaveChat(chatId) + } } - override fun setArchivePinned(pinned: Boolean) { appPreferences.setArchivePinned(pinned) } + override fun setArchivePinned(pinned: Boolean) { + appPreferences.setArchivePinned(pinned) + } - override suspend fun createFolder(title: String, iconName: String?, includedChatIds: List) = + override suspend fun createFolder(title: String, iconName: String?, includedChatIds: List) { folderManager.createFolder(title, iconName, includedChatIds) + } - override suspend fun deleteFolder(folderId: Int) = chatRemoteSource.deleteFolder(folderId) + override suspend fun deleteFolder(folderId: Int) { + chatRemoteSource.deleteFolder(folderId) + } - override suspend fun updateFolder(folderId: Int, title: String, iconName: String?, includedChatIds: List) = + override suspend fun updateFolder( + folderId: Int, + title: String, + iconName: String?, + includedChatIds: List + ) { folderManager.updateFolder(folderId, title, iconName, includedChatIds) + } - override suspend fun reorderFolders(folderIds: List) = folderManager.reorderFolders(folderIds) + override suspend fun reorderFolders(folderIds: List) { + folderManager.reorderFolders(folderIds) + } override suspend fun getForumTopics( - chatId: Long, query: String, offsetDate: Int, - offsetMessageId: Long, offsetForumTopicId: Int, limit: Int + chatId: Long, + query: String, + offsetDate: Int, + offsetMessageId: Long, + offsetForumTopicId: Int, + limit: Int ): List { - activeForumChatId = chatId - val result = chatRemoteSource.getForumTopics(chatId, query, offsetDate, offsetMessageId, offsetForumTopicId, limit) - ?: return emptyList() - - val models = result.topics.map { topic -> - val (txt, entities, time) = chatMapper.formatMessageInfo(topic.lastMessage, null) { userId -> - cache.usersCache[userId]?.firstName ?: run { fetchUser(userId); null } - } - val emojiId = topic.info.icon.customEmojiId - var emojiPath: String? = null - if (emojiId != 0L) { - emojiPath = fileManager.getEmojiPath(emojiId) - if (emojiPath == null) fileManager.loadEmoji(emojiId) - } - - var senderName: String? = null - var senderAvatar: String? = null - when (val senderId = topic.lastMessage?.senderId) { - is TdApi.MessageSenderUser -> { - cache.usersCache[senderId.userId]?.let { user -> - senderName = user.firstName - user.profilePhoto?.small?.let { small -> - fileManager.registerTrackedFile(small.id) - senderAvatar = small.local.path.ifEmpty { fileManager.getFilePath(small.id) } - if (senderAvatar.isNullOrEmpty()) fileManager.downloadFile(small.id, 24, synchronous = false) - } - } ?: fetchUser(senderId.userId) - } - is TdApi.MessageSenderChat -> cache.getChat(senderId.chatId)?.let { chat -> - senderName = chat.title - chat.photo?.small?.let { small -> - fileManager.registerTrackedFile(small.id) - senderAvatar = small.local.path.ifEmpty { fileManager.getFilePath(small.id) } - if (senderAvatar.isNullOrEmpty()) fileManager.downloadFile(small.id, 24, synchronous = false) - } - } - else -> {} - } - - TopicModel( - topic.info.forumTopicId, topic.info.name, emojiId, emojiPath, - topic.info.icon.color, topic.info.isClosed, topic.isPinned, - topic.unreadCount, txt, entities, time, topic.order, senderName, senderAvatar - ) - } + return forumTopicsManager.getForumTopics( + chatId = chatId, + query = query, + offsetDate = offsetDate, + offsetMessageId = offsetMessageId, + offsetForumTopicId = offsetForumTopicId, + limit = limit + ) + } + override fun clearChatHistory(chatId: Long, revoke: Boolean) { scope.launch(dispatchers.io) { - chatLocalDataSource.insertTopics(result.topics.map { topic -> - val (txt, _, time) = chatMapper.formatMessageInfo(topic.lastMessage, null) { null } - TopicEntity( - chatId = chatId, - id = topic.info.forumTopicId, - name = topic.info.name, - iconCustomEmojiId = topic.info.icon.customEmojiId, - iconColor = topic.info.icon.color, - isClosed = topic.info.isClosed, - isPinned = topic.isPinned, - unreadCount = topic.unreadCount, - lastMessageText = txt, - lastMessageTime = time, - order = topic.order, - lastMessageSenderName = null - ) - }) + chatRemoteSource.clearChatHistory(chatId, revoke) } - - _forumTopicsFlow.tryEmit(chatId to models) - return models } - override fun clearChatHistory(chatId: Long, revoke: Boolean) { - scope.launch(dispatchers.io) { chatRemoteSource.clearChatHistory(chatId, revoke) } + override suspend fun getChatLink(chatId: Long): String? { + return chatRemoteSource.getChatLink(chatId) } - override suspend fun getChatLink(chatId: Long) = chatRemoteSource.getChatLink(chatId) - override fun reportChat(chatId: Long, reason: String, messageIds: List) { - scope.launch(dispatchers.io) { chatRemoteSource.reportChat(chatId, reason, messageIds) } + scope.launch(dispatchers.io) { + chatRemoteSource.reportChat(chatId, reason, messageIds) + } } override fun addSearchChatId(chatId: Long) { - cacheProvider.addSearchChatId(chatId) - scope.launch(dispatchers.io) { - searchHistoryDao.insertSearchChatId(SearchHistoryEntity(chatId)) - } + searchManager.addSearchChatId(chatId) } override fun removeSearchChatId(chatId: Long) { - cacheProvider.removeSearchChatId(chatId) - scope.launch(dispatchers.io) { - searchHistoryDao.deleteSearchChatId(chatId) - } + searchManager.removeSearchChatId(chatId) } override fun clearSearchHistory() { - cacheProvider.clearSearchHistory() - scope.launch(dispatchers.io) { - searchHistoryDao.clearAll() - } + searchManager.clearSearchHistory() } - override suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int) = - chatRemoteSource.createGroup(title, userIds, messageAutoDeleteTime) + override suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int): Long { + return chatRemoteSource.createGroup(title, userIds, messageAutoDeleteTime) + } - override suspend fun createChannel(title: String, description: String, isMegagroup: Boolean, messageAutoDeleteTime: Int) = - chatRemoteSource.createChannel(title, description, isMegagroup, messageAutoDeleteTime) + override suspend fun createChannel( + title: String, + description: String, + isMegagroup: Boolean, + messageAutoDeleteTime: Int + ): Long { + return chatRemoteSource.createChannel(title, description, isMegagroup, messageAutoDeleteTime) + } - override suspend fun setChatPhoto(chatId: Long, photoPath: String) = chatRemoteSource.setChatPhoto(chatId, photoPath) - override suspend fun setChatTitle(chatId: Long, title: String) = chatRemoteSource.setChatTitle(chatId, title) - override suspend fun setChatDescription(chatId: Long, description: String) = chatRemoteSource.setChatDescription(chatId, description) - override suspend fun setChatUsername(chatId: Long, username: String) = chatRemoteSource.setChatUsername(chatId, username) - override suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) = chatRemoteSource.setChatPermissions(chatId, permissions) - override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) = chatRemoteSource.setChatSlowModeDelay(chatId, slowModeDelay) - override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) = chatRemoteSource.toggleChatIsForum(chatId, isForum) - override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) = chatRemoteSource.toggleChatIsTranslatable(chatId, isTranslatable) + override suspend fun setChatPhoto(chatId: Long, photoPath: String) { + chatRemoteSource.setChatPhoto(chatId, photoPath) + } + + override suspend fun setChatTitle(chatId: Long, title: String) { + chatRemoteSource.setChatTitle(chatId, title) + } + + override suspend fun setChatDescription(chatId: Long, description: String) { + chatRemoteSource.setChatDescription(chatId, description) + } + + override suspend fun setChatUsername(chatId: Long, username: String) { + chatRemoteSource.setChatUsername(chatId, username) + } + + override suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) { + chatRemoteSource.setChatPermissions(chatId, permissions) + } + + override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) { + chatRemoteSource.setChatSlowModeDelay(chatId, slowModeDelay) + } + + override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) { + chatRemoteSource.toggleChatIsForum(chatId, isForum) + } + + override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) { + chatRemoteSource.toggleChatIsTranslatable(chatId, isTranslatable) + } override fun getDatabaseSize(): Long { return if (databaseFile.exists()) databaseFile.length() else 0L @@ -1046,9 +699,7 @@ class ChatsListRepositoryImpl( override fun clearDatabase() { scope.launch(dispatchers.io) { chatLocalDataSource.clearAll() - lastSavedEntities.clear() - modelCache.clear() - invalidatedModels.clear() + clearTransientState() triggerUpdate() } } @@ -1057,15 +708,17 @@ class ChatsListRepositoryImpl( if (userId == 0L) return if (cache.pendingUsers.add(userId)) { scope.launch(dispatchers.io) { - coRunCatching { - val user = gateway.execute(TdApi.GetUser(userId)) - cache.putUser(user) - val privateChatId = cache.userIdToChatId[user.id] - if (privateChatId != null) { - triggerUpdate(privateChatId) + try { + val user = chatRemoteSource.getUser(userId) + if (user != null) { + cache.putUser(user) + cache.userIdToChatId[user.id]?.let { privateChatId -> + triggerUpdate(privateChatId) + } } + } finally { + cache.pendingUsers.remove(userId) } - cache.pendingUsers.remove(userId) } } } @@ -1074,23 +727,12 @@ class ChatsListRepositoryImpl( val chatObj = remoteDataSource.getChat(chatId) ?: return cache.putChat(chatObj) listManager.updateActiveListPositions(chatObj.id, chatObj.positions, activeChatList) - saveChatToDb(chatObj.id) + persistenceManager.scheduleChatSave(chatObj.id) triggerUpdate(chatObj.id) } - private fun saveChatsBySupergroupId(supergroupId: Long) { - cache.allChats.values - .asSequence() - .filter { (it.type as? TdApi.ChatTypeSupergroup)?.supergroupId == supergroupId } - .map { it.id } - .forEach { saveChatToDb(it) } - } - - private fun saveChatsByBasicGroupId(basicGroupId: Long) { - cache.allChats.values - .asSequence() - .filter { (it.type as? TdApi.ChatTypeBasicGroup)?.basicGroupId == basicGroupId } - .map { it.id } - .forEach { saveChatToDb(it) } + companion object { + private const val TAG = "ChatsListRepository" + private const val REBUILD_THROTTLE_MS = 250L } } diff --git a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt new file mode 100644 index 00000000..a61a6123 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt @@ -0,0 +1,80 @@ +package org.monogram.data.repository + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.core.DispatcherProvider +import org.monogram.core.ScopeProvider +import org.monogram.data.datasource.cache.StickerLocalDataSource +import org.monogram.data.datasource.remote.EmojiRemoteSource +import org.monogram.data.infra.EmojiLoader +import org.monogram.domain.models.RecentEmojiModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.EmojiRepository + +class EmojiRepositoryImpl( + private val remote: EmojiRemoteSource, + private val localDataSource: StickerLocalDataSource, + private val cacheProvider: CacheProvider, + private val dispatchers: DispatcherProvider, + private val context: Context, + scopeProvider: ScopeProvider +) : EmojiRepository { + + private val scope = scopeProvider.appScope + + override val recentEmojis: Flow> = cacheProvider.recentEmojis + + private var cachedEmojis: List? = null + private var fallbackEmojisCache: List? = null + + init { + scope.launch { + localDataSource.getRecentEmojis().collect { cacheProvider.setRecentEmojis(it) } + } + } + + override suspend fun getDefaultEmojis(): List { + cachedEmojis?.let { return it } + + val fetched = remote.getEmojiCategories().toMutableSet() + if (fetched.size < MIN_REMOTE_EMOJIS) { + fetched.addAll(getFallbackEmojis()) + } + + return fetched.toList().also { cachedEmojis = it } + } + + override suspend fun searchEmojis(query: String): List { + return remote.searchEmojis(query) + } + + override suspend fun searchCustomEmojis(query: String): List { + return remote.searchCustomEmojis(query) + } + + override suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) { + cacheProvider.addRecentEmoji(recentEmoji) + localDataSource.addRecentEmoji(recentEmoji) + } + + override suspend fun clearRecentEmojis() { + cacheProvider.clearRecentEmojis() + localDataSource.clearRecentEmojis() + } + + override suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List { + return remote.getMessageAvailableReactions(chatId, messageId) + } + + private suspend fun getFallbackEmojis(): List = withContext(dispatchers.default) { + fallbackEmojisCache?.let { return@withContext it } + EmojiLoader.getSupportedEmojis(context).also { fallbackEmojisCache = it } + } + + companion object { + private const val MIN_REMOTE_EMOJIS = 100 + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/GifRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/GifRepositoryImpl.kt new file mode 100644 index 00000000..1bed8490 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/GifRepositoryImpl.kt @@ -0,0 +1,42 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.monogram.data.datasource.remote.GifRemoteSource +import org.monogram.data.stickers.StickerFileManager +import org.monogram.domain.models.GifModel +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.GifRepository + +class GifRepositoryImpl( + private val remote: GifRemoteSource, + private val cacheProvider: CacheProvider, + private val stickerFileManager: StickerFileManager +) : GifRepository { + + override fun getGifFile(gif: GifModel): Flow { + return if (gif.fileId == 0L) { + flowOf(null) + } else { + stickerFileManager.getStickerFile(gif.fileId) + } + } + + override suspend fun getSavedGifs(): List { + val cached = cacheProvider.savedGifs.value + if (cached.isNotEmpty()) return cached + + val remoteGifs = remote.getSavedGifs() + cacheProvider.setSavedGifs(remoteGifs) + return remoteGifs + } + + override suspend fun addSavedGif(path: String) { + remote.addSavedGif(path) + cacheProvider.setSavedGifs(remote.getSavedGifs()) + } + + override suspend fun searchGifs(query: String): List { + return remote.searchGifs(query) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt index 8a82d342..4cf10551 100644 --- a/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt @@ -1,185 +1,171 @@ package org.monogram.data.repository -import org.monogram.data.core.coRunCatching -import androidx.core.net.toUri +import android.util.Log import org.drinkless.tdlib.TdApi -import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.remote.LinkRemoteDataSource import org.monogram.data.infra.FileDownloadQueue -import org.monogram.domain.models.ProxyTypeModel -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.data.mapper.toLinkProxyTypeOrNull +import org.monogram.data.mapper.toLinkSettingsType +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.LinkAction import org.monogram.domain.repository.LinkHandlerRepository -import org.monogram.domain.repository.UserRepository class LinkHandlerRepositoryImpl( - private val gateway: TelegramGateway, - private val chatsListRepository: ChatsListRepository, - private val userRepository: UserRepository, + private val parser: LinkParser, + private val remote: LinkRemoteDataSource, + private val chatListRepository: ChatListRepository, + private val chatInfoRepository: ChatInfoRepository, private val fileQueue: FileDownloadQueue ) : LinkHandlerRepository { - override suspend fun handleLink(link: String): LinkAction { - val normalized = normalizeLink(link) - - tryParseProxyLink(normalized)?.let { return it } - tryParseUserLink(normalized)?.let { return it } - - return coRunCatching { - when (val result = gateway.execute(TdApi.GetInternalLinkType(normalized))) { - is TdApi.InternalLinkTypePublicChat -> - handlePublicChat(result.chatUsername) - - is TdApi.InternalLinkTypeMessage -> { - val info = coRunCatching { - gateway.execute(TdApi.GetMessageLinkInfo(result.url)) - }.getOrNull() - when { - info == null || info.chatId == 0L -> LinkAction.ShowToast("Message not found") - info.message != null -> LinkAction.OpenMessage(info.chatId, info.message!!.id) - else -> LinkAction.OpenChat(info.chatId) - } - } - - is TdApi.InternalLinkTypeSettings -> - LinkAction.OpenSettings(result.section.toDomain()) - - is TdApi.InternalLinkTypeStickerSet -> - LinkAction.OpenStickerSet(result.stickerSetName) - - is TdApi.InternalLinkTypeChatInvite -> { - val inviteInfo = coRunCatching { - gateway.execute(TdApi.CheckChatInviteLink(result.inviteLink)) - }.getOrNull() - - if (inviteInfo == null) return LinkAction.JoinChat(result.inviteLink) - - val photo = inviteInfo.photo?.small ?: inviteInfo.photo?.big - if (photo != null && photo.local.path.isEmpty()) { - fileQueue.enqueue(photo.id, 1, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - coRunCatching { fileQueue.waitForDownload(photo.id).await() } - } - - LinkAction.ConfirmJoinInviteLink( - inviteLink = result.inviteLink, - title = inviteInfo.title, - description = inviteInfo.description, - memberCount = inviteInfo.memberCount, - avatarPath = photo?.local?.path?.ifEmpty { null }, - isChannel = inviteInfo.type is TdApi.InviteLinkChatTypeChannel - ) - } - - is TdApi.InternalLinkTypeBotStart -> - handlePublicChat(result.botUsername) - - is TdApi.InternalLinkTypeBotStartInGroup -> - handlePublicChat(result.botUsername) - - is TdApi.InternalLinkTypeVideoChat -> - handlePublicChat(result.chatUsername) - - is TdApi.InternalLinkTypeStory -> - handlePublicChat(result.storyPosterUsername) + val normalized = parser.normalize(link) - is TdApi.InternalLinkTypeStoryAlbum -> - handlePublicChat(result.storyAlbumOwnerUsername) + parser.parsePrimary(normalized)?.let { return handleParsedLink(it) } - is TdApi.InternalLinkTypeMyProfilePage -> - handleMyProfileLink() + val internalLink = remote.getInternalLinkType(normalized) + ?: return handleParsedLink(parser.parseFallback(normalized)) - is TdApi.InternalLinkTypeSavedMessages -> - handleSavedMessagesLink() - - is TdApi.InternalLinkTypeUserPhoneNumber -> - handleUserPhoneNumberLink(result.phoneNumber, result.openProfile) + return coRunCatching { + handleInternalLink(internalLink, normalized) + }.onFailure { + Log.w(TAG, "Failed to handle internal link: $normalized", it) + }.getOrElse { + handleParsedLink(parser.parseFallback(normalized)) + } + } - is TdApi.InternalLinkTypeUserToken -> - handleUserTokenLink(result.token) + override suspend fun joinChat(inviteLink: String): Long? { + val chat = remote.joinChatByInviteLink(inviteLink) + if (chat == null) { + Log.w(TAG, "Failed to join chat by invite link") + } + return chat?.id + } - is TdApi.InternalLinkTypePremiumFeaturesPage, - is TdApi.InternalLinkTypePremiumGiftCode, - is TdApi.InternalLinkTypePremiumGiftPurchase, - is TdApi.InternalLinkTypeStarPurchase, - is TdApi.InternalLinkTypeRestorePurchases -> - LinkAction.OpenSettings(LinkAction.SettingsType.PREMIUM) + private suspend fun handleInternalLink( + internalLink: TdApi.InternalLinkType, + normalized: String + ): LinkAction = when (internalLink) { + is TdApi.InternalLinkTypePublicChat -> handlePublicChat(internalLink.chatUsername) + is TdApi.InternalLinkTypeMessage -> handleMessageLink(internalLink.url) + is TdApi.InternalLinkTypeSettings -> LinkAction.OpenSettings(internalLink.section.toLinkSettingsType()) + is TdApi.InternalLinkTypeStickerSet -> LinkAction.OpenStickerSet(internalLink.stickerSetName) + is TdApi.InternalLinkTypeChatInvite -> handleChatInviteLink(internalLink.inviteLink) + is TdApi.InternalLinkTypeBotStart -> handlePublicChat(internalLink.botUsername) + is TdApi.InternalLinkTypeBotStartInGroup -> handlePublicChat(internalLink.botUsername) + is TdApi.InternalLinkTypeVideoChat -> handlePublicChat(internalLink.chatUsername) + is TdApi.InternalLinkTypeStory -> handlePublicChat(internalLink.storyPosterUsername) + is TdApi.InternalLinkTypeStoryAlbum -> handlePublicChat(internalLink.storyAlbumOwnerUsername) + is TdApi.InternalLinkTypeMyProfilePage -> handleMyProfileLink() + is TdApi.InternalLinkTypeSavedMessages -> handleSavedMessagesLink() + is TdApi.InternalLinkTypeUserPhoneNumber -> + handleUserPhoneNumberLink(internalLink.phoneNumber, internalLink.openProfile) + + is TdApi.InternalLinkTypeUserToken -> handleUserTokenLink(internalLink.token) + + is TdApi.InternalLinkTypePremiumFeaturesPage, + is TdApi.InternalLinkTypePremiumGiftCode, + is TdApi.InternalLinkTypePremiumGiftPurchase, + is TdApi.InternalLinkTypeStarPurchase, + is TdApi.InternalLinkTypeRestorePurchases -> + LinkAction.OpenSettings(LinkAction.SettingsType.PREMIUM) + + is TdApi.InternalLinkTypeWebApp -> LinkAction.OpenWebApp(0L, internalLink.webAppShortName) + is TdApi.InternalLinkTypeProxy -> handleInternalProxy(internalLink) + is TdApi.InternalLinkTypeUnknownDeepLink -> handleParsedLink(parser.parseFallback(internalLink.link)) + else -> handleParsedLink(parser.parseFallback(normalized)) + } - is TdApi.InternalLinkTypeWebApp -> - LinkAction.OpenWebApp(0L, result.webAppShortName) + private suspend fun handleParsedLink(parsed: ParsedLink): LinkAction = when (parsed) { + is ParsedLink.AddProxy -> LinkAction.AddProxy(parsed.server, parsed.port, parsed.type) + is ParsedLink.OpenUser -> LinkAction.OpenUser(parsed.userId) + is ParsedLink.ResolveByPhone -> handleUserPhoneNumberLink(parsed.phoneNumber, parsed.openProfile) + is ParsedLink.OpenPublicChat -> handlePublicChat(parsed.username) + is ParsedLink.JoinChat -> LinkAction.JoinChat(parsed.inviteLink) + is ParsedLink.OpenExternal -> LinkAction.OpenExternalLink(parsed.url) + ParsedLink.None -> LinkAction.None + } - is TdApi.InternalLinkTypeProxy -> { - val proxy = result.proxy ?: return LinkAction.ShowToast("Unsupported proxy type") - val type = proxy.type.toDomain() - ?: return LinkAction.ShowToast("Unsupported proxy type") - LinkAction.AddProxy(proxy.server, proxy.port, type) - } + private suspend fun handleMessageLink(url: String): LinkAction { + val info = remote.getMessageLinkInfo(url) + val message = info?.message + return when { + info == null || info.chatId == 0L -> LinkAction.ShowToast(MSG_MESSAGE_NOT_FOUND) + message != null -> LinkAction.OpenMessage(info.chatId, message.id) + else -> LinkAction.OpenChat(info.chatId) + } + } - is TdApi.InternalLinkTypeUnknownDeepLink -> - handleExternalOrUnknownLink(result.link) + private suspend fun handleChatInviteLink(inviteLink: String): LinkAction { + val inviteInfo = remote.checkChatInviteLink(inviteLink) ?: return LinkAction.JoinChat(inviteLink) - else -> handleExternalOrUnknownLink(normalized) + val photo = inviteInfo.photo?.small ?: inviteInfo.photo?.big + if (photo != null && photo.local.path.isEmpty()) { + fileQueue.enqueue(photo.id, 1, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) + coRunCatching { + fileQueue.waitForDownload(photo.id).await() + }.onFailure { + Log.w(TAG, "Failed to download invite photo for file ${photo.id}", it) } - }.getOrElse { - handleExternalOrUnknownLink(normalized) } - } - override suspend fun joinChat(inviteLink: String): Long? = - coRunCatching { - gateway.execute(TdApi.JoinChatByInviteLink(inviteLink)).id - }.getOrNull() + return LinkAction.ConfirmJoinInviteLink( + inviteLink = inviteLink, + title = inviteInfo.title, + description = inviteInfo.description, + memberCount = inviteInfo.memberCount, + avatarPath = photo?.local?.path?.ifEmpty { null }, + isChannel = inviteInfo.type is TdApi.InviteLinkChatTypeChannel + ) + } private suspend fun handlePublicChat(username: String): LinkAction { - val chat = coRunCatching { - gateway.execute(TdApi.SearchPublicChat(username)) - }.getOrNull() ?: return LinkAction.ShowToast("Chat not found") + val chat = remote.searchPublicChat(username) + ?: return LinkAction.ShowToast(MSG_CHAT_NOT_FOUND) return resolveChatAction(chat) } private suspend fun handleMyProfileLink(): LinkAction { - val me = coRunCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() - ?: return LinkAction.ShowToast("User not found") + val me = remote.getMe() ?: return LinkAction.ShowToast(MSG_USER_NOT_FOUND) return LinkAction.OpenUser(me.id) } private suspend fun handleSavedMessagesLink(): LinkAction { - val me = coRunCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() - ?: return LinkAction.ShowToast("Chat not found") - val chat = coRunCatching { - gateway.execute(TdApi.CreatePrivateChat(me.id, false)) - }.getOrNull() ?: return LinkAction.ShowToast("Chat not found") + val me = remote.getMe() ?: return LinkAction.ShowToast(MSG_CHAT_NOT_FOUND) + val chat = remote.createPrivateChat(me.id) ?: return LinkAction.ShowToast(MSG_CHAT_NOT_FOUND) return LinkAction.OpenChat(chat.id) } private suspend fun handleUserPhoneNumberLink(phoneNumber: String, openProfile: Boolean): LinkAction { - val user = coRunCatching { - gateway.execute(TdApi.SearchUserByPhoneNumber(phoneNumber, true)) - }.getOrNull() ?: return LinkAction.ShowToast("User not found") + val user = remote.searchUserByPhoneNumber(phoneNumber) + ?: return LinkAction.ShowToast(MSG_USER_NOT_FOUND) if (openProfile) return LinkAction.OpenUser(user.id) - - val chat = coRunCatching { - gateway.execute(TdApi.CreatePrivateChat(user.id, false)) - }.getOrNull() - return if (chat != null) LinkAction.OpenChat(chat.id) else LinkAction.OpenUser(user.id) + return resolveUserAction(user.id) } private suspend fun handleUserTokenLink(token: String): LinkAction { - val user = coRunCatching { - gateway.execute(TdApi.SearchUserByToken(token)) - }.getOrNull() ?: return LinkAction.ShowToast("User not found") + val user = remote.searchUserByToken(token) ?: return LinkAction.ShowToast(MSG_USER_NOT_FOUND) + return resolveUserAction(user.id) + } - val chat = coRunCatching { - gateway.execute(TdApi.CreatePrivateChat(user.id, false)) - }.getOrNull() - return if (chat != null) LinkAction.OpenChat(chat.id) else LinkAction.OpenUser(user.id) + private suspend fun resolveUserAction(userId: Long): LinkAction { + val chat = remote.createPrivateChat(userId) + return if (chat != null) { + LinkAction.OpenChat(chat.id) + } else { + LinkAction.OpenUser(userId) + } } private suspend fun resolveChatAction(chat: TdApi.Chat): LinkAction { val needsConfirm = chat.type is TdApi.ChatTypeSupergroup && chat.positions.isEmpty() if (!needsConfirm) return LinkAction.OpenChat(chat.id) - val chatModel = chatsListRepository.getChatById(chat.id) - val fullInfo = userRepository.getChatFullInfo(chat.id) + val chatModel = chatListRepository.getChatById(chat.id) + val fullInfo = chatInfoRepository.getChatFullInfo(chat.id) return if (chatModel != null && fullInfo != null) { LinkAction.ConfirmJoinChat(chatModel, fullInfo) } else { @@ -187,121 +173,18 @@ class LinkHandlerRepositoryImpl( } } - private suspend fun handleExternalOrUnknownLink(link: String): LinkAction { - val uri = coRunCatching { link.toUri() }.getOrNull() - - if (uri != null && uri.scheme.equals("tg", ignoreCase = true)) { - if (uri.host.equals("resolve", ignoreCase = true)) { - uri.getQueryParameter("user_id")?.toLongOrNull()?.let { return LinkAction.OpenUser(it) } - - uri.getQueryParameter("phone")?.takeIf { it.isNotBlank() }?.let { - return handleUserPhoneNumberLink(it, openProfile = false) - } - - val username = uri.getQueryParameter("domain")?.takeIf { it.isNotBlank() } - if (username != null) { - val hasMessageTarget = uri.getQueryParameter("post") != null || - uri.getQueryParameter("thread") != null || - uri.getQueryParameter("comment") != null - if (!hasMessageTarget) { - return handlePublicChat(username) - } - } - } - - return LinkAction.None - } - - if (uri != null && (uri.scheme.equals("https", ignoreCase = true) || uri.scheme.equals("http", ignoreCase = true))) { - val host = uri.host?.lowercase() - val pathSegments = uri.pathSegments.orEmpty() - - if (host == "t.me" || host == "www.t.me" || host == "telegram.me" || host == "www.telegram.me") { - val first = pathSegments.firstOrNull() - val second = pathSegments.getOrNull(1) - - if (!first.isNullOrBlank()) { - if (first == "joinchat" && !second.isNullOrBlank()) { - return LinkAction.JoinChat("https://t.me/joinchat/$second") - } - - if (first.startsWith("+")) { - return LinkAction.JoinChat("https://t.me/$first") - } - - if (pathSegments.size == 1) { - return handlePublicChat(first) - } - } - } - } - - return if (link.startsWith("http://") || link.startsWith("https://")) LinkAction.OpenExternalLink(link) else LinkAction.None - } - - private fun normalizeLink(link: String): String = when { - link.startsWith("tg://") -> link - link.startsWith("https://t.me/") -> link - link.startsWith("http://t.me/") -> link.replace("http://", "https://") - link.startsWith("t.me/") -> "https://$link" - else -> link - } - - private fun tryParseProxyLink(link: String): LinkAction? { - val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null - - val isProxy = link.contains("/proxy?") || link.startsWith("tg://proxy") - val isSocks = link.contains("/socks?") || link.startsWith("tg://socks") - val isHttp = link.contains("/http?") || link.startsWith("tg://http") - if (!isProxy && !isSocks && !isHttp) return null - - val server = uri.getQueryParameter("server") ?: return null - val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null - val secret = uri.getQueryParameter("secret") - val user = uri.getQueryParameter("user") - val pass = uri.getQueryParameter("pass") - - val type = when { - secret != null -> ProxyTypeModel.Mtproto(secret) - isHttp -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) - else -> ProxyTypeModel.Socks5(user ?: "", pass ?: "") - } - return LinkAction.AddProxy(server, port, type) - } - - private fun tryParseUserLink(link: String): LinkAction? { - val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null - if (!uri.scheme.equals("tg", ignoreCase = true)) return null - - val userId = when { - uri.host.equals("user", ignoreCase = true) -> - uri.getQueryParameter("id")?.toLongOrNull() - - uri.host.equals("openmessage", ignoreCase = true) -> - uri.getQueryParameter("user_id")?.toLongOrNull() - - else -> null - } ?: return null - - return LinkAction.OpenUser(userId) - } - - private fun TdApi.SettingsSection?.toDomain(): LinkAction.SettingsType = when (this) { - is TdApi.SettingsSectionPrivacyAndSecurity -> LinkAction.SettingsType.PRIVACY - is TdApi.SettingsSectionDevices -> LinkAction.SettingsType.SESSIONS - is TdApi.SettingsSectionChatFolders -> LinkAction.SettingsType.FOLDERS - is TdApi.SettingsSectionAppearance, - is TdApi.SettingsSectionNotifications -> LinkAction.SettingsType.CHAT - is TdApi.SettingsSectionDataAndStorage -> LinkAction.SettingsType.DATA_STORAGE - is TdApi.SettingsSectionPowerSaving -> LinkAction.SettingsType.POWER_SAVING - is TdApi.SettingsSectionPremium -> LinkAction.SettingsType.PREMIUM - else -> LinkAction.SettingsType.MAIN + private fun handleInternalProxy(internalLink: TdApi.InternalLinkTypeProxy): LinkAction { + val proxy = internalLink.proxy ?: return LinkAction.ShowToast(MSG_UNSUPPORTED_PROXY_TYPE) + val type = proxy.type.toLinkProxyTypeOrNull() + ?: return LinkAction.ShowToast(MSG_UNSUPPORTED_PROXY_TYPE) + return LinkAction.AddProxy(proxy.server, proxy.port, type) } - private fun TdApi.ProxyType?.toDomain(): ProxyTypeModel? = when (this) { - is TdApi.ProxyTypeMtproto -> ProxyTypeModel.Mtproto(secret) - is TdApi.ProxyTypeSocks5 -> ProxyTypeModel.Socks5(username, password) - is TdApi.ProxyTypeHttp -> ProxyTypeModel.Http(username, password, httpOnly) - else -> null + companion object { + private const val TAG = "LinkHandlerRepository" + private const val MSG_CHAT_NOT_FOUND = "Chat not found" + private const val MSG_USER_NOT_FOUND = "User not found" + private const val MSG_MESSAGE_NOT_FOUND = "Message not found" + private const val MSG_UNSUPPORTED_PROXY_TYPE = "Unsupported proxy type" } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/LinkParser.kt b/data/src/main/java/org/monogram/data/repository/LinkParser.kt new file mode 100644 index 00000000..b7019054 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/LinkParser.kt @@ -0,0 +1,123 @@ +package org.monogram.data.repository + +import androidx.core.net.toUri +import org.monogram.data.core.coRunCatching +import org.monogram.domain.models.ProxyTypeModel + +class LinkParser { + + fun normalize(link: String): String = when { + link.startsWith("tg://") -> link + link.startsWith("https://t.me/") -> link + link.startsWith("http://t.me/") -> link.replace("http://", "https://") + link.startsWith("t.me/") -> "https://$link" + else -> link + } + + fun parsePrimary(link: String): ParsedLink? { + parseProxyLink(link)?.let { return it } + parseUserLink(link)?.let { return it } + return null + } + + fun parseFallback(link: String): ParsedLink { + val uri = coRunCatching { link.toUri() }.getOrNull() ?: return parseExternalOrNone(link) + + if (uri.scheme.equals("tg", ignoreCase = true)) { + if (uri.host.equals("resolve", ignoreCase = true)) { + uri.getQueryParameter("user_id")?.toLongOrNull()?.let { + return ParsedLink.OpenUser(it) + } + + uri.getQueryParameter("phone") + ?.takeIf { it.isNotBlank() } + ?.let { return ParsedLink.ResolveByPhone(phoneNumber = it, openProfile = false) } + + val username = uri.getQueryParameter("domain")?.takeIf { it.isNotBlank() } + if (username != null) { + val hasMessageTarget = uri.getQueryParameter("post") != null || + uri.getQueryParameter("thread") != null || + uri.getQueryParameter("comment") != null + if (!hasMessageTarget) { + return ParsedLink.OpenPublicChat(username) + } + } + } + + return ParsedLink.None + } + + if (uri.scheme.equals("https", ignoreCase = true) || uri.scheme.equals("http", ignoreCase = true)) { + val host = uri.host?.lowercase() + val pathSegments = uri.pathSegments.orEmpty() + + if (host == "t.me" || host == "www.t.me" || host == "telegram.me" || host == "www.telegram.me") { + val first = pathSegments.firstOrNull() + val second = pathSegments.getOrNull(1) + + if (!first.isNullOrBlank()) { + if (first == "joinchat" && !second.isNullOrBlank()) { + return ParsedLink.JoinChat("https://t.me/joinchat/$second") + } + + if (first.startsWith("+")) { + return ParsedLink.JoinChat("https://t.me/$first") + } + + if (pathSegments.size == 1) { + return ParsedLink.OpenPublicChat(first) + } + } + } + } + + return parseExternalOrNone(link) + } + + private fun parseProxyLink(link: String): ParsedLink.AddProxy? { + val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null + + val isProxy = link.contains("/proxy?") || link.startsWith("tg://proxy") + val isSocks = link.contains("/socks?") || link.startsWith("tg://socks") + val isHttp = link.contains("/http?") || link.startsWith("tg://http") + if (!isProxy && !isSocks && !isHttp) return null + + val server = uri.getQueryParameter("server") ?: return null + val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null + val secret = uri.getQueryParameter("secret") + val user = uri.getQueryParameter("user") + val pass = uri.getQueryParameter("pass") + + val type = when { + secret != null -> ProxyTypeModel.Mtproto(secret) + isHttp -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) + else -> ProxyTypeModel.Socks5(user ?: "", pass ?: "") + } + return ParsedLink.AddProxy(server, port, type) + } + + private fun parseUserLink(link: String): ParsedLink.OpenUser? { + val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null + if (!uri.scheme.equals("tg", ignoreCase = true)) return null + + val userId = when { + uri.host.equals("user", ignoreCase = true) -> + uri.getQueryParameter("id")?.toLongOrNull() + + uri.host.equals("openmessage", ignoreCase = true) -> + uri.getQueryParameter("user_id")?.toLongOrNull() + + else -> null + } ?: return null + + return ParsedLink.OpenUser(userId) + } + + private fun parseExternalOrNone(link: String): ParsedLink { + return if (link.startsWith("http://") || link.startsWith("https://")) { + ParsedLink.OpenExternal(link) + } else { + ParsedLink.None + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/LocationRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/LocationRepositoryImpl.kt index a3c2775f..e5bcf036 100644 --- a/data/src/main/java/org/monogram/data/repository/LocationRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/LocationRepositoryImpl.kt @@ -1,85 +1,11 @@ package org.monogram.data.repository -import android.util.Log -import org.monogram.domain.models.webapp.OSMReverseResponse +import org.monogram.data.datasource.remote.NominatimRemoteDataSource import org.monogram.domain.repository.LocationRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -class LocationRepositoryImpl : LocationRepository { - private val json = Json { - ignoreUnknownKeys = true - coerceInputValues = true - isLenient = true - } - - private val userAgent = "MonoGram-Android-App/1.0" - - override suspend fun reverseGeocode(lat: Double, lon: Double): OSMReverseResponse? { - val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$lat&lon=$lon&addressdetails=1" - - return try { - val responseText = makeHttpRequest(url) - if (responseText != null) { - json.decodeFromString(responseText) - } else { - null - } - } catch (e: Exception) { - Log.e("LocationRepo", "Error parsing reverse geocode", e) - null - } - } - - override suspend fun searchLocation(query: String): List { - return try { - val encodedQuery = withContext(Dispatchers.IO) { - URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) - } - val url = - "https://nominatim.openstreetmap.org/search?q=$encodedQuery&format=jsonv2&addressdetails=1&limit=10" - - val responseText = makeHttpRequest(url) - if (responseText != null) { - json.decodeFromString>(responseText) - } else { - emptyList() - } - } catch (e: Exception) { - Log.e("LocationRepo", "Error parsing search results", e) - emptyList() - } - } - - private suspend fun makeHttpRequest(urlString: String): String? = withContext(Dispatchers.IO) { - var connection: HttpURLConnection? = null - try { - val url = URL(urlString) - connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 15_000 - readTimeout = 15_000 - setRequestProperty("User-Agent", userAgent) - setRequestProperty("Accept", "application/json") - } - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - connection.inputStream.bufferedReader().use { it.readText() } - } else { - Log.e("LocationRepo", "Nominatim Error: $responseCode for URL: $urlString") - null - } - } catch (e: Exception) { - Log.e("LocationRepo", "Network error", e) - null - } finally { - connection?.disconnect() - } - } +class LocationRepositoryImpl( + private val remote: NominatimRemoteDataSource +) : LocationRepository { + override suspend fun reverseGeocode(lat: Double, lon: Double) = + remote.reverseGeocode(lat, lon) } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/NetworkStatisticsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NetworkStatisticsRepositoryImpl.kt new file mode 100644 index 00000000..d4d58c1f --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/NetworkStatisticsRepositoryImpl.kt @@ -0,0 +1,31 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.mapper.NetworkMapper +import org.monogram.domain.models.NetworkUsageModel +import org.monogram.domain.repository.NetworkStatisticsRepository + +class NetworkStatisticsRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val networkMapper: NetworkMapper +) : NetworkStatisticsRepository { + + override suspend fun getNetworkUsage(): NetworkUsageModel? { + return remote.getNetworkStatistics()?.let { networkMapper.mapToDomain(it) } + } + + override suspend fun getNetworkStatisticsEnabled(): Boolean { + val result = remote.getOption("disable_network_statistics") + return if (result is TdApi.OptionValueBoolean) !result.value else true + } + + override suspend fun setNetworkStatisticsEnabled(enabled: Boolean) { + remote.setOption("disable_network_statistics", TdApi.OptionValueBoolean(!enabled)) + remote.setOption("disable_persistent_network_statistics", TdApi.OptionValueBoolean(!enabled)) + } + + override suspend fun resetNetworkStatistics(): Boolean { + return remote.resetNetworkStatistics() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt new file mode 100644 index 00000000..74cad092 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -0,0 +1,112 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.core.DispatcherProvider +import org.monogram.core.ScopeProvider +import org.monogram.data.datasource.cache.SettingsCacheDataSource +import org.monogram.data.datasource.remote.ChatsRemoteDataSource +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.mapper.toApi +import org.monogram.data.mapper.user.toDomain +import org.monogram.domain.models.ChatModel +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +class NotificationSettingsRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val cache: SettingsCacheDataSource, + private val chatsRemote: ChatsRemoteDataSource, + private val updates: UpdateDispatcher, + scopeProvider: ScopeProvider, + private val dispatchers: DispatcherProvider +) : NotificationSettingsRepository { + + private val scope = scopeProvider.appScope + + init { + scope.launch { + updates.newChat.collect { update -> + cache.putChat(update.chat) + } + } + + scope.launch { + updates.chatTitle.collect { update -> + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.title = update.title + } + } + } + } + + scope.launch { + updates.chatPhoto.collect { update -> + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.photo = update.photo + } + } + } + } + + scope.launch { + updates.chatNotificationSettings.collect { update -> + cache.getChat(update.chatId)?.let { chat -> + synchronized(chat) { + chat.notificationSettings = update.notificationSettings + } + } + } + } + } + + override suspend fun getNotificationSettings(scope: TdNotificationScope): Boolean { + val result = remote.getScopeNotificationSettings(scope.toApi()) + return result?.muteFor == 0 + } + + override suspend fun setNotificationSettings(scope: TdNotificationScope, enabled: Boolean) { + val settings = TdApi.ScopeNotificationSettings().apply { + muteFor = if (enabled) 0 else Int.MAX_VALUE + useDefaultMuteStories = false + } + remote.setScopeNotificationSettings(scope.toApi(), settings) + } + + override suspend fun getExceptions(scope: TdNotificationScope): List = coroutineScope { + val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) + chats?.chatIds?.map { chatId -> + async(dispatchers.io) { + cache.getChat(chatId)?.toDomain() + ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }?.toDomain() + } + }?.awaitAll()?.filterNotNull() ?: emptyList() + } + + override suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) { + val settings = TdApi.ChatNotificationSettings().apply { + useDefaultMuteFor = false + muteFor = if (enabled) 0 else Int.MAX_VALUE + useDefaultSound = true + useDefaultShowPreview = true + useDefaultMuteStories = true + } + remote.setChatNotificationSettings(chatId, settings) + } + + override suspend fun resetChatNotificationSettings(chatId: Long) { + val settings = TdApi.ChatNotificationSettings().apply { + useDefaultMuteFor = true + useDefaultSound = true + useDefaultShowPreview = true + useDefaultMuteStories = true + } + remote.setChatNotificationSettings(chatId, settings) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/ParsedLink.kt b/data/src/main/java/org/monogram/data/repository/ParsedLink.kt new file mode 100644 index 00000000..3eec9260 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ParsedLink.kt @@ -0,0 +1,22 @@ +package org.monogram.data.repository + +import org.monogram.domain.models.ProxyTypeModel + +sealed class ParsedLink { + data class AddProxy( + val server: String, + val port: Int, + val type: ProxyTypeModel + ) : ParsedLink() + + data class OpenUser(val userId: Long) : ParsedLink() + data class ResolveByPhone( + val phoneNumber: String, + val openProfile: Boolean + ) : ParsedLink() + + data class OpenPublicChat(val username: String) : ParsedLink() + data class JoinChat(val inviteLink: String) : ParsedLink() + data class OpenExternal(val url: String) : ParsedLink() + data object None : ParsedLink() +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt new file mode 100644 index 00000000..8d8f2583 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt @@ -0,0 +1,30 @@ +package org.monogram.data.repository + +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.data.mapper.user.toApi +import org.monogram.data.mapper.user.toDomain +import org.monogram.domain.models.PremiumFeatureType +import org.monogram.domain.models.PremiumLimitType +import org.monogram.domain.models.PremiumSource +import org.monogram.domain.models.PremiumStateModel +import org.monogram.domain.repository.PremiumRepository + +class PremiumRepositoryImpl( + private val remote: UserRemoteDataSource +) : PremiumRepository { + override suspend fun getPremiumState(): PremiumStateModel? { + val state = remote.getPremiumState() ?: return null + return state.toDomain() + } + + override suspend fun getPremiumFeatures(source: PremiumSource): List { + val tdSource = source.toApi() ?: return emptyList() + val result = remote.getPremiumFeatures(tdSource) ?: return emptyList() + return result.features.map { it.toDomain() } + } + + override suspend fun getPremiumLimit(limitType: PremiumLimitType): Int { + val tdType = limitType.toApi() ?: return 0 + return remote.getPremiumLimit(tdType)?.premiumValue ?: 0 + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt new file mode 100644 index 00000000..4a910a0a --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -0,0 +1,264 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.toEntity +import org.monogram.data.mapper.user.toTdApiChat +import org.monogram.domain.repository.ProfilePhotoRepository + +class ProfilePhotoRepositoryImpl( + private val remote: UserRemoteDataSource, + private val chatLocal: ChatLocalDataSource, + private val gateway: TelegramGateway, + private val updates: UpdateDispatcher, + private val fileQueue: FileDownloadQueue +) : ProfilePhotoRepository { + private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY + private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY + + override suspend fun getUserProfilePhotos( + userId: Long, + offset: Int, + limit: Int, + ensureFullRes: Boolean + ): List { + if (userId <= 0) return emptyList() + val result = remote.getUserProfilePhotos(userId, offset, limit) ?: return emptyList() + return coroutineScope { + result.photos + .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } + .awaitAll() + .filterNotNull() + } + } + + override suspend fun getChatProfilePhotos( + chatId: Long, + offset: Int, + limit: Int, + ensureFullRes: Boolean + ): List { + if (chatId == 0L) return emptyList() + val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes) + if (paths.isNotEmpty()) return paths + + val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes) + return listOfNotNull(currentPath) + } + + override fun getUserProfilePhotosFlow(userId: Long): Flow> = flow { + if (userId <= 0) { + emit(emptyList()) + return@flow + } + emit(getUserProfilePhotos(userId)) + updates.file.collect { emit(getUserProfilePhotos(userId)) } + } + + override fun getChatProfilePhotosFlow(chatId: Long): Flow> = flow { + if (chatId == 0L) { + emit(emptyList()) + return@flow + } + emit(getChatProfilePhotos(chatId)) + updates.file.collect { emit(getChatProfilePhotos(chatId)) } + } + + private suspend fun loadChatPhotoHistoryPaths( + chatId: Long, + offset: Int, + limit: Int, + ensureFullRes: Boolean + ): List { + if (limit <= 0) return emptyList() + + val request = TdApi.SearchChatMessages().apply { + this.chatId = chatId + this.query = "" + this.senderId = null + this.fromMessageId = 0L + this.offset = 0 + this.limit = (offset + limit).coerceAtMost(100) + this.filter = TdApi.SearchMessagesFilterChatPhoto() + } + + val result = coRunCatching { + gateway.execute(request) as? TdApi.FoundChatMessages + }.getOrNull() ?: return emptyList() + + val chatPhotos = result.messages + .asSequence() + .mapNotNull { (it.content as? TdApi.MessageChatChangePhoto)?.photo } + .drop(offset) + .take(limit) + .toList() + + if (chatPhotos.isEmpty()) return emptyList() + + return coroutineScope { + chatPhotos + .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } + .awaitAll() + .filterNotNull() + .distinct() + } + } + + private suspend fun resolveCurrentChatPhotoPath(chatId: Long, ensureFullRes: Boolean): String? { + val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } + ?: chatLocal.getChat(chatId)?.toTdApiChat() + ?: return null + return resolveChatPhotoInfoPath(chat.photo, ensureFullRes) + } + + private suspend fun resolveChatPhotoInfoPath( + photoInfo: TdApi.ChatPhotoInfo?, + ensureFullRes: Boolean + ): String? { + val smallId = photoInfo?.small?.id?.takeIf { it != 0 } + val bigId = photoInfo?.big?.id?.takeIf { it != 0 } + val preferredFile = if (ensureFullRes) { + photoInfo?.big ?: photoInfo?.small + } else { + photoInfo?.small ?: photoInfo?.big + } ?: return null + + val directPath = preferredFile.local.path.ifEmpty { null } + if (directPath != null) { + if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + return directPath + } + + val downloadedPath = resolveDownloadedFilePath(preferredFile.id) + if (downloadedPath != null) { + if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + return downloadedPath + } + + if (!ensureFullRes) { + if (smallId != null) { + fileQueue.enqueue( + smallId, + avatarDownloadPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + if (bigId != null && bigId != smallId) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + } else if (bigId != null) { + fileQueue.enqueue( + bigId, + avatarDownloadPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + return null + } + + val fileId = preferredFile.id.takeIf { it != 0 } ?: return null + fileQueue.enqueue( + fileId = fileId, + priority = FULL_RES_DOWNLOAD_PRIORITY, + type = FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false, + ignoreSuppression = true + ) + withTimeoutOrNull(FILE_DOWNLOAD_TIMEOUT_MS) { + coRunCatching { fileQueue.waitForDownload(fileId).await() } + } + return resolveDownloadedFilePath(fileId) + } + + private suspend fun resolveUserProfilePhotoPath( + photo: TdApi.ChatPhoto, + ensureFullRes: Boolean + ): String? { + val animationFile = photo.animation?.file + val animationPath = animationFile?.local?.path?.ifEmpty { null } + if (animationPath != null) return animationPath + + val bestPhotoFile = photo.sizes + .maxByOrNull { it.width.toLong() * it.height.toLong() } + ?.photo + ?: photo.sizes.lastOrNull()?.photo + ?: return null + + val directPath = bestPhotoFile.local.path.ifEmpty { null } + if (directPath != null) return directPath + + if (!ensureFullRes) { + val fallbackFile = photo.sizes.find { it.type == "m" }?.photo + ?: photo.sizes.find { it.type == "s" }?.photo + ?: photo.sizes.find { it.type == "c" }?.photo + ?: photo.sizes.find { it.type == "b" }?.photo + ?: photo.sizes.find { it.type == "a" }?.photo + ?: photo.sizes.firstOrNull()?.photo + + val fallbackDirectPath = fallbackFile?.local?.path?.ifEmpty { null } + if (fallbackDirectPath != null) return fallbackDirectPath + + val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id) + if (fallbackDownloadedPath != null) return fallbackDownloadedPath + + return null + } + + val fileId = bestPhotoFile.id.takeIf { it != 0 } ?: return null + fileQueue.enqueue( + fileId = fileId, + priority = FULL_RES_DOWNLOAD_PRIORITY, + type = FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false, + ignoreSuppression = true + ) + withTimeoutOrNull(FILE_DOWNLOAD_TIMEOUT_MS) { + coRunCatching { fileQueue.waitForDownload(fileId).await() } + } + return resolveDownloadedFilePath(fileId) + } + + private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { + if (fileId == null || fileId == 0) return null + val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null + return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + } + + companion object { + private const val AVATAR_DOWNLOAD_PRIORITY = 24 + private const val AVATAR_HD_PREFETCH_PRIORITY = 8 + private const val FULL_RES_DOWNLOAD_PRIORITY = 32 + private const val FILE_DOWNLOAD_TIMEOUT_MS = 15_000L + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/SessionRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/SessionRepositoryImpl.kt new file mode 100644 index 00000000..af169cb1 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/SessionRepositoryImpl.kt @@ -0,0 +1,23 @@ +package org.monogram.data.repository + +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.mapper.toDomain +import org.monogram.domain.models.SessionModel +import org.monogram.domain.repository.SessionRepository + +class SessionRepositoryImpl( + private val remote: SettingsRemoteDataSource +) : SessionRepository { + + override suspend fun getActiveSessions(): List { + return remote.getActiveSessions()?.sessions?.map { it.toDomain() } ?: emptyList() + } + + override suspend fun terminateSession(sessionId: Long): Boolean { + return remote.terminateSession(sessionId) + } + + override suspend fun confirmQrCode(link: String): Boolean { + return remote.confirmQrCode(link) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/SettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/SettingsRepositoryImpl.kt deleted file mode 100644 index 761d752b..00000000 --- a/data/src/main/java/org/monogram/data/repository/SettingsRepositoryImpl.kt +++ /dev/null @@ -1,340 +0,0 @@ -package org.monogram.data.repository - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* -import kotlinx.serialization.json.Json -import org.drinkless.tdlib.TdApi -import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider -import org.monogram.data.datasource.cache.SettingsCacheDataSource -import org.monogram.data.datasource.remote.ChatsRemoteDataSource -import org.monogram.data.datasource.remote.SettingsRemoteDataSource -import org.monogram.data.db.dao.AttachBotDao -import org.monogram.data.db.dao.KeyValueDao -import org.monogram.data.db.dao.WallpaperDao -import org.monogram.data.db.model.AttachBotEntity -import org.monogram.data.db.model.KeyValueEntity -import org.monogram.data.db.model.WallpaperEntity -import org.monogram.data.gateway.UpdateDispatcher -import org.monogram.data.mapper.* -import org.monogram.data.mapper.user.toDomain -import org.monogram.domain.models.* -import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.CacheProvider -import org.monogram.domain.repository.SettingsRepository -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope -import org.monogram.domain.repository.StringProvider - - -class SettingsRepositoryImpl( - private val remote: SettingsRemoteDataSource, - private val cache: SettingsCacheDataSource, - private val chatsRemote: ChatsRemoteDataSource, - private val updates: UpdateDispatcher, - private val appPreferences: AppPreferencesProvider, - private val cacheProvider: CacheProvider, - scopeProvider: ScopeProvider, - private val dispatchers: DispatcherProvider, - private val attachBotDao: AttachBotDao, - private val keyValueDao: KeyValueDao, - private val wallpaperDao: WallpaperDao, - private val storageMapper: StorageMapper, - private val stringProvider: StringProvider, - private val networkMapper: NetworkMapper -) : SettingsRepository { - - private val scope = scopeProvider.appScope - - private val _wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) - private val _attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value) - private val _wallpapers = MutableStateFlow>(emptyList()) - - override val autoDownloadMobile = appPreferences.autoDownloadMobile - override val autoDownloadWifi = appPreferences.autoDownloadWifi - override val autoDownloadRoaming = appPreferences.autoDownloadRoaming - override val autoDownloadFiles = appPreferences.autoDownloadFiles - override val autoDownloadStickers = appPreferences.autoDownloadStickers - override val autoDownloadVideoNotes = appPreferences.autoDownloadVideoNotes - - init { - scope.launch { - updates.newChat.collect { update -> cache.putChat(update.chat) } - } - scope.launch { - updates.chatTitle.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { chat.title = update.title } - } - } - } - scope.launch { - updates.chatPhoto.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { chat.photo = update.photo } - } - } - } - scope.launch { - updates.chatNotificationSettings.collect { update -> - cache.getChat(update.chatId)?.let { chat -> - synchronized(chat) { chat.notificationSettings = update.notificationSettings } - } - } - } - scope.launch { - updates.attachmentMenuBots.collect { update -> - cache.putAttachMenuBots(update.bots) - val bots = update.bots.map { it.toDomain() } - _attachMenuBots.value = bots - cacheProvider.setAttachBots(bots) - - saveAttachBotsToDb(bots) - - update.bots.forEach { bot -> - bot.androidSideMenuIcon?.let { icon -> - if (icon.local.path.isEmpty()) { - remote.downloadFile(icon.id, 1) - } - } - } - } - } - scope.launch { - updates.file.collect { update -> - _wallpaperUpdates.emit(Unit) - - val currentBots = _attachMenuBots.value - if (currentBots.any { it.icon?.icon?.id == update.file.id }) { - cache.getAttachMenuBots()?.let { bots -> - val domainBots = bots.map { it.toDomain() } - _attachMenuBots.value = domainBots - cacheProvider.setAttachBots(domainBots) - saveAttachBotsToDb(domainBots) - } - } - } - } - - scope.launch { - attachBotDao.getAttachBots().collect { entities -> - val bots = entities.mapNotNull { - try { - Json.decodeFromString(it.data) - } catch (e: Exception) { - null - } - } - if (bots.isNotEmpty()) { - _attachMenuBots.value = bots - cacheProvider.setAttachBots(bots) - } - } - } - - scope.launch { - keyValueDao.observeValue("cached_sim_country_iso").collect { entity -> - cacheProvider.setCachedSimCountryIso(entity?.value) - } - } - - scope.launch { - wallpaperDao.getWallpapers().collect { entities -> - val wallpapers = entities.mapNotNull { - try { - Json.decodeFromString(it.data) - } catch (e: Exception) { - null - } - } - if (wallpapers.isNotEmpty()) { - _wallpapers.value = wallpapers - } - } - } - } - - private suspend fun saveAttachBotsToDb(bots: List) { - withContext(dispatchers.io) { - attachBotDao.clearAll() - attachBotDao.insertAttachBots(bots.map { - AttachBotEntity(it.botUserId, Json.encodeToString(it)) - }) - } - } - - private suspend fun saveWallpapersToDb(wallpapers: List) { - withContext(dispatchers.io) { - wallpaperDao.clearAll() - wallpaperDao.insertWallpapers(wallpapers.map { - WallpaperEntity(it.id, Json.encodeToString(it)) - }) - } - } - - override fun setAutoDownloadMobile(enabled: Boolean) = appPreferences.setAutoDownloadMobile(enabled) - override fun setAutoDownloadWifi(enabled: Boolean) = appPreferences.setAutoDownloadWifi(enabled) - override fun setAutoDownloadRoaming(enabled: Boolean) = appPreferences.setAutoDownloadRoaming(enabled) - override fun setAutoDownloadFiles(enabled: Boolean) = appPreferences.setAutoDownloadFiles(enabled) - override fun setAutoDownloadStickers(enabled: Boolean) = appPreferences.setAutoDownloadStickers(enabled) - override fun setAutoDownloadVideoNotes(enabled: Boolean) = appPreferences.setAutoDownloadVideoNotes(enabled) - - override suspend fun getNotificationSettings(scope: TdNotificationScope): Boolean { - val result = remote.getScopeNotificationSettings(scope.toApi()) - return result?.muteFor == 0 - } - - override suspend fun setNotificationSettings(scope: TdNotificationScope, enabled: Boolean) { - val settings = TdApi.ScopeNotificationSettings().apply { - muteFor = if (enabled) 0 else Int.MAX_VALUE - useDefaultMuteStories = false - } - remote.setScopeNotificationSettings(scope.toApi(), settings) - } - - override suspend fun getExceptions(scope: TdNotificationScope): List = coroutineScope { - val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) - chats?.chatIds?.map { chatId -> - async(dispatchers.io) { - cache.getChat(chatId)?.toDomain() - ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }?.toDomain() - } - }?.awaitAll()?.filterNotNull() ?: emptyList() - } - - override suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) { - val settings = TdApi.ChatNotificationSettings().apply { - useDefaultMuteFor = false - muteFor = if (enabled) 0 else Int.MAX_VALUE - useDefaultSound = true - useDefaultShowPreview = true - useDefaultMuteStories = true - } - remote.setChatNotificationSettings(chatId, settings) - } - - override suspend fun resetChatNotificationSettings(chatId: Long) { - val settings = TdApi.ChatNotificationSettings().apply { - useDefaultMuteFor = true - useDefaultSound = true - useDefaultShowPreview = true - useDefaultMuteStories = true - } - remote.setChatNotificationSettings(chatId, settings) - } - - override fun getAttachMenuBots(): Flow> { - return _attachMenuBots - } - - override suspend fun setCachedSimCountryIso(iso: String?) { - withContext(dispatchers.io) { - if (iso != null) { - keyValueDao.insertValue(KeyValueEntity("cached_sim_country_iso", iso)) - } else { - keyValueDao.deleteValue("cached_sim_country_iso") - } - } - } - - override suspend fun getActiveSessions(): List { - return remote.getActiveSessions()?.sessions?.map { it.toDomain() } ?: emptyList() - } - - override suspend fun terminateSession(sessionId: Long): Boolean = - remote.terminateSession(sessionId) - - override suspend fun confirmQrCode(link: String): Boolean = - remote.confirmQrCode(link) - - override fun getWallpapers(): Flow> = callbackFlow { - suspend fun fetch() { - val result = remote.getInstalledBackgrounds(false) - val wallpapers = mapBackgrounds(result?.backgrounds ?: emptyArray()) - _wallpapers.value = wallpapers - saveWallpapersToDb(wallpapers) - trySend(wallpapers) - } - - val wallpaperJob = _wallpaperUpdates - .onEach { fetch() } - .launchIn(this) - - if (_wallpapers.value.isNotEmpty()) { - trySend(_wallpapers.value) - } else { - fetch() - } - awaitClose { wallpaperJob.cancel() } - } - - override suspend fun downloadWallpaper(fileId: Int) { - remote.downloadFile(fileId, 1) - } - - override suspend fun getStorageUsage(): StorageUsageModel? = coroutineScope { - val stats = remote.getStorageStatistics(100) ?: return@coroutineScope null - val processed_chats = (stats.byChat ?: emptyArray()).map { chatStat -> - async(dispatchers.default) { - val title = when { - chatStat.chatId == 0L -> stringProvider.getString("storage_other_cache") - else -> cache.getChat(chatStat.chatId)?.title - ?: chatsRemote.getChat(chatStat.chatId)?.title - ?: stringProvider.getString("storage_chat_format", chatStat.chatId) - } - storageMapper.mapChatStatsToDomain(chatStat, title) - } - }.awaitAll() - storageMapper.mapToDomain(stats, processed_chats) - } - - override suspend fun getNetworkUsage(): NetworkUsageModel? = - remote.getNetworkStatistics()?.let { networkMapper.mapToDomain(it) } - - override suspend fun clearStorage(chatId: Long?): Boolean = - remote.optimizeStorage( - size = 0, - ttl = 0, - count = 0, - immunityDelay = 0, - chatIds = chatId?.let { longArrayOf(it) }, - returnDeletedFileStatistics = false, - chatLimit = 20 - ) - - override suspend fun resetNetworkStatistics(): Boolean = - remote.resetNetworkStatistics() - - override suspend fun setDatabaseMaintenanceSettings( - maxDatabaseSize: Long, - maxTimeFromLastAccess: Int - ): Boolean = - remote.optimizeStorage( - size = maxDatabaseSize, - ttl = maxTimeFromLastAccess, - count = -1, - immunityDelay = -1, - chatIds = null, - returnDeletedFileStatistics = true, - chatLimit = 0 - ) - - override suspend fun getNetworkStatisticsEnabled(): Boolean { - val result = remote.getOption("disable_network_statistics") - return if (result is TdApi.OptionValueBoolean) !result.value else true - } - - override suspend fun setNetworkStatisticsEnabled(enabled: Boolean) { - remote.setOption("disable_network_statistics", TdApi.OptionValueBoolean(!enabled)) - remote.setOption("disable_persistent_network_statistics", TdApi.OptionValueBoolean(!enabled)) - } - - override suspend fun getStorageOptimizerEnabled(): Boolean { - val result = remote.getOption("use_storage_optimizer") - return if (result is TdApi.OptionValueBoolean) result.value else false - } - - override suspend fun setStorageOptimizerEnabled(enabled: Boolean) { - remote.setOption("use_storage_optimizer", TdApi.OptionValueBoolean(enabled)) - } -} diff --git a/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt new file mode 100644 index 00000000..ec18f228 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt @@ -0,0 +1,12 @@ +package org.monogram.data.repository + +import org.monogram.data.infra.SponsorSyncManager +import org.monogram.domain.repository.SponsorRepository + +class SponsorRepositoryImpl( + private val sponsorSyncManager: SponsorSyncManager +) : SponsorRepository { + override fun forceSponsorSync() { + sponsorSyncManager.forceSync() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt index 81ecca19..ac9f9edc 100644 --- a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt @@ -1,46 +1,33 @@ package org.monogram.data.repository -import org.monogram.data.core.coRunCatching -import android.content.Context import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.datasource.remote.StickerRemoteSource -import org.monogram.data.db.dao.RecentEmojiDao -import org.monogram.data.db.dao.StickerPathDao -import org.monogram.data.db.dao.StickerSetDao -import org.monogram.data.db.model.RecentEmojiEntity -import org.monogram.data.db.model.StickerPathEntity -import org.monogram.data.db.model.StickerSetEntity import org.monogram.data.gateway.UpdateDispatcher -import org.monogram.data.infra.EmojiLoader -import org.monogram.data.infra.FileDownloadQueue -import org.monogram.data.infra.FileUpdateHandler -import org.monogram.domain.models.* +import org.monogram.data.stickers.StickerFileManager +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.models.StickerType import org.monogram.domain.repository.CacheProvider import org.monogram.domain.repository.StickerRepository -import java.io.File -import java.io.FileInputStream -import java.util.concurrent.ConcurrentHashMap -import java.util.zip.GZIPInputStream class StickerRepositoryImpl( private val remote: StickerRemoteSource, - private val fileQueue: FileDownloadQueue, - private val fileUpdateHandler: FileUpdateHandler, + private val fileManager: StickerFileManager, private val updates: UpdateDispatcher, private val cacheProvider: CacheProvider, private val dispatchers: DispatcherProvider, - private val context: Context, - private val stickerSetDao: StickerSetDao, - private val recentEmojiDao: RecentEmojiDao, - private val stickerPathDao: StickerPathDao, + private val localDataSource: StickerLocalDataSource, scopeProvider: ScopeProvider ) : StickerRepository { @@ -50,10 +37,10 @@ class StickerRepositoryImpl( override val customEmojiStickerSets: StateFlow> = cacheProvider.customEmojiStickerSets private val _archivedStickerSets = MutableStateFlow>(emptyList()) - override val archivedStickerSets = _archivedStickerSets.asStateFlow() + override val archivedStickerSets: StateFlow> = _archivedStickerSets.asStateFlow() private val _archivedEmojiSets = MutableStateFlow>(emptyList()) - override val archivedEmojiSets = _archivedEmojiSets.asStateFlow() + override val archivedEmojiSets: StateFlow> = _archivedEmojiSets.asStateFlow() private val regularMutex = Mutex() private val customEmojiMutex = Mutex() @@ -62,16 +49,10 @@ class StickerRepositoryImpl( @Volatile private var lastRegularLoadTime = 0L + @Volatile private var lastCustomEmojiLoadTime = 0L - override val recentEmojis: Flow> = cacheProvider.recentEmojis - - private val tgsCache = mutableMapOf() - private val filePathsCache = ConcurrentHashMap() - private var cachedEmojis: List? = null - private var fallbackEmojisCache: List? = null - init { scope.launch { updates.installedStickerSets.collect { update -> @@ -83,33 +64,28 @@ class StickerRepositoryImpl( } scope.launch { - stickerSetDao.getInstalledStickerSetsByType("REGULAR").collect { entities -> - if (installedStickerSets.value.isEmpty()) { - val sets = entities.mapNotNull { it.toModel() } - if (sets.isNotEmpty()) cacheProvider.setInstalledStickerSets(sets) + localDataSource.getInstalledStickerSetsByType(REGULAR_TYPE).collect { sets -> + if (installedStickerSets.value.isEmpty() && sets.isNotEmpty()) { + cacheProvider.setInstalledStickerSets(sets) } } } scope.launch { - recentEmojiDao.getRecentEmojis().collect { entities -> - val models = entities.mapNotNull { - try { - Json.decodeFromString(it.data) - } catch (e: Exception) { - null - } + localDataSource.getInstalledStickerSetsByType(CUSTOM_EMOJI_TYPE).collect { sets -> + if (customEmojiStickerSets.value.isEmpty() && sets.isNotEmpty()) { + cacheProvider.setCustomEmojiStickerSets(sets) } - cacheProvider.setRecentEmojis(models) } } scope.launch(dispatchers.default) { - delay(60_000L) + delay(VERIFY_INITIAL_DELAY_MS) while (isActive) { - coRunCatching { verifyInstalledStickerSets() } - .onFailure { Log.w("StickerRepo", "verifyInstalledStickerSets failed", it) } - delay(120_000L) + coRunCatching { + fileManager.verifyInstalledStickerSets(installedStickerSets.value + customEmojiStickerSets.value) + }.onFailure { Log.w(TAG, "verifyInstalledStickerSets failed", it) } + delay(VERIFY_INTERVAL_MS) } } } @@ -119,7 +95,9 @@ class StickerRepositoryImpl( private suspend fun loadInstalledStickerSets(force: Boolean) = regularMutex.withLock { val now = System.currentTimeMillis() if (!force && installedStickerSets.value.isNotEmpty()) return@withLock - if (force && installedStickerSets.value.isNotEmpty() && now - lastRegularLoadTime < 1000) return@withLock + if (force && installedStickerSets.value.isNotEmpty() && now - lastRegularLoadTime < LOAD_DEBOUNCE_MS) { + return@withLock + } val sets = remote.getInstalledStickerSets(StickerType.REGULAR) if (force && installedStickerSets.value.map { it.id } == sets.map { it.id }) { @@ -128,7 +106,7 @@ class StickerRepositoryImpl( } cacheProvider.setInstalledStickerSets(sets) - saveStickerSetsToDb(sets, "REGULAR", isInstalled = true, isArchived = false) + localDataSource.saveStickerSets(sets, REGULAR_TYPE, isInstalled = true, isArchived = false) lastRegularLoadTime = System.currentTimeMillis() } @@ -141,10 +119,13 @@ class StickerRepositoryImpl( val hasMissingCustomEmojiIds = customEmojiStickerSets.value.any { set -> set.stickerType == StickerType.CUSTOM_EMOJI && set.stickers.any { it.customEmojiId == null } } - val needsRefresh = lastCustomEmojiLoadTime == 0L || (now - lastCustomEmojiLoadTime) > 10 * 60 * 1000 + val needsRefresh = + lastCustomEmojiLoadTime == 0L || (now - lastCustomEmojiLoadTime) > CUSTOM_EMOJI_REFRESH_MS if (!hasMissingCustomEmojiIds && !needsRefresh) return@withLock } - if (force && customEmojiStickerSets.value.isNotEmpty() && now - lastCustomEmojiLoadTime < 1000) return@withLock + if (force && customEmojiStickerSets.value.isNotEmpty() && now - lastCustomEmojiLoadTime < LOAD_DEBOUNCE_MS) { + return@withLock + } val sets = remote.getInstalledStickerSets(StickerType.CUSTOM_EMOJI) if (sets.isNotEmpty()) { @@ -154,13 +135,13 @@ class StickerRepositoryImpl( } cacheProvider.setCustomEmojiStickerSets(sets) - saveStickerSetsToDb(sets, "CUSTOM_EMOJI", isInstalled = true, isArchived = false) + localDataSource.saveStickerSets(sets, CUSTOM_EMOJI_TYPE, isInstalled = true, isArchived = false) lastCustomEmojiLoadTime = System.currentTimeMillis() return@withLock } if (customEmojiStickerSets.value.isEmpty()) { - val cached = stickerSetDao.getInstalledStickerSetsByType("CUSTOM_EMOJI").first().mapNotNull { it.toModel() } + val cached = localDataSource.getInstalledStickerSetsByType(CUSTOM_EMOJI_TYPE).first() if (cached.isNotEmpty()) { cacheProvider.setCustomEmojiStickerSets(cached) } @@ -168,86 +149,57 @@ class StickerRepositoryImpl( lastCustomEmojiLoadTime = System.currentTimeMillis() } - private suspend fun saveStickerSetsToDb( - sets: List, - type: String, - isInstalled: Boolean, - isArchived: Boolean - ) { - withContext(dispatchers.io) { - stickerSetDao.deleteStickerSets(type, isInstalled, isArchived) - stickerSetDao.insertStickerSets(sets.map { it.toEntity(type) }) - } - } - override suspend fun loadArchivedStickerSets() = archivedMutex.withLock { - val cached = stickerSetDao.getArchivedStickerSetsByType("REGULAR").first().mapNotNull { it.toModel() } - if (cached.isNotEmpty()) _archivedStickerSets.value = cached + val cached = localDataSource.getArchivedStickerSetsByType(REGULAR_TYPE).first() + if (cached.isNotEmpty()) { + _archivedStickerSets.value = cached + } val remoteSets = remote.getArchivedStickerSets(StickerType.REGULAR) _archivedStickerSets.value = remoteSets - saveStickerSetsToDb(remoteSets, "REGULAR", isInstalled = false, isArchived = true) + localDataSource.saveStickerSets(remoteSets, REGULAR_TYPE, isInstalled = false, isArchived = true) } override suspend fun loadArchivedEmojiSets() = archivedEmojiMutex.withLock { - val cached = stickerSetDao.getArchivedStickerSetsByType("CUSTOM_EMOJI").first().mapNotNull { it.toModel() } - if (cached.isNotEmpty()) _archivedEmojiSets.value = cached + val cached = localDataSource.getArchivedStickerSetsByType(CUSTOM_EMOJI_TYPE).first() + if (cached.isNotEmpty()) { + _archivedEmojiSets.value = cached + } val remoteSets = remote.getArchivedStickerSets(StickerType.CUSTOM_EMOJI) _archivedEmojiSets.value = remoteSets - saveStickerSetsToDb(remoteSets, "CUSTOM_EMOJI", isInstalled = false, isArchived = true) + localDataSource.saveStickerSets(remoteSets, CUSTOM_EMOJI_TYPE, isInstalled = false, isArchived = true) } override suspend fun getStickerSet(setId: Long): StickerSetModel? { - val cached = stickerSetDao.getStickerSetById(setId)?.toModel() + val cached = localDataSource.getStickerSetById(setId) if (cached != null) { - prefetchStickers(cached.stickers) - scope.launch(dispatchers.default) { verifyStickerSet(cached.id) } + onStickerSetResolved(cached) return cached } val remoteSet = remote.getStickerSet(setId) ?: return null - withContext(dispatchers.io) { - stickerSetDao.insertStickerSet(remoteSet.toEntity(remoteSet.stickerType.name)) - } - prefetchStickers(remoteSet.stickers) - scope.launch(dispatchers.default) { verifyStickerSet(remoteSet.id) } + localDataSource.insertStickerSet(remoteSet, remoteSet.stickerType.name) + onStickerSetResolved(remoteSet) return remoteSet } override suspend fun getStickerSetByName(name: String): StickerSetModel? { - val cached = stickerSetDao.getStickerSetByName(name)?.toModel() + val cached = localDataSource.getStickerSetByName(name) if (cached != null) { - prefetchStickers(cached.stickers) - scope.launch(dispatchers.default) { verifyStickerSet(cached.id) } + onStickerSetResolved(cached) return cached } val remoteSet = remote.getStickerSetByName(name) ?: return null - withContext(dispatchers.io) { - stickerSetDao.insertStickerSet(remoteSet.toEntity(remoteSet.stickerType.name)) - } - prefetchStickers(remoteSet.stickers) - scope.launch(dispatchers.default) { verifyStickerSet(remoteSet.id) } + localDataSource.insertStickerSet(remoteSet, remoteSet.stickerType.name) + onStickerSetResolved(remoteSet) return remoteSet } override suspend fun verifyStickerSet(setId: Long) { - val set = stickerSetDao.getStickerSetById(setId)?.toModel() ?: remote.getStickerSet(setId) ?: return - val missing = mutableListOf() - - for (sticker in set.stickers) { - if (!isStickerFileAvailable(sticker.id)) { - missing += sticker.id - } - } - - if (missing.isEmpty()) return - - Log.d("StickerRepo", "verifyStickerSet($setId): missing ${missing.size}/${set.stickers.size}") - missing.forEach { stickerId -> - fileQueue.enqueue(stickerId.toInt(), 32, FileDownloadQueue.DownloadType.STICKER) - } + val set = localDataSource.getStickerSetById(setId) ?: remote.getStickerSet(setId) ?: return + fileManager.verifyStickerSet(set) } override suspend fun toggleStickerSetInstalled(setId: Long, isInstalled: Boolean) { @@ -276,254 +228,61 @@ class StickerRepositoryImpl( remote.reorderStickerSets(type, stickerSetIds) } - override suspend fun getDefaultEmojis(): List { - cachedEmojis?.let { return it } - - val fetched = remote.getEmojiCategories().toMutableSet() - if (fetched.size < 100) fetched.addAll(getFallbackEmojis()) - - return fetched.toList().also { cachedEmojis = it } - } - - override suspend fun searchEmojis(query: String) = remote.searchEmojis(query) - - override suspend fun searchCustomEmojis(query: String) = remote.searchCustomEmojis(query) - - override suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long) = - remote.getMessageAvailableReactions(chatId, messageId) - - override suspend fun getRecentStickers() = remote.getRecentStickers() - - override suspend fun clearRecentStickers() = remote.clearRecentStickers() - - override suspend fun searchStickers(query: String) = remote.searchStickers(query) - - override suspend fun searchStickerSets(query: String) = remote.searchStickerSets(query) - - override suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) { - cacheProvider.addRecentEmoji(recentEmoji) - withContext(dispatchers.io) { - recentEmojiDao.deleteRecentEmoji(recentEmoji.emoji, recentEmoji.sticker?.id) - recentEmojiDao.insertRecentEmoji( - RecentEmojiEntity( - emoji = recentEmoji.emoji, - stickerId = recentEmoji.sticker?.id, - data = Json.encodeToString(recentEmoji) - ) - ) - } - } - - override suspend fun clearRecentEmojis() { - cacheProvider.clearRecentEmojis() - withContext(dispatchers.io) { - recentEmojiDao.clearAll() - } - } - - override suspend fun getSavedGifs(): List { - val cached = cacheProvider.savedGifs.value - if (cached.isNotEmpty()) return cached - - val remoteGifs = remote.getSavedGifs() - cacheProvider.setSavedGifs(remoteGifs) - return remoteGifs - } - - override suspend fun addSavedGif(path: String) { - remote.addSavedGif(path) - val remoteGifs = remote.getSavedGifs() - cacheProvider.setSavedGifs(remoteGifs) - } - - override suspend fun searchGifs(query: String) = remote.searchGifs(query) - - override fun getStickerFile(fileId: Long): Flow = flow { - filePathsCache[fileId]?.let { path -> - if (path.isNotEmpty() && File(path).exists()) { - emit(path) - return@flow - } - filePathsCache.remove(fileId) - stickerPathDao.deletePath(fileId) - } - - val dbPath = stickerPathDao.getPath(fileId) - if (!dbPath.isNullOrEmpty()) { - if (File(dbPath).exists()) { - filePathsCache[fileId] = dbPath - emit(dbPath) - return@flow - } - stickerPathDao.deletePath(fileId) - } - - val cachedPath = fileUpdateHandler.fileDownloadCompleted - .replayCache - .firstOrNull { it.first == fileId && it.second.isNotEmpty() && File(it.second).exists() } - ?.second - - if (cachedPath != null) { - filePathsCache[fileId] = cachedPath - stickerPathDao.insertPath(StickerPathEntity(fileId, cachedPath)) - emit(cachedPath) - return@flow - } - - fileQueue.enqueue(fileId.toInt(), 32, FileDownloadQueue.DownloadType.STICKER) - - val firstPath = withTimeoutOrNull(90_000L) { - fileUpdateHandler.fileDownloadCompleted - .filter { it.first == fileId } - .mapNotNull { (_, path) -> path.takeIf { it.isNotEmpty() && File(it).exists() } } - .first() - } - - val resultPath = firstPath ?: fileUpdateHandler.fileDownloadCompleted - .replayCache - .firstOrNull { it.first == fileId && it.second.isNotEmpty() && File(it.second).exists() } - ?.second - - if (!resultPath.isNullOrEmpty()) { - filePathsCache[fileId] = resultPath - stickerPathDao.insertPath(StickerPathEntity(fileId, resultPath)) - emit(resultPath) - } else { - fileQueue.enqueue(fileId.toInt(), 32, FileDownloadQueue.DownloadType.STICKER) - } + override suspend fun getRecentStickers(): List { + return remote.getRecentStickers() } - private fun prefetchStickers(stickers: List) { - scope.launch(dispatchers.default) { - stickers.take(20).forEach { sticker -> - val cachedPath = filePathsCache[sticker.id] - if (!cachedPath.isNullOrEmpty() && !File(cachedPath).exists()) { - filePathsCache.remove(sticker.id) - stickerPathDao.deletePath(sticker.id) - } - - val dbPath = stickerPathDao.getPath(sticker.id) - val hasValidDbPath = !dbPath.isNullOrEmpty() && File(dbPath).exists() - if (!dbPath.isNullOrEmpty() && !hasValidDbPath) { - stickerPathDao.deletePath(sticker.id) - } - - if (filePathsCache[sticker.id].isNullOrEmpty() && !hasValidDbPath) { - fileQueue.enqueue(sticker.id.toInt(), 16, FileDownloadQueue.DownloadType.STICKER) - } - } - } + override suspend fun clearRecentStickers() { + remote.clearRecentStickers() } - private suspend fun verifyInstalledStickerSets() { - val allSets = installedStickerSets.value + customEmojiStickerSets.value - var requeued = 0 - val maxPerPass = 20 - - for (set in allSets) { - for (sticker in set.stickers) { - if (requeued >= maxPerPass) break - if (isStickerFileAvailable(sticker.id)) continue - - fileQueue.enqueue(sticker.id.toInt(), 8, FileDownloadQueue.DownloadType.STICKER) - requeued++ - } - if (requeued >= maxPerPass) break - } - - if (requeued > 0) { - Log.d("StickerRepo", "verifyInstalledStickerSets: re-enqueued $requeued stickers") - } + override suspend fun searchStickers(query: String): List { + return remote.searchStickers(query) } - private suspend fun isStickerFileAvailable(stickerId: Long): Boolean { - val memoryPath = filePathsCache[stickerId] - if (!memoryPath.isNullOrEmpty()) { - if (File(memoryPath).exists()) { - return true - } - filePathsCache.remove(stickerId) - stickerPathDao.deletePath(stickerId) - } - - val dbPath = stickerPathDao.getPath(stickerId) - if (!dbPath.isNullOrEmpty()) { - if (File(dbPath).exists()) { - filePathsCache[stickerId] = dbPath - return true - } - stickerPathDao.deletePath(stickerId) - } - - val completedPath = fileUpdateHandler.fileDownloadCompleted.replayCache - .firstOrNull { it.first == stickerId && it.second.isNotEmpty() && File(it.second).exists() } - ?.second - if (!completedPath.isNullOrEmpty()) { - filePathsCache[stickerId] = completedPath - stickerPathDao.insertPath(StickerPathEntity(stickerId, completedPath)) - return true - } - - return false + override suspend fun searchStickerSets(query: String): List { + return remote.searchStickerSets(query) } - override fun getGifFile(gif: GifModel): Flow = flow { - if (gif.fileId == 0L) { - emit(null); return@flow - } - getStickerFile(gif.fileId).collect { emit(it) } + override fun getStickerFile(fileId: Long): Flow { + return fileManager.getStickerFile(fileId) } - override suspend fun getTgsJson(path: String): String? = withContext(dispatchers.io) { - tgsCache[path]?.let { return@withContext it } - coRunCatching { - val file = File(path) - if (!file.exists() || file.length() == 0L) return@withContext null - GZIPInputStream(FileInputStream(file)) - .bufferedReader() - .use { it.readText() } - .also { tgsCache[path] = it } - }.getOrNull() + override suspend fun getTgsJson(path: String): String? { + return fileManager.getTgsJson(path) } override fun clearCache() { - tgsCache.clear() - filePathsCache.clear() - cachedEmojis = null - fallbackEmojisCache = null + fileManager.clearCache() invalidateStickerSetCaches() - scope.launch { - stickerPathDao.clearAll() + } + + private fun onStickerSetResolved(set: StickerSetModel) { + fileManager.prefetchStickers(set.stickers) + scope.launch(dispatchers.default) { + fileManager.verifyStickerSet(set) } } private fun invalidateStickerSetCaches() { cacheProvider.setInstalledStickerSets(emptyList()) cacheProvider.setCustomEmojiStickerSets(emptyList()) + _archivedStickerSets.value = emptyList() + _archivedEmojiSets.value = emptyList() lastRegularLoadTime = 0 lastCustomEmojiLoadTime = 0 scope.launch { - stickerSetDao.clearAll() + localDataSource.clearStickerSets() } } - private suspend fun getFallbackEmojis(): List = withContext(dispatchers.default) { - fallbackEmojisCache?.let { return@withContext it } - EmojiLoader.getSupportedEmojis(context).also { fallbackEmojisCache = it } - } - - private fun StickerSetModel.toEntity(type: String) = StickerSetEntity( - id = id, - name = name, - type = type, - isInstalled = isInstalled, - isArchived = isArchived, - data = Json.encodeToString(this) - ) - - private fun StickerSetEntity.toModel(): StickerSetModel? = try { - Json.decodeFromString(data) - } catch (e: Exception) { - null + companion object { + private const val TAG = "StickerRepository" + private const val REGULAR_TYPE = "REGULAR" + private const val CUSTOM_EMOJI_TYPE = "CUSTOM_EMOJI" + private const val LOAD_DEBOUNCE_MS = 1_000L + private const val CUSTOM_EMOJI_REFRESH_MS = 10 * 60 * 1_000L + private const val VERIFY_INITIAL_DELAY_MS = 60_000L + private const val VERIFY_INTERVAL_MS = 120_000L } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt new file mode 100644 index 00000000..ac2c8182 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/StorageRepositoryImpl.kt @@ -0,0 +1,80 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.monogram.core.DispatcherProvider +import org.monogram.data.datasource.cache.SettingsCacheDataSource +import org.monogram.data.datasource.remote.ChatsRemoteDataSource +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.mapper.StorageMapper +import org.monogram.domain.models.StorageUsageModel +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider + +class StorageRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val cache: SettingsCacheDataSource, + private val chatsRemote: ChatsRemoteDataSource, + private val dispatchers: DispatcherProvider, + private val storageMapper: StorageMapper, + private val stringProvider: StringProvider +) : StorageRepository { + + override suspend fun getStorageUsage(): StorageUsageModel? = coroutineScope { + val stats = remote.getStorageStatistics(100) ?: return@coroutineScope null + val processedChats = (stats.byChat ?: emptyArray()).map { chatStat -> + async(dispatchers.default) { + val title = when { + chatStat.chatId == 0L -> stringProvider.getString("storage_other_cache") + else -> cache.getChat(chatStat.chatId)?.title + ?: chatsRemote.getChat(chatStat.chatId)?.title + ?: stringProvider.getString("storage_chat_format", chatStat.chatId) + } + storageMapper.mapChatStatsToDomain(chatStat, title) + } + }.awaitAll() + + storageMapper.mapToDomain(stats, processedChats) + } + + override suspend fun clearStorage(chatId: Long?): Boolean { + return remote.optimizeStorage( + size = 0, + ttl = 0, + count = 0, + immunityDelay = 0, + chatIds = chatId?.let { longArrayOf(it) }, + returnDeletedFileStatistics = false, + chatLimit = 20 + ) + } + + override suspend fun setDatabaseMaintenanceSettings( + maxDatabaseSize: Long, + maxTimeFromLastAccess: Int + ): Boolean { + return remote.optimizeStorage( + size = maxDatabaseSize, + ttl = maxTimeFromLastAccess, + count = -1, + immunityDelay = -1, + chatIds = null, + returnDeletedFileStatistics = true, + chatLimit = 0 + ) + } + + override suspend fun getStorageOptimizerEnabled(): Boolean { + val result = remote.getOption("use_storage_optimizer") + return if (result is org.drinkless.tdlib.TdApi.OptionValueBoolean) { + result.value + } else { + false + } + } + + override suspend fun setStorageOptimizerEnabled(enabled: Boolean) { + remote.setOption("use_storage_optimizer", org.drinkless.tdlib.TdApi.OptionValueBoolean(enabled)) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/UserProfileEditRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/UserProfileEditRepositoryImpl.kt new file mode 100644 index 00000000..50d7487e --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/UserProfileEditRepositoryImpl.kt @@ -0,0 +1,61 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.domain.models.BirthdateModel +import org.monogram.domain.models.BusinessOpeningHoursModel +import org.monogram.domain.repository.UserProfileEditRepository + +class UserProfileEditRepositoryImpl( + private val remote: UserRemoteDataSource +) : UserProfileEditRepository { + override suspend fun setName(firstName: String, lastName: String) = + remote.setName(firstName, lastName) + + override suspend fun setBio(bio: String) = + remote.setBio(bio) + + override suspend fun setUsername(username: String) = + remote.setUsername(username) + + override suspend fun setEmojiStatus(customEmojiId: Long?) = + remote.setEmojiStatus(customEmojiId) + + override suspend fun setProfilePhoto(path: String) = + remote.setProfilePhoto(path) + + override suspend fun setBirthdate(birthdate: BirthdateModel?) = + remote.setBirthdate(birthdate?.let { TdApi.Birthdate(it.day, it.month, it.year ?: 0) }) + + override suspend fun setPersonalChat(chatId: Long) = + remote.setPersonalChat(chatId) + + override suspend fun setBusinessBio(bio: String) = + remote.setBusinessBio(bio) + + override suspend fun setBusinessLocation(address: String, latitude: Double, longitude: Double) = + remote.setBusinessLocation( + if (address.isNotEmpty()) TdApi.BusinessLocation( + TdApi.Location(latitude, longitude, 0.0), + address + ) else null + ) + + override suspend fun setBusinessOpeningHours(openingHours: BusinessOpeningHoursModel?) = + remote.setBusinessOpeningHours( + openingHours?.let { + TdApi.BusinessOpeningHours( + it.timeZoneId, + it.intervals.map { interval -> + TdApi.BusinessOpeningHoursInterval(interval.startMinute, interval.endMinute) + }.toTypedArray() + ) + } + ) + + override suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) = + remote.toggleUsernameIsActive(username, isActive) + + override suspend fun reorderActiveUsernames(usernames: List) = + remote.reorderActiveUsernames(usernames.toTypedArray()) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/UserRepositoryImpl.kt deleted file mode 100644 index 238e8571..00000000 --- a/data/src/main/java/org/monogram/data/repository/UserRepositoryImpl.kt +++ /dev/null @@ -1,1044 +0,0 @@ -package org.monogram.data.repository - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* -import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider -import org.monogram.data.chats.ChatCache -import org.monogram.data.core.coRunCatching -import org.monogram.data.datasource.cache.ChatLocalDataSource -import org.monogram.data.datasource.cache.RoomUserLocalDataSource -import org.monogram.data.datasource.cache.UserLocalDataSource -import org.monogram.data.datasource.remote.UserRemoteDataSource -import org.monogram.data.db.dao.KeyValueDao -import org.monogram.data.db.model.KeyValueEntity -import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.gateway.UpdateDispatcher -import org.monogram.data.infra.FileDownloadQueue -import org.monogram.data.infra.SponsorSyncManager -import org.monogram.data.mapper.user.* -import org.monogram.domain.models.* -import org.monogram.domain.repository.ChatMemberStatus -import org.monogram.domain.repository.ChatMembersFilter -import org.monogram.domain.repository.UserRepository -import java.util.concurrent.ConcurrentHashMap - -class UserRepositoryImpl( - private val remote: UserRemoteDataSource, - private val userLocal: UserLocalDataSource, - private val chatLocal: ChatLocalDataSource, - private val chatCache: ChatCache, - private val gateway: TelegramGateway, - private val updates: UpdateDispatcher, - private val fileQueue: FileDownloadQueue, - private val keyValueDao: KeyValueDao, - private val sponsorSyncManager: SponsorSyncManager, - scopeProvider: ScopeProvider -) : UserRepository { - - private val scope = scopeProvider.appScope - private var currentUserId: Long = 0L - private val userRequests = ConcurrentHashMap>() - private val fullInfoRequests = ConcurrentHashMap>() - private val missingUsersUntilMs = ConcurrentHashMap() - private val missingUserFullInfoUntilMs = ConcurrentHashMap() - - private val emojiPathCache = ConcurrentHashMap() - private val fileIdToUserIdMap = ConcurrentHashMap() - private val avatarDownloadPriority = 24 - private val avatarHdPrefetchPriority = 8 - - private val _currentUserFlow = MutableStateFlow(null) - override val currentUserFlow = _currentUserFlow.asStateFlow() - - private val _userUpdateFlow = MutableSharedFlow( - extraBufferCapacity = 10, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val anyUserUpdateFlow = _userUpdateFlow.asSharedFlow() - - init { - scope.launch { - restoreCurrentUserFromLocal() - } - - scope.launch { - updates.user.collect { update -> - cacheUser(update.user) - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(update.user.id) - userLocal.saveUser(update.user.toEntity(personalAvatarPath)) - } - if (update.user.id == currentUserId) refreshCurrentUser() - _userUpdateFlow.emit(update.user.id) - } - } - scope.launch { - updates.userStatus.collect { update -> - userLocal.getUser(update.userId)?.let { cached -> - cached.status = update.status - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(cached.id) - userLocal.saveUser(cached.toEntity(personalAvatarPath)) - } - if (update.userId == currentUserId) refreshCurrentUser() - _userUpdateFlow.emit(update.userId) - } - } - } - scope.launch { - updates.file.collect { update -> - val file = update.file - if (file.local.isDownloadingCompleted) { - userLocal.getAllUsers().forEach { user -> - val small = user.profilePhoto?.small - val big = user.profilePhoto?.big - if (small?.id == file.id || big?.id == file.id) { - _userUpdateFlow.emit(user.id) - if (user.id == currentUserId) refreshCurrentUser() - } - } - if (file.local.path.isNotEmpty()) { - val userId = fileIdToUserIdMap.remove(file.id) - if (userId != null) { - userLocal.getUser(userId)?.let { user -> - val emojiId = user.extractEmojiStatusId() - if (emojiId != 0L) { - emojiPathCache[emojiId] = file.local.path - } - } - _userUpdateFlow.emit(userId) - if (userId == currentUserId) refreshCurrentUser() - } - } - } - } - } - } - - private suspend fun restoreCurrentUserFromLocal() { - val cachedUserId = keyValueDao.getValue(KEY_CURRENT_USER_ID)?.value?.toLongOrNull() ?: return - if (cachedUserId <= 0L) return - - currentUserId = cachedUserId - val user = userLocal.getUser(cachedUserId) ?: return - val model = mapUserModel(user, userLocal.getUserFullInfo(cachedUserId)) - _currentUserFlow.value = model - } - - private fun TdApi.User.extractEmojiStatusId(): Long { - return when (val type = this.emojiStatus?.type) { - is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId - is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L - } - } - - private suspend fun resolveEmojiPath(user: TdApi.User): String? { - val emojiId = user.extractEmojiStatusId() - if (emojiId == 0L) return null - - emojiPathCache[emojiId]?.let { return it } - - return try { - val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) - if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { - val file = result.stickers.first().sticker - if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { - emojiPathCache[emojiId] = file.local.path - file.local.path - } else { - fileIdToUserIdMap[file.id] = user.id - fileQueue.enqueue(file.id, 1, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - coRunCatching { fileQueue.waitForDownload(file.id).await() } - - val refreshedPath = coRunCatching { - (gateway.execute(TdApi.GetFile(file.id)) as? TdApi.File) - ?.local - ?.path - ?.takeIf { it.isNotEmpty() } - }.getOrNull() - if (refreshedPath != null) { - emojiPathCache[emojiId] = refreshedPath - } - refreshedPath - } - } else null - } catch (e: Exception) { - null - } - } - - private suspend fun refreshCurrentUser() { - val user = userLocal.getUser(currentUserId) ?: return - val model = mapUserModel(user, userLocal.getUserFullInfo(currentUserId)) - _currentUserFlow.value = model - } - - override suspend fun getMe(): UserModel { - val user = remote.getMe() ?: return UserModel(0, "Error") - currentUserId = user.id - coRunCatching { keyValueDao.insertValue(KeyValueEntity(KEY_CURRENT_USER_ID, user.id.toString())) } - cacheUser(user) - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(user.id) - userLocal.saveUser(user.toEntity(personalAvatarPath)) - } - val model = mapUserModel(user, userLocal.getUserFullInfo(user.id)) - _currentUserFlow.update { model } - return model - } - - override suspend fun getUser(userId: Long): UserModel? { - if (userId <= 0) return null - userLocal.getUser(userId)?.let { - return mapUserModel(it, userLocal.getUserFullInfo(userId)) - } - - if (userLocal is RoomUserLocalDataSource) { - userLocal.loadUser(userId)?.let { entity -> - val user = entity.toTdApi() - cacheUser(user) - return mapUserModel(user, userLocal.getUserFullInfo(userId)) - } - } - - if (isNegativeCached(missingUsersUntilMs, userId)) return null - - val deferred = userRequests.getOrPut(userId) { - scope.async { - fetchAndCacheUser(userId)?.also { - cacheUser(it) - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(it.id) - userLocal.saveUser(it.toEntity(personalAvatarPath)) - } - } - } - } - return try { - deferred.await()?.let { user -> - mapUserModel(user, userLocal.getUserFullInfo(userId)) - } - } finally { - userRequests.remove(userId) - } - } - - override suspend fun getUserFullInfo(userId: Long): UserModel? { - if (userId <= 0) return null - val user = userLocal.getUser(userId) ?: fetchAndCacheUser(userId)?.also { - cacheUser(it) - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(it.id) - userLocal.saveUser(it.toEntity(personalAvatarPath)) - } - } ?: return null - - val cachedFullInfo = userLocal.getUserFullInfo(userId) - if (cachedFullInfo != null) return mapUserModel(user, cachedFullInfo) - - val dbFullInfo = userLocal.getFullInfoEntity(userId) - if (dbFullInfo != null) { - val fullInfo = dbFullInfo.toTdApi() - cacheUserFullInfo(userId, fullInfo) - return mapUserModel(user, fullInfo) - } - - if (isNegativeCached(missingUserFullInfoUntilMs, userId)) { - return mapUserModel(user, null) - } - - val deferred = fullInfoRequests.getOrPut(userId) { - scope.async { - fetchAndCacheUserFullInfo(userId)?.also { - cacheUserFullInfo(userId, it) - userLocal.saveFullInfoEntity(it.toEntity(userId)) - syncUserPersonalAvatarPath(userId, it) - } - } - } - return try { - val fullInfo = deferred.await() - mapUserModel(user, fullInfo) - } finally { - fullInfoRequests.remove(userId) - } - } - - private suspend fun mapUserModel(user: TdApi.User, fullInfo: TdApi.UserFullInfo?): UserModel { - val emojiPath = resolveEmojiPath(user) - val avatarPath = resolveAvatarPath(user) - val model = user.toDomain(fullInfo, emojiPath) - return if (avatarPath == null || avatarPath == model.avatarPath) model else model.copy(avatarPath = avatarPath) - } - - private suspend fun resolveAvatarPath(user: TdApi.User): String? { - val bigPhoto = user.profilePhoto?.big - val smallPhoto = user.profilePhoto?.small - val bigDirectPath = bigPhoto?.local?.path?.ifEmpty { null } - if (bigDirectPath != null) return bigDirectPath - - val smallDirectPath = smallPhoto?.local?.path?.ifEmpty { null } - if (smallDirectPath != null) { - val bigId = bigPhoto?.id?.takeIf { it != 0 } - if (bigId != null && bigId != smallPhoto.id) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - return smallDirectPath - } - - val resolvedSmallPath = resolveDownloadedFilePath(smallPhoto?.id) - if (resolvedSmallPath != null) { - val bigId = bigPhoto?.id?.takeIf { it != 0 } - if (bigId != null && bigId != smallPhoto?.id) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - return resolvedSmallPath - } - - val resolvedBigPath = resolveDownloadedFilePath(bigPhoto?.id) - if (resolvedBigPath != null) return resolvedBigPath - - val smallId = smallPhoto?.id?.takeIf { it != 0 } - val bigId = bigPhoto?.id?.takeIf { it != 0 } - if (smallId != null) { - fileQueue.enqueue(smallId, avatarDownloadPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - if (bigId != null && bigId != smallId) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - } else if (bigId != null) { - fileQueue.enqueue(bigId, avatarDownloadPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - - return null - } - - private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { - if (fileId == null || fileId == 0) return null - val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null - return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null - } - - override fun getUserFlow(userId: Long): Flow = flow { - if (userId <= 0) { - emit(null) - return@flow - } - emit(getUser(userId)) - _userUpdateFlow - .filter { it == userId } - .collect { emit(getUser(userId)) } - } - - override suspend fun getUserProfilePhotos( - userId: Long, - offset: Int, - limit: Int, - ensureFullRes: Boolean - ): List { - if (userId <= 0) return emptyList() - val result = remote.getUserProfilePhotos(userId, offset, limit) ?: return emptyList() - return coroutineScope { - result.photos - .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } - .awaitAll() - .filterNotNull() - } - } - - override suspend fun getChatProfilePhotos( - chatId: Long, - offset: Int, - limit: Int, - ensureFullRes: Boolean - ): List { - if (chatId == 0L) return emptyList() - val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes) - if (paths.isNotEmpty()) return paths - - val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes) - return listOfNotNull(currentPath) - } - - private suspend fun loadChatPhotoHistoryPaths( - chatId: Long, - offset: Int, - limit: Int, - ensureFullRes: Boolean - ): List { - if (limit <= 0) return emptyList() - - val request = TdApi.SearchChatMessages().apply { - this.chatId = chatId - this.query = "" - this.senderId = null - this.fromMessageId = 0L - this.offset = 0 - this.limit = (offset + limit).coerceAtMost(100) - this.filter = TdApi.SearchMessagesFilterChatPhoto() - } - - val result = coRunCatching { - gateway.execute(request) as? TdApi.FoundChatMessages - }.getOrNull() ?: return emptyList() - - val chatPhotos = result.messages - .asSequence() - .mapNotNull { (it.content as? TdApi.MessageChatChangePhoto)?.photo } - .drop(offset) - .take(limit) - .toList() - - if (chatPhotos.isEmpty()) return emptyList() - - return coroutineScope { - chatPhotos - .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } - .awaitAll() - .filterNotNull() - .distinct() - } - } - - private suspend fun resolveCurrentChatPhotoPath(chatId: Long, ensureFullRes: Boolean): String? { - val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } - ?: chatLocal.getChat(chatId)?.toTdApiChat() - ?: return null - return resolveChatPhotoInfoPath(chat.photo, ensureFullRes) - } - - private suspend fun resolveChatPhotoInfoPath( - photoInfo: TdApi.ChatPhotoInfo?, - ensureFullRes: Boolean - ): String? { - val smallId = photoInfo?.small?.id?.takeIf { it != 0 } - val bigId = photoInfo?.big?.id?.takeIf { it != 0 } - val preferredFile = if (ensureFullRes) { - photoInfo?.big ?: photoInfo?.small - } else { - photoInfo?.small ?: photoInfo?.big - } ?: return null - - val directPath = preferredFile.local.path.ifEmpty { null } - if (directPath != null) { - if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - return directPath - } - - val downloadedPath = resolveDownloadedFilePath(preferredFile.id) - if (downloadedPath != null) { - if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - return downloadedPath - } - - if (!ensureFullRes) { - if (smallId != null) { - fileQueue.enqueue(smallId, avatarDownloadPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - if (bigId != null && bigId != smallId) { - fileQueue.enqueue(bigId, avatarHdPrefetchPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - } else if (bigId != null) { - fileQueue.enqueue(bigId, avatarDownloadPriority, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) - } - return null - } - - val fileId = preferredFile.id.takeIf { it != 0 } ?: return null - fileQueue.enqueue( - fileId = fileId, - priority = 32, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false, - ignoreSuppression = true - ) - withTimeoutOrNull(15_000) { - coRunCatching { fileQueue.waitForDownload(fileId).await() } - } - return resolveDownloadedFilePath(fileId) - } - - private suspend fun resolveUserProfilePhotoPath( - photo: TdApi.ChatPhoto, - ensureFullRes: Boolean - ): String? { - val animationFile = photo.animation?.file - val animationPath = animationFile?.local?.path?.ifEmpty { null } - if (animationPath != null) return animationPath - - val bestPhotoFile = photo.sizes - .maxByOrNull { it.width.toLong() * it.height.toLong() } - ?.photo - ?: photo.sizes.lastOrNull()?.photo - ?: return null - - val directPath = bestPhotoFile.local.path.ifEmpty { null } - if (directPath != null) return directPath - - if (!ensureFullRes) { - val fallbackFile = photo.sizes.find { it.type == "m" }?.photo - ?: photo.sizes.find { it.type == "s" }?.photo - ?: photo.sizes.find { it.type == "c" }?.photo - ?: photo.sizes.find { it.type == "b" }?.photo - ?: photo.sizes.find { it.type == "a" }?.photo - ?: photo.sizes.firstOrNull()?.photo - - val fallbackDirectPath = fallbackFile?.local?.path?.ifEmpty { null } - if (fallbackDirectPath != null) return fallbackDirectPath - - val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id) - if (fallbackDownloadedPath != null) return fallbackDownloadedPath - - return null - } - - val fileId = bestPhotoFile.id.takeIf { it != 0 } ?: return null - fileQueue.enqueue( - fileId = fileId, - priority = 32, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false, - ignoreSuppression = true - ) - withTimeoutOrNull(15_000) { - coRunCatching { fileQueue.waitForDownload(fileId).await() } - } - return resolveDownloadedFilePath(fileId) - } - - override fun getUserProfilePhotosFlow(userId: Long): Flow> = flow { - if (userId <= 0) { - emit(emptyList()) - return@flow - } - emit(getUserProfilePhotos(userId)) - updates.file.collect { emit(getUserProfilePhotos(userId)) } - } - - override fun getChatProfilePhotosFlow(chatId: Long): Flow> = flow { - if (chatId == 0L) { - emit(emptyList()) - return@flow - } - emit(getChatProfilePhotos(chatId)) - updates.file.collect { emit(getChatProfilePhotos(chatId)) } - } - - override suspend fun getChatFullInfo(chatId: Long): ChatFullInfoModel? { - if (chatId == 0L) return null - - val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } - ?: chatLocal.getChat(chatId)?.let { it.toTdApiChat() } - - if (chat != null) { - val dbFullInfo = chatLocal.getChatFullInfo(chatId) - return when (val type = chat.type) { - is TdApi.ChatTypePrivate -> { - val userId = type.userId - val fullInfo = userLocal.getUserFullInfo(userId) ?: userLocal.getFullInfoEntity(userId)?.let { - val info = it.toTdApi() - cacheUserFullInfo(userId, info) - info - } ?: fetchAndCacheUserFullInfo(userId)?.also { - cacheUserFullInfo(userId, it) - userLocal.saveFullInfoEntity(it.toEntity(userId)) - syncUserPersonalAvatarPath(userId, it) - } - fullInfo?.mapUserFullInfoToChat() ?: dbFullInfo?.toDomain() - } - - is TdApi.ChatTypeSupergroup -> { - val fullInfo = remote.getSupergroupFullInfo(type.supergroupId) - val supergroup = remote.getSupergroup(type.supergroupId) - fullInfo?.let { - chatLocal.insertChatFullInfo(it.toEntity(chatId)) - } - fullInfo?.mapSupergroupFullInfoToChat(supergroup) ?: dbFullInfo?.toDomain() - } - - is TdApi.ChatTypeBasicGroup -> { - val fullInfo = remote.getBasicGroupFullInfo(type.basicGroupId) - fullInfo?.let { - chatLocal.insertChatFullInfo(it.toEntity(chatId)) - } - fullInfo?.mapBasicGroupFullInfoToChat() ?: dbFullInfo?.toDomain() - } - - else -> dbFullInfo?.toDomain() - } - } - - val userId = chatId - val fullInfo = userLocal.getUserFullInfo(userId) ?: userLocal.getFullInfoEntity(userId)?.let { - val info = it.toTdApi() - cacheUserFullInfo(userId, info) - info - } ?: fetchAndCacheUserFullInfo(userId)?.also { - cacheUserFullInfo(userId, it) - userLocal.saveFullInfoEntity(it.toEntity(userId)) - syncUserPersonalAvatarPath(userId, it) - } - return fullInfo?.mapUserFullInfoToChat() - } - - private suspend fun resolveStoredPersonalAvatarPath(userId: Long): String? { - val cachedFullInfo = userLocal.getUserFullInfo(userId) - val cachedPath = cachedFullInfo?.extractPersonalAvatarPath() - if (!cachedPath.isNullOrBlank()) return cachedPath - return userLocal.getFullInfoEntity(userId)?.personalPhotoPath?.ifBlank { null } - } - - private suspend fun syncUserPersonalAvatarPath(userId: Long, fullInfo: TdApi.UserFullInfo) { - val roomUserLocal = userLocal as? RoomUserLocalDataSource ?: return - val personalAvatarPath = fullInfo.extractPersonalAvatarPath() ?: return - val existing = roomUserLocal.loadUser(userId) ?: return - if (existing.personalAvatarPath == personalAvatarPath) return - roomUserLocal.saveUser(existing.copy(personalAvatarPath = personalAvatarPath)) - } - - private fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? { - val bestPhotoSize = personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull() - return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } - ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } - } - - private suspend fun fetchAndCacheUser(userId: Long): TdApi.User? { - if (userId <= 0 || isNegativeCached(missingUsersUntilMs, userId)) return null - val user = remote.getUser(userId) - if (user != null) { - missingUsersUntilMs.remove(userId) - } else { - rememberNegative(missingUsersUntilMs, userId) - } - return user - } - - private suspend fun fetchAndCacheUserFullInfo(userId: Long): TdApi.UserFullInfo? { - if (userId <= 0 || isNegativeCached(missingUserFullInfoUntilMs, userId)) return null - val info = remote.getUserFullInfo(userId) - if (info != null) { - missingUserFullInfoUntilMs.remove(userId) - } else { - rememberNegative(missingUserFullInfoUntilMs, userId) - } - return info - } - - private fun isNegativeCached(cache: ConcurrentHashMap, id: Long): Boolean { - val until = cache[id] ?: return false - if (until > System.currentTimeMillis()) return true - cache.remove(id, until) - return false - } - - private fun rememberNegative(cache: ConcurrentHashMap, id: Long) { - cache[id] = System.currentTimeMillis() + NEGATIVE_CACHE_TTL_MS - } - - private suspend fun cacheUser(user: TdApi.User) { - userLocal.putUser(user) - chatCache.putUser(user) - } - - private suspend fun cacheUserFullInfo(userId: Long, info: TdApi.UserFullInfo) { - userLocal.putUserFullInfo(userId, info) - chatCache.putUserFullInfo(userId, info) - } - - override suspend fun getContacts(): List { - val result = remote.getContacts() ?: return emptyList() - return result.userIds.map { scope.async { getUser(it) } }.awaitAll().filterNotNull() - } - - override suspend fun searchContacts(query: String): List { - val result = remote.searchContacts(query) ?: return emptyList() - return result.userIds.map { scope.async { getUser(it) } }.awaitAll().filterNotNull() - } - - override suspend fun addContact(user: UserModel) { - val contact = TdApi.ImportedContact( - user.phoneNumber.orEmpty(), - user.firstName, - user.lastName.orEmpty(), - null - ) - remote.addContact(user.id, contact, true) - - remote.getUser(user.id)?.let { refreshedUser -> - cacheUser(refreshedUser) - if (userLocal is RoomUserLocalDataSource) { - val personalAvatarPath = resolveStoredPersonalAvatarPath(refreshedUser.id) - userLocal.saveUser(refreshedUser.toEntity(personalAvatarPath)) - } - } - - _userUpdateFlow.emit(user.id) - } - - override suspend fun removeContact(userId: Long) { - remote.removeContacts(longArrayOf(userId)) - _userUpdateFlow.emit(userId) - } - - override suspend fun searchPublicChat(username: String): ChatModel? { - val chat = remote.searchPublicChat(username) ?: return null - chatLocal.insertChat(chat.toEntity()) - return chat.toDomain() - } - - override suspend fun getChatMembers( - chatId: Long, - offset: Int, - limit: Int, - filter: ChatMembersFilter - ): List { - val chat = remote.getChat(chatId) ?: return emptyList() - val members: List = when (val type = chat.type) { - is TdApi.ChatTypeSupergroup -> { - val tdFilter = filter.toApi() - remote.getSupergroupMembers(type.supergroupId, tdFilter, offset, limit) - ?.members?.toList() ?: emptyList() - } - is TdApi.ChatTypeBasicGroup -> { - if (offset > 0) return emptyList() - val fullInfo = remote.getBasicGroupMembers(type.basicGroupId) ?: return emptyList() - fullInfo.members.filter { member -> - when (filter) { - is ChatMembersFilter.Administrators -> - member.status is TdApi.ChatMemberStatusAdministrator || - member.status is TdApi.ChatMemberStatusCreator - else -> true - } - } - } - else -> emptyList() - } - - return members.map { member -> - scope.async { - val sender = member.memberId as? TdApi.MessageSenderUser ?: return@async null - val user = getUser(sender.userId) ?: return@async null - member.toDomain(user) - } - }.awaitAll().filterNotNull() - } - - override suspend fun getChatMember(chatId: Long, userId: Long): GroupMemberModel? { - val member = remote.getChatMember(chatId, userId) ?: return null - val user = getUser(userId) ?: return null - return member.toDomain(user) - } - - override suspend fun setChatMemberStatus( - chatId: Long, - userId: Long, - status: ChatMemberStatus - ) { - remote.setChatMemberStatus(chatId, userId, status.toApi()) - _userUpdateFlow.emit(userId) - } - - override suspend fun getPremiumState(): PremiumStateModel? { - val state = remote.getPremiumState() ?: return null - return state.toDomain() - } - - override suspend fun getPremiumFeatures(source: PremiumSource): List { - val tdSource = source.toApi() ?: return emptyList() - val result = remote.getPremiumFeatures(tdSource) ?: return emptyList() - return result.features.map { it.toDomain() } - } - - override suspend fun getPremiumLimit(limitType: PremiumLimitType): Int { - val tdType = limitType.toApi() ?: return 0 - return remote.getPremiumLimit(tdType)?.premiumValue ?: 0 - } - - override suspend fun getBotCommands(botId: Long): List { - val fullInfo = remote.getBotFullInfo(botId) ?: return emptyList() - return fullInfo.botInfo?.commands?.map { - BotCommandModel(it.command, it.description) - } ?: emptyList() - } - - override suspend fun getBotInfo(botId: Long): BotInfoModel? { - val fullInfo = remote.getBotFullInfo(botId) ?: return null - val commands = fullInfo.botInfo?.commands?.map { - BotCommandModel(it.command, it.description) - } ?: emptyList() - val menuButton = when (val btn = fullInfo.botInfo?.menuButton) { - is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(btn.text, btn.url) - else -> BotMenuButtonModel.Default - } - return BotInfoModel(commands, menuButton) - } - - override suspend fun getChatStatistics(chatId: Long, isDark: Boolean): ChatStatisticsModel? { - val stats = remote.getChatStatistics(chatId, isDark) ?: return null - return stats.toDomain() - } - - override suspend fun getChatRevenueStatistics( - chatId: Long, - isDark: Boolean - ): ChatRevenueStatisticsModel? { - val stats = remote.getChatRevenueStatistics(chatId, isDark) ?: return null - return stats.toDomain() - } - - override suspend fun loadStatisticsGraph( - chatId: Long, - token: String, - x: Long - ): StatisticsGraphModel? { - val graph = remote.getStatisticsGraph(chatId, token, x) ?: return null - return graph.toDomain() - } - - override fun logOut() { - scope.launch { coRunCatching { remote.logout() } } - scope.launch { userLocal.clearAll() } - if (userLocal is RoomUserLocalDataSource) { - scope.launch { userLocal.clearDatabase() } - } - scope.launch { - coRunCatching { keyValueDao.deleteValue(KEY_CURRENT_USER_ID) } - currentUserId = 0L - _currentUserFlow.value = null - } - scope.launch { chatLocal.clearAll() } - } - - override suspend fun setName(firstName: String, lastName: String) = - remote.setName(firstName, lastName) - - override suspend fun setBio(bio: String) = - remote.setBio(bio) - - override suspend fun setUsername(username: String) = - remote.setUsername(username) - - override suspend fun setEmojiStatus(customEmojiId: Long?) = - remote.setEmojiStatus(customEmojiId) - - override suspend fun setProfilePhoto(path: String) = - remote.setProfilePhoto(path) - - override suspend fun setBirthdate(birthdate: BirthdateModel?) = - remote.setBirthdate(birthdate?.let { TdApi.Birthdate(it.day, it.month, it.year ?: 0) }) - - override suspend fun setPersonalChat(chatId: Long) = - remote.setPersonalChat(chatId) - - override suspend fun setBusinessBio(bio: String) = - remote.setBusinessBio(bio) - - override suspend fun setBusinessLocation(address: String, latitude: Double, longitude: Double) = - remote.setBusinessLocation( - if (address.isNotEmpty()) TdApi.BusinessLocation( - TdApi.Location(latitude, longitude, 0.0), address - ) else null - ) - - override suspend fun setBusinessOpeningHours(openingHours: BusinessOpeningHoursModel?) = - remote.setBusinessOpeningHours( - openingHours?.let { - TdApi.BusinessOpeningHours( - it.timeZoneId, - it.intervals.map { interval -> - TdApi.BusinessOpeningHoursInterval(interval.startMinute, interval.endMinute) - }.toTypedArray() - ) - } - ) - - override suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) = - remote.toggleUsernameIsActive(username, isActive) - - override suspend fun reorderActiveUsernames(usernames: List) = - remote.reorderActiveUsernames(usernames.toTypedArray()) - - override fun forceSponsorSync() { - sponsorSyncManager.forceSync() - } - - private fun TdApi.Chat.toEntity(): org.monogram.data.db.model.ChatEntity { - val isChannel = (type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false - val isArchived = positions.any { it.list is TdApi.ChatListArchive } - val permissions = permissions ?: TdApi.ChatPermissions() - val cachedCounts = parseCachedCounts(clientData) - val senderId = when (val sender = messageSenderId) { - is TdApi.MessageSenderUser -> sender.userId - is TdApi.MessageSenderChat -> sender.chatId - else -> null - } - val privateUserId = (type as? TdApi.ChatTypePrivate)?.userId ?: 0L - val basicGroupId = (type as? TdApi.ChatTypeBasicGroup)?.basicGroupId ?: 0L - val supergroupId = (type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: 0L - val secretChatId = (type as? TdApi.ChatTypeSecret)?.secretChatId ?: 0 - return org.monogram.data.db.model.ChatEntity( - id = id, - title = title, - unreadCount = unreadCount, - avatarPath = photo?.small?.local?.path, - lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "", - lastMessageTime = (lastMessage?.date?.toLong() ?: 0L).toString(), - lastMessageDate = lastMessage?.date ?: 0, - order = positions.firstOrNull()?.order ?: 0L, - isPinned = positions.firstOrNull()?.isPinned ?: false, - isMuted = notificationSettings.muteFor > 0, - isChannel = isChannel, - isGroup = type is TdApi.ChatTypeBasicGroup || (type is TdApi.ChatTypeSupergroup && !isChannel), - type = when (type) { - is TdApi.ChatTypePrivate -> "PRIVATE" - is TdApi.ChatTypeBasicGroup -> "BASIC_GROUP" - is TdApi.ChatTypeSupergroup -> "SUPERGROUP" - is TdApi.ChatTypeSecret -> "SECRET" - else -> "PRIVATE" - }, - privateUserId = privateUserId, - basicGroupId = basicGroupId, - supergroupId = supergroupId, - secretChatId = secretChatId, - positionsCache = encodePositions(positions), - isArchived = isArchived, - memberCount = cachedCounts.first, - onlineCount = cachedCounts.second, - unreadMentionCount = unreadMentionCount, - unreadReactionCount = unreadReactionCount, - isMarkedAsUnread = isMarkedAsUnread, - hasProtectedContent = hasProtectedContent, - isTranslatable = isTranslatable, - hasAutomaticTranslation = false, - messageAutoDeleteTime = messageAutoDeleteTime, - canBeDeletedOnlyForSelf = canBeDeletedOnlyForSelf, - canBeDeletedForAllUsers = canBeDeletedForAllUsers, - canBeReported = canBeReported, - lastReadInboxMessageId = lastReadInboxMessageId, - lastReadOutboxMessageId = lastReadOutboxMessageId, - lastMessageId = lastMessage?.id ?: 0L, - isLastMessageOutgoing = lastMessage?.isOutgoing ?: false, - replyMarkupMessageId = replyMarkupMessageId, - messageSenderId = senderId, - blockList = blockList != null, - emojiStatusId = (emojiStatus?.type as? TdApi.EmojiStatusTypeCustomEmoji)?.customEmojiId, - accentColorId = accentColorId, - profileAccentColorId = profileAccentColorId, - backgroundCustomEmojiId = backgroundCustomEmojiId, - photoId = photo?.small?.id ?: 0, - isSupergroup = type is TdApi.ChatTypeSupergroup, - isAdmin = false, - isOnline = false, - typingAction = null, - draftMessage = (draftMessage?.inputMessageText as? TdApi.InputMessageText)?.text?.text, - isVerified = false, - viewAsTopics = viewAsTopics, - isForum = false, - isBot = false, - isMember = true, - username = null, - description = null, - inviteLink = null, - permissionCanSendBasicMessages = permissions.canSendBasicMessages, - permissionCanSendAudios = permissions.canSendAudios, - permissionCanSendDocuments = permissions.canSendDocuments, - permissionCanSendPhotos = permissions.canSendPhotos, - permissionCanSendVideos = permissions.canSendVideos, - permissionCanSendVideoNotes = permissions.canSendVideoNotes, - permissionCanSendVoiceNotes = permissions.canSendVoiceNotes, - permissionCanSendPolls = permissions.canSendPolls, - permissionCanSendOtherMessages = permissions.canSendOtherMessages, - permissionCanAddLinkPreviews = permissions.canAddLinkPreviews, - permissionCanEditTag = permissions.canEditTag, - permissionCanChangeInfo = permissions.canChangeInfo, - permissionCanInviteUsers = permissions.canInviteUsers, - permissionCanPinMessages = permissions.canPinMessages, - permissionCanCreateTopics = permissions.canCreateTopics, - createdAt = System.currentTimeMillis() - ) - } - - private fun encodePositions(positions: Array): String? { - if (positions.isEmpty()) return null - val encoded = positions.mapNotNull { pos -> - if (pos.order == 0L) return@mapNotNull null - val pinned = if (pos.isPinned) 1 else 0 - when (val list = pos.list) { - is TdApi.ChatListMain -> "m:${pos.order}:$pinned" - is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" - is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" - else -> null - } - } - return if (encoded.isEmpty()) null else encoded.joinToString("|") - } - - private fun TdApi.User.toEntity(personalAvatarPath: String?): org.monogram.data.db.model.UserEntity { - val usernamesData = buildString { - append(usernames?.activeUsernames?.joinToString("|").orEmpty()) - append('\n') - append(usernames?.disabledUsernames?.joinToString("|").orEmpty()) - append('\n') - append(usernames?.editableUsername.orEmpty()) - append('\n') - append(usernames?.collectibleUsernames?.joinToString("|").orEmpty()) - } - - val statusType = when (status) { - is TdApi.UserStatusOnline -> "ONLINE" - is TdApi.UserStatusRecently -> "RECENTLY" - is TdApi.UserStatusLastWeek -> "LAST_WEEK" - is TdApi.UserStatusLastMonth -> "LAST_MONTH" - else -> "OFFLINE" - } - - val statusEmojiId = when (val type = emojiStatus?.type) { - is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId - is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L - } - - return org.monogram.data.db.model.UserEntity( - id = id, - firstName = firstName, - lastName = lastName.ifEmpty { null }, - phoneNumber = phoneNumber.ifEmpty { null }, - avatarPath = profilePhoto?.small?.local?.path?.ifEmpty { null }, - personalAvatarPath = personalAvatarPath, - isPremium = isPremium, - isVerified = verificationStatus?.isVerified ?: false, - isSupport = isSupport, - isContact = isContact, - isMutualContact = isMutualContact, - isCloseFriend = isCloseFriend, - haveAccess = haveAccess, - username = usernames?.activeUsernames?.firstOrNull(), - usernamesData = usernamesData, - statusType = statusType, - accentColorId = accentColorId, - profileAccentColorId = profileAccentColorId, - statusEmojiId = statusEmojiId, - languageCode = languageCode.ifEmpty { null }, - lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, - createdAt = System.currentTimeMillis() - ) - } - - private fun parseCachedCounts(clientData: String?): Pair { - if (clientData.isNullOrBlank()) return 0 to 0 - val memberCount = Regex("""mc:(\d+)""").find(clientData)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 0 - val onlineCount = Regex("""oc:(\d+)""").find(clientData)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 0 - return memberCount to onlineCount - } - - companion object { - private const val NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000L - private const val KEY_CURRENT_USER_ID = "current_user_id" - } -} diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt new file mode 100644 index 00000000..eb1a3771 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -0,0 +1,90 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.monogram.core.DispatcherProvider +import org.monogram.core.ScopeProvider +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.db.dao.WallpaperDao +import org.monogram.data.db.model.WallpaperEntity +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.mapper.mapBackgrounds +import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.repository.WallpaperRepository + +class WallpaperRepositoryImpl( + private val remote: SettingsRemoteDataSource, + private val updates: UpdateDispatcher, + private val wallpaperDao: WallpaperDao, + private val dispatchers: DispatcherProvider, + scopeProvider: ScopeProvider +) : WallpaperRepository { + + private val scope = scopeProvider.appScope + + private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) + private val wallpapers = MutableStateFlow>(emptyList()) + + init { + scope.launch { + updates.file.collect { + wallpaperUpdates.emit(Unit) + } + } + + scope.launch { + wallpaperDao.getWallpapers().collect { entities -> + val models = entities.mapNotNull { + try { + Json.decodeFromString(it.data) + } catch (_: Exception) { + null + } + } + if (models.isNotEmpty()) { + wallpapers.value = models + } + } + } + } + + override fun getWallpapers() = callbackFlow { + suspend fun fetch() { + val result = remote.getInstalledBackgrounds(false) + val mappedWallpapers = mapBackgrounds(result?.backgrounds ?: emptyArray()) + wallpapers.value = mappedWallpapers + saveWallpapersToDb(mappedWallpapers) + trySend(mappedWallpapers) + } + + val wallpaperJob = wallpaperUpdates + .onEach { fetch() } + .launchIn(this) + + if (wallpapers.value.isNotEmpty()) { + trySend(wallpapers.value) + } else { + fetch() + } + + awaitClose { wallpaperJob.cancel() } + } + + override suspend fun downloadWallpaper(fileId: Int) { + remote.downloadFile(fileId, 1) + } + + private suspend fun saveWallpapersToDb(wallpapers: List) { + withContext(dispatchers.io) { + wallpaperDao.clearAll() + wallpaperDao.insertWallpapers( + wallpapers.map { + WallpaperEntity(it.id, Json.encodeToString(it)) + } + ) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt new file mode 100644 index 00000000..a1ef60f3 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt @@ -0,0 +1,139 @@ +package org.monogram.data.repository.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.FileDownloadQueue +import java.util.concurrent.ConcurrentHashMap + +internal class UserMediaResolver( + private val gateway: TelegramGateway, + private val fileQueue: FileDownloadQueue, + val emojiPathCache: ConcurrentHashMap = ConcurrentHashMap(), + val fileIdToUserIdMap: ConcurrentHashMap = ConcurrentHashMap() +) { + private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY + private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY + + suspend fun resolveEmojiPath(user: TdApi.User): String? { + val emojiId = user.extractEmojiStatusId() + if (emojiId == 0L) return null + + emojiPathCache[emojiId]?.let { return it } + + return try { + val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) + if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { + val file = result.stickers.first().sticker + if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { + emojiPathCache[emojiId] = file.local.path + file.local.path + } else { + fileIdToUserIdMap[file.id] = user.id + fileQueue.enqueue(file.id, 1, FileDownloadQueue.DownloadType.DEFAULT, synchronous = false) + coRunCatching { fileQueue.waitForDownload(file.id).await() } + + val refreshedPath = coRunCatching { + (gateway.execute(TdApi.GetFile(file.id)) as? TdApi.File) + ?.local + ?.path + ?.takeIf { it.isNotEmpty() } + }.getOrNull() + if (refreshedPath != null) { + emojiPathCache[emojiId] = refreshedPath + } + refreshedPath + } + } else { + null + } + } catch (_: Exception) { + null + } + } + + suspend fun resolveAvatarPath(user: TdApi.User): String? { + val bigPhoto = user.profilePhoto?.big + val smallPhoto = user.profilePhoto?.small + val bigDirectPath = bigPhoto?.local?.path?.ifEmpty { null } + if (bigDirectPath != null) return bigDirectPath + + val smallDirectPath = smallPhoto?.local?.path?.ifEmpty { null } + if (smallDirectPath != null) { + val bigId = bigPhoto?.id?.takeIf { it != 0 } + if (bigId != null && bigId != smallPhoto.id) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + return smallDirectPath + } + + val resolvedSmallPath = resolveDownloadedFilePath(smallPhoto?.id) + if (resolvedSmallPath != null) { + val bigId = bigPhoto?.id?.takeIf { it != 0 } + if (bigId != null && bigId != smallPhoto?.id) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + return resolvedSmallPath + } + + val resolvedBigPath = resolveDownloadedFilePath(bigPhoto?.id) + if (resolvedBigPath != null) return resolvedBigPath + + val smallId = smallPhoto?.id?.takeIf { it != 0 } + val bigId = bigPhoto?.id?.takeIf { it != 0 } + if (smallId != null) { + fileQueue.enqueue( + smallId, + avatarDownloadPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + if (bigId != null && bigId != smallId) { + fileQueue.enqueue( + bigId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + } else if (bigId != null) { + fileQueue.enqueue( + bigId, + avatarDownloadPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + + return null + } + + private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { + if (fileId == null || fileId == 0) return null + val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null + return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + } + + companion object { + private const val AVATAR_DOWNLOAD_PRIORITY = 24 + private const val AVATAR_HD_PREFETCH_PRIORITY = 8 + } +} + +internal fun TdApi.User.extractEmojiStatusId(): Long { + return when (val type = this.emojiStatus?.type) { + is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId + is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId + else -> 0L + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt new file mode 100644 index 00000000..c0828556 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -0,0 +1,318 @@ +package org.monogram.data.repository.user + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.core.ScopeProvider +import org.monogram.data.chats.ChatCache +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.cache.UserLocalDataSource +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.data.db.dao.KeyValueDao +import org.monogram.data.db.model.KeyValueEntity +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.user.* +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.UserRepository +import java.util.concurrent.ConcurrentHashMap + +class UserRepositoryImpl( + private val remote: UserRemoteDataSource, + private val userLocal: UserLocalDataSource, + private val chatLocal: ChatLocalDataSource, + private val chatCache: ChatCache, + gateway: TelegramGateway, + private val updates: UpdateDispatcher, + fileQueue: FileDownloadQueue, + private val keyValueDao: KeyValueDao, + private val cacheProvider: CacheProvider, + scopeProvider: ScopeProvider +) : UserRepository { + + private val scope = scopeProvider.appScope + private val mediaResolver = UserMediaResolver(gateway = gateway, fileQueue = fileQueue) + private var currentUserId: Long = 0L + private val userRequests = ConcurrentHashMap>() + private val fullInfoRequests = ConcurrentHashMap>() + private val missingUsersUntilMs = ConcurrentHashMap() + private val missingUserFullInfoUntilMs = ConcurrentHashMap() + + private val _currentUserFlow = MutableStateFlow(null) + override val currentUserFlow = _currentUserFlow.asStateFlow() + + private val _userUpdateFlow = MutableSharedFlow( + extraBufferCapacity = USER_UPDATE_BUFFER_SIZE, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val anyUserUpdateFlow = _userUpdateFlow.asSharedFlow() + + init { + scope.launch { + restoreCurrentUserFromLocal() + } + + UserUpdateSynchronizer( + scope = scope, + updates = updates, + userLocal = userLocal, + keyValueDao = keyValueDao, + emojiPathCache = mediaResolver.emojiPathCache, + fileIdToUserIdMap = mediaResolver.fileIdToUserIdMap, + onUserUpdated = { user -> handleUserUpdated(user) }, + onUserIdChanged = { userId -> handleUserIdUpdated(userId) }, + onCachedSimCountryIsoChanged = { iso -> cacheProvider.setCachedSimCountryIso(iso) } + ).start() + } + + private suspend fun handleUserUpdated(user: TdApi.User) { + cacheUser(user) + handleUserIdUpdated(user.id) + } + + private suspend fun handleUserIdUpdated(userId: Long) { + if (userId == currentUserId) refreshCurrentUser() + _userUpdateFlow.emit(userId) + } + + private suspend fun restoreCurrentUserFromLocal() { + val cachedUserId = keyValueDao.getValue(KEY_CURRENT_USER_ID)?.value?.toLongOrNull() ?: return + if (cachedUserId <= 0L) return + + currentUserId = cachedUserId + val user = userLocal.getUser(cachedUserId) ?: return + val model = mapUserModel(user, userLocal.getUserFullInfo(cachedUserId)) + _currentUserFlow.value = model + } + + private suspend fun refreshCurrentUser() { + val user = userLocal.getUser(currentUserId) ?: return + val model = mapUserModel(user, userLocal.getUserFullInfo(currentUserId)) + _currentUserFlow.value = model + } + + override suspend fun getMe(): UserModel { + val user = remote.getMe() ?: return UserModel(0, "Error") + currentUserId = user.id + coRunCatching { keyValueDao.insertValue(KeyValueEntity(KEY_CURRENT_USER_ID, user.id.toString())) } + cacheUser(user) + val model = mapUserModel(user, userLocal.getUserFullInfo(user.id)) + _currentUserFlow.update { model } + return model + } + + override suspend fun getUser(userId: Long): UserModel? { + if (userId <= 0) return null + userLocal.getUser(userId)?.let { + return mapUserModel(it, userLocal.getUserFullInfo(userId)) + } + + userLocal.loadUser(userId)?.let { entity -> + val user = entity.toTdApi() + cacheUser(user) + return mapUserModel(user, userLocal.getUserFullInfo(userId)) + } + + if (isNegativeCached(missingUsersUntilMs, userId)) return null + + val deferred = userRequests.getOrPut(userId) { + scope.async { + fetchAndCacheUser(userId)?.also { + cacheUser(it) + } + } + } + return try { + deferred.await()?.let { user -> + mapUserModel(user, userLocal.getUserFullInfo(userId)) + } + } finally { + userRequests.remove(userId) + } + } + + override suspend fun getUserFullInfo(userId: Long): UserModel? { + if (userId <= 0) return null + val user = userLocal.getUser(userId) ?: fetchAndCacheUser(userId)?.also { + cacheUser(it) + } ?: return null + + val cachedFullInfo = userLocal.getUserFullInfo(userId) + if (cachedFullInfo != null) return mapUserModel(user, cachedFullInfo) + + val dbFullInfo = userLocal.getFullInfoEntity(userId) + if (dbFullInfo != null) { + val fullInfo = dbFullInfo.toTdApi() + cacheUserFullInfo(userId, fullInfo) + return mapUserModel(user, fullInfo) + } + + if (isNegativeCached(missingUserFullInfoUntilMs, userId)) { + return mapUserModel(user, null) + } + + val deferred = fullInfoRequests.getOrPut(userId) { + scope.async { + fetchAndCacheUserFullInfo(userId)?.also { + cacheUserFullInfo(userId, it) + userLocal.saveFullInfoEntity(it.toEntity(userId)) + syncUserPersonalAvatarPath(userId, it) + } + } + } + return try { + val fullInfo = deferred.await() + mapUserModel(user, fullInfo) + } finally { + fullInfoRequests.remove(userId) + } + } + + override suspend fun resolveUserChatFullInfo(userId: Long): ChatFullInfoModel? { + if (userId <= 0L) return null + val fullInfo = userLocal.getUserFullInfo(userId) ?: userLocal.getFullInfoEntity(userId)?.let { + val info = it.toTdApi() + cacheUserFullInfo(userId, info) + info + } ?: fetchAndCacheUserFullInfo(userId)?.also { + cacheUserFullInfo(userId, it) + userLocal.saveFullInfoEntity(it.toEntity(userId)) + syncUserPersonalAvatarPath(userId, it) + } + return fullInfo?.mapUserFullInfoToChat() + } + + private suspend fun mapUserModel(user: TdApi.User, fullInfo: TdApi.UserFullInfo?): UserModel { + val emojiPath = mediaResolver.resolveEmojiPath(user) + val avatarPath = mediaResolver.resolveAvatarPath(user) + val model = user.toDomain(fullInfo, emojiPath) + return if (avatarPath == null || avatarPath == model.avatarPath) model else model.copy(avatarPath = avatarPath) + } + + override fun getUserFlow(userId: Long): Flow = flow { + if (userId <= 0) { + emit(null) + return@flow + } + emit(getUser(userId)) + _userUpdateFlow + .filter { it == userId } + .collect { emit(getUser(userId)) } + } + + private suspend fun syncUserPersonalAvatarPath(userId: Long, fullInfo: TdApi.UserFullInfo) { + val personalAvatarPath = fullInfo.extractPersonalAvatarPath() ?: return + val existing = userLocal.loadUser(userId) ?: return + if (existing.personalAvatarPath == personalAvatarPath) return + userLocal.saveUser(existing.copy(personalAvatarPath = personalAvatarPath)) + } + + private suspend fun fetchAndCacheUser(userId: Long): TdApi.User? { + if (userId <= 0 || isNegativeCached(missingUsersUntilMs, userId)) return null + val user = remote.getUser(userId) + if (user != null) { + missingUsersUntilMs.remove(userId) + } else { + rememberNegative(missingUsersUntilMs, userId) + } + return user + } + + private suspend fun fetchAndCacheUserFullInfo(userId: Long): TdApi.UserFullInfo? { + if (userId <= 0 || isNegativeCached(missingUserFullInfoUntilMs, userId)) return null + val info = remote.getUserFullInfo(userId) + if (info != null) { + missingUserFullInfoUntilMs.remove(userId) + } else { + rememberNegative(missingUserFullInfoUntilMs, userId) + } + return info + } + + private fun isNegativeCached(cache: ConcurrentHashMap, id: Long): Boolean { + val until = cache[id] ?: return false + if (until > System.currentTimeMillis()) return true + cache.remove(id, until) + return false + } + + private fun rememberNegative(cache: ConcurrentHashMap, id: Long) { + cache[id] = System.currentTimeMillis() + NEGATIVE_CACHE_TTL_MS + } + + private suspend fun cacheUser(user: TdApi.User) { + userLocal.putUser(user) + chatCache.putUser(user) + } + + private suspend fun cacheUserFullInfo(userId: Long, info: TdApi.UserFullInfo) { + userLocal.putUserFullInfo(userId, info) + chatCache.putUserFullInfo(userId, info) + } + + override suspend fun getContacts(): List { + val result = remote.getContacts() ?: return emptyList() + return result.userIds.map { scope.async { getUser(it) } }.awaitAll().filterNotNull() + } + + override suspend fun searchContacts(query: String): List { + val result = remote.searchContacts(query) ?: return emptyList() + return result.userIds.map { scope.async { getUser(it) } }.awaitAll().filterNotNull() + } + + override suspend fun addContact(user: UserModel) { + val contact = TdApi.ImportedContact( + user.phoneNumber.orEmpty(), + user.firstName, + user.lastName.orEmpty(), + null + ) + remote.addContact(user.id, contact, true) + + remote.getUser(user.id)?.let { refreshedUser -> + cacheUser(refreshedUser) + } + + _userUpdateFlow.emit(user.id) + } + + override suspend fun removeContact(userId: Long) { + remote.removeContacts(longArrayOf(userId)) + _userUpdateFlow.emit(userId) + } + + override suspend fun setCachedSimCountryIso(iso: String?) { + if (iso != null) { + keyValueDao.insertValue(KeyValueEntity(KEY_CACHED_SIM_COUNTRY_ISO, iso)) + } else { + keyValueDao.deleteValue(KEY_CACHED_SIM_COUNTRY_ISO) + } + } + + override fun logOut() { + scope.launch { coRunCatching { remote.logout() } } + scope.launch { userLocal.clearAll() } + scope.launch { userLocal.clearDatabase() } + scope.launch { + coRunCatching { keyValueDao.deleteValue(KEY_CURRENT_USER_ID) } + currentUserId = 0L + _currentUserFlow.value = null + } + scope.launch { chatLocal.clearAll() } + } + + companion object { + private const val NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000L + private const val KEY_CURRENT_USER_ID = "current_user_id" + private const val KEY_CACHED_SIM_COUNTRY_ISO = "cached_sim_country_iso" + private const val USER_UPDATE_BUFFER_SIZE = 10 + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt new file mode 100644 index 00000000..a92cda70 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt @@ -0,0 +1,76 @@ +package org.monogram.data.repository.user + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.cache.UserLocalDataSource +import org.monogram.data.db.dao.KeyValueDao +import org.monogram.data.gateway.UpdateDispatcher +import java.util.concurrent.ConcurrentHashMap + +internal class UserUpdateSynchronizer( + private val scope: CoroutineScope, + private val updates: UpdateDispatcher, + private val userLocal: UserLocalDataSource, + private val keyValueDao: KeyValueDao, + private val emojiPathCache: ConcurrentHashMap, + private val fileIdToUserIdMap: ConcurrentHashMap, + private val onUserUpdated: suspend (TdApi.User) -> Unit, + private val onUserIdChanged: suspend (Long) -> Unit, + private val onCachedSimCountryIsoChanged: suspend (String?) -> Unit +) { + fun start() { + scope.launch { + updates.user.collect { update -> + onUserUpdated(update.user) + } + } + + scope.launch { + updates.userStatus.collect { update -> + userLocal.getUser(update.userId)?.let { cached -> + cached.status = update.status + onUserUpdated(cached) + } + } + } + + scope.launch { + updates.file.collect { update -> + val file = update.file + if (!file.local.isDownloadingCompleted) return@collect + + userLocal.getAllUsers().forEach { user -> + val small = user.profilePhoto?.small + val big = user.profilePhoto?.big + if (small?.id == file.id || big?.id == file.id) { + onUserIdChanged(user.id) + } + } + + if (file.local.path.isNotEmpty()) { + val userId = fileIdToUserIdMap.remove(file.id) + if (userId != null) { + userLocal.getUser(userId)?.let { user -> + val emojiId = user.extractEmojiStatusId() + if (emojiId != 0L) { + emojiPathCache[emojiId] = file.local.path + } + } + onUserIdChanged(userId) + } + } + } + } + + scope.launch { + keyValueDao.observeValue(KEY_CACHED_SIM_COUNTRY_ISO).collect { entity -> + onCachedSimCountryIsoChanged(entity?.value) + } + } + } + + companion object { + private const val KEY_CACHED_SIM_COUNTRY_ISO = "cached_sim_country_iso" + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt new file mode 100644 index 00000000..5fa219f3 --- /dev/null +++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt @@ -0,0 +1,178 @@ +package org.monogram.data.stickers + +import android.util.Log +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.core.DispatcherProvider +import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.cache.StickerLocalDataSource +import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileUpdateHandler +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.StickerSetModel +import java.io.File +import java.io.FileInputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.GZIPInputStream + +class StickerFileManager( + private val localDataSource: StickerLocalDataSource, + private val fileQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, + private val dispatchers: DispatcherProvider, + scopeProvider: ScopeProvider +) { + private val scope = scopeProvider.appScope + + private val tgsCache = mutableMapOf() + private val filePathsCache = ConcurrentHashMap() + + fun getStickerFile(fileId: Long): Flow = flow { + resolveAvailablePath(fileId)?.let { path -> + emit(path) + return@flow + } + + enqueueDownload(fileId, STICKER_DOWNLOAD_PRIORITY) + + val firstPath = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { + fileUpdateHandler.fileDownloadCompleted + .filter { it.first == fileId } + .mapNotNull { (_, path) -> path.takeIf(::isPathValid) } + .first() + } + + val resultPath = firstPath ?: resolveAvailablePath(fileId) + if (!resultPath.isNullOrEmpty()) { + filePathsCache[fileId] = resultPath + localDataSource.insertPath(fileId, resultPath) + emit(resultPath) + } else { + enqueueDownload(fileId, STICKER_DOWNLOAD_PRIORITY) + } + } + + fun prefetchStickers(stickers: List) { + scope.launch(dispatchers.default) { + stickers.take(PREFETCH_COUNT).forEach { sticker -> + if (!isStickerFileAvailable(sticker.id)) { + enqueueDownload(sticker.id, PREFETCH_PRIORITY) + } + } + } + } + + suspend fun verifyStickerSet(set: StickerSetModel) { + val missing = mutableListOf() + for (sticker in set.stickers) { + if (!isStickerFileAvailable(sticker.id)) { + missing += sticker.id + } + } + + if (missing.isEmpty()) return + + Log.d(TAG, "verifyStickerSet(${set.id}): missing ${missing.size}/${set.stickers.size}") + missing.forEach { stickerId -> + enqueueDownload(stickerId, VERIFY_SET_PRIORITY) + } + } + + suspend fun verifyInstalledStickerSets(sets: List) { + var requeued = 0 + + for (set in sets) { + for (sticker in set.stickers) { + if (requeued >= MAX_VERIFY_PER_PASS) break + if (isStickerFileAvailable(sticker.id)) continue + + enqueueDownload(sticker.id, VERIFY_PASS_PRIORITY) + requeued++ + } + if (requeued >= MAX_VERIFY_PER_PASS) break + } + + if (requeued > 0) { + Log.d(TAG, "verifyInstalledStickerSets: re-enqueued $requeued stickers") + } + } + + suspend fun getTgsJson(path: String): String? = withContext(dispatchers.io) { + tgsCache[path]?.let { return@withContext it } + coRunCatching { + val file = File(path) + if (!file.exists() || file.length() == 0L) return@withContext null + + GZIPInputStream(FileInputStream(file)) + .bufferedReader() + .use { it.readText() } + .also { tgsCache[path] = it } + }.getOrNull() + } + + fun clearCache() { + tgsCache.clear() + filePathsCache.clear() + scope.launch { + localDataSource.clearPaths() + } + } + + private suspend fun isStickerFileAvailable(stickerId: Long): Boolean { + return resolveAvailablePath(stickerId) != null + } + + private suspend fun resolveAvailablePath(fileId: Long): String? { + filePathsCache[fileId]?.let { path -> + if (isPathValid(path)) { + return path + } + filePathsCache.remove(fileId) + localDataSource.deletePath(fileId) + } + + val dbPath = localDataSource.getPath(fileId) + if (!dbPath.isNullOrEmpty()) { + if (isPathValid(dbPath)) { + filePathsCache[fileId] = dbPath + return dbPath + } + localDataSource.deletePath(fileId) + } + + val completedPath = fileUpdateHandler.fileDownloadCompleted + .replayCache + .firstOrNull { it.first == fileId && isPathValid(it.second) } + ?.second + + if (!completedPath.isNullOrEmpty()) { + filePathsCache[fileId] = completedPath + localDataSource.insertPath(fileId, completedPath) + return completedPath + } + + return null + } + + private fun enqueueDownload(fileId: Long, priority: Int) { + fileQueue.enqueue(fileId.toInt(), priority, FileDownloadQueue.DownloadType.STICKER) + } + + private fun isPathValid(path: String): Boolean { + return path.isNotEmpty() && File(path).exists() + } + + companion object { + private const val TAG = "StickerFileManager" + private const val DOWNLOAD_TIMEOUT_MS = 90_000L + private const val STICKER_DOWNLOAD_PRIORITY = 32 + private const val PREFETCH_PRIORITY = 16 + private const val VERIFY_SET_PRIORITY = 32 + private const val VERIFY_PASS_PRIORITY = 8 + private const val PREFETCH_COUNT = 20 + private const val MAX_VERIFY_PER_PASS = 20 + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/AttachMenuBotRepository.kt b/domain/src/main/java/org/monogram/domain/repository/AttachMenuBotRepository.kt new file mode 100644 index 00000000..4f27b590 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/AttachMenuBotRepository.kt @@ -0,0 +1,8 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.AttachMenuBotModel + +interface AttachMenuBotRepository { + fun getAttachMenuBots(): Flow> +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/BotRepository.kt b/domain/src/main/java/org/monogram/domain/repository/BotRepository.kt new file mode 100644 index 00000000..5612f411 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/BotRepository.kt @@ -0,0 +1,9 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotInfoModel + +interface BotRepository { + suspend fun getBotCommands(botId: Long): List + suspend fun getBotInfo(botId: Long): BotInfoModel? +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatCreationRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatCreationRepository.kt new file mode 100644 index 00000000..29a2706a --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatCreationRepository.kt @@ -0,0 +1,15 @@ +package org.monogram.domain.repository + +interface ChatCreationRepository { + suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int = 0): Long + + suspend fun createChannel( + title: String, + description: String, + isMegagroup: Boolean = false, + messageAutoDeleteTime: Int = 0 + ): Long + + fun getDatabaseSize(): Long + fun clearDatabase() +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatEventLogRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatEventLogRepository.kt new file mode 100644 index 00000000..e847d17e --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatEventLogRepository.kt @@ -0,0 +1,15 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ChatEventLogFiltersModel +import org.monogram.domain.models.ChatEventModel + +interface ChatEventLogRepository { + suspend fun getChatEventLog( + chatId: Long, + query: String = "", + fromEventId: Long = 0, + limit: Int = 50, + filters: ChatEventLogFiltersModel = ChatEventLogFiltersModel(), + userIds: List = emptyList() + ): List +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatFolderRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatFolderRepository.kt new file mode 100644 index 00000000..63d54ade --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatFolderRepository.kt @@ -0,0 +1,27 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.FolderModel + +data class FolderChatsUpdate( + val folderId: Int, + val chats: List +) + +data class FolderLoadingUpdate( + val folderId: Int, + val isLoading: Boolean +) + +interface ChatFolderRepository { + val folderChatsFlow: Flow + val foldersFlow: StateFlow> + val folderLoadingFlow: Flow + + suspend fun createFolder(title: String, iconName: String?, includedChatIds: List) + suspend fun deleteFolder(folderId: Int) + suspend fun updateFolder(folderId: Int, title: String, iconName: String?, includedChatIds: List) + suspend fun reorderFolders(folderIds: List) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt new file mode 100644 index 00000000..3f5d869b --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatInfoRepository.kt @@ -0,0 +1,63 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.GroupMemberModel + +interface ChatInfoRepository { + suspend fun getChatFullInfo(chatId: Long): ChatFullInfoModel? + suspend fun searchPublicChat(username: String): ChatModel? + suspend fun getChatMembers( + chatId: Long, + offset: Int, + limit: Int, + filter: ChatMembersFilter = ChatMembersFilter.Recent + ): List + + suspend fun getChatMember(chatId: Long, userId: Long): GroupMemberModel? + suspend fun setChatMemberStatus(chatId: Long, userId: Long, status: ChatMemberStatus) +} + +sealed class ChatMembersFilter { + data object Recent : ChatMembersFilter() + data object Administrators : ChatMembersFilter() + data object Banned : ChatMembersFilter() + data object Restricted : ChatMembersFilter() + data object Bots : ChatMembersFilter() + data class Search(val query: String) : ChatMembersFilter() +} + +sealed class ChatMemberStatus { + data object Member : ChatMemberStatus() + data class Administrator( + val customTitle: String = "", + val canBeEdited: Boolean = true, + val canManageChat: Boolean = true, + val canChangeInfo: Boolean = true, + val canPostMessages: Boolean = true, + val canEditMessages: Boolean = true, + val canDeleteMessages: Boolean = true, + val canInviteUsers: Boolean = true, + val canRestrictMembers: Boolean = true, + val canPinMessages: Boolean = true, + val canPromoteMembers: Boolean = true, + val canManageVideoChats: Boolean = true, + val canManageTopics: Boolean = true, + val canPostStories: Boolean = true, + val canEditStories: Boolean = true, + val canDeleteStories: Boolean = true, + val canManageDirectMessages: Boolean = true, + val isAnonymous: Boolean = false + ) : ChatMemberStatus() + + data class Restricted( + val isMember: Boolean = true, + val restrictedUntilDate: Int = 0, + val permissions: ChatPermissionsModel = ChatPermissionsModel() + ) : ChatMemberStatus() + + data object Left : ChatMemberStatus() + data class Banned(val bannedUntilDate: Int = 0) : ChatMemberStatus() + data object Creator : ChatMemberStatus() +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt new file mode 100644 index 00000000..b68c7a1d --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt @@ -0,0 +1,16 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.ChatModel + +interface ChatListRepository { + val chatListFlow: StateFlow> + val isLoadingFlow: StateFlow + val connectionStateFlow: StateFlow + + fun loadNextChunk(limit: Int) + fun selectFolder(folderId: Int) + fun refresh() + suspend fun getChatById(chatId: Long): ChatModel? + fun retryConnection() +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt new file mode 100644 index 00000000..f017e17a --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt @@ -0,0 +1,20 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +interface ChatOperationsRepository { + val isArchivePinned: StateFlow + val isArchiveAlwaysVisible: StateFlow + + fun toggleMuteChats(chatIds: Set, mute: Boolean) + fun toggleArchiveChats(chatIds: Set, archive: Boolean) + fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) + fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) + fun deleteChats(chatIds: Set) + fun leaveChat(chatId: Long) + fun setArchivePinned(pinned: Boolean) + + fun clearChatHistory(chatId: Long, revoke: Boolean) + suspend fun getChatLink(chatId: Long): String? + fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList()) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatSearchRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatSearchRepository.kt new file mode 100644 index 00000000..91a70688 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatSearchRepository.kt @@ -0,0 +1,22 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.MessageModel + +data class SearchMessagesResult( + val messages: List, + val nextOffset: String +) + +interface ChatSearchRepository { + val searchHistory: Flow> + + suspend fun searchChats(query: String): List + suspend fun searchPublicChats(query: String): List + suspend fun searchMessages(query: String, offset: String = "", limit: Int = 50): SearchMessagesResult + + fun addSearchChatId(chatId: Long) + fun removeSearchChatId(chatId: Long) + fun clearSearchHistory() +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt new file mode 100644 index 00000000..95dedd02 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt @@ -0,0 +1,14 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ChatPermissionsModel + +interface ChatSettingsRepository { + suspend fun setChatPhoto(chatId: Long, photoPath: String) + suspend fun setChatTitle(chatId: Long, title: String) + suspend fun setChatDescription(chatId: Long, description: String) + suspend fun setChatUsername(chatId: Long, username: String) + suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) + suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) + suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) + suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatStatisticsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatStatisticsRepository.kt new file mode 100644 index 00000000..445b0eb8 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ChatStatisticsRepository.kt @@ -0,0 +1,11 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ChatRevenueStatisticsModel +import org.monogram.domain.models.ChatStatisticsModel +import org.monogram.domain.models.StatisticsGraphModel + +interface ChatStatisticsRepository { + suspend fun getChatStatistics(chatId: Long, isDark: Boolean): ChatStatisticsModel? + suspend fun getChatRevenueStatistics(chatId: Long, isDark: Boolean): ChatRevenueStatisticsModel? + suspend fun loadStatisticsGraph(chatId: Long, token: String, x: Long): StatisticsGraphModel? +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatsListRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatsListRepository.kt deleted file mode 100644 index c004083a..00000000 --- a/domain/src/main/java/org/monogram/domain/repository/ChatsListRepository.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.monogram.domain.repository - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.* - -data class SearchMessagesResult( - val messages: List, - val nextOffset: String -) - -data class FolderChatsUpdate( - val folderId: Int, - val chats: List -) - -data class FolderLoadingUpdate( - val folderId: Int, - val isLoading: Boolean -) - -interface ChatsListRepository { - val chatListFlow: StateFlow> - val folderChatsFlow: Flow - val foldersFlow: StateFlow> - val isLoadingFlow: StateFlow - val folderLoadingFlow: Flow - val connectionStateFlow: StateFlow - val isArchivePinned: StateFlow - val isArchiveAlwaysVisible: StateFlow - val searchHistory: Flow> - val forumTopicsFlow: Flow>> - - fun loadNextChunk(limit: Int) - fun selectFolder(folderId: Int) - fun refresh() - suspend fun getChatById(chatId: Long): ChatModel? - - suspend fun searchChats(query: String): List - suspend fun searchPublicChats(query: String): List - suspend fun searchMessages(query: String, offset: String = "", limit: Int = 50): SearchMessagesResult - fun toggleMuteChats(chatIds: Set, mute: Boolean) - fun toggleArchiveChats(chatIds: Set, archive: Boolean) - fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) - fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) - fun deleteChats(chatIds: Set) - fun leaveChat(chatId: Long) - fun setArchivePinned(pinned: Boolean) - - fun retryConnection() - - suspend fun createFolder(title: String, iconName: String?, includedChatIds: List) - suspend fun deleteFolder(folderId: Int) - suspend fun updateFolder(folderId: Int, title: String, iconName: String?, includedChatIds: List) - suspend fun reorderFolders(folderIds: List) - - suspend fun getForumTopics( - chatId: Long, - query: String = "", - offsetDate: Int = 0, - offsetMessageId: Long = 0, - offsetForumTopicId: Int = 0, - limit: Int = 20 - ): List - - fun clearChatHistory(chatId: Long, revoke: Boolean) - suspend fun getChatLink(chatId: Long): String? - fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList()) - - fun addSearchChatId(chatId: Long) - fun removeSearchChatId(chatId: Long) - fun clearSearchHistory() - - suspend fun createGroup(title: String, userIds: List, messageAutoDeleteTime: Int = 0): Long - suspend fun createChannel( - title: String, - description: String, - isMegagroup: Boolean = false, - messageAutoDeleteTime: Int = 0 - ): Long - - suspend fun setChatPhoto(chatId: Long, photoPath: String) - suspend fun setChatTitle(chatId: Long, title: String) - suspend fun setChatDescription(chatId: Long, description: String) - suspend fun setChatUsername(chatId: Long, username: String) - suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) - suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) - suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) - suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) - - fun getDatabaseSize(): Long - fun clearDatabase() -} - -sealed class ConnectionStatus { - data object Connected : ConnectionStatus() - data object Connecting : ConnectionStatus() - data object Updating : ConnectionStatus() - data object WaitingForNetwork : ConnectionStatus() - data object ConnectingToProxy : ConnectionStatus() -} diff --git a/domain/src/main/java/org/monogram/domain/repository/ConnectionStatus.kt b/domain/src/main/java/org/monogram/domain/repository/ConnectionStatus.kt new file mode 100644 index 00000000..034cd984 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ConnectionStatus.kt @@ -0,0 +1,9 @@ +package org.monogram.domain.repository + +sealed class ConnectionStatus { + data object Connected : ConnectionStatus() + data object Connecting : ConnectionStatus() + data object Updating : ConnectionStatus() + data object WaitingForNetwork : ConnectionStatus() + data object ConnectingToProxy : ConnectionStatus() +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/EmojiRepository.kt b/domain/src/main/java/org/monogram/domain/repository/EmojiRepository.kt new file mode 100644 index 00000000..6b6555d8 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/EmojiRepository.kt @@ -0,0 +1,16 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.RecentEmojiModel +import org.monogram.domain.models.StickerModel + +interface EmojiRepository { + val recentEmojis: Flow> + + suspend fun getDefaultEmojis(): List + suspend fun searchEmojis(query: String): List + suspend fun searchCustomEmojis(query: String): List + suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) + suspend fun clearRecentEmojis() + suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt new file mode 100644 index 00000000..dc00f8a4 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt @@ -0,0 +1,24 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.FileModel + +interface FileRepository { + val messageDownloadProgressFlow: Flow> + val messageDownloadCancelledFlow: Flow + val messageDownloadCompletedFlow: Flow> + + fun downloadFile( + fileId: Int, + priority: Int = 1, + offset: Long = 0, + limit: Long = 0, + synchronous: Boolean = false + ) + + suspend fun cancelDownloadFile(fileId: Int) + + suspend fun getFilePath(fileId: Int): String? + + suspend fun getFileInfo(fileId: Int): FileModel? +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ForumTopicsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ForumTopicsRepository.kt new file mode 100644 index 00000000..3b4d7f22 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ForumTopicsRepository.kt @@ -0,0 +1,17 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.TopicModel + +interface ForumTopicsRepository { + val forumTopicsFlow: Flow>> + + suspend fun getForumTopics( + chatId: Long, + query: String = "", + offsetDate: Int = 0, + offsetMessageId: Long = 0, + offsetForumTopicId: Int = 0, + limit: Int = 20 + ): List +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/GifRepository.kt b/domain/src/main/java/org/monogram/domain/repository/GifRepository.kt new file mode 100644 index 00000000..68785369 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/GifRepository.kt @@ -0,0 +1,11 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.GifModel + +interface GifRepository { + fun getGifFile(gif: GifModel): Flow + suspend fun getSavedGifs(): List + suspend fun addSavedGif(path: String) + suspend fun searchGifs(query: String): List +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/InlineBotRepository.kt b/domain/src/main/java/org/monogram/domain/repository/InlineBotRepository.kt new file mode 100644 index 00000000..86dcbcd1 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/InlineBotRepository.kt @@ -0,0 +1,30 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.InlineQueryResultModel + +interface InlineBotRepository { + suspend fun getInlineBotResults( + botUserId: Long, + chatId: Long, + query: String, + offset: String = "" + ): InlineBotResultsModel? + + suspend fun sendInlineBotResult( + chatId: Long, + queryId: Long, + resultId: String, + replyToMsgId: Long? = null, + threadId: Long? = null + ) + + suspend fun onCallbackQuery(chatId: Long, messageId: Long, data: ByteArray) +} + +data class InlineBotResultsModel( + val queryId: Long, + val nextOffset: String, + val results: List, + val switchPmText: String? = null, + val switchPmParameter: String? = null +) \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/LocationRepository.kt b/domain/src/main/java/org/monogram/domain/repository/LocationRepository.kt index 374f2dfd..c36291b2 100644 --- a/domain/src/main/java/org/monogram/domain/repository/LocationRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/LocationRepository.kt @@ -4,5 +4,4 @@ import org.monogram.domain.models.webapp.OSMReverseResponse interface LocationRepository { suspend fun reverseGeocode(lat: Double, lon: Double): OSMReverseResponse? - suspend fun searchLocation(query: String): List } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageAiRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageAiRepository.kt new file mode 100644 index 00000000..149fafe2 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/MessageAiRepository.kt @@ -0,0 +1,22 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.MessageEntity + +interface MessageAiRepository { + val textCompositionStyles: StateFlow> + + suspend fun summarizeMessage(chatId: Long, messageId: Long, toLanguageCode: String = ""): String? + + suspend fun translateMessage(chatId: Long, messageId: Long, toLanguageCode: String): String? + + suspend fun composeTextWithAi( + text: String, + entities: List, + translateToLanguageCode: String = "", + styleName: String = "", + addEmojis: Boolean = false + ): FormattedTextResult? + + suspend fun fixTextWithAi(text: String, entities: List): FixedTextResult? +} \ No newline at end of file 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 c9dcd521..e4667b98 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -1,12 +1,8 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.* import org.monogram.domain.models.webapp.InstantViewModel -import org.monogram.domain.models.webapp.InvoiceModel -import org.monogram.domain.models.webapp.ThemeParams -import org.monogram.domain.models.webapp.WebAppInfoModel sealed interface ReadUpdate { val chatId: Long @@ -34,22 +30,23 @@ data class OlderMessagesPage( val isRemote: Boolean ) -interface MessageRepository { +interface MessageRepository : + FileRepository, + InlineBotRepository, + ChatEventLogRepository, + MessageAiRepository, + PaymentRepository, + WebAppRepository { val newMessageFlow: Flow val senderUpdateFlow: Flow val messageReadFlow: Flow val messageUploadProgressFlow: Flow> - val messageDownloadProgressFlow: Flow> - val messageDownloadCancelledFlow: Flow - val messageDownloadCompletedFlow: Flow> val messageDeletedFlow: Flow>> val messageEditedFlow: Flow val messageIdUpdateFlow: Flow> val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow - val textCompositionStyles: StateFlow> suspend fun getHighResFileId(chatId: Long, messageId: Long): Int? - suspend fun getFileInfo(fileId: Int): FileModel? suspend fun getProfileMedia( chatId: Long, filter: ProfileMediaFilter, @@ -144,20 +141,6 @@ interface MessageRepository { suspend fun sendChatAction(chatId: Long, action: ChatAction, threadId: Long? = null) suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int suspend fun getMessageViewers(chatId: Long, messageId: Long): List - suspend fun summarizeMessage(chatId: Long, messageId: Long, toLanguageCode: String = ""): String? - suspend fun translateMessage(chatId: Long, messageId: Long, toLanguageCode: String): String? - suspend fun composeTextWithAi( - text: String, - entities: List, - translateToLanguageCode: String = "", - styleName: String = "", - addEmojis: Boolean = false - ): FormattedTextResult? - - suspend fun fixTextWithAi( - text: String, - entities: List - ): FixedTextResult? suspend fun addMessageReaction(chatId: Long, messageId: Long, reaction: String) suspend fun removeMessageReaction(chatId: Long, messageId: Long, reaction: String) suspend fun setPollAnswer(chatId: Long, messageId: Long, optionIds: List) @@ -182,27 +165,6 @@ interface MessageRepository { fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) - suspend fun onCallbackQuery(chatId: Long, messageId: Long, data: ByteArray) - - suspend fun openWebApp( - chatId: Long, - botUserId: Long, - url: String, - themeParams: ThemeParams? = null - ): WebAppInfoModel? - - suspend fun closeWebApp(launchId: Long) - - suspend fun getInvoice(slug: String? = null, chatId: Long? = null, messageId: Long? = null): InvoiceModel? - - suspend fun payInvoice(slug: String? = null, chatId: Long? = null, messageId: Long? = null): Boolean - - suspend fun getFilePath(fileId: Int): String? - - suspend fun onCallbackQueryBuy(chatId: Long, messageId: Long) - - suspend fun sendWebAppResult(launchId: Long, queryId: String) - sealed interface ChatAction { data object Typing : ChatAction data object RecordingVideo : ChatAction @@ -240,50 +202,10 @@ interface MessageRepository { suspend fun getPinnedMessage(chatId: Long, threadId: Long? = null): MessageModel? suspend fun getAllPinnedMessages(chatId: Long, threadId: Long? = null): List suspend fun getPinnedMessageCount(chatId: Long, threadId: Long? = null): Int - fun downloadFile( - fileId: Int, - priority: Int = 1, - offset: Long = 0, - limit: Long = 0, - synchronous: Boolean = false - ) fun invalidateSenderCache(userId: Long) - suspend fun cancelDownloadFile(fileId: Int) suspend fun joinChat(chatId: Long) suspend fun restrictChatMember(chatId: Long, userId: Long, permissions: ChatPermissionsModel, untilDate: Int = 0) - suspend fun getInlineBotResults( - botUserId: Long, - chatId: Long, - query: String, - offset: String = "" - ): InlineBotResultsModel? - - suspend fun sendInlineBotResult( - chatId: Long, - queryId: Long, - resultId: String, - replyToMsgId: Long? = null, - threadId: Long? = null - ) - - suspend fun getChatEventLog( - chatId: Long, - query: String = "", - fromEventId: Long = 0, - limit: Int = 50, - filters: ChatEventLogFiltersModel = ChatEventLogFiltersModel(), - userIds: List = emptyList() - ): List - fun clearMessages(chatId: Long) fun clearAllCache() -} - -data class InlineBotResultsModel( - val queryId: Long, - val nextOffset: String, - val results: List, - val switchPmText: String? = null, - val switchPmParameter: String? = null -) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/NetworkStatisticsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/NetworkStatisticsRepository.kt new file mode 100644 index 00000000..5feac839 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/NetworkStatisticsRepository.kt @@ -0,0 +1,10 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.NetworkUsageModel + +interface NetworkStatisticsRepository { + suspend fun getNetworkUsage(): NetworkUsageModel? + suspend fun getNetworkStatisticsEnabled(): Boolean + suspend fun setNetworkStatisticsEnabled(enabled: Boolean) + suspend fun resetNetworkStatistics(): Boolean +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/NotificationSettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/NotificationSettingsRepository.kt new file mode 100644 index 00000000..362ec1ff --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/NotificationSettingsRepository.kt @@ -0,0 +1,16 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ChatModel + +interface NotificationSettingsRepository { + suspend fun getNotificationSettings(scope: TdNotificationScope): Boolean + suspend fun setNotificationSettings(scope: TdNotificationScope, enabled: Boolean) + + suspend fun getExceptions(scope: TdNotificationScope): List + suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) + suspend fun resetChatNotificationSettings(chatId: Long) + + enum class TdNotificationScope { + PRIVATE_CHATS, GROUPS, CHANNELS + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/PaymentRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PaymentRepository.kt new file mode 100644 index 00000000..366dd3f4 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/PaymentRepository.kt @@ -0,0 +1,11 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.webapp.InvoiceModel + +interface PaymentRepository { + suspend fun getInvoice(slug: String? = null, chatId: Long? = null, messageId: Long? = null): InvoiceModel? + + suspend fun payInvoice(slug: String? = null, chatId: Long? = null, messageId: Long? = null): Boolean + + suspend fun onCallbackQueryBuy(chatId: Long, messageId: Long) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt new file mode 100644 index 00000000..3b25e541 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt @@ -0,0 +1,12 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.PremiumFeatureType +import org.monogram.domain.models.PremiumLimitType +import org.monogram.domain.models.PremiumSource +import org.monogram.domain.models.PremiumStateModel + +interface PremiumRepository { + suspend fun getPremiumState(): PremiumStateModel? + suspend fun getPremiumFeatures(source: PremiumSource): List + suspend fun getPremiumLimit(limitType: PremiumLimitType): Int +} diff --git a/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt new file mode 100644 index 00000000..bd0209f2 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt @@ -0,0 +1,22 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface ProfilePhotoRepository { + suspend fun getUserProfilePhotos( + userId: Long, + offset: Int = 0, + limit: Int = 10, + ensureFullRes: Boolean = false + ): List + + suspend fun getChatProfilePhotos( + chatId: Long, + offset: Int = 0, + limit: Int = 10, + ensureFullRes: Boolean = false + ): List + + fun getUserProfilePhotosFlow(userId: Long): Flow> + fun getChatProfilePhotosFlow(chatId: Long): Flow> +} diff --git a/domain/src/main/java/org/monogram/domain/repository/SessionRepository.kt b/domain/src/main/java/org/monogram/domain/repository/SessionRepository.kt new file mode 100644 index 00000000..2616efed --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/SessionRepository.kt @@ -0,0 +1,9 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.SessionModel + +interface SessionRepository { + suspend fun getActiveSessions(): List + suspend fun terminateSession(sessionId: Long): Boolean + suspend fun confirmQrCode(link: String): Boolean +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/SettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/SettingsRepository.kt deleted file mode 100644 index b47dc460..00000000 --- a/domain/src/main/java/org/monogram/domain/repository/SettingsRepository.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.monogram.domain.repository - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.* - -interface SettingsRepository { - suspend fun getActiveSessions(): List - suspend fun terminateSession(sessionId: Long): Boolean - suspend fun confirmQrCode(link: String): Boolean - fun getWallpapers(): Flow> - suspend fun downloadWallpaper(fileId: Int) - - suspend fun getStorageUsage(): StorageUsageModel? - suspend fun getNetworkUsage(): NetworkUsageModel? - suspend fun clearStorage(chatId: Long? = null): Boolean - suspend fun resetNetworkStatistics(): Boolean - suspend fun setDatabaseMaintenanceSettings(maxDatabaseSize: Long, maxTimeFromLastAccess: Int): Boolean - - suspend fun getNetworkStatisticsEnabled(): Boolean - suspend fun setNetworkStatisticsEnabled(enabled: Boolean) - - suspend fun getStorageOptimizerEnabled(): Boolean - suspend fun setStorageOptimizerEnabled(enabled: Boolean) - - val autoDownloadMobile: StateFlow - val autoDownloadWifi: StateFlow - val autoDownloadRoaming: StateFlow - val autoDownloadFiles: StateFlow - val autoDownloadStickers: StateFlow - val autoDownloadVideoNotes: StateFlow - - fun setAutoDownloadMobile(enabled: Boolean) - fun setAutoDownloadWifi(enabled: Boolean) - fun setAutoDownloadRoaming(enabled: Boolean) - fun setAutoDownloadFiles(enabled: Boolean) - fun setAutoDownloadStickers(enabled: Boolean) - fun setAutoDownloadVideoNotes(enabled: Boolean) - - suspend fun getNotificationSettings(scope: TdNotificationScope): Boolean - suspend fun setNotificationSettings(scope: TdNotificationScope, enabled: Boolean) - - suspend fun getExceptions(scope: TdNotificationScope): List - suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) - suspend fun resetChatNotificationSettings(chatId: Long) - - fun getAttachMenuBots(): Flow> - - suspend fun setCachedSimCountryIso(iso: String?) - - enum class TdNotificationScope { - PRIVATE_CHATS, GROUPS, CHANNELS - } -} diff --git a/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt new file mode 100644 index 00000000..89fa0bbf --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt @@ -0,0 +1,5 @@ +package org.monogram.domain.repository + +interface SponsorRepository { + fun forceSponsorSync() +} diff --git a/domain/src/main/java/org/monogram/domain/repository/StickerRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StickerRepository.kt index 32c3946f..154a988a 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StickerRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StickerRepository.kt @@ -2,8 +2,6 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.GifModel -import org.monogram.domain.models.RecentEmojiModel import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel @@ -12,26 +10,18 @@ interface StickerRepository { val customEmojiStickerSets: StateFlow> val archivedStickerSets: StateFlow> val archivedEmojiSets: StateFlow> - val recentEmojis: Flow> suspend fun loadInstalledStickerSets() suspend fun loadCustomEmojiStickerSets() suspend fun loadArchivedStickerSets() suspend fun loadArchivedEmojiSets() - suspend fun getDefaultEmojis(): List + suspend fun getRecentStickers(): List - suspend fun addRecentEmoji(recentEmoji: RecentEmojiModel) suspend fun clearRecentStickers() - suspend fun clearRecentEmojis() fun getStickerFile(fileId: Long): Flow - fun getGifFile(gif: GifModel): Flow - suspend fun searchGifs(query: String): List - suspend fun getSavedGifs(): List - suspend fun addSavedGif(path: String) suspend fun getTgsJson(path: String): String? fun clearCache() - suspend fun getMessageAvailableReactions(chatId: Long, messageId: Long): List suspend fun getStickerSet(setId: Long): StickerSetModel? suspend fun getStickerSetByName(name: String): StickerSetModel? @@ -40,8 +30,6 @@ interface StickerRepository { suspend fun toggleStickerSetArchived(setId: Long, isArchived: Boolean) suspend fun reorderStickerSets(stickerType: TdLibStickerType, stickerSetIds: List) - suspend fun searchEmojis(query: String): List - suspend fun searchCustomEmojis(query: String): List suspend fun searchStickers(query: String): List suspend fun searchStickerSets(query: String): List diff --git a/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt new file mode 100644 index 00000000..2e44609f --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/StorageRepository.kt @@ -0,0 +1,12 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.StorageUsageModel + +interface StorageRepository { + suspend fun getStorageUsage(): StorageUsageModel? + suspend fun clearStorage(chatId: Long? = null): Boolean + suspend fun setDatabaseMaintenanceSettings(maxDatabaseSize: Long, maxTimeFromLastAccess: Int): Boolean + + suspend fun getStorageOptimizerEnabled(): Boolean + suspend fun setStorageOptimizerEnabled(enabled: Boolean) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/UserProfileEditRepository.kt b/domain/src/main/java/org/monogram/domain/repository/UserProfileEditRepository.kt new file mode 100644 index 00000000..f54d679e --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/UserProfileEditRepository.kt @@ -0,0 +1,19 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.BirthdateModel +import org.monogram.domain.models.BusinessOpeningHoursModel + +interface UserProfileEditRepository { + suspend fun setName(firstName: String, lastName: String) + suspend fun setBio(bio: String) + suspend fun setUsername(username: String) + suspend fun setEmojiStatus(customEmojiId: Long?) + suspend fun setProfilePhoto(path: String) + suspend fun setBirthdate(birthdate: BirthdateModel?) + suspend fun setPersonalChat(chatId: Long) + suspend fun setBusinessBio(bio: String) + suspend fun setBusinessLocation(address: String, latitude: Double = 0.0, longitude: Double = 0.0) + suspend fun setBusinessOpeningHours(openingHours: BusinessOpeningHoursModel?) + suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) + suspend fun reorderActiveUsernames(usernames: List) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/UserRepository.kt b/domain/src/main/java/org/monogram/domain/repository/UserRepository.kt index aa4f3109..d9de2f56 100644 --- a/domain/src/main/java/org/monogram/domain/repository/UserRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/UserRepository.kt @@ -2,116 +2,24 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.* +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.UserModel interface UserRepository { val currentUserFlow: StateFlow val anyUserUpdateFlow: Flow + suspend fun getMe(): UserModel suspend fun getUser(userId: Long): UserModel? suspend fun getUserFullInfo(userId: Long): UserModel? + suspend fun resolveUserChatFullInfo(userId: Long): ChatFullInfoModel? fun getUserFlow(userId: Long): Flow - suspend fun getUserProfilePhotos( - userId: Long, - offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): List - - suspend fun getChatProfilePhotos( - chatId: Long, - offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): List - - fun getUserProfilePhotosFlow(userId: Long): Flow> - fun getChatProfilePhotosFlow(chatId: Long): Flow> - - suspend fun getChatFullInfo(chatId: Long): ChatFullInfoModel? - suspend fun getPremiumState(): PremiumStateModel? - suspend fun getPremiumFeatures(source: PremiumSource): List - suspend fun getPremiumLimit(limitType: PremiumLimitType): Int fun logOut() - suspend fun getBotCommands(botId: Long): List - suspend fun getBotInfo(botId: Long): BotInfoModel? - suspend fun getContacts(): List suspend fun searchContacts(query: String): List suspend fun addContact(user: UserModel) suspend fun removeContact(userId: Long) - suspend fun getChatMembers( - chatId: Long, - offset: Int, - limit: Int, - filter: ChatMembersFilter = ChatMembersFilter.Recent - ): List - - suspend fun getChatMember(chatId: Long, userId: Long): GroupMemberModel? - suspend fun searchPublicChat(username: String): ChatModel? - - suspend fun setChatMemberStatus(chatId: Long, userId: Long, status: ChatMemberStatus) - - suspend fun getChatStatistics(chatId: Long, isDark: Boolean): ChatStatisticsModel? - suspend fun getChatRevenueStatistics(chatId: Long, isDark: Boolean): ChatRevenueStatisticsModel? - suspend fun loadStatisticsGraph(chatId: Long, token: String, x: Long): StatisticsGraphModel? - - suspend fun setName(firstName: String, lastName: String) - suspend fun setBio(bio: String) - suspend fun setUsername(username: String) - suspend fun setEmojiStatus(customEmojiId: Long?) - suspend fun setProfilePhoto(path: String) - suspend fun setBirthdate(birthdate: BirthdateModel?) - suspend fun setPersonalChat(chatId: Long) - suspend fun setBusinessBio(bio: String) - suspend fun setBusinessLocation(address: String, latitude: Double = 0.0, longitude: Double = 0.0) - suspend fun setBusinessOpeningHours(openingHours: BusinessOpeningHoursModel?) - suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) - suspend fun reorderActiveUsernames(usernames: List) - fun forceSponsorSync() -} - -sealed class ChatMembersFilter { - data object Recent : ChatMembersFilter() - data object Administrators : ChatMembersFilter() - data object Banned : ChatMembersFilter() - data object Restricted : ChatMembersFilter() - data object Bots : ChatMembersFilter() - data class Search(val query: String) : ChatMembersFilter() -} - -sealed class ChatMemberStatus { - data object Member : ChatMemberStatus() - data class Administrator( - val customTitle: String = "", - val canBeEdited: Boolean = true, - val canManageChat: Boolean = true, - val canChangeInfo: Boolean = true, - val canPostMessages: Boolean = true, - val canEditMessages: Boolean = true, - val canDeleteMessages: Boolean = true, - val canInviteUsers: Boolean = true, - val canRestrictMembers: Boolean = true, - val canPinMessages: Boolean = true, - val canPromoteMembers: Boolean = true, - val canManageVideoChats: Boolean = true, - val canManageTopics: Boolean = true, - val canPostStories: Boolean = true, - val canEditStories: Boolean = true, - val canDeleteStories: Boolean = true, - val canManageDirectMessages: Boolean = true, - val isAnonymous: Boolean = false - ) : ChatMemberStatus() - - data class Restricted( - val isMember: Boolean = true, - val restrictedUntilDate: Int = 0, - val permissions: ChatPermissionsModel = ChatPermissionsModel() - ) : ChatMemberStatus() - - data object Left : ChatMemberStatus() - data class Banned(val bannedUntilDate: Int = 0) : ChatMemberStatus() - data object Creator : ChatMemberStatus() + suspend fun setCachedSimCountryIso(iso: String?) } diff --git a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt new file mode 100644 index 00000000..415db6f8 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt @@ -0,0 +1,9 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.WallpaperModel + +interface WallpaperRepository { + fun getWallpapers(): Flow> + suspend fun downloadWallpaper(fileId: Int) +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/WebAppRepository.kt b/domain/src/main/java/org/monogram/domain/repository/WebAppRepository.kt new file mode 100644 index 00000000..d82ab270 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/WebAppRepository.kt @@ -0,0 +1,17 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.webapp.ThemeParams +import org.monogram.domain.models.webapp.WebAppInfoModel + +interface WebAppRepository { + suspend fun openWebApp( + chatId: Long, + botUserId: Long, + url: String, + themeParams: ThemeParams? = null + ): WebAppInfoModel? + + suspend fun closeWebApp(launchId: Long) + + suspend fun sendWebAppResult(launchId: Long, queryId: String) +} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt index 508a3477..085f12e4 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt @@ -25,7 +25,7 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter import org.koin.compose.koinInject import org.monogram.domain.models.FileModel -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.FileRepository import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -89,13 +89,13 @@ fun SettingsItem( tint = iconBackgroundColor ) } else if (icon is FileModel) { - val messageRepository: MessageRepository = koinInject() + val fileRepository: FileRepository = koinInject() var localPath by remember(icon.id) { mutableStateOf(icon.local.path) } LaunchedEffect(icon.id) { if (localPath.isEmpty() || !File(localPath).exists()) { - messageRepository.downloadFile(icon.id, 32) - messageRepository.messageDownloadCompletedFlow + fileRepository.downloadFile(icon.id, 32) + fileRepository.messageDownloadCompletedFlow .filter { it.first == icon.id.toLong() } .collect { (_, _, completedPath) -> localPath = completedPath } } 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 2446ffb1..dc356e6f 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -28,15 +28,41 @@ interface PreferencesContainer { interface RepositoriesContainer { val authRepository: AuthRepository - val chatsListRepository: ChatsListRepository + val chatListRepository: ChatListRepository + val chatFolderRepository: ChatFolderRepository + val chatOperationsRepository: ChatOperationsRepository + val chatSearchRepository: ChatSearchRepository + val forumTopicsRepository: ForumTopicsRepository + val chatSettingsRepository: ChatSettingsRepository + val chatCreationRepository: ChatCreationRepository val messageRepository: MessageRepository + val inlineBotRepository: InlineBotRepository + val chatEventLogRepository: ChatEventLogRepository + val messageAiRepository: MessageAiRepository + val paymentRepository: PaymentRepository + val fileRepository: FileRepository + val webAppRepository: WebAppRepository val userRepository: UserRepository - val settingsRepository: SettingsRepository + val userProfileEditRepository: UserProfileEditRepository + val profilePhotoRepository: ProfilePhotoRepository + val chatInfoRepository: ChatInfoRepository + val premiumRepository: PremiumRepository + val botRepository: BotRepository + val chatStatisticsRepository: ChatStatisticsRepository + val sponsorRepository: SponsorRepository + val notificationSettingsRepository: NotificationSettingsRepository + val sessionRepository: SessionRepository + val wallpaperRepository: WallpaperRepository + val storageRepository: StorageRepository + val networkStatisticsRepository: NetworkStatisticsRepository + val attachMenuBotRepository: AttachMenuBotRepository val locationRepository: LocationRepository val privacyRepository: PrivacyRepository val linkHandlerRepository: LinkHandlerRepository val externalProxyRepository: ExternalProxyRepository val stickerRepository: StickerRepository + val gifRepository: GifRepository + val emojiRepository: EmojiRepository val updateRepository: UpdateRepository } 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 78222891..50f44bd9 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -29,15 +29,41 @@ class KoinPreferencesContainer(private val koin: Koin) : PreferencesContainer { class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer { override val authRepository: AuthRepository by lazy { koin.get() } - override val chatsListRepository: ChatsListRepository by lazy { koin.get() } + override val chatListRepository: ChatListRepository by lazy { koin.get() } + override val chatFolderRepository: ChatFolderRepository by lazy { koin.get() } + override val chatOperationsRepository: ChatOperationsRepository by lazy { koin.get() } + override val chatSearchRepository: ChatSearchRepository by lazy { koin.get() } + override val forumTopicsRepository: ForumTopicsRepository by lazy { koin.get() } + override val chatSettingsRepository: ChatSettingsRepository by lazy { koin.get() } + override val chatCreationRepository: ChatCreationRepository by lazy { koin.get() } override val messageRepository: MessageRepository by lazy { koin.get() } + override val inlineBotRepository: InlineBotRepository by lazy { koin.get() } + override val chatEventLogRepository: ChatEventLogRepository by lazy { koin.get() } + override val messageAiRepository: MessageAiRepository by lazy { koin.get() } + override val paymentRepository: PaymentRepository by lazy { koin.get() } + override val fileRepository: FileRepository by lazy { koin.get() } + override val webAppRepository: WebAppRepository by lazy { koin.get() } override val userRepository: UserRepository by lazy { koin.get() } - override val settingsRepository: SettingsRepository by lazy { koin.get() } + override val userProfileEditRepository: UserProfileEditRepository by lazy { koin.get() } + override val profilePhotoRepository: ProfilePhotoRepository by lazy { koin.get() } + override val chatInfoRepository: ChatInfoRepository by lazy { koin.get() } + override val premiumRepository: PremiumRepository by lazy { koin.get() } + override val botRepository: BotRepository by lazy { koin.get() } + override val chatStatisticsRepository: ChatStatisticsRepository by lazy { koin.get() } + override val sponsorRepository: SponsorRepository by lazy { koin.get() } + override val notificationSettingsRepository: NotificationSettingsRepository by lazy { koin.get() } + override val sessionRepository: SessionRepository by lazy { koin.get() } + override val wallpaperRepository: WallpaperRepository by lazy { koin.get() } + override val storageRepository: StorageRepository by lazy { koin.get() } + override val networkStatisticsRepository: NetworkStatisticsRepository by lazy { koin.get() } + override val attachMenuBotRepository: AttachMenuBotRepository by lazy { koin.get() } override val locationRepository: LocationRepository by lazy { koin.get() } override val privacyRepository: PrivacyRepository by lazy { koin.get() } override val linkHandlerRepository: LinkHandlerRepository by lazy { koin.get() } override val externalProxyRepository: ExternalProxyRepository by lazy { koin.get() } override val stickerRepository: StickerRepository by lazy { koin.get() } + override val gifRepository: GifRepository by lazy { koin.get() } + override val emojiRepository: EmojiRepository by lazy { koin.get() } override val updateRepository: UpdateRepository by lazy { koin.get() } } 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/chatList/ChatListContent.kt index ac236ada..a08e4df7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -1250,6 +1250,7 @@ fun ChatListContent(component: ChatListComponent) { InstantViewer( url = url, messageRepository = koinInject(), + fileRepository = koinInject(), onDismiss = { component.onDismissInstantView() }, onOpenWebView = { component.onOpenWebView(it) } ) @@ -1273,7 +1274,7 @@ fun ChatListContent(component: ChatListComponent) { botUserId = botUserId, baseUrl = webAppUrl ?: "", botName = botName ?: stringResource(R.string.mini_app_default_name), - messageRepository = koinInject(), + webAppRepository = koinInject(), onDismiss = { component.onDismissWebApp() } ) } 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/chatList/DefaultChatListComponent.kt index 0a154148..b1bef737 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt @@ -12,10 +12,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.monogram.domain.models.BotMenuButtonModel import org.monogram.domain.models.UpdateState -import org.monogram.domain.repository.ChatsListRepository -import org.monogram.domain.repository.SettingsRepository -import org.monogram.domain.repository.UpdateRepository -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.* import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope @@ -37,9 +34,14 @@ class DefaultChatListComponent( activeChatId: Value ) : ChatListComponent, AppComponentContext by context { - private val repository: ChatsListRepository = container.repositories.chatsListRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val chatFolderRepository: ChatFolderRepository = container.repositories.chatFolderRepository + private val chatSearchRepository: ChatSearchRepository = container.repositories.chatSearchRepository + private val chatOperationsRepository: ChatOperationsRepository = container.repositories.chatOperationsRepository private val repositoryUser: UserRepository = container.repositories.userRepository - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val userProfileEditRepository: UserProfileEditRepository = container.repositories.userProfileEditRepository + private val botRepository: BotRepository = container.repositories.botRepository + private val attachMenuBotRepository: AttachMenuBotRepository = container.repositories.attachMenuBotRepository private val updateRepository: UpdateRepository = container.repositories.updateRepository override val appPreferences: AppPreferences = container.preferences.appPreferences @@ -81,7 +83,7 @@ class DefaultChatListComponent( repositoryUser.getMe() } - repository.folderChatsFlow + chatFolderRepository.folderChatsFlow .onEach { update -> val distinctList = update.chats.distinctBy { it.id } val pinnedCount = distinctList.count { it.isPinned } @@ -101,13 +103,13 @@ class DefaultChatListComponent( } .launchIn(scope) - repository.foldersFlow + chatFolderRepository.foldersFlow .onEach { folders -> _state.update { it.copy(folders = folders) } } .launchIn(scope) - repository.folderLoadingFlow + chatFolderRepository.folderLoadingFlow .onEach { update -> _state.update { val newLoadingByFolder = it.isLoadingByFolder.toMutableMap() @@ -117,7 +119,7 @@ class DefaultChatListComponent( } .launchIn(scope) - repository.connectionStateFlow + chatListRepository.connectionStateFlow .onEach { status -> _state.update { it.copy(connectionStatus = status) } } @@ -129,31 +131,31 @@ class DefaultChatListComponent( } .launchIn(scope) - repository.isArchivePinned + chatOperationsRepository.isArchivePinned .onEach { isPinned -> _state.update { it.copy(isArchivePinned = isPinned) } } .launchIn(scope) - repository.isArchiveAlwaysVisible + chatOperationsRepository.isArchiveAlwaysVisible .onEach { alwaysVisible -> _state.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } } .launchIn(scope) - repository.searchHistory + chatSearchRepository.searchHistory .onEach { history -> _state.update { it.copy(searchHistory = history) } } .launchIn(scope) - settingsRepository.getAttachMenuBots() + attachMenuBotRepository.getAttachMenuBots() .onEach { bots -> _state.update { it.copy(attachMenuBots = bots) } bots.firstOrNull()?.let { bot -> if (bot.botUserId != 0L) { - val botInfo = repositoryUser.getBotInfo(bot.botUserId) + val botInfo = botRepository.getBotInfo(bot.botUserId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { _state.update { @@ -183,12 +185,12 @@ class DefaultChatListComponent( }.launchIn(scope) scope.launch(Dispatchers.IO) { - repository.selectFolder(_state.value.selectedFolderId) + chatListRepository.selectFolder(_state.value.selectedFolderId) } } override fun retryConnection() { - repository.retryConnection() + chatListRepository.retryConnection() } override fun onFolderClicked(id: Int) { @@ -204,7 +206,7 @@ class DefaultChatListComponent( } scope.launch(Dispatchers.IO) { - repository.selectFolder(id) + chatListRepository.selectFolder(id) } } @@ -216,7 +218,7 @@ class DefaultChatListComponent( if (folderId != null && folderId != _state.value.selectedFolderId) { return@launch } - repository.loadNextChunk(20) + chatListRepository.loadNextChunk(20) } } @@ -226,7 +228,7 @@ class DefaultChatListComponent( isFetchingMoreMessages = true val query = _state.value.searchQuery scope.launch(Dispatchers.IO) { - val result = repository.searchMessages(query, offset = nextMessagesOffset) + val result = chatSearchRepository.searchMessages(query, offset = nextMessagesOffset) nextMessagesOffset = result.nextOffset _state.update { it.copy( @@ -245,7 +247,7 @@ class DefaultChatListComponent( toggleSelection(id) } else { if (_state.value.isSearchActive) { - repository.addSearchChatId(id) + chatSearchRepository.addSearchChatId(id) } onSelect(id, null) } @@ -262,7 +264,7 @@ class DefaultChatListComponent( toggleSelection(chatId) } else { if (_state.value.isSearchActive) { - repository.addSearchChatId(chatId) + chatSearchRepository.addSearchChatId(chatId) } onSelect(chatId, messageId) } @@ -321,13 +323,13 @@ class DefaultChatListComponent( return@launch } - val localResults = repository.searchChats(query) + val localResults = chatSearchRepository.searchChats(query) _state.update { it.copy(searchResults = localResults) } - val globalResults = repository.searchPublicChats(query) + val globalResults = chatSearchRepository.searchPublicChats(query) _state.update { it.copy(globalSearchResults = globalResults) } - val messageResults = repository.searchMessages(query) + val messageResults = chatSearchRepository.searchMessages(query) nextMessagesOffset = messageResults.nextOffset _state.update { it.copy( @@ -362,17 +364,17 @@ class DefaultChatListComponent( scope.launch(Dispatchers.IO) { coRunCatching { - repositoryUser.setEmojiStatus(customEmojiId) + userProfileEditRepository.setEmojiStatus(customEmojiId) } } } override fun onClearSearchHistory() { - repository.clearSearchHistory() + chatSearchRepository.clearSearchHistory() } override fun onRemoveSearchHistoryItem(chatId: Long) { - repository.removeSearchChatId(chatId) + chatSearchRepository.removeSearchChatId(chatId) } override fun onMuteSelected(mute: Boolean) { @@ -381,7 +383,7 @@ class DefaultChatListComponent( val shouldMute = selectedChats.any { !it.isMuted } scope.launch(Dispatchers.IO) { - repository.toggleMuteChats(selectedIds, shouldMute) + chatOperationsRepository.toggleMuteChats(selectedIds, shouldMute) clearSelection() } } @@ -389,7 +391,7 @@ class DefaultChatListComponent( override fun onArchiveSelected(archive: Boolean) { val selectedIds = _state.value.selectedChatIds scope.launch(Dispatchers.IO) { - repository.toggleArchiveChats(selectedIds, archive) + chatOperationsRepository.toggleArchiveChats(selectedIds, archive) clearSelection() } } @@ -401,7 +403,7 @@ class DefaultChatListComponent( val folderId = _state.value.selectedFolderId scope.launch(Dispatchers.IO) { - repository.togglePinChats(selectedIds, shouldPin, folderId) + chatOperationsRepository.togglePinChats(selectedIds, shouldPin, folderId) clearSelection() } } @@ -412,7 +414,7 @@ class DefaultChatListComponent( val shouldMarkUnread = selectedChats.any { !it.isMarkedAsUnread } scope.launch(Dispatchers.IO) { - repository.toggleReadChats(selectedIds, shouldMarkUnread) + chatOperationsRepository.toggleReadChats(selectedIds, shouldMarkUnread) clearSelection() } } @@ -420,13 +422,13 @@ class DefaultChatListComponent( override fun onDeleteSelected() { val selectedIds = _state.value.selectedChatIds scope.launch(Dispatchers.IO) { - repository.deleteChats(selectedIds) + chatOperationsRepository.deleteChats(selectedIds) clearSelection() } } override fun onArchivePinToggle() { - repository.setArchivePinned(!_state.value.isArchivePinned) + chatOperationsRepository.setArchivePinned(!_state.value.isArchivePinned) } override fun onConfirmForwarding() { @@ -451,7 +453,7 @@ class DefaultChatListComponent( if (folderId <= 0) return scope.launch(Dispatchers.IO) { - repository.deleteFolder(folderId) + chatFolderRepository.deleteFolder(folderId) if (_state.value.selectedFolderId == folderId) { onFolderClicked(-1) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index 6a0e20d8..d4ab0c21 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -8,56 +8,17 @@ import com.arkivanov.essenty.lifecycle.doOnStop import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider import org.monogram.domain.managers.DistrManager -import org.monogram.domain.models.BotMenuButtonModel -import org.monogram.domain.models.ChatPermissionsModel -import org.monogram.domain.models.GifModel -import org.monogram.domain.models.InlineKeyboardButtonModel -import org.monogram.domain.models.KeyboardButtonModel -import org.monogram.domain.models.MessageContent -import org.monogram.domain.models.MessageEntity -import org.monogram.domain.models.MessageModel -import org.monogram.domain.models.MessageSendOptions -import org.monogram.domain.models.MessageViewerModel -import org.monogram.domain.models.UserModel -import org.monogram.domain.models.WallpaperModel -import org.monogram.domain.repository.BotPreferencesProvider -import org.monogram.domain.repository.CacheProvider -import org.monogram.domain.repository.ChatMembersFilter -import org.monogram.domain.repository.ChatsListRepository -import org.monogram.domain.repository.MessageDisplayer -import org.monogram.domain.repository.MessageRepository -import org.monogram.domain.repository.PrivacyRepository -import org.monogram.domain.repository.SettingsRepository -import org.monogram.domain.repository.StickerRepository -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.models.* +import org.monogram.domain.repository.* 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.currentChat.impl.* import org.monogram.presentation.root.AppComponentContext import org.monogram.presentation.settings.storage.CacheController import java.io.File @@ -74,15 +35,22 @@ class DefaultChatComponent( private val initialMessageId: Long? = null ) : ChatComponent, AppComponentContext by context { - internal val settingsRepository: SettingsRepository = container.repositories.settingsRepository + internal val wallpaperRepository: WallpaperRepository = container.repositories.wallpaperRepository override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() internal val userRepository: UserRepository = container.repositories.userRepository + internal val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository + internal val botRepository: BotRepository = container.repositories.botRepository override val stickerRepository: StickerRepository = container.repositories.stickerRepository + internal val gifRepository: GifRepository = container.repositories.gifRepository internal val privacyRepository: PrivacyRepository = container.repositories.privacyRepository internal val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider internal val toastMessageDisplayer: MessageDisplayer = container.utils.messageDisplayer() - internal val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + internal val chatListRepository: ChatListRepository = container.repositories.chatListRepository + internal val chatOperationsRepository: ChatOperationsRepository = container.repositories.chatOperationsRepository + internal val forumTopicsRepository: ForumTopicsRepository = container.repositories.forumTopicsRepository override val repositoryMessage: MessageRepository = container.repositories.messageRepository + internal val inlineBotRepository: InlineBotRepository = container.repositories.inlineBotRepository + internal val paymentRepository: PaymentRepository = container.repositories.paymentRepository override val appPreferences: AppPreferences = container.preferences.appPreferences internal val cacheProvider: CacheProvider = container.cacheProvider internal val cacheController: CacheController = container.utils.cacheController @@ -274,7 +242,7 @@ class DefaultChatComponent( if (currentState.isChannel && !currentState.isAdmin) return@launch try { - allMembers = userRepository.getChatMembers(chatId, 0, 200, ChatMembersFilter.Recent) + allMembers = chatInfoRepository.getChatMembers(chatId, 0, 200, ChatMembersFilter.Recent) .map { it.user } } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to load members", e) @@ -549,7 +517,7 @@ class DefaultChatComponent( override fun onSendInlineResult(resultId: String) = store.accept(ChatStore.Intent.SendInlineResult(resultId)) override fun onOpenAttachBot(botUserId: Long, fallbackName: String) { scope.launch { - val botInfo = userRepository.getBotInfo(botUserId) + val botInfo = botRepository.getBotInfo(botUserId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { onOpenMiniApp( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt index 565cd23c..df669f6a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt @@ -2,19 +2,8 @@ package org.monogram.presentation.features.chats.currentChat.chatContent import android.content.ClipData import android.util.Log -import androidx.compose.animation.AnimatedVisibility -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.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.animation.* +import androidx.compose.runtime.* import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.text.AnnotatedString import org.monogram.domain.models.MessageContent @@ -44,6 +33,7 @@ fun ChatContentViewers( InstantViewer( url = url, messageRepository = component.repositoryMessage, + fileRepository = component.repositoryMessage, onDismiss = { component.onDismissInstantView() }, onOpenWebView = { component.onOpenWebView(it) } ) @@ -93,7 +83,7 @@ fun ChatContentViewers( baseUrl = state.miniAppUrl, botName = state.chatTitle, botAvatarPath = state.chatAvatar, - messageRepository = component.repositoryMessage, + webAppRepository = component.repositoryMessage, onDismiss = { component.onDismissMiniApp() } ) } @@ -367,7 +357,8 @@ fun ChatContentViewers( slug = state.invoiceSlug, chatId = state.chatId, messageId = state.invoiceMessageId, - messageRepository = component.repositoryMessage, + paymentRepository = component.repositoryMessage, + fileRepository = component.repositoryMessage, onDismiss = { status -> component.onDismissInvoice(status) } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index 0912ee2b..880c4137 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -2,13 +2,7 @@ package org.monogram.presentation.features.chats.currentChat.chatContent import android.content.ClipData import android.util.Log -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.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.Clipboard @@ -26,13 +20,13 @@ import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageViewerModel -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.MessageAiRepository 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.stickers.ui.menu.MessageOptionsMenu -import java.util.Locale +import java.util.* @Composable fun ChatMessageOptionsMenu( @@ -53,7 +47,7 @@ fun ChatMessageOptionsMenu( onDismiss: () -> Unit ) { val nativeClipboard = localClipboard.nativeClipboard - val messageRepository: MessageRepository = koinInject() + val messageRepository: MessageAiRepository = koinInject() val canCheckViewersList = remember(state.isChannel, state.isGroup, state.memberCount) { !state.isChannel && (!state.isGroup || state.memberCount in 1 until 100) } 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/currentChat/components/inputbar/FullScreenEditorSheet.kt index 3fe4076a..cad1cc92 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/currentChat/components/inputbar/FullScreenEditorSheet.kt @@ -178,7 +178,7 @@ fun FullScreenEditorSheet( var aiLoading by remember { mutableStateOf(false) } val snippetProvider: EditorSnippetProvider = koinInject() - val messageRepository: MessageRepository = koinInject() + val messageRepository: MessageAiRepository = koinInject() val textCompositionStyles by messageRepository.textCompositionStyles.collectAsState() val effectiveAiStyles = remember(textCompositionStyles) { if (textCompositionStyles.isEmpty()) DEFAULT_AI_STYLES else textCompositionStyles 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/currentChat/impl/BotOperations.kt index 779eb278..1672faf0 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/currentChat/impl/BotOperations.kt @@ -25,7 +25,7 @@ internal fun DefaultChatComponent.handleMentionQueryChange( val canLoadMembers = !currentState.isChannel || currentState.isAdmin if (canLoadMembers && (currentState.isGroup || currentState.isChannel)) { try { - val members = userRepository.getChatMembers(chatId, 0, 200, ChatMembersFilter.Recent) + val members = chatInfoRepository.getChatMembers(chatId, 0, 200, ChatMembersFilter.Recent) .map { it.user } onMembersUpdated(members) members @@ -103,7 +103,7 @@ internal fun DefaultChatComponent.handleInlineQueryChange(botUsername: String, q } try { - val botId = cachedBotId ?: userRepository.searchPublicChat(normalizedUsername) + val botId = cachedBotId ?: chatInfoRepository.searchPublicChat(normalizedUsername) ?.id if (!isActive) return@launch @@ -113,7 +113,7 @@ internal fun DefaultChatComponent.handleInlineQueryChange(botUsername: String, q return@launch } - val results = repositoryMessage.getInlineBotResults(botId, chatId, normalizedQuery) + val results = inlineBotRepository.getInlineBotResults(botId, chatId, normalizedQuery) if (!isActive) return@launch _state.update { liveState -> @@ -161,7 +161,7 @@ internal fun DefaultChatComponent.handleLoadMoreInlineResults(offset: String) { inlineBotJob = scope.launch { _state.update { it.copy(isInlineBotLoading = true) } try { - val results = repositoryMessage.getInlineBotResults(botId, chatId, query, offset) + val results = inlineBotRepository.getInlineBotResults(botId, chatId, query, offset) if (!isActive) return@launch if (results != null) { @@ -204,7 +204,7 @@ internal fun DefaultChatComponent.handleSendInlineResult(resultId: String) { val results = _state.value.inlineBotResults ?: return scope.launch { try { - repositoryMessage.sendInlineBotResult( + inlineBotRepository.sendInlineBotResult( chatId = chatId, queryId = results.queryId, resultId = resultId, @@ -276,7 +276,7 @@ private suspend fun DefaultChatComponent.refreshInlinePreviews( delay(500L + attempt * 350L) val refreshed = try { - repositoryMessage.getInlineBotResults(botId, chatId, query) + inlineBotRepository.getInlineBotResults(botId, chatId, query) } catch (e: Exception) { Log.w("DefaultChatComponent", "Inline preview refresh failed", e) null @@ -357,7 +357,7 @@ internal fun DefaultChatComponent.handleReplyMarkupButtonClick( scope.launch { when (val type = button.type) { is InlineKeyboardButtonType.Callback -> { - repositoryMessage.onCallbackQuery(chatId, messageId, type.data) + inlineBotRepository.onCallbackQuery(chatId, messageId, type.data) } is InlineKeyboardButtonType.Url -> { @@ -369,7 +369,7 @@ internal fun DefaultChatComponent.handleReplyMarkupButtonClick( } is InlineKeyboardButtonType.Buy -> { - repositoryMessage.onCallbackQueryBuy(chatId, messageId) + paymentRepository.onCallbackQueryBuy(chatId, messageId) } is InlineKeyboardButtonType.User -> { 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/currentChat/impl/ChatInfo.kt index 38b3368b..b2b01b23 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/currentChat/impl/ChatInfo.kt @@ -11,7 +11,7 @@ import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent internal fun DefaultChatComponent.loadChatInfo() { scope.launch { - val chat = chatsListRepository.getChatById(chatId) + val chat = chatListRepository.getChatById(chatId) if (chat != null) { updateChatState(chat) if (chat.viewAsTopics && _state.value.topics.isEmpty()) { @@ -20,7 +20,7 @@ internal fun DefaultChatComponent.loadChatInfo() { val isBot = chat.type == ChatType.PRIVATE && chat.isBot if (isBot) { - val botInfo = userRepository.getBotInfo(chatId) + val botInfo = botRepository.getBotInfo(chatId) if (botInfo != null) { _state.update { it.copy( @@ -34,7 +34,7 @@ internal fun DefaultChatComponent.loadChatInfo() { } } - chatsListRepository.chatListFlow + chatListRepository.chatListFlow .map { chats -> chats.find { it.id == chatId } } .filterNotNull() .distinctUntilChanged { old, new -> @@ -65,7 +65,7 @@ internal fun DefaultChatComponent.loadChatInfo() { } .launchIn(scope) - chatsListRepository.forumTopicsFlow + forumTopicsRepository.forumTopicsFlow .filter { it.first == chatId } .onEach { (_, topics) -> _state.update { it.copy(topics = topics) } @@ -78,7 +78,7 @@ internal fun DefaultChatComponent.loadTopics() { scope.launch { _state.update { it.copy(isLoadingTopics = true) } try { - val topics = chatsListRepository.getForumTopics(chatId) + val topics = forumTopicsRepository.getForumTopics(chatId) _state.update { it.copy(topics = topics) } } finally { _state.update { it.copy(isLoadingTopics = false) } @@ -140,7 +140,7 @@ internal fun DefaultChatComponent.updateChatState(chat: ChatModel) { } internal fun DefaultChatComponent.handleToggleMute() { - chatsListRepository.toggleMuteChats(setOf(chatId), !_state.value.isMuted) + chatOperationsRepository.toggleMuteChats(setOf(chatId), !_state.value.isMuted) } internal fun DefaultChatComponent.handleAddToAdBlockWhitelist() { @@ -160,24 +160,24 @@ internal fun DefaultChatComponent.handleRemoveFromAdBlockWhitelist() { } internal fun DefaultChatComponent.handleClearHistory() { - chatsListRepository.clearChatHistory(chatId, true) + chatOperationsRepository.clearChatHistory(chatId, true) } internal fun DefaultChatComponent.handleDeleteChat() { - chatsListRepository.deleteChats(setOf(chatId)) + chatOperationsRepository.deleteChats(setOf(chatId)) onBackClicked() } internal fun DefaultChatComponent.handleJoinChat() { scope.launch { - userRepository.setChatMemberStatus(chatId, userRepository.getMe().id, ChatMemberStatus.Member) + chatInfoRepository.setChatMemberStatus(chatId, userRepository.getMe().id, ChatMemberStatus.Member) } } internal fun DefaultChatComponent.handleBlockUser(userId: Long) { scope.launch { if (_state.value.isGroup || _state.value.isChannel) { - userRepository.setChatMemberStatus(chatId, userId, ChatMemberStatus.Banned()) + chatInfoRepository.setChatMemberStatus(chatId, userId, ChatMemberStatus.Banned()) } else { privacyRepository.blockUser(userId) } @@ -196,7 +196,7 @@ internal fun DefaultChatComponent.handleConfirmRestrict( ) { val userId = _state.value.restrictUserId ?: return scope.launch { - userRepository.setChatMemberStatus( + chatInfoRepository.setChatMemberStatus( chatId, userId, ChatMemberStatus.Restricted(isMember = true, restrictedUntilDate = untilDate, permissions = permissions) 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/currentChat/impl/MessageActions.kt index a5f98277..49085ce5 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/currentChat/impl/MessageActions.kt @@ -10,11 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.monogram.domain.models.GifModel -import org.monogram.domain.models.MessageContent -import org.monogram.domain.models.MessageEntity -import org.monogram.domain.models.MessageModel -import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.* 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 @@ -332,12 +328,12 @@ internal fun DefaultChatComponent.handleReportMessage(message: MessageModel) { } internal fun DefaultChatComponent.handleReportReasonSelected(reason: String) { - chatsListRepository.reportChat(chatId, reason) + chatOperationsRepository.reportChat(chatId, reason) } internal fun DefaultChatComponent.handleCopyLink(localClipboard: Clipboard) { scope.launch { - val link = chatsListRepository.getChatLink(chatId) + val link = chatOperationsRepository.getChatLink(chatId) if (link != null) { localClipboard.nativeClipboard.setPrimaryClip( ClipData.newPlainText("", AnnotatedString(link)) 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/currentChat/impl/MessageLoading.kt index 3ecbd2ef..ca66f437 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/currentChat/impl/MessageLoading.kt @@ -208,7 +208,7 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { } else if (savedScrollPosition != 0L) { loadAroundMessage(savedScrollPosition, threadId, shouldHighlight = false) } else { - val chat = chatsListRepository.getChatById(chatId) + val chat = chatListRepository.getChatById(chatId) val firstUnreadId = chat?.lastReadInboxMessageId?.let { lastRead -> if (chat.unreadCount > 0) { repositoryMessage.getMessagesNewer(chatId, lastRead, 1, threadId).firstOrNull()?.id 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/currentChat/impl/MessageOperations.kt index d21ac483..98795df8 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/currentChat/impl/MessageOperations.kt @@ -129,7 +129,7 @@ internal fun DefaultChatComponent.handleUnpinMessage(message: MessageModel) { } internal fun DefaultChatComponent.handleClearMessages() { - chatsListRepository.clearChatHistory(chatId, false) + chatOperationsRepository.clearChatHistory(chatId, false) } internal fun DefaultChatComponent.handleSendScheduledNow(message: MessageModel) { 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/currentChat/impl/Preferences.kt index e423c2bc..89fc59bd 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/currentChat/impl/Preferences.kt @@ -139,7 +139,7 @@ internal fun DefaultChatComponent.observePreferences(availableWallpapers: List) -> Unit) { - settingsRepository.getWallpapers() + wallpaperRepository.getWallpapers() .onEach { wallpapers -> onLoaded(wallpapers) val currentWallpaper = appPreferences.wallpaper.value 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/currentChat/impl/Stickers.kt index df5cb1f7..5c2cc20f 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/currentChat/impl/Stickers.kt @@ -19,6 +19,6 @@ internal fun DefaultChatComponent.handleStickerClick(setId: Long) { internal fun DefaultChatComponent.handleAddToGifs(path: String) { scope.launch { - stickerRepository.addSavedGif(path) + gifRepository.addSavedGif(path) } } 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/newChat/DefaultNewChatComponent.kt index 7eec5a77..b221d8d0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/DefaultNewChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/DefaultNewChatComponent.kt @@ -8,7 +8,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -21,7 +22,8 @@ class DefaultNewChatComponent( ) : NewChatComponent, AppComponentContext by context { private val userRepository: UserRepository = container.repositories.userRepository - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatCreationRepository: ChatCreationRepository = container.repositories.chatCreationRepository + private val chatSettingsRepository: ChatSettingsRepository = container.repositories.chatSettingsRepository private val scope = componentScope private val _state = MutableStateFlow(NewChatComponent.State()) @@ -246,14 +248,14 @@ class DefaultNewChatComponent( scope.launch { _state.update { it.copy(isCreating = true, validationError = null) } try { - val chatId = chatsListRepository.createGroup( + val chatId = chatCreationRepository.createGroup( currentState.title, currentState.selectedUserIds.toList(), currentState.autoDeleteTime ) if (chatId != 0L) { if (currentState.photoPath != null) { - chatsListRepository.setChatPhoto(chatId, currentState.photoPath) + chatSettingsRepository.setChatPhoto(chatId, currentState.photoPath) } onChatCreated(chatId) } @@ -274,14 +276,14 @@ class DefaultNewChatComponent( scope.launch { _state.update { it.copy(isCreating = true, validationError = null) } try { - val chatId = chatsListRepository.createChannel( + val chatId = chatCreationRepository.createChannel( currentState.title, currentState.description, messageAutoDeleteTime = currentState.autoDeleteTime ) if (chatId != 0L) { if (currentState.photoPath != null) { - chatsListRepository.setChatPhoto(chatId, currentState.photoPath) + chatSettingsRepository.setChatPhoto(chatId, currentState.photoPath) } onChatCreated(chatId) } 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 6f665cb7..cd19b383 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import org.monogram.domain.models.webapp.InstantViewModel import org.monogram.domain.models.webapp.PageBlock import org.monogram.domain.models.webapp.RichText +import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.components.chats.normalizeUrl @@ -56,6 +57,7 @@ import java.util.* fun InstantViewer( url: String, messageRepository: MessageRepository, + fileRepository: FileRepository, onDismiss: () -> Unit, onOpenWebView: (String) -> Unit ) { @@ -111,7 +113,7 @@ fun InstantViewer( CompositionLocalProvider( LocalOnUrlClick provides { newUrl -> urlStack.add(newUrl) }, - LocalMessageRepository provides messageRepository + LocalFileRepository provides fileRepository ) { Box(modifier = Modifier.fillMaxSize()) { Scaffold( 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 588afa3d..18fae7d5 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 @@ -82,7 +82,7 @@ fun AsyncImageWithDownload( minithumbnail: ByteArray? = null, contentScale: ContentScale = ContentScale.Fit ) { - val messageRepository = LocalMessageRepository.current + val fileRepository = LocalFileRepository.current var currentPath by remember(fileId) { mutableStateOf(path) } var progress by remember { mutableFloatStateOf(0f) } @@ -91,22 +91,22 @@ fun AsyncImageWithDownload( currentPath = path } if (currentPath == null) { - messageRepository.downloadFile(fileId) + fileRepository.downloadFile(fileId) val progressJob = launch { - messageRepository.messageDownloadProgressFlow + fileRepository.messageDownloadProgressFlow .filter { it.first == fileId.toLong() } .collect { progress = it.second } } val completedPath = withTimeoutOrNull(60_000L) { - messageRepository.messageDownloadCompletedFlow + fileRepository.messageDownloadCompletedFlow .filter { it.first == fileId.toLong() } .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } .first() } - currentPath = completedPath ?: messageRepository.getFilePath(fileId) + currentPath = completedPath ?: fileRepository.getFilePath(fileId) progressJob.cancel() } } @@ -163,7 +163,7 @@ fun AsyncVideoWithDownload( shouldLoop: Boolean = true, contentScale: ContentScale = ContentScale.Fit ) { - val messageRepository = LocalMessageRepository.current + val fileRepository = LocalFileRepository.current var currentPath by remember(fileId) { mutableStateOf(path) } var progress by remember { mutableFloatStateOf(0f) } @@ -172,22 +172,22 @@ fun AsyncVideoWithDownload( currentPath = path } if (currentPath == null) { - messageRepository.downloadFile(fileId) + fileRepository.downloadFile(fileId) val progressJob = launch { - messageRepository.messageDownloadProgressFlow + fileRepository.messageDownloadProgressFlow .filter { it.first == fileId.toLong() } .collect { progress = it.second } } val completedPath = withTimeoutOrNull(60_000L) { - messageRepository.messageDownloadCompletedFlow + fileRepository.messageDownloadCompletedFlow .filter { it.first == fileId.toLong() } .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } .first() } - currentPath = completedPath ?: messageRepository.getFilePath(fileId) + currentPath = completedPath ?: fileRepository.getFilePath(fileId) progressJob.cancel() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt index 039e0c1b..bb432227 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt @@ -13,13 +13,13 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import org.monogram.domain.models.webapp.PageBlock import org.monogram.domain.models.webapp.RichText -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.FileRepository import java.text.SimpleDateFormat import java.util.* val LocalOnUrlClick = staticCompositionLocalOf<(String) -> Unit> { { } } -val LocalMessageRepository = - staticCompositionLocalOf { error("No MessageRepository provided") } +val LocalFileRepository = + staticCompositionLocalOf { error("No FileRepository provided") } fun renderRichText(richText: RichText, linkColor: Color = Color(0xFF2196F3)): AnnotatedString { return buildAnnotatedString { diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 894e43c6..9a0029b1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -28,8 +28,14 @@ class DefaultProfileComponent( private val onMemberLongClicked: (Long, Long) -> Unit = { _, _ -> } ) : ProfileComponent, AppComponentContext by context { - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val chatOperationsRepository: ChatOperationsRepository = container.repositories.chatOperationsRepository + private val chatSettingsRepository: ChatSettingsRepository = container.repositories.chatSettingsRepository private val userRepository: UserRepository = container.repositories.userRepository + private val profilePhotoRepository: ProfilePhotoRepository = container.repositories.profilePhotoRepository + private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository + private val botRepository: BotRepository = container.repositories.botRepository + private val chatStatisticsRepository: ChatStatisticsRepository = container.repositories.chatStatisticsRepository private val privacyRepository: PrivacyRepository = container.repositories.privacyRepository override val messageRepository: MessageRepository = container.repositories.messageRepository private val locationRepository: LocationRepository = container.repositories.locationRepository @@ -60,7 +66,7 @@ class DefaultProfileComponent( scope.launch { _state.update { it.copy(isLoading = true) } try { - val chat = coRunCatching { chatsListRepository.getChatById(chatId) }.getOrNull() + val chat = coRunCatching { chatListRepository.getChatById(chatId) }.getOrNull() val user = if (chat == null || (!chat.isGroup && !chat.isChannel)) { userRepository.getUser(chatId) } else null @@ -69,7 +75,7 @@ class DefaultProfileComponent( } else { chat?.blockList == true } - val fullInfo = coRunCatching { userRepository.getChatFullInfo(chatId) }.getOrNull() + val fullInfo = coRunCatching { chatInfoRepository.getChatFullInfo(chatId) }.getOrNull() val description = fullInfo?.description val link = fullInfo?.inviteLink ?: chat?.username?.let { "https://t.me/$it" } @@ -80,7 +86,7 @@ class DefaultProfileComponent( var isTOSAccepted = false if (user?.type == UserTypeEnum.BOT) { - val botInfo = userRepository.getBotInfo(chatId) + val botInfo = botRepository.getBotInfo(chatId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { botWebAppUrl = menuButton.url @@ -91,7 +97,7 @@ class DefaultProfileComponent( val linkedChatId = fullInfo?.linkedChatId?.takeIf { it != 0L } val linkedChat = linkedChatId?.let { - coRunCatching { chatsListRepository.getChatById(it) }.getOrNull() + coRunCatching { chatListRepository.getChatById(it) }.getOrNull() } _state.update { @@ -237,7 +243,8 @@ class DefaultProfileComponent( _state.update { it.copy(isLoadingMembers = true) } try { val limit = 20 - val newMembers = userRepository.getChatMembers(chatId, membersOffset, limit, ChatMembersFilter.Recent) + val newMembers = + chatInfoRepository.getChatMembers(chatId, membersOffset, limit, ChatMembersFilter.Recent) if (newMembers.isEmpty()) { _state.update { it.copy(canLoadMoreMembers = false) } @@ -450,9 +457,9 @@ class DefaultProfileComponent( private fun observeProfilePhotos() { val profilePhotosFlow = if (isGroupOrChannelProfile()) { - userRepository.getChatProfilePhotosFlow(chatId) + profilePhotoRepository.getChatProfilePhotosFlow(chatId) } else { - userRepository.getUserProfilePhotosFlow(chatId) + profilePhotoRepository.getUserProfilePhotosFlow(chatId) } profilePhotosFlow @@ -606,7 +613,7 @@ class DefaultProfileComponent( try { val refreshedPhotos = if (isGroupOrChannel) { coRunCatching { - userRepository.getChatProfilePhotos( + profilePhotoRepository.getChatProfilePhotos( chatId = snapshot.chatId, offset = 0, limit = PROFILE_PHOTOS_LIMIT, @@ -617,7 +624,7 @@ class DefaultProfileComponent( val userId = snapshot.user?.id?.takeIf { it > 0 } ?: snapshot.chatId.takeIf { it > 0 } if (userId == null) return@launch coRunCatching { - userRepository.getUserProfilePhotos( + profilePhotoRepository.getUserProfilePhotos( userId = userId, offset = 0, limit = PROFILE_PHOTOS_LIMIT, @@ -706,7 +713,7 @@ class DefaultProfileComponent( val shouldMute = !chat.isMuted scope.launch { - chatsListRepository.toggleMuteChats(setOf(chatId), shouldMute) + chatOperationsRepository.toggleMuteChats(setOf(chatId), shouldMute) updateChat(chatId) } } @@ -743,7 +750,7 @@ class DefaultProfileComponent( override fun onDeleteChat() { scope.launch { - chatsListRepository.deleteChats(setOf(chatId)) + chatOperationsRepository.deleteChats(setOf(chatId)) } } @@ -820,7 +827,7 @@ class DefaultProfileComponent( override fun onLeave() { scope.launch { - chatsListRepository.leaveChat(chatId) + chatOperationsRepository.leaveChat(chatId) updateChat(chatId) } } @@ -834,7 +841,7 @@ class DefaultProfileComponent( override fun onReport(reason: String) { scope.launch(Dispatchers.IO) { - chatsListRepository.reportChat(chatId, reason) + chatOperationsRepository.reportChat(chatId, reason) withContext(Dispatchers.Main) { _state.update { it.copy(isReportVisible = false) } } @@ -855,7 +862,7 @@ class DefaultProfileComponent( override fun onMemberClick(userId: Long) { scope.launch { - val chat = chatsListRepository.getChatById(userId) + val chat = chatListRepository.getChatById(userId) if (chat != null && (chat.isGroup || chat.isChannel)) { onMemberClicked(userId) } else { @@ -866,7 +873,7 @@ class DefaultProfileComponent( override fun onMemberLongClick(userId: Long) { scope.launch { - val member = userRepository.getChatMember(chatId, userId) + val member = chatInfoRepository.getChatMember(chatId, userId) if (member?.status is ChatMemberStatus.Administrator || member?.status is ChatMemberStatus.Creator) { onMemberLongClicked(chatId, userId) } @@ -875,42 +882,42 @@ class DefaultProfileComponent( override fun onUpdateChatTitle(title: String) { scope.launch { - chatsListRepository.setChatTitle(chatId, title) + chatSettingsRepository.setChatTitle(chatId, title) updateChat(chatId) } } override fun onUpdateChatDescription(description: String) { scope.launch { - chatsListRepository.setChatDescription(chatId, description) + chatSettingsRepository.setChatDescription(chatId, description) loadData() } } override fun onUpdateChatUsername(username: String) { scope.launch { - chatsListRepository.setChatUsername(chatId, username) + chatSettingsRepository.setChatUsername(chatId, username) loadData() } } override fun onUpdateChatPermissions(permissions: ChatPermissionsModel) { scope.launch { - chatsListRepository.setChatPermissions(chatId, permissions) + chatSettingsRepository.setChatPermissions(chatId, permissions) updateChat(chatId) } } override fun onUpdateChatSlowModeDelay(delay: Int) { scope.launch { - chatsListRepository.setChatSlowModeDelay(chatId, delay) + chatSettingsRepository.setChatSlowModeDelay(chatId, delay) loadData() } } override fun onUpdateMemberStatus(userId: Long, status: ChatMemberStatus) { scope.launch { - userRepository.setChatMemberStatus(chatId, userId, status) + chatInfoRepository.setChatMemberStatus(chatId, userId, status) membersOffset = 0 _state.update { it.copy(members = emptyList(), canLoadMoreMembers = true) } loadMembersNextPage() @@ -919,7 +926,7 @@ class DefaultProfileComponent( override fun onShowStatistics() { scope.launch { - val stats = userRepository.getChatStatistics(chatId, false) + val stats = chatStatisticsRepository.getChatStatistics(chatId, false) if (stats != null) { val enrichedStats = enrichInteractionPreviews(stats) _state.update { it.copy(statistics = enrichedStats, isStatisticsVisible = true) } @@ -931,7 +938,7 @@ class DefaultProfileComponent( override fun onShowRevenueStatistics() { scope.launch { - val stats = userRepository.getChatRevenueStatistics(chatId, false) + val stats = chatStatisticsRepository.getChatRevenueStatistics(chatId, false) if (stats != null) { _state.update { it.copy(revenueStatistics = stats, isRevenueStatisticsVisible = true) @@ -955,7 +962,7 @@ class DefaultProfileComponent( override fun onLoadStatisticsGraph(token: String) { scope.launch { - val graph = userRepository.loadStatisticsGraph(chatId, token, 0L) + val graph = chatStatisticsRepository.loadStatisticsGraph(chatId, token, 0L) if (graph != null) { _state.update { state -> @@ -1147,7 +1154,7 @@ class DefaultProfileComponent( private fun updateChat(chatId: Long) { scope.launch { - val updatedChat = chatsListRepository.getChatById(chatId) + val updatedChat = chatListRepository.getChatById(chatId) withContext(Dispatchers.Main) { _state.update { it.copy( 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 19d145d2..ef0340f4 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 @@ -1,31 +1,13 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.profile import android.content.ClipData import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -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.* import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -36,23 +18,8 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Person -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -71,25 +38,11 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R -import org.monogram.presentation.core.ui.CollapsingToolbarScaffold -import org.monogram.presentation.core.ui.ConfirmationSheet -import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState -import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.ui.* 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.features.profile.components.LocationViewer -import org.monogram.presentation.features.profile.components.ProfileHeaderTransformed -import org.monogram.presentation.features.profile.components.ProfileInfoSection -import org.monogram.presentation.features.profile.components.ProfileInfoSectionSkeleton -import org.monogram.presentation.features.profile.components.ProfilePermissionsDialog -import org.monogram.presentation.features.profile.components.ProfileQRDialog -import org.monogram.presentation.features.profile.components.ProfileReportDialog -import org.monogram.presentation.features.profile.components.ProfileTOSDialog -import org.monogram.presentation.features.profile.components.ProfileTopBar -import org.monogram.presentation.features.profile.components.StatisticsViewer -import org.monogram.presentation.features.profile.components.profileMediaSection +import org.monogram.presentation.features.profile.components.* import org.monogram.presentation.features.viewers.ImageViewer import org.monogram.presentation.features.viewers.VideoViewer import org.monogram.presentation.features.webapp.MiniAppViewer @@ -515,7 +468,7 @@ fun ProfileContent(component: ProfileComponent) { onDismiss = { component.onDismissMiniApp() }, chatId = state.chatId, botUserId = state.user!!.id, - messageRepository = component.messageRepository, + webAppRepository = component.messageRepository, ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultAdminManageComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultAdminManageComponent.kt index 5b449714..7db4b45f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultAdminManageComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultAdminManageComponent.kt @@ -4,9 +4,9 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.ChatMemberStatus -import org.monogram.domain.repository.ChatsListRepository -import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -17,8 +17,8 @@ class DefaultAdminManageComponent( private val onBackClicked: () -> Unit ) : AdminManageComponent, AppComponentContext by context { - private val userRepository: UserRepository = container.repositories.userRepository - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository private val scope = componentScope private val _state = MutableValue(AdminManageComponent.State(chatId = chatId, userId = userId)) @@ -32,8 +32,8 @@ class DefaultAdminManageComponent( scope.launch { _state.update { it.copy(isLoading = true) } try { - val chat = chatsListRepository.getChatById(chatId) - val member = userRepository.getChatMember(chatId, userId) + val chat = chatListRepository.getChatById(chatId) + val member = chatInfoRepository.getChatMember(chatId, userId) val initialStatus = when (val status = member?.status) { is ChatMemberStatus.Administrator -> status is ChatMemberStatus.Creator -> ChatMemberStatus.Administrator( @@ -84,7 +84,7 @@ class DefaultAdminManageComponent( _state.update { it.copy(isLoading = true) } scope.launch { try { - userRepository.setChatMemberStatus(chatId, userId, status) + chatInfoRepository.setChatMemberStatus(chatId, userId, status) onBackClicked() } catch (e: Exception) { e.printStackTrace() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt index 90785684..aaa0c083 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt @@ -4,8 +4,10 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch -import org.monogram.domain.repository.ChatsListRepository -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -19,8 +21,10 @@ class DefaultChatEditComponent( private val onManagePermissionsClicked: (Long) -> Unit ) : ChatEditComponent, AppComponentContext by context { - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository - private val userRepository: UserRepository = container.repositories.userRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val chatSettingsRepository: ChatSettingsRepository = container.repositories.chatSettingsRepository + private val chatOperationsRepository: ChatOperationsRepository = container.repositories.chatOperationsRepository + private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository private val scope = componentScope private val _state = MutableValue(ChatEditComponent.State(chatId = chatId)) @@ -33,8 +37,8 @@ class DefaultChatEditComponent( private fun loadChatData() { scope.launch { _state.update { it.copy(isLoading = true) } - val chat = chatsListRepository.getChatById(chatId) - val fullInfo = userRepository.getChatFullInfo(chatId) + val chat = chatListRepository.getChatById(chatId) + val fullInfo = chatInfoRepository.getChatFullInfo(chatId) if (chat != null) { _state.update { it.copy( @@ -75,21 +79,21 @@ class DefaultChatEditComponent( override fun onToggleTopics(enabled: Boolean) { _state.update { it.copy(isForum = enabled) } scope.launch { - chatsListRepository.toggleChatIsForum(chatId, enabled) + chatSettingsRepository.toggleChatIsForum(chatId, enabled) } } override fun onToggleAutoTranslate(enabled: Boolean) { _state.update { it.copy(isTranslatable = enabled) } scope.launch { - chatsListRepository.toggleChatIsTranslatable(chatId, enabled) + chatSettingsRepository.toggleChatIsTranslatable(chatId, enabled) } } override fun onChangeAvatar(path: String) { _state.update { it.copy(avatarPath = path) } scope.launch { - chatsListRepository.setChatPhoto(chatId, path) + chatSettingsRepository.setChatPhoto(chatId, path) } } @@ -98,14 +102,14 @@ class DefaultChatEditComponent( _state.update { it.copy(isLoading = true) } val currentState = _state.value if (currentState.title != currentState.chat?.title) { - chatsListRepository.setChatTitle(chatId, currentState.title) + chatSettingsRepository.setChatTitle(chatId, currentState.title) } - val fullInfo = userRepository.getChatFullInfo(chatId) + val fullInfo = chatInfoRepository.getChatFullInfo(chatId) if (currentState.description != (fullInfo?.description ?: "")) { - chatsListRepository.setChatDescription(chatId, currentState.description) + chatSettingsRepository.setChatDescription(chatId, currentState.description) } if (currentState.username != (currentState.chat?.username ?: "")) { - chatsListRepository.setChatUsername(chatId, currentState.username) + chatSettingsRepository.setChatUsername(chatId, currentState.username) } onBackClicked() } @@ -113,7 +117,7 @@ class DefaultChatEditComponent( override fun onDeleteChat() { scope.launch { - chatsListRepository.deleteChats(setOf(chatId)) + chatOperationsRepository.deleteChats(setOf(chatId)) onBackClicked() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatPermissionsComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatPermissionsComponent.kt index f3013df9..7aaeae5d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatPermissionsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatPermissionsComponent.kt @@ -4,7 +4,8 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -14,7 +15,8 @@ class DefaultChatPermissionsComponent( private val onBackClicked: () -> Unit ) : ChatPermissionsComponent, AppComponentContext by context { - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val chatSettingsRepository: ChatSettingsRepository = container.repositories.chatSettingsRepository private val scope = componentScope private val _state = MutableValue(ChatPermissionsComponent.State(chatId = chatId)) override val state: Value = _state @@ -25,7 +27,7 @@ class DefaultChatPermissionsComponent( private fun loadPermissions() { scope.launch { - val chat = chatsListRepository.getChatById(chatId) + val chat = chatListRepository.getChatById(chatId) if (chat != null) { _state.update { it.copy(permissions = chat.permissions) } } @@ -36,7 +38,7 @@ class DefaultChatPermissionsComponent( override fun onSave() { scope.launch { - chatsListRepository.setChatPermissions(chatId, _state.value.permissions) + chatSettingsRepository.setChatPermissions(chatId, _state.value.permissions) onBackClicked() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultMemberListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultMemberListComponent.kt index 38d236c1..824a40aa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultMemberListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultMemberListComponent.kt @@ -7,9 +7,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.monogram.domain.models.GroupMemberModel +import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.ChatMembersFilter -import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -22,7 +22,7 @@ class DefaultMemberListComponent( private val onMemberLongClicked: (Long) -> Unit ) : MemberListComponent, AppComponentContext by context { - private val userRepository: UserRepository = container.repositories.userRepository + private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository private val scope = componentScope private val _state = MutableValue(MemberListComponent.State(chatId = chatId, type = type)) @@ -48,7 +48,7 @@ class DefaultMemberListComponent( MemberListComponent.MemberListType.BLACKLIST -> ChatMembersFilter.Banned } - val members = userRepository.getChatMembers(chatId, offset, limit, filter) + val members = chatInfoRepository.getChatMembers(chatId, offset, limit, filter) if (members.isEmpty()) { _state.update { it.copy(canLoadMore = false) } @@ -101,7 +101,7 @@ class DefaultMemberListComponent( _state.update { it.copy(isLoading = true) } try { val filter = ChatMembersFilter.Search(query) - val results = userRepository.getChatMembers(chatId, 0, 50, filter) + val results = chatInfoRepository.getChatMembers(chatId, 0, 50, filter) val filtered = when (type) { MemberListComponent.MemberListType.ADMINS -> results.filter { it.status is ChatMemberStatus.Administrator || it.status is ChatMemberStatus.Creator } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/DefaultProfileLogsComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/DefaultProfileLogsComponent.kt index 10379173..ca8fd880 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/DefaultProfileLogsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/DefaultProfileLogsComponent.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatEventLogFiltersModel import org.monogram.domain.models.MessageSenderModel -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.ChatEventLogRepository import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope @@ -21,7 +21,7 @@ class DefaultProfileLogsComponent( override val downloadUtils: IDownloadUtils ) : ProfileLogsComponent, AppComponentContext by context { - override val messageRepository: MessageRepository = container.repositories.messageRepository + override val messageRepository: ChatEventLogRepository = container.repositories.chatEventLogRepository private val userRepository: UserRepository = container.repositories.userRepository private val scope = componentScope diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsComponent.kt index 7d6d598a..16f07b3a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsComponent.kt @@ -3,12 +3,12 @@ package org.monogram.presentation.features.profile.logs import com.arkivanov.decompose.value.Value import org.monogram.domain.models.ChatEventLogFiltersModel import org.monogram.domain.models.ChatEventModel -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.ChatEventLogRepository import org.monogram.presentation.core.util.IDownloadUtils interface ProfileLogsComponent { val state: Value - val messageRepository: MessageRepository + val messageRepository: ChatEventLogRepository val downloadUtils: IDownloadUtils fun onBack() 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 f26dd2d7..abc329a8 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 @@ -37,6 +37,7 @@ import org.koin.compose.koinInject import org.monogram.domain.models.RecentEmojiModel import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences @@ -52,12 +53,13 @@ fun EmojisGrid( onSearchFocused: (Boolean) -> Unit = {}, contentPadding: PaddingValues = PaddingValues(0.dp), stickerRepository: StickerRepository = koinInject(), + emojiRepository: EmojiRepository = koinInject(), appPreferences: AppPreferences = koinInject() ) { var standardEmojis by remember { mutableStateOf>(emptyList()) } val customEmojiSets by stickerRepository.customEmojiStickerSets.collectAsState(initial = emptyList()) var selectedSetId by remember { mutableLongStateOf(-1L) } // -1 for recent, -2 for standard - val recentEmojis by stickerRepository.recentEmojis.collectAsState(initial = emptyList()) + val recentEmojis by emojiRepository.recentEmojis.collectAsState(initial = emptyList()) var searchQuery by remember { mutableStateOf("") } var debouncedSearchQuery by remember { mutableStateOf("") } var isSearchFocused by remember { mutableStateOf(false) } @@ -79,7 +81,7 @@ fun EmojisGrid( } LaunchedEffect(Unit) { - standardEmojis = stickerRepository.getDefaultEmojis() + standardEmojis = emojiRepository.getDefaultEmojis() stickerRepository.loadCustomEmojiStickerSets() } @@ -97,9 +99,9 @@ fun EmojisGrid( searchResultsEmojis = if (emojiOnlyMode) { emptyList() } else { - stickerRepository.searchEmojis(debouncedSearchQuery) + emojiRepository.searchEmojis(debouncedSearchQuery) } - searchResultsCustomEmojis = stickerRepository.searchCustomEmojis(debouncedSearchQuery) + searchResultsCustomEmojis = emojiRepository.searchCustomEmojis(debouncedSearchQuery) } else { searchResultsEmojis = emptyList() searchResultsCustomEmojis = emptyList() @@ -225,7 +227,7 @@ fun EmojisGrid( itemsIndexed(searchResultsEmojis, key = { index, emoji -> "search_emoji_${index}_$emoji" }) { _, emoji -> EmojiGridItem(emoji, emojiFontFamily) { onEmojiSelected(emoji, null) - scope.launch { stickerRepository.addRecentEmoji(RecentEmojiModel(emoji)) } + scope.launch { emojiRepository.addRecentEmoji(RecentEmojiModel(emoji)) } } } } @@ -257,7 +259,7 @@ fun EmojisGrid( itemsIndexed(localSearchFallbackResults, key = { index, emoji -> "search_local_${index}_$emoji" }) { _, emoji -> EmojiGridItem(emoji, emojiFontFamily) { onEmojiSelected(emoji, null) - scope.launch { stickerRepository.addRecentEmoji(RecentEmojiModel(emoji)) } + scope.launch { emojiRepository.addRecentEmoji(RecentEmojiModel(emoji)) } } } } @@ -315,7 +317,7 @@ fun EmojisGrid( ) { _, emoji -> EmojiGridItem(emoji, emojiFontFamily) { onEmojiSelected(emoji, null) - scope.launch { stickerRepository.addRecentEmoji(RecentEmojiModel(emoji)) } + scope.launch { emojiRepository.addRecentEmoji(RecentEmojiModel(emoji)) } } } } 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 95702139..13a3ac0d 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 @@ -32,7 +32,9 @@ import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.delay +import org.koin.compose.koinInject import org.monogram.domain.models.GifModel +import org.monogram.domain.repository.GifRepository import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer @@ -45,7 +47,8 @@ fun GifsView( onGifSelected: (GifModel) -> Unit, onSearchFocused: (Boolean) -> Unit, contentPadding: PaddingValues = PaddingValues(0.dp), - stickerRepository: StickerRepository + stickerRepository: StickerRepository, + gifRepository: GifRepository = koinInject() ) { var searchQuery by remember { mutableStateOf("") } var debouncedSearchQuery by remember { mutableStateOf("") } @@ -53,11 +56,10 @@ fun GifsView( var searchResults by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() val gridState = rememberLazyGridState() LaunchedEffect(Unit) { - savedGifs = stickerRepository.getSavedGifs() + savedGifs = gifRepository.getSavedGifs() } LaunchedEffect(searchQuery) { @@ -74,7 +76,7 @@ fun GifsView( LaunchedEffect(debouncedSearchQuery) { if (debouncedSearchQuery.isNotEmpty()) { - searchResults = stickerRepository.searchGifs(debouncedSearchQuery) + searchResults = gifRepository.searchGifs(debouncedSearchQuery) isLoading = false } else { searchResults = emptyList() @@ -101,7 +103,7 @@ fun GifsView( } } - val onGifClick: (GifModel) -> Unit = remember(scope, stickerRepository, onGifSelected) { + val onGifClick: (GifModel) -> Unit = remember(onGifSelected) { { gif: GifModel -> onGifSelected(gif) } @@ -167,6 +169,7 @@ fun GifsView( ) { index, gif -> GifItem( gif = gif, + gifRepository = gifRepository, stickerRepository = stickerRepository, onGifSelected = onGifClick, animate = index in visibleRange @@ -286,6 +289,7 @@ fun GifSearchBar( @Composable fun GifItem( gif: GifModel, + gifRepository: GifRepository, stickerRepository: StickerRepository, onGifSelected: (GifModel) -> Unit, animate: Boolean = true @@ -295,7 +299,7 @@ fun GifItem( LaunchedEffect(gif, animate) { if (animate) { - stickerRepository.getGifFile(gif).collect { + gifRepository.getGifFile(gif).collect { gifPath = it } } 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 e42734d8..dd3fab13 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 @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.stickers.ui.menu @@ -45,7 +45,7 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.RecentEmojiModel -import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.EmojiRepository import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.AppPreferences @@ -107,7 +107,7 @@ fun MessageOptionsMenu( val configuration = LocalConfiguration.current val haptic = LocalHapticFeedback.current val scope = rememberCoroutineScope() - val stickerRepository: StickerRepository = koinInject() + val emojiRepository: EmojiRepository = koinInject() val screenHeight = with(density) { configuration.screenHeightDp.dp.toPx() }.toInt() val windowInsets = WindowInsets.systemBars.union(WindowInsets.ime) @@ -198,7 +198,7 @@ fun MessageOptionsMenu( var availableReactions by remember(message.chatId, message.id) { mutableStateOf>(emptyList()) } LaunchedEffect(message.chatId, message.id) { - availableReactions = stickerRepository.getMessageAvailableReactions(message.chatId, message.id) + availableReactions = emojiRepository.getMessageAvailableReactions(message.chatId, message.id) } fun animateOutAndDismiss(action: (() -> Unit)? = null) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt index 06e96b8e..213261d6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt @@ -41,8 +41,8 @@ import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppPopupButton import org.monogram.domain.repository.BotPreferencesProvider import org.monogram.domain.repository.LocationRepository -import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.toHex @@ -56,7 +56,7 @@ class MiniAppState( val botUserId: Long, val botName: String, val botAvatarPath: String?, - val messageRepository: MessageRepository, + val webAppRepository: WebAppRepository, val locationRepository: LocationRepository, val botPreferences: BotPreferencesProvider, val userRepository: UserRepository, @@ -151,7 +151,7 @@ class MiniAppState( } else { if (launchId != 0L) { scope.launch { - messageRepository.closeWebApp(launchId) + webAppRepository.closeWebApp(launchId) } } onDismiss() @@ -642,7 +642,7 @@ class MiniAppState( override fun onSendWebViewData(data: String) { if (launchId != 0L) { scope.launch { - messageRepository.sendWebAppResult(launchId, data) + webAppRepository.sendWebAppResult(launchId, data) } onDismiss() } @@ -1178,7 +1178,7 @@ fun rememberMiniAppState( botUserId: Long, botName: String, botAvatarPath: String?, - messageRepository: MessageRepository, + webAppRepository: WebAppRepository, locationRepository: LocationRepository, botPreferences: BotPreferencesProvider, userRepository: UserRepository, @@ -1190,7 +1190,7 @@ fun rememberMiniAppState( botUserId, botName, botAvatarPath, - messageRepository, + webAppRepository, locationRepository, botPreferences, userRepository, diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt index 0812810a..85eea84d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt @@ -26,10 +26,7 @@ import kotlinx.coroutines.launch import org.json.JSONObject import org.koin.compose.koinInject import org.monogram.domain.models.webapp.ThemeParams -import org.monogram.domain.repository.BotPreferencesProvider -import org.monogram.domain.repository.LocationRepository -import org.monogram.domain.repository.MessageRepository -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.* import org.monogram.presentation.core.util.CryptoManager import org.monogram.presentation.features.webapp.components.* import java.util.* @@ -44,7 +41,7 @@ fun MiniAppViewer( baseUrl: String, botName: String, botAvatarPath: String? = null, - messageRepository: MessageRepository, + webAppRepository: WebAppRepository, onDismiss: () -> Unit ) { val context = LocalContext.current @@ -55,6 +52,8 @@ fun MiniAppViewer( val locationRepository: LocationRepository = koinInject() val botPreferences: BotPreferencesProvider = koinInject() val userRepository: UserRepository = koinInject() + val paymentRepository: PaymentRepository = koinInject() + val fileRepository: FileRepository = koinInject() val isDark = isSystemInDarkTheme() val scope = rememberCoroutineScope() val currentUser by userRepository.currentUserFlow.collectAsState() @@ -91,7 +90,7 @@ fun MiniAppViewer( botUserId = botUserId, botName = botName, botAvatarPath = botAvatarPath, - messageRepository = messageRepository, + webAppRepository = webAppRepository, locationRepository = locationRepository, botPreferences = botPreferences, userRepository = userRepository, @@ -226,7 +225,7 @@ fun MiniAppViewer( if (!state.isTOSVisible) { if (botUserId != 0L) { state.isInitializing = true - val result = messageRepository.openWebApp(chatId, botUserId, baseUrl, themeParams) + val result = webAppRepository.openWebApp(chatId, botUserId, baseUrl, themeParams) if (result != null) { state.launchId = result.launchId state.currentUrl = result.url @@ -289,7 +288,7 @@ fun MiniAppViewer( state.showClosingConfirmation = false if (state.launchId != 0L) { scope.launch { - messageRepository.closeWebApp(state.launchId) + webAppRepository.closeWebApp(state.launchId) } } @@ -302,7 +301,8 @@ fun MiniAppViewer( if (state.activeInvoiceSlug != null) { InvoiceDialog( slug = state.activeInvoiceSlug!!, - messageRepository = messageRepository, + paymentRepository = paymentRepository, + fileRepository = fileRepository, onDismiss = { status -> val slug = state.activeInvoiceSlug state.activeInvoiceSlug = null diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt index ae0cc6b2..a739101a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt @@ -17,7 +17,8 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import org.monogram.domain.models.webapp.InvoiceModel -import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.PaymentRepository @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -25,7 +26,8 @@ fun InvoiceDialog( slug: String? = null, chatId: Long? = null, messageId: Long? = null, - messageRepository: MessageRepository, + paymentRepository: PaymentRepository, + fileRepository: FileRepository, onDismiss: (status: String) -> Unit ) { var invoice by remember { mutableStateOf(null) } @@ -37,22 +39,22 @@ fun InvoiceDialog( var progress by remember { mutableStateOf(0f) } LaunchedEffect(slug, chatId, messageId) { - val inv = messageRepository.getInvoice(slug = slug, chatId = chatId, messageId = messageId) + val inv = paymentRepository.getInvoice(slug = slug, chatId = chatId, messageId = messageId) invoice = inv isLoading = false inv?.photoUrl?.let { fileIdStr -> val fileId = fileIdStr.toIntOrNull() if (fileId != null) { - photoPath = messageRepository.getFilePath(fileId) + photoPath = fileRepository.getFilePath(fileId) if (photoPath == null) { - messageRepository.downloadFile(fileId) + fileRepository.downloadFile(fileId) launch { - messageRepository.messageDownloadProgressFlow + fileRepository.messageDownloadProgressFlow .filter { it.first == fileId.toLong() } .collect { progress = it.second } } - messageRepository.messageDownloadCompletedFlow + fileRepository.messageDownloadCompletedFlow .filter { it.first == fileId.toLong() } .collect { (_, _, completedPath) -> photoPath = completedPath } } @@ -165,7 +167,7 @@ fun InvoiceDialog( isPaying = true scope.launch { val success = - messageRepository.payInvoice(slug = slug, chatId = chatId, messageId = messageId) + paymentRepository.payInvoice(slug = slug, chatId = chatId, messageId = messageId) isPaying = false if (success) { onDismiss("paid") 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 1b70eec0..91d9246f 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -58,7 +58,7 @@ class DefaultRootComponent( private val authRepository: AuthRepository = container.repositories.authRepository private val messageRepository: MessageRepository = container.repositories.messageRepository - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val storageRepository: StorageRepository = container.repositories.storageRepository private val linkHandlerRepository: LinkHandlerRepository = container.repositories.linkHandlerRepository private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository private val stickerRepository: StickerRepository = container.repositories.stickerRepository @@ -155,7 +155,7 @@ class DefaultRootComponent( limit to time }.onEach { (limit, time) -> val ttl = if (time > 0) time * 24 * 60 * 60 else -1 - settingsRepository.setDatabaseMaintenanceSettings(limit, ttl) + storageRepository.setDatabaseMaintenanceSettings(limit, ttl) }.launchIn(scope) } @@ -187,7 +187,7 @@ class DefaultRootComponent( val countryCode = phoneManager.getSimCountryIso() if (!countryCode.isNullOrBlank()) { scope.launch { - settingsRepository.setCachedSimCountryIso(countryCode) + userRepository.setCachedSimCountryIso(countryCode) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt index d144af46..c08b58a2 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt @@ -5,23 +5,11 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ShapeDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -40,7 +28,7 @@ class DefaultStartupComponent( context: AppComponentContext ) : StartupComponent, AppComponentContext by context { override val connectionStatus: StateFlow - get() = container.repositories.chatsListRepository.connectionStateFlow + get() = container.repositories.chatListRepository.connectionStateFlow } @OptIn(ExperimentalMaterial3ExpressiveApi::class) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockComponent.kt index 46d4afb3..5804036a 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/adblock/AdBlockComponent.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.withContext import org.monogram.domain.managers.AssetsManager import org.monogram.domain.managers.ClipManager import org.monogram.domain.models.ChatModel -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.domain.repository.ChatListRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -48,7 +48,7 @@ class DefaultAdBlockComponent( ) : AdBlockComponent, AppComponentContext by context { private val appPreferences: AppPreferences = container.preferences.appPreferences - private val chatsRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatsRepository: ChatListRepository = container.repositories.chatListRepository private val clipManager: ClipManager = container.utils.clipManager private val assetsManager: AssetsManager = container.utils.assetsManager() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index 3683cd4c..a1c5d595 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -1,8 +1,7 @@ package org.monogram.presentation.settings.chatSettings -import org.monogram.presentation.core.util.coRunCatching -import android.graphics.Color.colorToHSV import android.graphics.Color.HSVToColor +import android.graphics.Color.colorToHSV import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update @@ -11,14 +10,15 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONObject import org.monogram.domain.managers.AssetsManager import org.monogram.domain.managers.DistrManager import org.monogram.domain.models.WallpaperModel -import org.monogram.domain.repository.SettingsRepository +import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.WallpaperRepository import org.monogram.presentation.core.util.* import org.monogram.presentation.root.AppComponentContext -import org.json.JSONObject import java.io.File import java.net.URL @@ -163,8 +163,9 @@ class DefaultChatSettingsComponent( private val appPreferences: AppPreferences = container.preferences.appPreferences override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val wallpaperRepository: WallpaperRepository = container.repositories.wallpaperRepository private val stickerRepository: StickerRepository = container.repositories.stickerRepository + private val emojiRepository: EmojiRepository = container.repositories.emojiRepository private val distrManager: DistrManager = container.utils.distrManager() private val assetsManager: AssetsManager = container.utils.assetsManager() @@ -604,7 +605,7 @@ class DefaultChatSettingsComponent( } private fun loadWallpapers() { - settingsRepository.getWallpapers() + wallpaperRepository.getWallpapers() .onEach { wallpapers -> _state.update { it.copy(availableWallpapers = wallpapers) } } @@ -640,7 +641,7 @@ class DefaultChatSettingsComponent( if (!wallpaper.isDownloaded && wallpaper.documentId != 0L) { scope.launch { - settingsRepository.downloadWallpaper(wallpaper.documentId.toInt()) + wallpaperRepository.downloadWallpaper(wallpaper.documentId.toInt()) } } @@ -699,7 +700,7 @@ class DefaultChatSettingsComponent( override fun onClearRecentEmojis() { scope.launch { - stickerRepository.clearRecentEmojis() + emojiRepository.clearRecentEmojis() } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/dataStorage/DataStorageComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/dataStorage/DataStorageComponent.kt index 6e324c82..8052fb26 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/dataStorage/DataStorageComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/dataStorage/DataStorageComponent.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.monogram.domain.repository.ChatCreationRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -47,7 +48,7 @@ class DefaultDataStorageComponent( ) : DataStorageComponent, AppComponentContext by context { private val appPreferences: AppPreferences = container.preferences.appPreferences - private val chatsRepository = container.repositories.chatsListRepository + private val chatCreationRepository: ChatCreationRepository = container.repositories.chatCreationRepository private val _state = MutableValue(DataStorageComponent.State()) override val state: Value = _state private val scope = componentScope @@ -93,7 +94,7 @@ class DefaultDataStorageComponent( } private fun updateDatabaseSize() { - val size = chatsRepository.getDatabaseSize() + val size = chatCreationRepository.getDatabaseSize() _state.update { it.copy(databaseSize = formatSize(size)) } } @@ -153,7 +154,7 @@ class DefaultDataStorageComponent( } override fun onClearDatabaseClicked() { - chatsRepository.clearDatabase() + chatCreationRepository.clearDatabase() updateDatabaseSize() } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt index 70d1fdf1..484ca8e5 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt @@ -11,7 +11,7 @@ class DefaultDebugComponent( private val messageDisplayer = container.utils.messageDisplayer() private val assetsManager = container.utils.assetsManager() private val externalNavigator = container.utils.externalNavigator() - private val userRepository = container.repositories.userRepository + private val sponsorRepository = container.repositories.sponsorRepository override fun onBackClicked() { onBack() @@ -26,7 +26,7 @@ class DefaultDebugComponent( } override fun onForceSponsorSyncClicked() { - userRepository.forceSponsorSync() + sponsorRepository.forceSponsorSync() messageDisplayer.show("Sponsor sync started") } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/folders/FoldersComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/folders/FoldersComponent.kt index 13c71734..e2815d26 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/folders/FoldersComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/folders/FoldersComponent.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.monogram.domain.models.ChatModel import org.monogram.domain.models.FolderModel -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatSearchRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -39,7 +41,9 @@ class DefaultFoldersComponent( private val onBack: () -> Unit ) : FoldersComponent, AppComponentContext by context { - private val repository: ChatsListRepository = container.repositories.chatsListRepository + private val folderRepository: ChatFolderRepository = container.repositories.chatFolderRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val searchRepository: ChatSearchRepository = container.repositories.chatSearchRepository private val _state = MutableValue(FoldersComponent.State()) override val state: Value = _state @@ -47,11 +51,11 @@ class DefaultFoldersComponent( private val scope = componentScope init { - repository.foldersFlow.onEach { folders -> + folderRepository.foldersFlow.onEach { folders -> _state.update { it.copy(folders = folders) } }.launchIn(scope) - repository.chatListFlow.onEach { chats -> + chatListRepository.chatListFlow.onEach { chats -> _state.update { it.copy(availableChats = chats) } }.launchIn(scope) } @@ -79,21 +83,21 @@ class DefaultFoldersComponent( override fun onDeleteFolder(folderId: Int) { scope.launch { - repository.deleteFolder(folderId) + folderRepository.deleteFolder(folderId) _state.update { it.copy(showEditFolderDialog = false, selectedFolder = null) } } } override fun onEditFolder(folderId: Int, newTitle: String, iconName: String?, includedChatIds: List) { scope.launch { - repository.updateFolder(folderId, newTitle, iconName, includedChatIds) + folderRepository.updateFolder(folderId, newTitle, iconName, includedChatIds) _state.update { it.copy(showEditFolderDialog = false, selectedFolder = null) } } } override fun onAddFolder(title: String, iconName: String?, includedChatIds: List) { scope.launch { - repository.createFolder(title, iconName, includedChatIds) + folderRepository.createFolder(title, iconName, includedChatIds) _state.update { it.copy(showAddFolderDialog = false) } } } @@ -112,13 +116,13 @@ class DefaultFoldersComponent( .filter { it.id >= 0 } .map { it.id } - repository.reorderFolders(newUserFolderIds) + folderRepository.reorderFolders(newUserFolderIds) } } override fun onSearchChats(query: String) { scope.launch { - val results = repository.searchChats(query) + val results = searchRepository.searchChats(query) _state.update { it.copy(availableChats = results) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/networkUsage/NetworkUsageComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/networkUsage/NetworkUsageComponent.kt index 4d1dc720..80ea54e3 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/networkUsage/NetworkUsageComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/networkUsage/NetworkUsageComponent.kt @@ -5,7 +5,7 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch import org.monogram.domain.models.NetworkUsageModel -import org.monogram.domain.repository.SettingsRepository +import org.monogram.domain.repository.NetworkStatisticsRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -27,7 +27,8 @@ class DefaultNetworkUsageComponent( private val onBack: () -> Unit ) : NetworkUsageComponent, AppComponentContext by context { - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val networkStatisticsRepository: NetworkStatisticsRepository = + container.repositories.networkStatisticsRepository private val _state = MutableValue(NetworkUsageComponent.State()) override val state: Value = _state private val scope = componentScope @@ -39,8 +40,8 @@ class DefaultNetworkUsageComponent( private fun loadStatistics() { _state.update { it.copy(isLoading = true) } scope.launch { - val isEnabled = settingsRepository.getNetworkStatisticsEnabled() - val usage = if (isEnabled) settingsRepository.getNetworkUsage() else null + val isEnabled = networkStatisticsRepository.getNetworkStatisticsEnabled() + val usage = if (isEnabled) networkStatisticsRepository.getNetworkUsage() else null _state.update { it.copy(usage = usage, isLoading = false, isNetworkStatsEnabled = isEnabled) } } } @@ -51,7 +52,7 @@ class DefaultNetworkUsageComponent( override fun onResetClicked() { scope.launch { - val success = settingsRepository.resetNetworkStatistics() + val success = networkStatisticsRepository.resetNetworkStatistics() if (success) { loadStatistics() } @@ -60,9 +61,9 @@ class DefaultNetworkUsageComponent( override fun onToggleNetworkStats(enabled: Boolean) { scope.launch { - settingsRepository.setNetworkStatisticsEnabled(enabled) + networkStatisticsRepository.setNetworkStatisticsEnabled(enabled) if (!enabled) { - settingsRepository.resetNetworkStatistics() + networkStatisticsRepository.resetNetworkStatistics() } _state.update { it.copy(isNetworkStatsEnabled = enabled) } if (enabled) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 2c2028a7..643c32de 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -12,9 +12,9 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.monogram.domain.managers.DistrManager import org.monogram.domain.models.ChatModel +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.domain.repository.PushProvider -import org.monogram.domain.repository.SettingsRepository -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -78,7 +78,8 @@ class DefaultNotificationsComponent( ) : NotificationsComponent, AppComponentContext by context { private val appPreferences: AppPreferences = container.preferences.appPreferences - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val notificationSettingsRepository: NotificationSettingsRepository = + container.repositories.notificationSettingsRepository private val distrManager: DistrManager = container.utils.distrManager() private val scope = componentScope @@ -169,13 +170,14 @@ class DefaultNotificationsComponent( private fun syncSettings() { scope.launch { - val privateEnabled = settingsRepository.getNotificationSettings(TdNotificationScope.PRIVATE_CHATS) + val privateEnabled = + notificationSettingsRepository.getNotificationSettings(TdNotificationScope.PRIVATE_CHATS) appPreferences.setPrivateChatsNotifications(privateEnabled) - val groupsEnabled = settingsRepository.getNotificationSettings(TdNotificationScope.GROUPS) + val groupsEnabled = notificationSettingsRepository.getNotificationSettings(TdNotificationScope.GROUPS) appPreferences.setGroupsNotifications(groupsEnabled) - val channelsEnabled = settingsRepository.getNotificationSettings(TdNotificationScope.CHANNELS) + val channelsEnabled = notificationSettingsRepository.getNotificationSettings(TdNotificationScope.CHANNELS) appPreferences.setChannelsNotifications(channelsEnabled) } } @@ -190,7 +192,7 @@ class DefaultNotificationsComponent( } } - val exceptions = settingsRepository.getExceptions(scope) + val exceptions = notificationSettingsRepository.getExceptions(scope) _state.update { when (scope) { @@ -213,21 +215,21 @@ class DefaultNotificationsComponent( override fun onPrivateChatsToggled(enabled: Boolean) { appPreferences.setPrivateChatsNotifications(enabled) scope.launch { - settingsRepository.setNotificationSettings(TdNotificationScope.PRIVATE_CHATS, enabled) + notificationSettingsRepository.setNotificationSettings(TdNotificationScope.PRIVATE_CHATS, enabled) } } override fun onGroupsToggled(enabled: Boolean) { appPreferences.setGroupsNotifications(enabled) scope.launch { - settingsRepository.setNotificationSettings(TdNotificationScope.GROUPS, enabled) + notificationSettingsRepository.setNotificationSettings(TdNotificationScope.GROUPS, enabled) } } override fun onChannelsToggled(enabled: Boolean) { appPreferences.setChannelsNotifications(enabled) scope.launch { - settingsRepository.setNotificationSettings(TdNotificationScope.CHANNELS, enabled) + notificationSettingsRepository.setNotificationSettings(TdNotificationScope.CHANNELS, enabled) } } @@ -304,7 +306,7 @@ class DefaultNotificationsComponent( override fun onChatExceptionToggled(chatId: Long, enabled: Boolean) { scope.launch { - settingsRepository.setChatNotificationSettings(chatId, enabled) + notificationSettingsRepository.setChatNotificationSettings(chatId, enabled) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { loadExceptions(currentChild.scope) @@ -314,7 +316,7 @@ class DefaultNotificationsComponent( override fun onChatExceptionReset(chatId: Long) { scope.launch { - settingsRepository.resetChatNotificationSettings(chatId) + notificationSettingsRepository.resetChatNotificationSettings(chatId) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { loadExceptions(currentChild.scope) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt index 504732d4..15ab5036 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt @@ -22,8 +22,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.domain.repository.PushProvider -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope import org.monogram.presentation.R import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.ItemPosition diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsExceptionsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsExceptionsContent.kt index a701f5ba..c0e827ba 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsExceptionsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsExceptionsContent.kt @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.settings.notifications @@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ChatModel -import org.monogram.domain.repository.SettingsRepository.TdNotificationScope +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ItemPosition diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt index 14326f23..9dc3e60c 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.PremiumFeatureType import org.monogram.domain.models.PremiumLimitType import org.monogram.domain.models.PremiumSource +import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -39,6 +40,7 @@ class DefaultPremiumComponent( ) : PremiumComponent, AppComponentContext by context { private val userRepository: UserRepository = container.repositories.userRepository + private val premiumRepository: PremiumRepository = container.repositories.premiumRepository private val stringProvider = container.utils.stringProvider() private val scope = componentScope @@ -59,8 +61,8 @@ class DefaultPremiumComponent( scope.launch { _state.update { it.copy(isLoading = true) } - val premiumState = userRepository.getPremiumState() - val features = userRepository.getPremiumFeatures(PremiumSource.SETTINGS) + val premiumState = premiumRepository.getPremiumState() + val features = premiumRepository.getPremiumFeatures(PremiumSource.SETTINGS) val mappedFeatures = features.mapNotNull { featureType -> mapToPremiumFeature(featureType) @@ -79,10 +81,10 @@ class DefaultPremiumComponent( private suspend fun mapToPremiumFeature(featureType: PremiumFeatureType): PremiumComponent.PremiumFeature? { return when (featureType) { PremiumFeatureType.DOUBLE_LIMITS -> { - val channels = userRepository.getPremiumLimit(PremiumLimitType.SUPERGROUP_COUNT) - val folders = userRepository.getPremiumLimit(PremiumLimitType.CHAT_FOLDER_COUNT) - val pins = userRepository.getPremiumLimit(PremiumLimitType.PINNED_CHAT_COUNT) - val publicLinks = userRepository.getPremiumLimit(PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT) + val channels = premiumRepository.getPremiumLimit(PremiumLimitType.SUPERGROUP_COUNT) + val folders = premiumRepository.getPremiumLimit(PremiumLimitType.CHAT_FOLDER_COUNT) + val pins = premiumRepository.getPremiumLimit(PremiumLimitType.PINNED_CHAT_COUNT) + val publicLinks = premiumRepository.getPremiumLimit(PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT) PremiumComponent.PremiumFeature( icon = "star", title = stringProvider.getString("premium_feature_doubled_limits_title"), diff --git a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacySettingComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacySettingComponent.kt index bd1c76bd..4093d650 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacySettingComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacySettingComponent.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.ChatModel import org.monogram.domain.models.PrivacyValue import org.monogram.domain.models.UserModel -import org.monogram.domain.repository.ChatsListRepository +import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.PrivacyKey import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.UserRepository @@ -50,7 +50,7 @@ class DefaultPrivacySettingComponent( private val userRepository: UserRepository = container.repositories.userRepository private val privacyRepository: PrivacyRepository = container.repositories.privacyRepository - private val chatsRepository: ChatsListRepository = container.repositories.chatsListRepository + private val chatsRepository: ChatListRepository = container.repositories.chatListRepository private val _state = MutableValue(PrivacySettingComponent.State(titleRes = getTitleRes(privacyKey), privacyKey = privacyKey)) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt index 159b2a55..9617812e 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt @@ -6,9 +6,7 @@ import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch import org.monogram.domain.models.BirthdateModel import org.monogram.domain.models.BusinessOpeningHoursModel -import org.monogram.domain.repository.ChatsListRepository -import org.monogram.domain.repository.LocationRepository -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.* import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -18,7 +16,9 @@ class DefaultEditProfileComponent( ) : EditProfileComponent, AppComponentContext by context { private val userRepository: UserRepository = container.repositories.userRepository - private val chatsListRepository: ChatsListRepository = container.repositories.chatsListRepository + private val userProfileEditRepository: UserProfileEditRepository = container.repositories.userProfileEditRepository + private val chatInfoRepository: ChatInfoRepository = container.repositories.chatInfoRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository private val locationRepository: LocationRepository = container.repositories.locationRepository private val _state = MutableValue(EditProfileComponent.State()) @@ -30,9 +30,9 @@ class DefaultEditProfileComponent( _state.update { it.copy(isLoading = true) } try { val me = userRepository.getMe() - val fullInfo = userRepository.getChatFullInfo(me.id) + val fullInfo = chatInfoRepository.getChatFullInfo(me.id) val linkedChat = - fullInfo?.linkedChatId?.let { if (it != 0L) chatsListRepository.getChatById(it) else null } + fullInfo?.linkedChatId?.let { if (it != 0L) chatListRepository.getChatById(it) else null } _state.update { it.copy( @@ -87,7 +87,7 @@ class DefaultEditProfileComponent( _state.update { it.copy(personalChatId = chatId) } if (chatId != 0L) { scope.launch { - val chat = chatsListRepository.getChatById(chatId) + val chat = chatListRepository.getChatById(chatId) _state.update { it.copy(linkedChat = chat) } } } else { @@ -117,7 +117,7 @@ class DefaultEditProfileComponent( _state.update { it.copy(avatarPath = path) } scope.launch { try { - userRepository.setProfilePhoto(path) + userProfileEditRepository.setProfilePhoto(path) } catch (e: Exception) { _state.update { it.copy(error = e.message) } } @@ -150,7 +150,7 @@ class DefaultEditProfileComponent( override fun onToggleUsername(username: String, active: Boolean) { scope.launch { try { - userRepository.toggleUsernameIsActive(username, active) + userProfileEditRepository.toggleUsernameIsActive(username, active) val me = userRepository.getMe() _state.update { it.copy(user = me) } } catch (e: Exception) { @@ -162,7 +162,7 @@ class DefaultEditProfileComponent( override fun onReorderUsernames(usernames: List) { scope.launch { try { - userRepository.reorderActiveUsernames(usernames) + userProfileEditRepository.reorderActiveUsernames(usernames) val me = userRepository.getMe() _state.update { it.copy(user = me, username = me.username ?: "") } } catch (e: Exception) { @@ -179,35 +179,35 @@ class DefaultEditProfileComponent( val user = currentState.user ?: return@launch if (currentState.firstName != user.firstName || currentState.lastName != (user.lastName ?: "")) { - userRepository.setName(currentState.firstName, currentState.lastName) + userProfileEditRepository.setName(currentState.firstName, currentState.lastName) } - val fullInfo = userRepository.getChatFullInfo(user.id) + val fullInfo = chatInfoRepository.getChatFullInfo(user.id) if (currentState.bio != (fullInfo?.description ?: "")) { - userRepository.setBio(currentState.bio) + userProfileEditRepository.setBio(currentState.bio) } if (currentState.username != (user.username ?: "")) { - userRepository.setUsername(currentState.username) + userProfileEditRepository.setUsername(currentState.username) } if (currentState.birthdate != fullInfo?.birthdate) { - userRepository.setBirthdate(currentState.birthdate) + userProfileEditRepository.setBirthdate(currentState.birthdate) } if (currentState.personalChatId != (fullInfo?.linkedChatId ?: 0L)) { - userRepository.setPersonalChat(currentState.personalChatId) + userProfileEditRepository.setPersonalChat(currentState.personalChatId) } if (currentState.businessBio != (fullInfo?.businessInfo?.startPage?.message ?: "")) { - userRepository.setBusinessBio(currentState.businessBio) + userProfileEditRepository.setBusinessBio(currentState.businessBio) } if (currentState.businessAddress != (fullInfo?.businessInfo?.location?.address ?: "") || currentState.businessLatitude != (fullInfo?.businessInfo?.location?.latitude ?: 0.0) || currentState.businessLongitude != (fullInfo?.businessInfo?.location?.longitude ?: 0.0) ) { - userRepository.setBusinessLocation( + userProfileEditRepository.setBusinessLocation( currentState.businessAddress, currentState.businessLatitude, currentState.businessLongitude @@ -215,7 +215,7 @@ class DefaultEditProfileComponent( } if (currentState.businessOpeningHours != fullInfo?.businessInfo?.openingHours) { - userRepository.setBusinessOpeningHours(currentState.businessOpeningHours) + userProfileEditRepository.setBusinessOpeningHours(currentState.businessOpeningHours) } onBack() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionsComponent.kt index 242f8b7e..fb97d006 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionsComponent.kt @@ -5,7 +5,7 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch import org.monogram.domain.models.SessionModel -import org.monogram.domain.repository.SettingsRepository +import org.monogram.domain.repository.SessionRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -33,7 +33,7 @@ class DefaultSessionsComponent( private val onBack: () -> Unit ) : SessionsComponent, AppComponentContext by context { - private val repository: SettingsRepository = container.repositories.settingsRepository + private val repository: SessionRepository = container.repositories.sessionRepository private val _state = MutableValue(SessionsComponent.State()) override val state: Value = _state private val scope = componentScope diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt index 352116be..8688fa35 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt @@ -1,6 +1,5 @@ package org.monogram.presentation.settings.settings -import org.monogram.presentation.core.util.coRunCatching import android.os.Build import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value @@ -11,9 +10,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.monogram.domain.managers.DomainManager -import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.ExternalNavigator -import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.* +import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -36,6 +34,8 @@ class DefaultSettingsComponent( ) : SettingsComponent, AppComponentContext by context { private val repository: UserRepository = container.repositories.userRepository + private val userProfileEditRepository: UserProfileEditRepository = container.repositories.userProfileEditRepository + private val profilePhotoRepository: ProfilePhotoRepository = container.repositories.profilePhotoRepository private val externalNavigator: ExternalNavigator = container.utils.externalNavigator() private val domainManager: DomainManager = container.utils.domainManager() private val preferences: AppPreferencesProvider = container.preferences.appPreferences @@ -57,7 +57,7 @@ class DefaultSettingsComponent( ) } - repository.getUserProfilePhotosFlow(me.id) + profilePhotoRepository.getUserProfilePhotosFlow(me.id) .onEach { photos -> val highResPhoto = photos.firstOrNull { it.endsWith(".mp4", ignoreCase = true) } ?: photos.firstOrNull() @@ -203,7 +203,7 @@ class DefaultSettingsComponent( scope.launch(Dispatchers.IO) { coRunCatching { - repository.setEmojiStatus(customEmojiId) + userProfileEditRepository.setEmojiStatus(customEmojiId) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersComponent.kt index 4831032a..ecafe3a7 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersComponent.kt @@ -6,6 +6,7 @@ import com.arkivanov.decompose.value.update import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -46,6 +47,7 @@ class DefaultStickersComponent( ) : StickersComponent, AppComponentContext by context { private val stickerRepository: StickerRepository = container.repositories.stickerRepository + private val emojiRepository: EmojiRepository = container.repositories.emojiRepository private val _state = MutableValue( StickersComponent.State( stickerSets = stickerRepository.installedStickerSets.value, @@ -136,7 +138,7 @@ class DefaultStickersComponent( override fun onClearRecentEmojis() { scope.launch { - stickerRepository.clearRecentEmojis() + emojiRepository.clearRecentEmojis() } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersContent.kt index 9cea2c3f..9bba025c 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/stickers/StickersContent.kt @@ -288,7 +288,7 @@ fun StickersContent(component: StickersComponent) { botUserId = state.miniAppBotUserId, baseUrl = state.miniAppUrl!!, botName = state.miniAppName!!, - messageRepository = koinInject(), + webAppRepository = koinInject(), onDismiss = { component.onDismissMiniApp() } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt index 6a441176..51c27b25 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/storage/StorageUsageComponent.kt @@ -10,8 +10,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.monogram.domain.models.StorageUsageModel -import org.monogram.domain.repository.SettingsRepository import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -39,7 +39,7 @@ class DefaultStorageUsageComponent( private val onBack: () -> Unit ) : StorageUsageComponent, AppComponentContext by context { - private val settingsRepository: SettingsRepository = container.repositories.settingsRepository + private val storageRepository: StorageRepository = container.repositories.storageRepository private val appPreferences: AppPreferences = container.preferences.appPreferences private val stickerRepository: StickerRepository = container.repositories.stickerRepository private val cacheController: CacheController = container.utils.cacheController @@ -59,7 +59,7 @@ class DefaultStorageUsageComponent( }.launchIn(scope) scope.launch { - val enabled = settingsRepository.getStorageOptimizerEnabled() + val enabled = storageRepository.getStorageOptimizerEnabled() _state.update { it.copy(isStorageOptimizerEnabled = enabled) } } } @@ -67,7 +67,7 @@ class DefaultStorageUsageComponent( private fun loadStatistics() { _state.update { it.copy(isLoading = true) } scope.launch { - val usage = settingsRepository.getStorageUsage() + val usage = storageRepository.getStorageUsage() _state.update { it.copy(usage = usage, isLoading = false) } } } @@ -78,7 +78,7 @@ class DefaultStorageUsageComponent( ) { scope.launch { val ttl = if (time > 0) time * 24 * 60 * 60 else -1 - settingsRepository.setDatabaseMaintenanceSettings(limit, ttl) + storageRepository.setDatabaseMaintenanceSettings(limit, ttl) loadStatistics() } } @@ -90,7 +90,7 @@ class DefaultStorageUsageComponent( @OptIn(UnstableApi::class, ExperimentalCoilApi::class) override fun onClearAllClicked() { scope.launch { - val success = settingsRepository.clearStorage() + val success = storageRepository.clearStorage() if (success) { stickerRepository.clearCache() cacheController.clearAllCache() @@ -101,7 +101,7 @@ class DefaultStorageUsageComponent( override fun onClearChatClicked(chatId: Long) { scope.launch { - val success = settingsRepository.clearStorage(chatId) + val success = storageRepository.clearStorage(chatId) if (success) { loadStatistics() } @@ -120,7 +120,7 @@ class DefaultStorageUsageComponent( override fun onStorageOptimizerChanged(enabled: Boolean) { scope.launch { - settingsRepository.setStorageOptimizerEnabled(enabled) + storageRepository.setStorageOptimizerEnabled(enabled) _state.update { it.copy(isStorageOptimizerEnabled = enabled) } } } From 1c3d3c66bb9980202ed786538dca85553234cd64 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:58:17 +0300 Subject: [PATCH 28/53] Optimize chat recomposition with Compose stability config and stable callbacks (#172) --- .../org/monogram/data/mapper/MessageMapper.kt | 5 +- domain/build.gradle.kts | 1 + .../monogram/domain/models/BotCommandModel.kt | 5 + .../org/monogram/domain/models/ChatModel.kt | 2 + .../monogram/domain/models/MessageModel.kt | 14 +- .../org/monogram/domain/models/UserModel.kt | 3 + presentation/build.gradle.kts | 4 + presentation/compose-stability.conf | 12 ++ .../features/chats/currentChat/ChatContent.kt | 174 ++++++++++++------ .../chatContent/ChatContentList.kt | 14 +- .../chatContent/ChatContentUtils.kt | 5 + .../chats/currentChat/impl/MessageLoading.kt | 4 +- 12 files changed, 176 insertions(+), 67 deletions(-) create mode 100644 presentation/compose-stability.conf diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 4e0b209b..16e43099 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -568,7 +568,8 @@ class MessageMapper( }?.awaitAll()?.filterNotNull() ?: emptyList() val threadId = when (val topic = msg.topicId) { - is TdApi.MessageTopicForum -> topic.forumTopicId + is TdApi.MessageTopicForum -> topic.forumTopicId.toLong() + is TdApi.MessageTopicThread -> topic.messageThreadId else -> null } @@ -980,7 +981,7 @@ class MessageMapper( readDate: Int = 0, reactions: List = emptyList(), isSenderVerified: Boolean = false, - threadId: Int? = null, + threadId: Long? = null, replyCount: Int = 0, isReply: Boolean = false, viaBotUserId: Long = 0L, diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 828f79c2..5e1d955d 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -9,6 +9,7 @@ kotlin { dependencies { implementation(project(":core")) + implementation(libs.androidx.compose.runtime) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt index a513fb19..101bd7d8 100644 --- a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt @@ -1,12 +1,17 @@ package org.monogram.domain.models +import androidx.compose.runtime.Stable + +@Stable data class BotCommandModel( val command: String, val description: String ) +@Stable sealed interface BotMenuButtonModel { object Commands : BotMenuButtonModel + @Stable data class WebApp(val text: String, val url: String) : BotMenuButtonModel object Default : BotMenuButtonModel } diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index 63dcc4a9..fa44e635 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -1,5 +1,6 @@ package org.monogram.domain.models +import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable @Serializable @@ -70,6 +71,7 @@ enum class ChatType { PRIVATE, BASIC_GROUP, SUPERGROUP, SECRET } @Serializable +@Stable data class ChatPermissionsModel( val canSendBasicMessages: Boolean = true, val canSendAudios: Boolean = true, diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index f506629b..a2247268 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -1,5 +1,6 @@ package org.monogram.domain.models +import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable data class MessageModel( @@ -26,7 +27,7 @@ data class MessageModel( val readDate: Int = 0, val reactions: List = emptyList(), val isSenderVerified: Boolean = false, - val threadId: Int? = null, + val threadId: Long? = null, val canBeEdited: Boolean = false, val canBeForwarded: Boolean = true, val canBeDeletedOnlyForSelf: Boolean = true, @@ -106,7 +107,7 @@ sealed interface MessageContent { if (thumbnailPath != other.thumbnailPath) return false if (caption != other.caption) return false if (entities != other.entities) return false - if (!minithumbnail.contentEquals(other.minithumbnail)) return false + if (!(minithumbnail contentEquals other.minithumbnail)) return false return true } @@ -169,7 +170,7 @@ sealed interface MessageContent { if (thumbnailPath != other.thumbnailPath) return false if (caption != other.caption) return false if (entities != other.entities) return false - if (!minithumbnail.contentEquals(other.minithumbnail)) return false + if (!(minithumbnail contentEquals other.minithumbnail)) return false return true } @@ -220,7 +221,7 @@ sealed interface MessageContent { if (downloadError != other.downloadError) return false if (fileId != other.fileId) return false if (path != other.path) return false - if (!waveform.contentEquals(other.waveform)) return false + if (!(waveform contentEquals other.waveform)) return false return true } @@ -332,7 +333,7 @@ sealed interface MessageContent { if (path != other.path) return false if (caption != other.caption) return false if (entities != other.entities) return false - if (!minithumbnail.contentEquals(other.minithumbnail)) return false + if (!(minithumbnail contentEquals other.minithumbnail)) return false return true } @@ -477,7 +478,7 @@ data class WebPage( if (height != other.height) return false if (fileId != other.fileId) return false if (path != other.path) return false - if (!minithumbnail.contentEquals(other.minithumbnail)) return false + if (!(minithumbnail contentEquals other.minithumbnail)) return false return true } @@ -587,6 +588,7 @@ data class MessageReactionModel( val recentSenders: List = emptyList() ) +@Stable data class ReactionSender( val id: Long, val name: String = "", diff --git a/domain/src/main/java/org/monogram/domain/models/UserModel.kt b/domain/src/main/java/org/monogram/domain/models/UserModel.kt index 019c302b..95e83f2b 100644 --- a/domain/src/main/java/org/monogram/domain/models/UserModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/UserModel.kt @@ -1,5 +1,8 @@ package org.monogram.domain.models +import androidx.compose.runtime.Stable + +@Stable data class UserModel( val id: Long, val firstName: String, diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index ae531c04..e836a24a 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -50,6 +50,10 @@ android { } } +composeCompiler { + stabilityConfigurationFiles.add(project.layout.projectDirectory.file("compose-stability.conf")) +} + dependencies { implementation(project(":core")) implementation(project(":domain")) diff --git a/presentation/compose-stability.conf b/presentation/compose-stability.conf new file mode 100644 index 00000000..cc170fa9 --- /dev/null +++ b/presentation/compose-stability.conf @@ -0,0 +1,12 @@ +org.monogram.domain.models.MessageModel +org.monogram.domain.models.MessageReactionModel +org.monogram.domain.models.ForwardInfo +org.monogram.domain.models.MessageSendingState +org.monogram.domain.models.MessageEntity +org.monogram.domain.models.MessageEntityType +org.monogram.domain.models.WallpaperModel +org.monogram.domain.models.StickerSetModel +org.monogram.domain.models.TopicModel +org.monogram.domain.models.AttachMenuBotModel +org.monogram.domain.models.InlineBotResultsModel +org.monogram.domain.models.MessageContent diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 1942406b..2df3cd4a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -681,22 +681,30 @@ fun ChatContent( translationY = contentOffset.toPx() } ) { - ChatContentList( - showNavPadding = false, - state = state, - component = component, - scrollState = scrollState, - groupedMessages = groupedMessages, - onPhotoDownload = { fileId -> if (fileId != 0) component.onDownloadFile(fileId) }, - onPhotoClick = { msg, paths, captions, messageIds, index -> + 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) { - keyboardController?.hide() - focusManager.clearFocus() + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() val validItems = paths.mapIndexedNotNull { itemIndex, path -> val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } @@ -726,19 +734,23 @@ fun ChatContent( } else { content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) } - }, - onVideoClick = { msg, path, caption -> - if (!isVisible || showInitialLoading || scrollState.isScrollInProgress) { - return@ChatContentList - } + 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) { - keyboardController?.hide() - focusManager.clearFocus() + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) } else { val fileId = when (val c = msg.content) { @@ -747,23 +759,23 @@ fun ChatContent( else -> 0 } if (fileId != 0) { - when (msg.content) { - is MessageContent.Video -> "video" - is MessageContent.Gif -> "gif" - else -> "unknown" - } component.onDownloadFile(fileId) } } - }, - onDocumentClick = { msg -> - val doc = msg.content as? MessageContent.Document ?: return@ChatContentList + } + } + } + + 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")) { - keyboardController?.hide() - focusManager.clearFocus() + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() component.onOpenImages( images = listOf(validDocPath), captions = listOf(doc.caption), @@ -772,49 +784,101 @@ fun ChatContent( messageIds = listOf(msg.id) ) } else if (path.endsWith(".mp4")) { - keyboardController?.hide() - focusManager.clearFocus() + 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) - }, - onAudioClick = { msg -> - val audio = msg.content as? MessageContent.Audio ?: return@ChatContentList + } 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) { - keyboardController?.hide() - focusManager.clearFocus() + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() component.onOpenVideo( path = validAudioPath, messageId = msg.id, caption = audio.caption ) - } else component.onDownloadFile(audio.fileId) - }, - onMessageOptionsClick = { msg, pos, size, clickPos -> - keyboardController?.hide() - focusManager.clearFocus(force = true) + } 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 - }, - onGoToReply = { scrollToMessageState.value(it) }, - selectedMessageId = selectedMessageId, - onMessagePositionChange = { pos, size -> menuOffset = pos menuMessageSize = size - }, - onViaBotClick = { botUsername -> - val prefill = "@$botUsername " - component.onDraftChange(prefill) - component.onInlineQueryChange("", "") - }, - toProfile = { - it.let { component.toProfile(it) } - }, + 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) + } + } + + ChatContentList( + showNavPadding = false, + state = state, + component = component, + scrollState = scrollState, + groupedMessages = groupedMessages, + onPhotoDownload = onPhotoDownloadStable, + onPhotoClick = onPhotoClickStable, + onVideoClick = onVideoClickStable, + onDocumentClick = onDocumentClickStable, + onAudioClick = onAudioClickStable, + onMessageOptionsClick = onMessageOptionsClickStable, + onGoToReply = onGoToReplyStable, + selectedMessageId = selectedMessageId, + onMessagePositionChange = onMessagePositionChangeStable, + onViaBotClick = onViaBotClickStable, + toProfile = toProfileStable, downloadUtils = component.downloadUtils, isAnyViewerOpen = isAnyViewerOpen ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index b78cc1c7..14861e44 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -23,8 +23,6 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material3.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -183,6 +181,12 @@ fun ChatContentList( is GroupedMessageItem.Single -> "msg_${item.message.id}" is GroupedMessageItem.Album -> "album_${item.albumId}" } + }, + contentType = { _, item -> + when (item) { + is GroupedMessageItem.Single -> "single" + is GroupedMessageItem.Album -> "album" + } } ) { index, item -> val olderMsg = remember(groupedMessages, index) { getMessageAt(groupedMessages, index - 1) } @@ -250,6 +254,12 @@ fun ChatContentList( is GroupedMessageItem.Single -> "msg_${item.message.id}" is GroupedMessageItem.Album -> "album_${item.albumId}" } + }, + contentType = { _, item -> + when (item) { + is GroupedMessageItem.Single -> "single" + is GroupedMessageItem.Album -> "album" + } } ) { index, item -> val olderMsg = remember(groupedMessages, index) { getMessageAt(groupedMessages, index + 1) } 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/currentChat/chatContent/ChatContentUtils.kt index 001af250..abcca046 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/currentChat/chatContent/ChatContentUtils.kt @@ -1,11 +1,16 @@ package org.monogram.presentation.features.chats.currentChat.chatContent +import androidx.compose.runtime.Immutable import org.monogram.domain.models.MessageModel import java.text.SimpleDateFormat import java.util.* +@Immutable sealed class GroupedMessageItem { + @Immutable data class Single(val message: MessageModel) : GroupedMessageItem() + + @Immutable data class Album(val albumId: Long, val messages: List) : GroupedMessageItem() val firstMessageId: Long 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/currentChat/impl/MessageLoading.kt index ca66f437..c3bed7b9 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/currentChat/impl/MessageLoading.kt @@ -532,7 +532,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { return@onEach } val isCorrectThread = - _state.value.currentTopicId == null || message.threadId?.toLong() == _state.value.currentTopicId + _state.value.currentTopicId == null || message.threadId == _state.value.currentTopicId if (isCorrectThread) { updateMessages(listOf(message)) _state.update { state -> @@ -556,7 +556,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { messageMutex.withLock { _state.update { state -> val isCorrectThread = - state.currentTopicId == null || newMessage.threadId?.toLong() == state.currentTopicId + state.currentTopicId == null || newMessage.threadId == state.currentTopicId if (!isCorrectThread) { return@update state } From ebf1df7ad5f99442de8dcad9ab0785fee1097583 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:04:35 +0300 Subject: [PATCH 29/53] Fix malformed Spanish strings XML Remove duplicate closing tags and stray blank lines in presentation/src/main/res/values-es/string.xml to correct the XML structure and prevent potential resource parsing errors. --- presentation/src/main/res/values-es/string.xml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 95f55f71..41a3940e 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -20,7 +20,6 @@ Conectando a Telegram… - Acerca de Atrás @@ -104,7 +103,6 @@ Por favor, espera un momento ¿Está tardando demasiado? Restablecer Conexión - Reenviar a... @@ -976,8 +974,6 @@ Ubicación Mensaje - - Cancelar respuesta Editar mensaje @@ -1915,4 +1911,4 @@ Código de confirmación inválido Contraseña inválida Ocurrió un error inesperado - + \ No newline at end of file From 1d1fe81fb7b549e5892a5d82d6d4d1cef8e79514 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:09:20 +0300 Subject: [PATCH 30/53] remove compose runtime dependency from domain models --- domain/build.gradle.kts | 1 - .../main/java/org/monogram/domain/models/BotCommandModel.kt | 5 ----- domain/src/main/java/org/monogram/domain/models/ChatModel.kt | 2 -- .../src/main/java/org/monogram/domain/models/MessageModel.kt | 2 -- domain/src/main/java/org/monogram/domain/models/UserModel.kt | 3 --- 5 files changed, 13 deletions(-) diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 5e1d955d..828f79c2 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -9,7 +9,6 @@ kotlin { dependencies { implementation(project(":core")) - implementation(libs.androidx.compose.runtime) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt index 101bd7d8..a513fb19 100644 --- a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt @@ -1,17 +1,12 @@ package org.monogram.domain.models -import androidx.compose.runtime.Stable - -@Stable data class BotCommandModel( val command: String, val description: String ) -@Stable sealed interface BotMenuButtonModel { object Commands : BotMenuButtonModel - @Stable data class WebApp(val text: String, val url: String) : BotMenuButtonModel object Default : BotMenuButtonModel } diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index fa44e635..63dcc4a9 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -1,6 +1,5 @@ package org.monogram.domain.models -import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable @Serializable @@ -71,7 +70,6 @@ enum class ChatType { PRIVATE, BASIC_GROUP, SUPERGROUP, SECRET } @Serializable -@Stable data class ChatPermissionsModel( val canSendBasicMessages: Boolean = true, val canSendAudios: Boolean = true, diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index a2247268..117130a4 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -1,6 +1,5 @@ package org.monogram.domain.models -import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable data class MessageModel( @@ -588,7 +587,6 @@ data class MessageReactionModel( val recentSenders: List = emptyList() ) -@Stable data class ReactionSender( val id: Long, val name: String = "", diff --git a/domain/src/main/java/org/monogram/domain/models/UserModel.kt b/domain/src/main/java/org/monogram/domain/models/UserModel.kt index 95e83f2b..019c302b 100644 --- a/domain/src/main/java/org/monogram/domain/models/UserModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/UserModel.kt @@ -1,8 +1,5 @@ package org.monogram.domain.models -import androidx.compose.runtime.Stable - -@Stable data class UserModel( val id: Long, val firstName: String, From 43186a771cba678127f529078ad250e91de2edce Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:21:13 +0300 Subject: [PATCH 31/53] Fix random avatar/sticker/video/photo swapping caused by stale cache keys (#190) fix #123 --- .../java/org/monogram/data/chats/ChatCache.kt | 14 ++-- .../monogram/data/chats/ChatFileManager.kt | 18 ++--- .../remote/TdMessageRemoteDataSource.kt | 8 +-- .../java/org/monogram/data/di/dataModule.kt | 5 +- .../org/monogram/data/mapper/MessageMapper.kt | 16 +++-- .../repository/ChatsListRepositoryImpl.kt | 3 + .../presentation/core/util/ImageCacheKeys.kt | 39 +++++++++++ .../components/AlphaVideoPlayer.kt | 10 ++- .../components/CompactMediaMosaic.kt | 69 +++++++++++++++++-- .../channels/ChannelGifMessageBubble.kt | 19 ++++- .../channels/ChannelPhotoMessageBubble.kt | 10 +++ .../channels/ChannelVideoMessageBubble.kt | 34 ++++++++- .../components/chats/GifMessageBubble.kt | 23 ++++++- .../components/chats/LinkPreview.kt | 18 ++++- .../chats/MediaLoadingComponents.kt | 26 +++++-- .../components/chats/PhotoMessageBubble.kt | 16 +++-- .../components/chats/VideoMessageBubble.kt | 28 ++++++-- .../components/inputbar/InlineBotResults.kt | 10 ++- .../features/stickers/ui/view/StickerImage.kt | 10 +++ 19 files changed, 320 insertions(+), 56 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt index 356a3549..a7385306 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -3,6 +3,7 @@ package org.monogram.data.chats import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.cache.ChatsCacheDataSource import org.monogram.data.datasource.cache.UserCacheDataSource +import java.io.File import java.util.concurrent.ConcurrentHashMap class ChatCache : ChatsCacheDataSource, UserCacheDataSource { @@ -424,11 +425,14 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { ) clientData = "mc:${entity.memberCount};oc:${entity.onlineCount}" } - if (entity.photoId != 0) { - fileCache[entity.photoId] = TdApi.File().apply { - id = entity.photoId - local = TdApi.LocalFile().apply { - this.path = entity.avatarPath.orEmpty() + if (entity.photoId != 0 && !entity.avatarPath.isNullOrEmpty()) { + val avatarFile = File(entity.avatarPath) + if (avatarFile.exists()) { + fileCache[entity.photoId] = TdApi.File().apply { + id = entity.photoId + local = TdApi.LocalFile().apply { + this.path = entity.avatarPath + } } } } diff --git a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt index a199e115..04eb3b01 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt @@ -1,12 +1,13 @@ package org.monogram.data.chats -import org.monogram.data.core.coRunCatching import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileUpdateHandler import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -15,6 +16,7 @@ class ChatFileManager( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, private val fileQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, scopeProvider: ScopeProvider, private val onUpdate: () -> Unit ) { @@ -23,13 +25,11 @@ class ChatFileManager( private val downloadingFiles: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val loadingEmojis: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val filePaths = ConcurrentHashMap() - private val emojiPathsCache = ConcurrentHashMap() - private val fileIdToEmojiId = ConcurrentHashMap() private val chatPhotoIds = ConcurrentHashMap() private val trackedFileIds = Collections.newSetFromMap(ConcurrentHashMap()) fun getFilePath(fileId: Int): String? = filePaths[fileId] - fun getEmojiPath(emojiId: Long): String? = emojiPathsCache[emojiId] + fun getEmojiPath(emojiId: Long): String? = fileUpdateHandler.customEmojiPaths[emojiId] fun getChatIdByPhotoId(fileId: Int): Long? = chatPhotoIds[fileId] fun registerChatPhoto(fileId: Int, chatId: Long) { @@ -51,8 +51,8 @@ class ChatFileManager( private fun handleFileUpdated(fileId: Int, path: String): Boolean { if (path.isEmpty()) return false var updated = false - fileIdToEmojiId[fileId]?.let { emojiId -> - emojiPathsCache[emojiId] = path + fileUpdateHandler.fileIdToCustomEmojiId[fileId]?.let { emojiId -> + fileUpdateHandler.customEmojiPaths[emojiId] = path updated = true } if (chatPhotoIds.containsKey(fileId)) updated = true @@ -74,7 +74,7 @@ class ChatFileManager( } fun loadEmoji(emojiId: Long) { - if (emojiId == 0L || emojiPathsCache.containsKey(emojiId)) return + if (emojiId == 0L || fileUpdateHandler.customEmojiPaths.containsKey(emojiId)) return if (loadingEmojis.add(emojiId)) { scope.launch(dispatchers.io) { coRunCatching { @@ -82,9 +82,9 @@ class ChatFileManager( val sticker = result.stickers.firstOrNull() ?: return@launch val file = sticker.sticker val path = file.local.path.ifEmpty { filePaths[file.id] ?: "" } - fileIdToEmojiId[file.id] = emojiId + fileUpdateHandler.fileIdToCustomEmojiId[file.id] = emojiId if (path.isNotEmpty()) { - emojiPathsCache[emojiId] = path + fileUpdateHandler.customEmojiPaths[emojiId] = path onUpdate() } else { downloadFile(file.id, 32) 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 11632a7f..5911c70a 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 @@ -12,6 +12,7 @@ import org.monogram.data.chats.ChatCache import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.toApi import org.monogram.domain.models.* @@ -28,6 +29,7 @@ class TdMessageRemoteDataSource( private val cache: ChatCache, private val pollRepository: PollRepository, private val fileDownloadQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, private val dispatcherProvider: DispatcherProvider, scopeProvider: ScopeProvider ) : MessageRemoteDataSource { @@ -69,8 +71,6 @@ class TdMessageRemoteDataSource( enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } private val fileIdToMessageMap = fileDownloadQueue.registry.fileIdToMessageMap - val customEmojiPaths = ConcurrentHashMap() - val fileIdToCustomEmojiId = ConcurrentHashMap() private val messageUpdateJobs = ConcurrentHashMap, Job>() private val lastProgressMap = ConcurrentHashMap() private val lastDownloadActiveMap = ConcurrentHashMap() @@ -1357,8 +1357,8 @@ class TdMessageRemoteDataSource( if (isDC) { fileDownloadQueue.notifyDownloadComplete(file.id) lastProgressMap.remove(file.id) - fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> - customEmojiPaths[customEmojiId] = file.local?.path ?: "" + fileUpdateHandler.fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> + fileUpdateHandler.customEmojiPaths[customEmojiId] = file.local?.path ?: "" } val entries = fileIdToMessageMap[file.id] diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index ec4a9b83..93d924e8 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -288,8 +288,7 @@ val dataModule = module { gateway = get(), userRepository = get(), chatInfoRepository = get(), - customEmojiPaths = get().customEmojiPaths, - fileIdToCustomEmojiId = get().fileIdToCustomEmojiId, + fileUpdateHandler = get(), fileApi = get(), appPreferences = get(), cache = get(), @@ -329,6 +328,7 @@ val dataModule = module { chatFolderDao = get(), userFullInfoDao = get(), fileQueue = get(), + fileUpdateHandler = get(), stringProvider = get() ) } @@ -421,6 +421,7 @@ val dataModule = module { cache = get(), pollRepository = get(), fileDownloadQueue = get(), + fileUpdateHandler = get(), dispatcherProvider = get(), scopeProvider = get() ) diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 16e43099..0139f67a 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -10,6 +10,7 @@ import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.FileUpdateHandler import org.monogram.domain.models.* import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ChatInfoRepository @@ -22,14 +23,15 @@ class MessageMapper( private val gateway: TelegramGateway, private val userRepository: UserRepository, private val chatInfoRepository: ChatInfoRepository, - private val customEmojiPaths: ConcurrentHashMap, - private val fileIdToCustomEmojiId: ConcurrentHashMap, + private val fileUpdateHandler: FileUpdateHandler, private val fileApi: MessageFileApi, private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, scopeProvider: ScopeProvider ) { val scope = scopeProvider.appScope + private val customEmojiPaths = fileUpdateHandler.customEmojiPaths + private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId private data class SenderUserSnapshot( val name: String, @@ -125,12 +127,14 @@ class MessageMapper( } private fun resolveCachedPath(fileId: Int, storedPath: String?): String? { - val fromCache = fileId.takeIf { it != 0 } - ?.let { cache.fileCache[it]?.local?.path } + val fromStored = storedPath + ?.takeIf { it.isNotBlank() } ?.takeIf { isValidPath(it) } - if (fromCache != null) return fromCache + if (fromStored != null) return fromStored - return storedPath?.takeIf { it.isNotBlank() }?.takeIf { isValidPath(it) } + return fileId.takeIf { it != 0 } + ?.let { cache.fileCache[it]?.local?.path } + ?.takeIf { isValidPath(it) } } private fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 7c22ce6f..980e9888 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -23,6 +23,7 @@ import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.ConnectionManager import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.MessageMapper import org.monogram.domain.models.ChatModel @@ -53,6 +54,7 @@ class ChatsListRepositoryImpl( private val chatFolderDao: ChatFolderDao, private val userFullInfoDao: UserFullInfoDao, private val fileQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, private val stringProvider: StringProvider ) : ChatListRepository, ChatFolderRepository, @@ -96,6 +98,7 @@ class ChatsListRepositoryImpl( dispatchers = dispatchers, scopeProvider = scopeProvider, fileQueue = fileQueue, + fileUpdateHandler = fileUpdateHandler, onUpdate = { triggerUpdate() refreshActiveForumTopics() diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt b/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt new file mode 100644 index 00000000..56515dbe --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt @@ -0,0 +1,39 @@ +package org.monogram.presentation.core.util + +import java.io.File + +fun fileCacheKey(path: String?): String? { + if (path.isNullOrBlank()) return null + val file = File(path) + if (!file.exists()) return null + return "${file.absolutePath}:${file.lastModified()}:${file.length()}" +} + +fun miniThumbnailCacheKey(data: ByteArray): String { + return "mini:${data.contentHashCode()}:${data.size}" +} + +fun mediaCacheKey(data: Any?): String? { + return when (data) { + null -> null + is ByteArray -> miniThumbnailCacheKey(data) + is File -> "${data.absolutePath}:${data.lastModified()}:${data.length()}" + is String -> { + when { + data.startsWith("http://", ignoreCase = true) || + data.startsWith("https://", ignoreCase = true) || + data.startsWith("content:", ignoreCase = true) || + data.startsWith("file:", ignoreCase = true) -> data + + else -> fileCacheKey(data) ?: data + } + } + + else -> data.toString() + } +} + +fun namespacedCacheKey(namespace: String, data: Any?): String? { + val key = mediaCacheKey(data) ?: return null + return "$namespace:$key" +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt index cf84f114..467f106c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.isActive import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.presentation.core.util.LocalVideoPlayerPool import org.monogram.presentation.core.util.getMimeType +import org.monogram.presentation.core.util.namespacedCacheKey import java.io.File import java.io.FileNotFoundException import java.util.concurrent.ArrayBlockingQueue @@ -252,15 +253,20 @@ fun VideoStickerPlayer( exit = fadeOut(tween(250)), modifier = Modifier.fillMaxSize() ) { + val thumbnailCacheKey = remember(currentPath, thumbnailData, fileId) { + namespacedCacheKey("video_sticker_thumb:$fileId", thumbnailData ?: currentPath) + } AsyncImage( model = ImageRequest.Builder(context) .data(thumbnailData ?: currentPath) .apply { + thumbnailCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } if (thumbnailData == null) { decoderFactory(VideoFrameDecoder.Factory()) videoFrameMillis(0) - memoryCacheKey(currentPath) - diskCacheKey(currentPath) } } .crossfade(false) 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/currentChat/components/CompactMediaMosaic.kt index d5fff389..0983f907 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/currentChat/components/CompactMediaMosaic.kt @@ -31,10 +31,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState 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 @@ -291,8 +294,12 @@ fun PhotoItem( contentScale: ContentScale = ContentScale.Crop, downloadUtils: IDownloadUtils ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(photo.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, photo.fileId) { + namespacedCacheKey("mosaic_photo:${photo.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(photo.path) { @@ -328,7 +335,18 @@ fun PhotoItem( ) { resolved -> if (resolved && !stablePath.isNullOrBlank()) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -415,8 +433,15 @@ fun VideoItem( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(video.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, video.fileId) { + namespacedCacheKey("mosaic_video:${video.fileId}", stablePath) + } + val videoMiniCacheKey = remember(video.minithumbnail, video.fileId) { + video.minithumbnail?.let { namespacedCacheKey("mosaic_video_mini:${video.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(video.path) { @@ -476,7 +501,18 @@ fun VideoItem( } else { if (hasPath) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -495,7 +531,17 @@ fun VideoItem( } else { if (video.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(video.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(video.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -606,6 +652,7 @@ fun VideoNoteItem( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(videoNote.path) } !stablePath.isNullOrBlank() var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } @@ -659,8 +706,22 @@ fun VideoNoteItem( ) } else { val model = videoNote.thumbnail ?: path + val videoNoteCacheKey = remember(model, videoNote.fileId) { + namespacedCacheKey("mosaic_video_note:${videoNote.fileId}", model) + } Image( - painter = rememberAsyncImagePainter(model), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(model) + .apply { + videoNoteCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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/currentChat/components/channels/ChannelGifMessageBubble.kt index 23a73a7b..de3f2ab3 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/currentChat/components/channels/ChannelGifMessageBubble.kt @@ -30,10 +30,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState 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 @@ -83,6 +86,9 @@ fun ChannelGifMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val gifCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_gif:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(content.path) { @@ -191,7 +197,18 @@ fun ChannelGifMessageBubble( } } else { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + gifCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = content.caption, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit 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/currentChat/components/channels/ChannelPhotoMessageBubble.kt index 407f32d1..ce99f3c9 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/currentChat/components/channels/ChannelPhotoMessageBubble.kt @@ -34,6 +34,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel 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.* @@ -86,6 +87,9 @@ fun ChannelPhotoMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_photo:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } var isFullImageReady by remember(msg.id) { mutableStateOf(false) } val mediaAlpha by animateFloatAsState( @@ -203,6 +207,12 @@ fun ChannelPhotoMessageBubble( AsyncImage( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = content.caption, 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/currentChat/components/channels/ChannelVideoMessageBubble.kt index 258b492e..aec89204 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/currentChat/components/channels/ChannelVideoMessageBubble.kt @@ -37,9 +37,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel 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 @@ -100,6 +103,12 @@ fun ChannelVideoMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_video:${content.fileId}", stablePath) + } + val videoMiniCacheKey = remember(content.minithumbnail, content.fileId) { + content.minithumbnail?.let { namespacedCacheKey("channel_video_mini:${content.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } val hasCaption = content.caption.isNotEmpty() @@ -233,7 +242,18 @@ fun ChannelVideoMessageBubble( } else { if (hasPath) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit @@ -241,7 +261,17 @@ fun ChannelVideoMessageBubble( } else { if (content.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(content.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(content.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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/currentChat/components/chats/GifMessageBubble.kt index 104c0f2c..7a85ad4a 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/currentChat/components/chats/GifMessageBubble.kt @@ -15,8 +15,6 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,14 +25,18 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.media3.common.util.UnstableApi import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel 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 @@ -66,12 +68,16 @@ fun GifMessageBubble( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current val cornerRadius = 18.dp val smallCorner = 4.dp val tailCorner = 2.dp var stablePath by remember(msg.id) { mutableStateOf(content.path) } !stablePath.isNullOrBlank() + val gifCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_gif:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(content.path) { @@ -199,7 +205,18 @@ fun GifMessageBubble( ) } else { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + gifCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = content.caption, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit 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/currentChat/components/chats/LinkPreview.kt index e8ab1b63..08ab90b8 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/currentChat/components/chats/LinkPreview.kt @@ -24,6 +24,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import org.monogram.domain.models.WebPage +import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.viewers.extractYouTubeId @Composable @@ -209,6 +210,8 @@ private fun LinkPreviewTextContent( private fun LinkPreviewSmallImage(webPage: WebPage) { val photo = webPage.photo val context = LocalContext.current + val modelData = remember(photo) { photo?.path ?: photo?.minithumbnail } + val cacheKey = remember(modelData) { namespacedCacheKey("link_preview_small", modelData) } Box( modifier = Modifier @@ -218,7 +221,13 @@ private fun LinkPreviewSmallImage(webPage: WebPage) { ) { AsyncImage( model = ImageRequest.Builder(context) - .data(photo?.path ?: photo?.minithumbnail) + .data(modelData) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null, @@ -262,10 +271,17 @@ private fun LinkPreviewLargeMedia( photo?.path ?: photo?.minithumbnail ?: video?.path } } + val cacheKey = remember(modelData) { namespacedCacheKey("link_preview_large", modelData) } AsyncImage( model = ImageRequest.Builder(context) .data(modelData) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null, 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/currentChat/components/chats/MediaLoadingComponents.kt index 3fd2aaf8..2ad6b268 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/currentChat/components/chats/MediaLoadingComponents.kt @@ -10,12 +10,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -23,9 +20,12 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import org.monogram.presentation.core.util.namespacedCacheKey @Composable fun MediaLoadingBackground( @@ -34,6 +34,10 @@ fun MediaLoadingBackground( modifier: Modifier = Modifier, previewBlur: Dp = 10.dp ) { + val context = LocalContext.current + val previewCacheKey = remember(previewData) { + namespacedCacheKey("media_loading_preview", previewData) + } val pulse = rememberInfiniteTransition(label = "MediaLoadingPulse") val pulseAlpha = pulse.animateFloat( initialValue = 0.06f, @@ -53,7 +57,17 @@ fun MediaLoadingBackground( ) { if (previewData != null) { Image( - painter = rememberAsyncImagePainter(previewData), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(previewData) + .apply { + previewCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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/currentChat/components/chats/PhotoMessageBubble.kt index 661743f6..6166c0bd 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/currentChat/components/chats/PhotoMessageBubble.kt @@ -9,11 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +32,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -71,6 +68,9 @@ fun PhotoMessageBubble( var stablePath by remember(msg.id, content.fileId) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_photo:${content.fileId}", stablePath) + } var isFullImageReady by remember(msg.id, content.fileId) { mutableStateOf(false) } val mediaAlpha by animateFloatAsState( targetValue = if (hasPath && isFullImageReady) 1f else 0f, @@ -222,6 +222,12 @@ fun PhotoMessageBubble( AsyncImage( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = content.caption, 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/currentChat/components/chats/VideoMessageBubble.kt index 0e254b95..e30c3d73 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/currentChat/components/chats/VideoMessageBubble.kt @@ -15,9 +15,6 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.rounded.Stream import androidx.compose.material3.* -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,6 +38,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel 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 @@ -78,6 +76,12 @@ fun VideoMessageBubble( val context = LocalContext.current var stablePath by remember(msg.id, content.fileId) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_video:${content.fileId}", stablePath) + } + val videoMiniCacheKey = remember(content.minithumbnail, content.fileId) { + content.minithumbnail?.let { namespacedCacheKey("chat_video_mini:${content.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id, content.fileId) { mutableStateOf(false) } LaunchedEffect(content.path, content.fileId) { @@ -246,6 +250,12 @@ fun VideoMessageBubble( painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build() ), @@ -256,7 +266,17 @@ fun VideoMessageBubble( } else { if (content.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(content.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(content.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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/currentChat/components/inputbar/InlineBotResults.kt index b2ac0264..58535a8f 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/currentChat/components/inputbar/InlineBotResults.kt @@ -17,8 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +36,7 @@ import org.monogram.domain.models.InlineQueryResultModel import org.monogram.domain.models.MessageContent import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.namespacedCacheKey private enum class InlineResultsMode { Loading, @@ -335,9 +334,16 @@ private fun rememberMediaModel(result: InlineQueryResultModel): Any? { return remember(contentPath, result.thumbUrl) { val data = if (!contentPath.isNullOrBlank()) contentPath else result.thumbUrl if (data == null) return@remember null + val cacheKey = namespacedCacheKey("inline_result:${result.id}", data) ImageRequest.Builder(context) .data(data) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt index 3c3d17a6..75fbf26c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt @@ -2,12 +2,14 @@ package org.monogram.presentation.features.stickers.ui.view import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import org.monogram.presentation.core.util.namespacedCacheKey @Composable fun StickerImage( @@ -29,9 +31,17 @@ fun StickerImage( return } + val cacheKey = remember(path) { namespacedCacheKey("sticker", path) } + SubcomposeAsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(path) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null, From 389271a76fecce5f16aa5bffa98106bb3005b36b Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:42:54 +0300 Subject: [PATCH 32/53] avoid null cast to ShowKeyboard in chat input --- .../components/inputbar/ChatInputBarComposerSection.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 1b57570e..327658fd 100644 --- 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 @@ -293,8 +293,9 @@ fun ChatInputBarComposerSection( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { + val markup = replyMarkup as? ReplyMarkupModel.ShowKeyboard ?: return@AnimatedVisibility KeyboardMarkupView( - markup = replyMarkup as ReplyMarkupModel.ShowKeyboard, + markup = markup, onButtonClick = onReplyMarkupButtonClick, onOpenMiniApp = onOpenMiniApp ) From 0b1393320d8de9b397c27beb3a10187976fa29ae Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:48:27 +0300 Subject: [PATCH 33/53] Bump to version 6 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6bddb3b4..8fcdcc0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "org.monogram" minSdk = 25 targetSdk = 36 - versionCode = 5 - versionName = "1.0" + versionCode = 6 + versionName = "0.0.6" } splits { From 75f00abfcb68c501e1ae48f3f08e2f49ef6f6aca Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:36:30 +0300 Subject: [PATCH 34/53] refactor(data): replace ScopeProvider with CoroutineScope and remove dead scope wiring --- .../kotlin/org/monogram/core/ScopeProvider.kt | 7 ---- .../monogram/data/chats/ChatFileManager.kt | 6 +-- .../monogram/data/chats/ChatFolderManager.kt | 6 +-- .../monogram/data/chats/ChatModelFactory.kt | 7 ++-- .../remote/TdMessageRemoteDataSource.kt | 4 +- .../java/org/monogram/data/di/dataModule.kt | 42 +++++++++---------- .../monogram/data/infra/ConnectionManager.kt | 10 ++--- .../data/infra/DefaultScopeProvider.kt | 14 ------- .../monogram/data/infra/FileDownloadQueue.kt | 35 ++++++++-------- .../monogram/data/infra/FileUpdateHandler.kt | 12 +++--- .../org/monogram/data/infra/OfflineWarmup.kt | 6 +-- .../monogram/data/infra/SponsorSyncManager.kt | 10 ++--- .../org/monogram/data/mapper/MessageMapper.kt | 4 +- .../repository/AttachMenuBotRepositoryImpl.kt | 6 +-- .../data/repository/AuthRepositoryImpl.kt | 6 +-- .../repository/ChatsListRepositoryImpl.kt | 16 +++---- .../data/repository/EmojiRepositoryImpl.kt | 6 +-- .../data/repository/MessageRepositoryImpl.kt | 5 +-- .../NotificationSettingsRepositoryImpl.kt | 10 +---- .../data/repository/PrivacyRepositoryImpl.kt | 12 ++---- .../data/repository/StickerRepositoryImpl.kt | 6 +-- .../repository/StreamingRepositoryImpl.kt | 13 +++--- .../data/repository/UpdateRepositoryImpl.kt | 8 ++-- .../repository/WallpaperRepositoryImpl.kt | 6 +-- .../repository/user/UserRepositoryImpl.kt | 10 +---- .../data/stickers/StickerFileManager.kt | 6 +-- 26 files changed, 99 insertions(+), 174 deletions(-) delete mode 100644 core/src/main/kotlin/org/monogram/core/ScopeProvider.kt delete mode 100644 data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt diff --git a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt b/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt deleted file mode 100644 index 8a62a55d..00000000 --- a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.monogram.core - -import kotlinx.coroutines.CoroutineScope - -interface ScopeProvider { - val appScope: CoroutineScope -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt index 04eb3b01..0966f341 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt @@ -1,9 +1,9 @@ package org.monogram.data.chats +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue @@ -17,11 +17,9 @@ class ChatFileManager( private val dispatchers: DispatcherProvider, private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val onUpdate: () -> Unit ) { - private val scope = scopeProvider.appScope - private val downloadingFiles: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val loadingEmojis: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val filePaths = ConcurrentHashMap() diff --git a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt index 2212df88..230d0ab7 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt @@ -1,13 +1,13 @@ package org.monogram.data.chats import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.ChatFolderDao import org.monogram.data.db.model.ChatFolderEntity @@ -21,13 +21,11 @@ private const val TAG = "ChatFolderManager" class ChatFolderManager( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val foldersFlow: MutableStateFlow>, private val cacheProvider: CacheProvider, private val chatFolderDao: ChatFolderDao ) { - private val scope = scopeProvider.appScope - private val chatUnreadCounts = ConcurrentHashMap() private val folderChatIds = ConcurrentHashMap>() private val folderPinnedChatIds = ConcurrentHashMap>() diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 1e61ae07..ba96a4f7 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -1,10 +1,10 @@ package org.monogram.data.chats -import org.monogram.data.core.coRunCatching +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.ChatMapper @@ -22,7 +22,7 @@ import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val cache: ChatCache, private val chatMapper: ChatMapper, private val fileManager: ChatFileManager, @@ -32,7 +32,6 @@ class ChatModelFactory( private val triggerUpdate: (Long?) -> Unit, private val fetchUser: (Long) -> Unit ) { - private val scope = scopeProvider.appScope private val missingUserFullInfoUntilMs = ConcurrentHashMap() fun mapChatToModel( 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 5911c70a..e3bbc16e 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 @@ -7,7 +7,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway @@ -31,10 +30,9 @@ class TdMessageRemoteDataSource( private val fileDownloadQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val dispatcherProvider: DispatcherProvider, - scopeProvider: ScopeProvider + val scope: CoroutineScope ) : MessageRemoteDataSource { - val scope = scopeProvider.appScope private val chatRequests = ConcurrentHashMap>() private val messageRequests = ConcurrentHashMap, Deferred>() private val refreshJobs = ConcurrentHashMap, Job>() diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 93d924e8..d13ee94e 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -5,12 +5,10 @@ import android.net.ConnectivityManager import androidx.room.Room import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.PlayerDataSourceFactoryImpl @@ -34,17 +32,16 @@ import org.monogram.data.stickers.StickerFileManager import org.monogram.domain.repository.* val dataModule = module { - single { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + single { CoroutineScope(SupervisorJob() + get().default) } single(createdAtStart = true) { TdLibClient() } single { DefaultDispatcherProvider() } - single { DefaultScopeProvider(get()) } single { AndroidStringProvider(androidContext()) } single { TdLibParametersProvider(androidContext()) } single(createdAtStart = true) { OfflineWarmup( - scopeProvider = get(), + scope = get(), dispatchers = get(), gateway = get(), chatDao = get(), @@ -59,7 +56,7 @@ val dataModule = module { } single(createdAtStart = true) { SponsorSyncManager( - scopeProvider = get(), + scope = get(), gateway = get(), sponsorDao = get(), authRepository = get() @@ -103,7 +100,7 @@ val dataModule = module { parametersProvider = get(), remote = get(), updates = get(), - scopeProvider = get() + scope = get() ) } @@ -181,7 +178,7 @@ val dataModule = module { chatLocal = get(), chatCache = get(), updates = get(), - scopeProvider = get(), + scope = get(), gateway = get(), fileQueue = get(), keyValueDao = get(), @@ -292,7 +289,7 @@ val dataModule = module { fileApi = get(), appPreferences = get(), cache = get(), - scopeProvider = get() + scope = get() ) } @@ -304,7 +301,7 @@ val dataModule = module { appPreferences = get(), dispatchers = get(), connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, - scopeProvider = get() + scope = get() ) } @@ -320,7 +317,7 @@ val dataModule = module { chatMapper = get(), messageMapper = get(), gateway = get(), - scopeProvider = get(), + scope = get(), chatLocalDataSource = get(), connectionManager = get(), databaseFile = androidContext().getDatabasePath("monogram_db"), @@ -357,7 +354,7 @@ val dataModule = module { cache = get(), chatsRemote = get(), updates = get(), - scopeProvider = get(), + scope = get(), dispatchers = get() ) } @@ -374,7 +371,7 @@ val dataModule = module { updates = get(), wallpaperDao = get(), dispatchers = get(), - scopeProvider = get() + scope = get() ) } @@ -404,7 +401,7 @@ val dataModule = module { updates = get(), dispatchers = get(), attachBotDao = get(), - scopeProvider = get() + scope = get() ) } @@ -423,7 +420,7 @@ val dataModule = module { fileDownloadQueue = get(), fileUpdateHandler = get(), dispatcherProvider = get(), - scopeProvider = get() + scope = get() ) } @@ -436,7 +433,7 @@ val dataModule = module { messageRemoteDataSource = get(), cache = get(), dispatcherProvider = get(), - scopeProvider = get(), + scope = get(), fileDataSource = get(), chatLocalDataSource = get(), userLocalDataSource = get(), @@ -499,7 +496,7 @@ val dataModule = module { fileQueue = get(), fileUpdateHandler = get(), dispatchers = get(), - scopeProvider = get() + scope = get() ) } @@ -511,7 +508,7 @@ val dataModule = module { cacheProvider = get(), dispatchers = get(), localDataSource = get(), - scopeProvider = get() + scope = get() ) } @@ -530,7 +527,7 @@ val dataModule = module { cacheProvider = get(), dispatchers = get(), context = androidContext(), - scopeProvider = get() + scope = get() ) } @@ -543,8 +540,7 @@ val dataModule = module { single { PrivacyRepositoryImpl( remote = get(), - updates = get(), - scopeProvider = get() + updates = get() ) } @@ -560,7 +556,7 @@ val dataModule = module { StreamingRepositoryImpl( fileDataSource = get(), updates = get(), - scopeProvider = get() + scope = get() ) } @@ -598,7 +594,7 @@ val dataModule = module { fileQueue = get(), fileUpdateHandler = get(), authRepository = get(), - scopeProvider = get(), + scope = get(), ) } diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index df929ed2..1b5e313c 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -1,6 +1,5 @@ package org.monogram.data.infra -import org.monogram.data.core.coRunCatching import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest @@ -8,13 +7,13 @@ import android.net.Uri import android.os.Build import android.util.Log import kotlinx.coroutines.* -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.ChatRemoteSource import org.monogram.data.datasource.remote.ProxyRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher @@ -29,10 +28,9 @@ class ConnectionManager( private val appPreferences: AppPreferencesProvider, private val dispatchers: DispatcherProvider, private val connectivityManager: ConnectivityManager, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) { private val TAG = "ConnectionManager" - private val scope = scopeProvider.appScope private val _connectionStateFlow = MutableStateFlow(ConnectionStatus.Connecting) val connectionStateFlow = _connectionStateFlow.asStateFlow() diff --git a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt b/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt deleted file mode 100644 index ae4b3fd8..00000000 --- a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.monogram.data.infra - -import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob - -class DefaultScopeProvider( - dispatcherProvider: DispatcherProvider -) : ScopeProvider { - override val appScope: CoroutineScope = CoroutineScope( - SupervisorJob() + dispatcherProvider.default - ) -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index cbec4832..567f0acc 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TdLibException @@ -20,7 +19,7 @@ class FileDownloadQueue( private val gateway: TelegramGateway, val registry: FileMessageRegistry, private val cache: ChatCache, - private val scope: ScopeProvider, + private val scope: CoroutineScope, private val dispatcherProvider: DispatcherProvider ) { enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } @@ -84,7 +83,7 @@ class FileDownloadQueue( private val trigger = Channel(Channel.CONFLATED) init { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { trigger.receive() coRunCatching { dispatchTasks() } @@ -92,7 +91,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(TimeUnit.MINUTES.toMillis(1)) coRunCatching { retryFailedDownloads() } @@ -100,7 +99,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(15_000) coRunCatching { recoverStalledDownloads() } @@ -108,7 +107,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(TimeUnit.MINUTES.toMillis(5)) coRunCatching { cleanupDeadState() } @@ -165,7 +164,7 @@ class FileDownloadQueue( for (task in tasksToStart) { throttleTaskStart() - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { processDownload(task) } } @@ -279,7 +278,7 @@ class FileDownloadQueue( failedRequests.remove(req.fileId) } trigger.trySend(Unit) - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { delay(backoffMs) trigger.trySend(Unit) } @@ -304,7 +303,7 @@ class FileDownloadQueue( failedRequests.remove(req.fileId) } trigger.trySend(Unit) - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { delay(cooldownMs) trigger.trySend(Unit) } @@ -375,7 +374,7 @@ class FileDownloadQueue( if (now - recoveredAt < stalledRecoveryCooldownMs) return@forEach stalledRecoveryAt[req.fileId] = now - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val recovered = stateMutex.withLock { val active = activeRequests[req.fileId] ?: return@withLock false if (active.createdAt != req.createdAt || active.availableAt != req.availableAt) return@withLock false @@ -431,14 +430,14 @@ class FileDownloadQueue( failedRequests.remove(file.id) stalledRecoveryAt.remove(file.id) lastProgressAt.remove(file.id) - scope.appScope.launch { + scope.launch { stateMutex.withLock { pendingRequests.remove(file.id) } } notifyDownloadComplete(file.id) } else if (oldFile?.local?.isDownloadingActive == true && !file.local.isDownloadingActive) { val type = fileDownloadTypes[file.id] if (type == DownloadType.STICKER || manualDownloadIds.contains(file.id)) { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { enqueue( fileId = file.id, priority = if (type == DownloadType.STICKER) 32 else calculatePriority(file.id), @@ -477,7 +476,7 @@ class FileDownloadQueue( nearbyMessageIds[chatId] = nearby.toSet() activeChatId = chatId - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { cancelIrrelevantDownloads() (visible + nearby).forEach { messageId -> registry.getFileIdsForMessage(chatId, messageId).forEach { fileId -> @@ -498,7 +497,7 @@ class FileDownloadQueue( synchronous: Boolean = false, ignoreSuppression: Boolean = false ) { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) { return@launch } @@ -556,7 +555,7 @@ class FileDownloadQueue( val shouldKick = merged != active || cache.fileCache[fileId]?.local?.isDownloadingActive != true if (shouldKick) { activeRequests[fileId] = merged - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { try { gateway.execute( TdApi.DownloadFile( @@ -600,7 +599,7 @@ class FileDownloadQueue( suppressedAutoDownloadIds.add(fileId) } - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { try { gateway.execute(TdApi.CancelDownloadFile(fileId, false)) } catch (_: Exception) { @@ -709,7 +708,7 @@ class FileDownloadQueue( } private fun cancelIrrelevantDownloads() { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val toCancel = mutableListOf() for ((fileId, _) in pendingRequests) { @@ -721,7 +720,7 @@ class FileDownloadQueue( } private fun flushIrrelevantBackgroundDownloads() { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val toCancel = mutableListOf() stateMutex.withLock { diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index 9002a3e9..b97381b9 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -1,11 +1,11 @@ package org.monogram.data.infra +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.gateway.UpdateDispatcher import java.util.concurrent.ConcurrentHashMap @@ -13,7 +13,7 @@ class FileUpdateHandler( private val registry: FileMessageRegistry, private val queue: FileDownloadQueue, private val updates: UpdateDispatcher, - private val scope: ScopeProvider + private val scope: CoroutineScope ) { val customEmojiPaths = ConcurrentHashMap() val fileIdToCustomEmojiId = ConcurrentHashMap() @@ -33,7 +33,7 @@ class FileUpdateHandler( val uploadProgress = _uploadProgress.asSharedFlow() init { - scope.appScope.launch { + scope.launch { updates.file.collect { update -> handle(update.file) } } } @@ -52,7 +52,7 @@ class FileUpdateHandler( val entries = registry.getMessages(file.id) if (entries.isNotEmpty()) { - scope.appScope.launch { + scope.launch { if (downloadDone) { handleCustomEmoji(file.id, file.local?.path ?: "") _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) @@ -74,7 +74,7 @@ class FileUpdateHandler( } } } else if (registry.standaloneFileIds.contains(file.id)) { - scope.appScope.launch { + scope.launch { if (downloadDone) { _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) @@ -88,7 +88,7 @@ class FileUpdateHandler( } } } else { - scope.appScope.launch { + scope.launch { if (downloadDone) { _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index 5c5e2827..9d3c9130 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -1,11 +1,11 @@ package org.monogram.data.infra import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.* @@ -20,7 +20,7 @@ import org.monogram.domain.repository.StickerRepository private const val TAG = "OfflineWarmup" class OfflineWarmup( - private val scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val dispatchers: DispatcherProvider, private val gateway: TelegramGateway, private val chatDao: ChatDao, @@ -32,8 +32,6 @@ class OfflineWarmup( private val chatCache: ChatCache, private val stickerRepository: StickerRepository ) { - private val scope = scopeProvider.appScope - @Volatile private var warmupStarted = false diff --git a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt index d6f20302..c7c96cb8 100644 --- a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt +++ b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt @@ -1,12 +1,12 @@ package org.monogram.data.infra -import org.monogram.data.core.coRunCatching import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.SponsorDao import org.monogram.data.db.model.SponsorEntity import org.monogram.data.gateway.TelegramGateway @@ -26,7 +26,7 @@ private const val POST_LOGIN_SYNC_DELAY_MS = 60L * 1000L private const val ONE_DAY_MS = 24L * 60L * 60L * 1000L class SponsorSyncManager( - private val scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val gateway: TelegramGateway, private val sponsorDao: SponsorDao, private val authRepository: AuthRepository @@ -41,7 +41,7 @@ class SponsorSyncManager( fun start() { if (!started.compareAndSet(false, true)) return - scopeProvider.appScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { loadFromDatabase() var wasAuthorized = authRepository.authState.value is AuthStep.Ready @@ -78,7 +78,7 @@ class SponsorSyncManager( } fun forceSync() { - scopeProvider.appScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { syncOnce(force = true) } } diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 0139f67a..b498cdc8 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -5,7 +5,6 @@ import android.net.NetworkCapabilities import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource @@ -27,9 +26,8 @@ class MessageMapper( private val fileApi: MessageFileApi, private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, - scopeProvider: ScopeProvider + val scope: CoroutineScope ) { - val scope = scopeProvider.appScope private val customEmojiPaths = fileUpdateHandler.customEmojiPaths private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId diff --git a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt index ddd86da2..83c67501 100644 --- a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt @@ -1,12 +1,12 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.AttachBotDao @@ -24,10 +24,8 @@ class AttachMenuBotRepositoryImpl( private val updates: UpdateDispatcher, private val dispatchers: DispatcherProvider, private val attachBotDao: AttachBotDao, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : AttachMenuBotRepository { - - private val scope = scopeProvider.appScope private val attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value) init { diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt index 79b307c8..7dd3ff63 100644 --- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt @@ -1,9 +1,9 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.AuthRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher @@ -17,10 +17,8 @@ class AuthRepositoryImpl( private val parametersProvider: TdLibParametersProvider, private val remote: AuthRemoteDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : AuthRepository { - private val scope = scopeProvider.appScope - private val _authState = MutableStateFlow(AuthStep.Loading) override val authState = _authState.asStateFlow() diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 980e9888..df356557 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -1,16 +1,12 @@ package org.monogram.data.repository import android.util.Log -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.* import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource @@ -46,7 +42,7 @@ class ChatsListRepositoryImpl( private val chatMapper: ChatMapper, private val messageMapper: MessageMapper, private val gateway: TelegramGateway, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, private val connectionManager: ConnectionManager, private val databaseFile: File, @@ -64,8 +60,6 @@ class ChatsListRepositoryImpl( ChatSettingsRepository, ChatCreationRepository { - private val scope = scopeProvider.appScope - private val _chatListFlow = MutableStateFlow>(emptyList()) override val chatListFlow: StateFlow> = _chatListFlow.asStateFlow() @@ -96,7 +90,7 @@ class ChatsListRepositoryImpl( private val fileManager = ChatFileManager( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, fileQueue = fileQueue, fileUpdateHandler = fileUpdateHandler, onUpdate = { @@ -126,7 +120,7 @@ class ChatsListRepositoryImpl( private val modelFactory = ChatModelFactory( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, cache = cache, chatMapper = chatMapper, fileManager = fileManager, @@ -151,7 +145,7 @@ class ChatsListRepositoryImpl( private val folderManager = ChatFolderManager( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, foldersFlow = _foldersFlow, cacheProvider = cacheProvider, chatFolderDao = chatFolderDao diff --git a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt index a61a6123..0b8b9c13 100644 --- a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt @@ -1,11 +1,11 @@ package org.monogram.data.repository import android.content.Context +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.datasource.remote.EmojiRemoteSource import org.monogram.data.infra.EmojiLoader @@ -20,11 +20,9 @@ class EmojiRepositoryImpl( private val cacheProvider: CacheProvider, private val dispatchers: DispatcherProvider, private val context: Context, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : EmojiRepository { - private val scope = scopeProvider.appScope - override val recentEmojis: Flow> = cacheProvider.recentEmojis private var cachedEmojis: List? = null 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 10e2905d..a4963fba 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -2,12 +2,12 @@ package org.monogram.data.repository import android.content.Context import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.FileDataSource @@ -39,13 +39,12 @@ class MessageRepositoryImpl( private val cache: ChatCache, private val fileDataSource: FileDataSource, private val dispatcherProvider: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, private val userLocalDataSource: UserLocalDataSource, private val fileUpdateHandler: FileUpdateHandler, private val textCompositionStyleDao: TextCompositionStyleDao ) : MessageRepository { - private val scope = scopeProvider.appScope private val _textCompositionStyles = MutableStateFlow>(emptyList()) override val newMessageFlow = messageRemoteDataSource.newMessageFlow diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt index 74cad092..923b0eef 100644 --- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -1,12 +1,8 @@ package org.monogram.data.repository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource @@ -22,12 +18,10 @@ class NotificationSettingsRepositoryImpl( private val cache: SettingsCacheDataSource, private val chatsRemote: ChatsRemoteDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val dispatchers: DispatcherProvider ) : NotificationSettingsRepository { - private val scope = scopeProvider.appScope - init { scope.launch { updates.newChat.collect { update -> diff --git a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt index 14f52fe2..6d212661 100644 --- a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt @@ -1,9 +1,5 @@ package org.monogram.data.repository -import org.monogram.core.ScopeProvider -import org.monogram.domain.models.PrivacyRule -import org.monogram.domain.repository.PrivacyKey -import org.monogram.domain.repository.PrivacyRepository import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import org.drinkless.tdlib.TdApi @@ -11,15 +7,15 @@ import org.monogram.data.datasource.remote.PrivacyRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.toApi import org.monogram.data.mapper.toDomain +import org.monogram.domain.models.PrivacyRule +import org.monogram.domain.repository.PrivacyKey +import org.monogram.domain.repository.PrivacyRepository class PrivacyRepositoryImpl( private val remote: PrivacyRemoteDataSource, - private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val updates: UpdateDispatcher ) : PrivacyRepository { - private val scope = scopeProvider.appScope - override fun getPrivacyRules(key: PrivacyKey): Flow> = callbackFlow { val setting = key.toApi() diff --git a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt index ac9f9edc..38833a69 100644 --- a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt @@ -1,6 +1,7 @@ package org.monogram.data.repository import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive @@ -9,7 +10,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.datasource.remote.StickerRemoteSource @@ -28,11 +28,9 @@ class StickerRepositoryImpl( private val cacheProvider: CacheProvider, private val dispatchers: DispatcherProvider, private val localDataSource: StickerLocalDataSource, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : StickerRepository { - private val scope = scopeProvider.appScope - override val installedStickerSets: StateFlow> = cacheProvider.installedStickerSets override val customEmojiStickerSets: StateFlow> = cacheProvider.customEmojiStickerSets diff --git a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt index 9544d3f1..c3c9818b 100644 --- a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt @@ -1,11 +1,14 @@ package org.monogram.data.repository import androidx.media3.datasource.DataSource -import org.monogram.core.ScopeProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* -import org.monogram.data.datasource.FileDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.TelegramStreamingDataSource import org.monogram.data.gateway.UpdateDispatcher import org.monogram.domain.repository.PlayerDataSourceFactory @@ -14,11 +17,9 @@ import org.monogram.domain.repository.StreamingRepository class StreamingRepositoryImpl( private val fileDataSource: FileDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : StreamingRepository, PlayerDataSourceFactory { - private val scope = scopeProvider.appScope - private val _fileProgressFlow = MutableSharedFlow>( replay = 1, extraBufferCapacity = 100, diff --git a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt index 4efbb88a..4d9c4e8a 100644 --- a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt @@ -1,6 +1,5 @@ package org.monogram.data.repository -import org.monogram.data.core.coRunCatching import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -8,11 +7,12 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import androidx.core.content.FileProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.UpdateRemoteDateSource import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler @@ -31,11 +31,9 @@ class UpdateRepositoryImpl( private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val authRepository: AuthRepository, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : UpdateRepository { - private val scope = scopeProvider.appScope - private val _updateState = MutableStateFlow(UpdateState.Idle) override val updateState: StateFlow = _updateState.asStateFlow() diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index eb1a3771..4e67e638 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -1,12 +1,12 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.WallpaperDao import org.monogram.data.db.model.WallpaperEntity @@ -20,11 +20,9 @@ class WallpaperRepositoryImpl( private val updates: UpdateDispatcher, private val wallpaperDao: WallpaperDao, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : WallpaperRepository { - private val scope = scopeProvider.appScope - private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) private val wallpapers = MutableStateFlow>(emptyList()) diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index c0828556..e0b958c6 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -1,13 +1,9 @@ package org.monogram.data.repository.user -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource @@ -35,10 +31,8 @@ class UserRepositoryImpl( fileQueue: FileDownloadQueue, private val keyValueDao: KeyValueDao, private val cacheProvider: CacheProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : UserRepository { - - private val scope = scopeProvider.appScope private val mediaResolver = UserMediaResolver(gateway = gateway, fileQueue = fileQueue) private var currentUserId: Long = 0L private val userRequests = ConcurrentHashMap>() diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt index 5fa219f3..e0d003b6 100644 --- a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt +++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt @@ -1,12 +1,12 @@ package org.monogram.data.stickers import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.infra.FileDownloadQueue @@ -23,10 +23,8 @@ class StickerFileManager( private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) { - private val scope = scopeProvider.appScope - private val tgsCache = mutableMapOf() private val filePathsCache = ConcurrentHashMap() From 8dc669a9cf68d4275719d05943a0500b8862eb03 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:56:28 +0300 Subject: [PATCH 35/53] Rewritten mappings of chats/messages (#200) --- .../main/kotlin/org/monogram/core/Mapper.kt | 5 - .../kotlin/org/monogram/core/SuspendMapper.kt | 5 - .../monogram/data/chats/ChatModelFactory.kt | 14 +- .../remote/SettingsRemoteDataSource.kt | 5 + .../remote/TdSettingsRemoteDataSource.kt | 17 +- .../java/org/monogram/data/di/dataModule.kt | 70 +- .../monogram/data/mapper/ChatEntityMapper.kt | 62 +- .../org/monogram/data/mapper/ChatMapper.kt | 221 +- .../data/mapper/ChatPermissionsMapper.kt | 125 + .../data/mapper/ChatPositionsMapper.kt | 20 + .../monogram/data/mapper/ChatTypeHelper.kt | 41 + .../monogram/data/mapper/CustomEmojiLoader.kt | 46 + .../org/monogram/data/mapper/FilePathUtils.kt | 7 + .../monogram/data/mapper/InstantViewMapper.kt | 10 +- .../org/monogram/data/mapper/MessageMapper.kt | 2340 +++-------------- .../monogram/data/mapper/ReplyMarkupMapper.kt | 72 + .../data/mapper/SenderNameResolver.kt | 15 + .../monogram/data/mapper/StickersMapper.kt | 4 +- .../org/monogram/data/mapper/TdFileHelper.kt | 120 + .../monogram/data/mapper/TextEntityMapper.kt | 58 + .../org/monogram/data/mapper/UpdateMapper.kt | 23 +- .../data/mapper/UserStatusFormatter.kt | 60 + .../monogram/data/mapper/WallpaperMapper.kt | 125 +- .../org/monogram/data/mapper/WebPageMapper.kt | 267 ++ .../mapper/message/MessageContentMapper.kt | 593 +++++ .../message/MessagePersistenceMapper.kt | 746 ++++++ .../mapper/message/MessageSenderResolver.kt | 279 ++ .../monogram/data/mapper/user/UserMapper.kt | 82 +- .../data/repository/MessageRepositoryImpl.kt | 19 +- .../repository/ProfilePhotoRepositoryImpl.kt | 15 +- .../repository/WallpaperRepositoryImpl.kt | 36 + .../data/repository/user/UserMediaResolver.kt | 15 +- .../data/stickers/StickerFileManager.kt | 13 +- .../monogram/domain/models/WallpaperModel.kt | 14 +- .../domain/repository/WallpaperRepository.kt | 13 +- .../chatContent/ChatContentBackground.kt | 11 +- .../chatSettings/ChatSettingsComponent.kt | 65 +- .../chatSettings/ChatSettingsContent.kt | 75 +- .../components/WallpaperBackground.kt | 102 +- .../src/main/res/values-es/string.xml | 1 + .../src/main/res/values-hy/string.xml | 1 + .../src/main/res/values-pt-rBR/string.xml | 1 + .../src/main/res/values-ru-rRU/string.xml | 1 + .../src/main/res/values-sk/string.xml | 1 + .../src/main/res/values-uk/string.xml | 1 + .../src/main/res/values-zh-rCN/string.xml | 1 + presentation/src/main/res/values/string.xml | 1 + 47 files changed, 3334 insertions(+), 2484 deletions(-) delete mode 100644 core/src/main/kotlin/org/monogram/core/Mapper.kt delete mode 100644 core/src/main/kotlin/org/monogram/core/SuspendMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt diff --git a/core/src/main/kotlin/org/monogram/core/Mapper.kt b/core/src/main/kotlin/org/monogram/core/Mapper.kt deleted file mode 100644 index c7a496da..00000000 --- a/core/src/main/kotlin/org/monogram/core/Mapper.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.monogram.core - -interface Mapper { - fun map(input: I): O -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt b/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt deleted file mode 100644 index c059a41f..00000000 --- a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.monogram.core - -interface SuspendMapper { - suspend fun map(input: I): O -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index ba96a4f7..4523914b 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -7,16 +7,12 @@ import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.mapper.ChatMapper -import org.monogram.data.mapper.isForcedVerifiedChat -import org.monogram.data.mapper.isForcedVerifiedUser -import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.* import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UsernamesModel import org.monogram.domain.repository.AppPreferencesProvider -import java.io.File import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( @@ -295,12 +291,12 @@ class ChatModelFactory( } val localPath = photoFile.local.path - if (isValidPath(localPath)) { + if (isValidFilePath(localPath)) { return localPath } val cachedPath = photoFile.id.takeIf { it != 0 }?.let { fileManager.getFilePath(it) } - if (isValidPath(cachedPath)) { + if (isValidFilePath(cachedPath)) { return cachedPath } @@ -330,10 +326,6 @@ class ChatModelFactory( return (memberCount ?: 0) to (onlineCount ?: 0) } - private fun isValidPath(path: String?): Boolean { - return !path.isNullOrBlank() && File(path).exists() - } - companion object { private const val USER_FULL_INFO_RETRY_TTL_MS = 5 * 60 * 1000L } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt index 6503514b..4a06c2b0 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt @@ -14,6 +14,11 @@ interface SettingsRemoteDataSource { scope: TdApi.NotificationSettingsScope, compareSound: Boolean ): TdApi.Chats? + suspend fun setDefaultBackground( + background: TdApi.InputBackground?, + type: TdApi.BackgroundType?, + forDarkTheme: Boolean + ): TdApi.Background? // Setters suspend fun setScopeNotificationSettings( diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt index 56d27828..fcef9c0a 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt @@ -1,8 +1,8 @@ package org.monogram.data.datasource.remote -import org.monogram.data.core.coRunCatching import android.util.Log import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue @@ -32,6 +32,21 @@ class TdSettingsRemoteDataSource( result }.getOrNull() + override suspend fun setDefaultBackground( + background: TdApi.InputBackground?, + type: TdApi.BackgroundType?, + forDarkTheme: Boolean + ): TdApi.Background? = + coRunCatching { + val result = gateway.execute(TdApi.SetDefaultBackground(background, type, forDarkTheme)) + result.document?.thumbnail?.file?.let { file -> + if (file.local.path.isEmpty()) { + fileQueue.enqueue(file.id, 1, FileDownloadQueue.DownloadType.DEFAULT) + } + } + result + }.getOrNull() + override suspend fun getStorageStatistics(chatLimit: Int): TdApi.StorageStatistics? = coRunCatching { gateway.execute(TdApi.GetStorageStatistics(chatLimit)) }.getOrNull() diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index d13ee94e..2cb0be8f 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -22,10 +22,10 @@ import org.monogram.data.gateway.TelegramGatewayImpl import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.gateway.UpdateDispatcherImpl import org.monogram.data.infra.* -import org.monogram.data.mapper.ChatMapper -import org.monogram.data.mapper.MessageMapper -import org.monogram.data.mapper.NetworkMapper -import org.monogram.data.mapper.StorageMapper +import org.monogram.data.mapper.* +import org.monogram.data.mapper.message.MessageContentMapper +import org.monogram.data.mapper.message.MessagePersistenceMapper +import org.monogram.data.mapper.message.MessageSenderResolver import org.monogram.data.repository.* import org.monogram.data.repository.user.UserRepositoryImpl import org.monogram.data.stickers.StickerFileManager @@ -280,19 +280,70 @@ val dataModule = module { } single { - MessageMapper( + TdFileHelper( connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, + fileApi = get(), + appPreferences = get(), + cache = get() + ) + } + + single { + CustomEmojiLoader( gateway = get(), - userRepository = get(), - chatInfoRepository = get(), - fileUpdateHandler = get(), fileApi = get(), + fileUpdateHandler = get(), + fileHelper = get() + ) + } + + single { + WebPageMapper( + fileHelper = get(), + appPreferences = get() + ) + } + + single { + MessageContentMapper( + fileHelper = get(), appPreferences = get(), - cache = get(), + customEmojiLoader = get(), + webPageMapper = get(), scope = get() ) } + single { + MessageSenderResolver( + gateway = get(), + userRepository = get(), + chatInfoRepository = get(), + cache = get(), + fileHelper = get() + ) + } + + single { + MessagePersistenceMapper( + cache = get(), + fileHelper = get() + ) + } + + single { + MessageMapper( + gateway = get(), + userRepository = get(), + cache = get(), + fileHelper = get(), + senderResolver = get(), + contentMapper = get(), + persistenceMapper = get(), + customEmojiLoader = get() + ) + } + single { ConnectionManager( chatRemoteSource = get(), @@ -432,6 +483,7 @@ val dataModule = module { messageMapper = get(), messageRemoteDataSource = get(), cache = get(), + fileHelper = get(), dispatcherProvider = get(), scope = get(), fileDataSource = get(), diff --git a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt index 66cbe9d3..c0235ffe 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt @@ -4,19 +4,17 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.db.model.ChatEntity fun TdApi.Chat.toEntity(): ChatEntity { - val isChannel = (type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false + val isChannel = type.isChannelType() val isArchived = positions.any { it.list is TdApi.ChatListArchive } - val permissions = permissions ?: TdApi.ChatPermissions() val cachedCounts = parseCachedCounts(clientData) + val typeIds = type.extractTypeIds() + val chatPermissions = permissions.toDomainChatPermissions() val senderId = when (val sender = messageSenderId) { is TdApi.MessageSenderUser -> sender.userId is TdApi.MessageSenderChat -> sender.chatId else -> null } - val privateUserId = (type as? TdApi.ChatTypePrivate)?.userId ?: 0L - val basicGroupId = (type as? TdApi.ChatTypeBasicGroup)?.basicGroupId ?: 0L - val supergroupId = (type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: 0L - val secretChatId = (type as? TdApi.ChatTypeSecret)?.secretChatId ?: 0 + return ChatEntity( id = id, title = title, @@ -29,19 +27,13 @@ fun TdApi.Chat.toEntity(): ChatEntity { isPinned = positions.firstOrNull()?.isPinned ?: false, isMuted = notificationSettings.muteFor > 0, isChannel = isChannel, - isGroup = type is TdApi.ChatTypeBasicGroup || (type is TdApi.ChatTypeSupergroup && !isChannel), - type = when (type) { - is TdApi.ChatTypePrivate -> "PRIVATE" - is TdApi.ChatTypeBasicGroup -> "BASIC_GROUP" - is TdApi.ChatTypeSupergroup -> "SUPERGROUP" - is TdApi.ChatTypeSecret -> "SECRET" - else -> "PRIVATE" - }, - privateUserId = privateUserId, - basicGroupId = basicGroupId, - supergroupId = supergroupId, - secretChatId = secretChatId, - positionsCache = encodePositions(positions), + isGroup = type.isGroupType(), + type = type.toEntityChatType(), + privateUserId = typeIds.privateUserId, + basicGroupId = typeIds.basicGroupId, + supergroupId = typeIds.supergroupId, + secretChatId = typeIds.secretChatId, + positionsCache = encodeChatPositions(positions), isArchived = isArchived, memberCount = cachedCounts.first, onlineCount = cachedCounts.second, @@ -80,38 +72,8 @@ fun TdApi.Chat.toEntity(): ChatEntity { username = null, description = null, inviteLink = null, - permissionCanSendBasicMessages = permissions.canSendBasicMessages, - permissionCanSendAudios = permissions.canSendAudios, - permissionCanSendDocuments = permissions.canSendDocuments, - permissionCanSendPhotos = permissions.canSendPhotos, - permissionCanSendVideos = permissions.canSendVideos, - permissionCanSendVideoNotes = permissions.canSendVideoNotes, - permissionCanSendVoiceNotes = permissions.canSendVoiceNotes, - permissionCanSendPolls = permissions.canSendPolls, - permissionCanSendOtherMessages = permissions.canSendOtherMessages, - permissionCanAddLinkPreviews = permissions.canAddLinkPreviews, - permissionCanEditTag = permissions.canEditTag, - permissionCanChangeInfo = permissions.canChangeInfo, - permissionCanInviteUsers = permissions.canInviteUsers, - permissionCanPinMessages = permissions.canPinMessages, - permissionCanCreateTopics = permissions.canCreateTopics, createdAt = System.currentTimeMillis() - ) -} - -private fun encodePositions(positions: Array): String? { - if (positions.isEmpty()) return null - val encoded = positions.mapNotNull { pos -> - if (pos.order == 0L) return@mapNotNull null - val pinned = if (pos.isPinned) 1 else 0 - when (val list = pos.list) { - is TdApi.ChatListMain -> "m:${pos.order}:$pinned" - is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" - is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" - else -> null - } - } - return if (encoded.isEmpty()) null else encoded.joinToString("|") + ).withPermissions(chatPermissions) } private fun parseCachedCounts(clientData: String?): Pair { diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 5d7dbe27..3a0ed425 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -1,6 +1,5 @@ package org.monogram.data.mapper -import android.text.format.DateUtils import org.drinkless.tdlib.TdApi import org.monogram.data.db.model.ChatEntity import org.monogram.domain.models.* @@ -40,30 +39,14 @@ class ChatMapper(private val stringProvider: StringProvider) { hasAutomaticTranslation: Boolean = false, personalAvatarPath: String? = null ): ChatModel { - val p = chat.permissions ?: TdApi.ChatPermissions() - val permissions = ChatPermissionsModel( - canSendBasicMessages = p.canSendBasicMessages, - canSendAudios = p.canSendAudios, - canSendDocuments = p.canSendDocuments, - canSendPhotos = p.canSendPhotos, - canSendVideos = p.canSendVideos, - canSendVideoNotes = p.canSendVideoNotes, - canSendVoiceNotes = p.canSendVoiceNotes, - canSendPolls = p.canSendPolls, - canSendOtherMessages = p.canSendOtherMessages, - canAddLinkPreviews = p.canAddLinkPreviews, - canEditTag = p.canEditTag, - canChangeInfo = p.canChangeInfo, - canInviteUsers = p.canInviteUsers, - canPinMessages = p.canPinMessages, - canCreateTopics = p.canCreateTopics, - ) - - val isChannel = (chat.type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false + val permissions = chat.permissions.toDomainChatPermissions() + val isChannel = chat.type.isChannelType() val draft = chat.draftMessage?.inputMessageText as? TdApi.InputMessageText val draftText = draft?.text?.text - val draftEntities = draft?.text?.entities?.map { mapEntity(it) } ?: emptyList() + val draftEntities = draft?.text?.entities + ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } + ?: emptyList() return ChatModel( id = chat.id, @@ -78,7 +61,7 @@ class ChatMapper(private val stringProvider: StringProvider) { lastMessageTime = lastMessageTime, lastMessageDate = lastMessageDate, order = order, - isGroup = chat.type is TdApi.ChatTypeBasicGroup || (chat.type is TdApi.ChatTypeSupergroup && !isChannel), + isGroup = chat.type.isGroupType(), isSupergroup = chat.type is TdApi.ChatTypeSupergroup, isChannel = isChannel, memberCount = memberCount, @@ -126,13 +109,7 @@ class ChatMapper(private val stringProvider: StringProvider) { usernames = usernames, description = description, inviteLink = inviteLink, - type = when (chat.type) { - is TdApi.ChatTypePrivate -> ChatType.PRIVATE - is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP - is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP - is TdApi.ChatTypeSecret -> ChatType.SECRET - else -> ChatType.PRIVATE - }, + type = chat.type.toDomainChatType(), permissions = permissions, isMember = isMember ) @@ -192,23 +169,7 @@ class ChatMapper(private val stringProvider: StringProvider) { username = entity.username, description = entity.description, inviteLink = entity.inviteLink, - permissions = ChatPermissionsModel( - canSendBasicMessages = entity.permissionCanSendBasicMessages, - canSendAudios = entity.permissionCanSendAudios, - canSendDocuments = entity.permissionCanSendDocuments, - canSendPhotos = entity.permissionCanSendPhotos, - canSendVideos = entity.permissionCanSendVideos, - canSendVideoNotes = entity.permissionCanSendVideoNotes, - canSendVoiceNotes = entity.permissionCanSendVoiceNotes, - canSendPolls = entity.permissionCanSendPolls, - canSendOtherMessages = entity.permissionCanSendOtherMessages, - canAddLinkPreviews = entity.permissionCanAddLinkPreviews, - canEditTag = entity.permissionCanEditTag, - canChangeInfo = entity.permissionCanChangeInfo, - canInviteUsers = entity.permissionCanInviteUsers, - canPinMessages = entity.permissionCanPinMessages, - canCreateTopics = entity.permissionCanCreateTopics - ) + permissions = entity.toDomainChatPermissionsModel() ) } @@ -271,65 +232,15 @@ class ChatMapper(private val stringProvider: StringProvider) { username = domain.username, description = domain.description, inviteLink = domain.inviteLink, - permissionCanSendBasicMessages = domain.permissions.canSendBasicMessages, - permissionCanSendAudios = domain.permissions.canSendAudios, - permissionCanSendDocuments = domain.permissions.canSendDocuments, - permissionCanSendPhotos = domain.permissions.canSendPhotos, - permissionCanSendVideos = domain.permissions.canSendVideos, - permissionCanSendVideoNotes = domain.permissions.canSendVideoNotes, - permissionCanSendVoiceNotes = domain.permissions.canSendVoiceNotes, - permissionCanSendPolls = domain.permissions.canSendPolls, - permissionCanSendOtherMessages = domain.permissions.canSendOtherMessages, - permissionCanAddLinkPreviews = domain.permissions.canAddLinkPreviews, - permissionCanEditTag = domain.permissions.canEditTag, - permissionCanChangeInfo = domain.permissions.canChangeInfo, - permissionCanInviteUsers = domain.permissions.canInviteUsers, - permissionCanPinMessages = domain.permissions.canPinMessages, - permissionCanCreateTopics = domain.permissions.canCreateTopics, lastMessageContentType = "text", lastMessageSenderName = "", createdAt = System.currentTimeMillis() - ) + ).withPermissions(domain.permissions) } fun mapToEntity(chat: TdApi.Chat, domain: ChatModel): ChatEntity { - val privateUserId: Long - val basicGroupId: Long - val supergroupId: Long - val secretChatId: Int - when (val t = chat.type) { - is TdApi.ChatTypePrivate -> { - privateUserId = t.userId - basicGroupId = 0L - supergroupId = 0L - secretChatId = 0 - } - is TdApi.ChatTypeBasicGroup -> { - privateUserId = 0L - basicGroupId = t.basicGroupId - supergroupId = 0L - secretChatId = 0 - } - is TdApi.ChatTypeSupergroup -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = t.supergroupId - secretChatId = 0 - } - is TdApi.ChatTypeSecret -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = 0L - secretChatId = t.secretChatId - } - else -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = 0L - secretChatId = 0 - } - } - val encodedPositions = encodePositions(chat.positions) + val typeIds = chat.type.extractTypeIds() + val encodedPositions = encodeChatPositions(chat.positions) val (lastMessageContentType, lastMessageSenderName) = chat.lastMessage?.let { message -> val type = when (message.content) { is TdApi.MessageText -> "text" @@ -357,10 +268,10 @@ class ChatMapper(private val stringProvider: StringProvider) { } ?: ("text" to "") return mapToEntity(domain).copy( - privateUserId = privateUserId, - basicGroupId = basicGroupId, - supergroupId = supergroupId, - secretChatId = secretChatId, + privateUserId = typeIds.privateUserId, + basicGroupId = typeIds.basicGroupId, + supergroupId = typeIds.supergroupId, + secretChatId = typeIds.secretChatId, positionsCache = encodedPositions, lastMessageDate = chat.lastMessage?.date ?: domain.lastMessageDate, lastMessageContentType = lastMessageContentType, @@ -368,23 +279,6 @@ class ChatMapper(private val stringProvider: StringProvider) { ) } - private fun encodePositions(positions: Array): String? { - if (positions.isEmpty()) return null - - val encoded = positions.mapNotNull { pos -> - if (pos.order == 0L) return@mapNotNull null - val pinned = if (pos.isPinned) 1 else 0 - when (val list = pos.list) { - is TdApi.ChatListMain -> "m:${pos.order}:$pinned" - is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" - is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" - else -> null - } - } - - return if (encoded.isEmpty()) null else encoded.joinToString("|") - } - fun formatMessageInfo( lastMsg: TdApi.Message?, chat: TdApi.Chat?, @@ -396,7 +290,9 @@ class ChatMapper(private val stringProvider: StringProvider) { fun captionOrFallback(caption: TdApi.FormattedText?, emojiPrefix: String, fallbackKey: String): String { val text = caption?.text?.trim().orEmpty() if (text.isNotEmpty()) { - entities = caption?.entities?.map { mapEntity(it) } ?: emptyList() + entities = caption?.entities + ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } + ?: emptyList() return "$emojiPrefix$text" } return stringProvider.getString(fallbackKey) @@ -404,7 +300,8 @@ class ChatMapper(private val stringProvider: StringProvider) { var txt = when (val c = lastMsg.content) { is TdApi.MessageText -> { - entities = c.text.entities.map { mapEntity(it) } + entities = c.text.entities + .mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } c.text.text } is TdApi.MessagePhoto -> captionOrFallback(c.caption, "📷 ", "chat_mapper_photo") @@ -510,79 +407,11 @@ class ChatMapper(private val stringProvider: StringProvider) { return String(chars) } - private fun mapEntity(entity: TdApi.TextEntity): MessageEntity { - return MessageEntity( - offset = entity.offset, - length = entity.length, - type = when (entity.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl((entity.type as TdApi.TextEntityTypeTextUrl).url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention((entity.type as TdApi.TextEntityTypeMentionName).userId) - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji((entity.type as TdApi.TextEntityTypeCustomEmoji).customEmojiId) - is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote - is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable - else -> MessageEntityType.Other(entity.type.javaClass.simpleName) - } - ) - } - fun formatUserStatus(status: TdApi.UserStatus, isBot: Boolean = false): String { - if (isBot) return stringProvider.getString("chat_mapper_bot") - return when (status) { - is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online") - is TdApi.UserStatusOffline -> { - val wasOnline = status.wasOnline.toLong() * 1000L - if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline") - val now = System.currentTimeMillis() - val diff = now - wasOnline - when { - diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now") - diff < 60 * 60 * 1000 -> { - val minutes = diff / (60 * 1000L) - if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1) - else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes) - } - DateUtils.isToday(wasOnline) -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_at", format.format(date)) - } - - isYesterday(wasOnline) -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_yesterday", format.format(date)) - } - else -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_date", format.format(date)) - } - } - } - - is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently") - is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week") - is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month") - is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline") - else -> "" - } - } - - private fun isYesterday(timestamp: Long): Boolean { - return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) + return formatChatUserStatus( + status = status, + stringProvider = stringProvider, + isBot = isBot + ) } } diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt new file mode 100644 index 00000000..fb93b8dd --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt @@ -0,0 +1,125 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.ChatEntity +import org.monogram.domain.models.ChatPermissionsModel + +internal data class ChatEntityPermissionValues( + val canSendBasicMessages: Boolean, + val canSendAudios: Boolean, + val canSendDocuments: Boolean, + val canSendPhotos: Boolean, + val canSendVideos: Boolean, + val canSendVideoNotes: Boolean, + val canSendVoiceNotes: Boolean, + val canSendPolls: Boolean, + val canSendOtherMessages: Boolean, + val canAddLinkPreviews: Boolean, + val canEditTag: Boolean, + val canChangeInfo: Boolean, + val canInviteUsers: Boolean, + val canPinMessages: Boolean, + val canCreateTopics: Boolean +) + +internal fun TdApi.ChatPermissions?.toDomainChatPermissions(): ChatPermissionsModel { + val permissions = this ?: TdApi.ChatPermissions() + return ChatPermissionsModel( + canSendBasicMessages = permissions.canSendBasicMessages, + canSendAudios = permissions.canSendAudios, + canSendDocuments = permissions.canSendDocuments, + canSendPhotos = permissions.canSendPhotos, + canSendVideos = permissions.canSendVideos, + canSendVideoNotes = permissions.canSendVideoNotes, + canSendVoiceNotes = permissions.canSendVoiceNotes, + canSendPolls = permissions.canSendPolls, + canSendOtherMessages = permissions.canSendOtherMessages, + canAddLinkPreviews = permissions.canAddLinkPreviews, + canEditTag = permissions.canEditTag, + canChangeInfo = permissions.canChangeInfo, + canInviteUsers = permissions.canInviteUsers, + canPinMessages = permissions.canPinMessages, + canCreateTopics = permissions.canCreateTopics, + ) +} + +internal fun ChatPermissionsModel.toTdApiChatPermissions(): TdApi.ChatPermissions { + return TdApi.ChatPermissions( + canSendBasicMessages, + canSendAudios, + canSendDocuments, + canSendPhotos, + canSendVideos, + canSendVideoNotes, + canSendVoiceNotes, + canSendPolls, + canSendOtherMessages, + canAddLinkPreviews, + canEditTag, + canChangeInfo, + canInviteUsers, + canPinMessages, + canCreateTopics + ) +} + +internal fun ChatEntity.toDomainChatPermissionsModel(): ChatPermissionsModel { + return ChatPermissionsModel( + canSendBasicMessages = permissionCanSendBasicMessages, + canSendAudios = permissionCanSendAudios, + canSendDocuments = permissionCanSendDocuments, + canSendPhotos = permissionCanSendPhotos, + canSendVideos = permissionCanSendVideos, + canSendVideoNotes = permissionCanSendVideoNotes, + canSendVoiceNotes = permissionCanSendVoiceNotes, + canSendPolls = permissionCanSendPolls, + canSendOtherMessages = permissionCanSendOtherMessages, + canAddLinkPreviews = permissionCanAddLinkPreviews, + canEditTag = permissionCanEditTag, + canChangeInfo = permissionCanChangeInfo, + canInviteUsers = permissionCanInviteUsers, + canPinMessages = permissionCanPinMessages, + canCreateTopics = permissionCanCreateTopics + ) +} + +internal fun ChatPermissionsModel.toEntityPermissionValues(): ChatEntityPermissionValues { + return ChatEntityPermissionValues( + canSendBasicMessages = canSendBasicMessages, + canSendAudios = canSendAudios, + canSendDocuments = canSendDocuments, + canSendPhotos = canSendPhotos, + canSendVideos = canSendVideos, + canSendVideoNotes = canSendVideoNotes, + canSendVoiceNotes = canSendVoiceNotes, + canSendPolls = canSendPolls, + canSendOtherMessages = canSendOtherMessages, + canAddLinkPreviews = canAddLinkPreviews, + canEditTag = canEditTag, + canChangeInfo = canChangeInfo, + canInviteUsers = canInviteUsers, + canPinMessages = canPinMessages, + canCreateTopics = canCreateTopics + ) +} + +internal fun ChatEntity.withPermissions(permissions: ChatPermissionsModel): ChatEntity { + val values = permissions.toEntityPermissionValues() + return copy( + permissionCanSendBasicMessages = values.canSendBasicMessages, + permissionCanSendAudios = values.canSendAudios, + permissionCanSendDocuments = values.canSendDocuments, + permissionCanSendPhotos = values.canSendPhotos, + permissionCanSendVideos = values.canSendVideos, + permissionCanSendVideoNotes = values.canSendVideoNotes, + permissionCanSendVoiceNotes = values.canSendVoiceNotes, + permissionCanSendPolls = values.canSendPolls, + permissionCanSendOtherMessages = values.canSendOtherMessages, + permissionCanAddLinkPreviews = values.canAddLinkPreviews, + permissionCanEditTag = values.canEditTag, + permissionCanChangeInfo = values.canChangeInfo, + permissionCanInviteUsers = values.canInviteUsers, + permissionCanPinMessages = values.canPinMessages, + permissionCanCreateTopics = values.canCreateTopics + ) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt new file mode 100644 index 00000000..825ed0e5 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt @@ -0,0 +1,20 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi + +internal fun encodeChatPositions(positions: Array): String? { + if (positions.isEmpty()) return null + + val encoded = positions.mapNotNull { position -> + if (position.order == 0L) return@mapNotNull null + val pinned = if (position.isPinned) 1 else 0 + when (val list = position.list) { + is TdApi.ChatListMain -> "m:${position.order}:$pinned" + is TdApi.ChatListArchive -> "a:${position.order}:$pinned" + is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${position.order}:$pinned" + else -> null + } + } + + return if (encoded.isEmpty()) null else encoded.joinToString("|") +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt new file mode 100644 index 00000000..041b92f4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt @@ -0,0 +1,41 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.ChatType + +internal data class TdChatTypeIds( + val privateUserId: Long = 0L, + val basicGroupId: Long = 0L, + val supergroupId: Long = 0L, + val secretChatId: Int = 0 +) + +internal fun TdApi.ChatType.toDomainChatType(): ChatType { + return when (this) { + is TdApi.ChatTypePrivate -> ChatType.PRIVATE + is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP + is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP + is TdApi.ChatTypeSecret -> ChatType.SECRET + else -> ChatType.PRIVATE + } +} + +internal fun TdApi.ChatType.toEntityChatType(): String = toDomainChatType().name + +internal fun TdApi.ChatType.isChannelType(): Boolean { + return (this as? TdApi.ChatTypeSupergroup)?.isChannel ?: false +} + +internal fun TdApi.ChatType.isGroupType(): Boolean { + return this is TdApi.ChatTypeBasicGroup || (this is TdApi.ChatTypeSupergroup && !isChannel) +} + +internal fun TdApi.ChatType.extractTypeIds(): TdChatTypeIds { + return when (this) { + is TdApi.ChatTypePrivate -> TdChatTypeIds(privateUserId = userId) + is TdApi.ChatTypeBasicGroup -> TdChatTypeIds(basicGroupId = basicGroupId) + is TdApi.ChatTypeSupergroup -> TdChatTypeIds(supergroupId = supergroupId) + is TdApi.ChatTypeSecret -> TdChatTypeIds(secretChatId = secretChatId) + else -> TdChatTypeIds() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt new file mode 100644 index 00000000..a3169cf6 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt @@ -0,0 +1,46 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.FileUpdateHandler + +internal class CustomEmojiLoader( + private val gateway: TelegramGateway, + private val fileApi: MessageFileApi, + private val fileUpdateHandler: FileUpdateHandler, + private val fileHelper: TdFileHelper +) { + fun getPathIfValid(emojiId: Long): String? { + return fileUpdateHandler.customEmojiPaths[emojiId] + ?.takeIf { fileHelper.isValidPath(it) } + } + + suspend fun loadIfNeeded(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) { + if (getPathIfValid(emojiId) != null) return + + val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) + if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { + val fileToUse = result.stickers.first().sticker + + fileUpdateHandler.fileIdToCustomEmojiId[fileToUse.id] = emojiId + fileApi.registerFileForMessage(fileToUse.id, chatId, messageId) + + if (!fileHelper.isValidPath(fileToUse.local.path)) { + if (autoDownload) { + fileApi.enqueueDownload( + fileToUse.id, + 32, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } else { + fileUpdateHandler.customEmojiPaths[emojiId] = fileToUse.local.path + } + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt new file mode 100644 index 00000000..b4b401ac --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt @@ -0,0 +1,7 @@ +package org.monogram.data.mapper + +import java.io.File + +internal fun isValidFilePath(path: String?): Boolean { + return !path.isNullOrEmpty() && File(path).exists() +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt index 5651929b..7c1c4f9e 100644 --- a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt @@ -144,7 +144,7 @@ private fun TdApi.PageBlockRelatedArticle.toRelatedArticle() = PageBlockRelatedA private fun TdApi.Photo.toPhoto(): WebPage.Photo { val size = sizes.lastOrNull() return WebPage.Photo( - path = size?.photo?.local?.path?.ifEmpty { null }, + path = size?.photo?.local?.path?.takeIf { isValidFilePath(it) }, width = size?.width ?: 0, height = size?.height ?: 0, fileId = size?.photo?.id ?: 0, @@ -153,7 +153,7 @@ private fun TdApi.Photo.toPhoto(): WebPage.Photo { } private fun TdApi.Animation.toAnimation() = WebPage.Animation( - path = animation.local.path.ifEmpty { null }, + path = animation.local.path.takeIf { isValidFilePath(it) }, width = width, height = height, duration = duration, @@ -161,7 +161,7 @@ private fun TdApi.Animation.toAnimation() = WebPage.Animation( ) private fun TdApi.Audio.toAudio() = WebPage.Audio( - path = audio.local.path.ifEmpty { null }, + path = audio.local.path.takeIf { isValidFilePath(it) }, duration = duration, title = title, performer = performer, @@ -169,7 +169,7 @@ private fun TdApi.Audio.toAudio() = WebPage.Audio( ) private fun TdApi.Video.toVideo() = WebPage.Video( - path = video.local.path.ifEmpty { null }, + path = video.local.path.takeIf { isValidFilePath(it) }, width = width, height = height, duration = duration, @@ -177,7 +177,7 @@ private fun TdApi.Video.toVideo() = WebPage.Video( ) private fun TdApi.Document.toDocument() = WebPage.Document( - path = document.local.path.ifEmpty { null }, + path = document.local.path.takeIf { isValidFilePath(it) }, fileName = fileName, mimeType = mimeType, size = document.size, diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index b498cdc8..8e8a9caf 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -1,489 +1,344 @@ package org.monogram.data.mapper -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import org.drinkless.tdlib.TdApi import org.monogram.data.chats.ChatCache -import org.monogram.data.datasource.remote.MessageFileApi -import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.mapper.message.ContentMappingContext +import org.monogram.data.mapper.message.MessageContentMapper +import org.monogram.data.mapper.message.MessagePersistenceMapper +import org.monogram.data.mapper.message.MessageSenderResolver import org.monogram.domain.models.* -import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.UserRepository -import java.io.File -import java.util.concurrent.ConcurrentHashMap -class MessageMapper( - private val connectivityManager: ConnectivityManager, +class MessageMapper internal constructor( private val gateway: TelegramGateway, private val userRepository: UserRepository, - private val chatInfoRepository: ChatInfoRepository, - private val fileUpdateHandler: FileUpdateHandler, - private val fileApi: MessageFileApi, - private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, - val scope: CoroutineScope + private val fileHelper: TdFileHelper, + private val senderResolver: MessageSenderResolver, + private val contentMapper: MessageContentMapper, + private val persistenceMapper: MessagePersistenceMapper, + private val customEmojiLoader: CustomEmojiLoader ) { - private val customEmojiPaths = fileUpdateHandler.customEmojiPaths - private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId - - private data class SenderUserSnapshot( - val name: String, - val avatar: String?, - val personalAvatar: String?, - val isVerified: Boolean, - val isPremium: Boolean, - val statusEmojiId: Long, - val statusEmojiPath: String? - ) - - private data class SenderChatSnapshot( - val name: String, - val avatar: String? - ) - - private val senderUserSnapshotCache = ConcurrentHashMap() - private val senderChatSnapshotCache = ConcurrentHashMap() - private val senderRankCache = ConcurrentHashMap() - private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet() - val senderUpdateFlow: Flow - get() = userRepository.anyUserUpdateFlow + get() = senderResolver.senderUpdateFlow fun invalidateSenderCache(userId: Long) { - if (userId <= 0L) return - senderUserSnapshotCache.remove(userId) - senderChatSnapshotCache.remove(userId) - senderRankCache.entries.removeIf { it.key.endsWith(":$userId") } - } - - private companion object { - private const val NO_RANK_SENTINEL = "__NO_RANK__" - private const val META_SEPARATOR = '\u001F' - private const val MESSAGE_MAP_TIMEOUT_MS = 2500L - } - - private fun getCurrentNetworkType(): TdApi.NetworkType { - val activeNetwork = connectivityManager.activeNetwork - val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) - - return when { - capabilities == null -> TdApi.NetworkTypeNone() - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi() - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { - if (connectivityManager.isDefaultNetworkActive && capabilities.hasCapability( - NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) - .not() - ) { - TdApi.NetworkTypeMobileRoaming() - } else { - TdApi.NetworkTypeMobile() - } - } - else -> TdApi.NetworkTypeNone() - } - } - - private fun isNetworkAutoDownloadEnabled(): Boolean { - return when (getCurrentNetworkType()) { - is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value - is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value - is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value - else -> appPreferences.autoDownloadWifi.value - } - } - - private fun isValidPath(path: String?): Boolean { - return !path.isNullOrEmpty() && File(path).exists() - } - - private fun encodeMeta(vararg parts: Any?): String { - return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() } - } - - private fun decodeMeta(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|') - } - - private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair { - return when (contentType) { - "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) - "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2) - "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) - "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6) - "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5) - "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - else -> 0 to null - } + senderResolver.invalidateCache(userId) } - private fun resolveCachedPath(fileId: Int, storedPath: String?): String? { - val fromStored = storedPath - ?.takeIf { it.isNotBlank() } - ?.takeIf { isValidPath(it) } - if (fromStored != null) return fromStored - - return fileId.takeIf { it != 0 } - ?.let { cache.fileCache[it]?.local?.path } - ?.takeIf { isValidPath(it) } - } + suspend fun mapMessageToModel( + msg: TdApi.Message, + isChatOpen: Boolean = false, + isReply: Boolean = false + ): MessageModel = coroutineScope { + withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { + val sender = senderResolver.resolveSender(msg) - private fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { - if (fileId != 0) { - fileApi.registerFileForMessage(fileId, chatId, messageId) - } - } + val (replyToMsgId, replyToMsg) = resolveReplyInfo( + msg = msg, + isChatOpen = isChatOpen, + isReply = isReply + ) - private fun resolveLocalFilePath(file: TdApi.File?): String? { - if (file == null) return null - val directPath = file.local.path.takeIf { isValidPath(it) } - if (directPath != null) return directPath + val forwardInfo = resolveForwardInfo(msg) + val views = msg.interactionInfo?.viewCount + val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 + val sendingState = resolveSendingState(msg) + val reactions = resolveReactions(msg, isReply, isChatOpen) + val threadId = resolveThreadId(msg) + val viaBotName = resolveViaBotName(msg) - return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) } + createMessageModel( + msg = msg, + senderName = sender.senderName, + senderId = sender.senderId, + senderAvatar = sender.senderAvatar, + isReadOverride = false, + replyToMsgId = replyToMsgId, + replyToMsg = replyToMsg, + forwardInfo = forwardInfo, + views = views, + viewCount = views, + mediaAlbumId = msg.mediaAlbumId, + sendingState = sendingState, + isChatOpen = isChatOpen, + readDate = 0, + reactions = reactions, + isSenderVerified = sender.isSenderVerified, + threadId = threadId, + replyCount = replyCount, + isReply = isReply, + viaBotUserId = msg.viaBotUserId, + viaBotName = viaBotName, + senderPersonalAvatar = sender.senderPersonalAvatar, + senderCustomTitle = sender.senderCustomTitle, + isSenderPremium = sender.isSenderPremium, + senderStatusEmojiId = sender.senderStatusEmojiId, + senderStatusEmojiPath = sender.senderStatusEmojiPath + ) + } ?: mapMessageToModelFallback(msg, isChatOpen, isReply) } - private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String { - val user = cache.getUser(senderId) - if (user != null) { - return listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ").ifBlank { fallback.ifBlank { "User" } } - } - - val chat = cache.getChat(senderId) - if (chat != null) { - return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { + val chat = cache.getChat(chatId) + if (chat?.type !is TdApi.ChatTypePrivate) { + return 0 } - return fallback.ifBlank { "User" } - } - - private fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? { - if (mainFile != null && isValidPath(mainFile.local.path)) { - return mainFile.local.path + val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60) + if (messageDate < sevenDaysAgo) { + return 0 } - if (sizes != null) { - return sizes.sortedByDescending { it.width } - .map { getUpdatedFile(it.photo) } - .firstOrNull { isValidPath(it.local.path) } - ?.local?.path + return try { + val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId)) + if (result is TdApi.MessageReadDateRead) { + result.readDate + } else { + 0 + } + } catch (_: Exception) { + 0 } - return null } - private fun resolveFallbackSender(msg: TdApi.Message): Triple { - return when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> { - val senderId = sender.userId - val snapshot = senderUserSnapshotCache[senderId] - if (snapshot != null) { - val avatar = snapshot.avatar ?: snapshot.personalAvatar - Triple(senderId, snapshot.name.ifBlank { "User" }, avatar) - } else { - val user = cache.getUser(senderId) - val fallbackName = if (user != null) { - listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ").ifBlank { "User" } - } else { - "User" - } - val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { isValidPath(it) } - ?: user?.profilePhoto?.big?.local?.path?.takeIf { isValidPath(it) } - Triple(senderId, fallbackName, avatar) - } - } - - is TdApi.MessageSenderChat -> { - val senderId = sender.chatId - val snapshot = senderChatSnapshotCache[senderId] - if (snapshot != null) { - Triple(senderId, snapshot.name.ifBlank { "User" }, snapshot.avatar) - } else { - val chat = cache.getChat(senderId) - val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User" - val avatar = chat?.photo?.small?.local?.path?.takeIf { isValidPath(it) } - Triple(senderId, fallbackName, avatar) - } - } - - else -> Triple(0L, "User", null) - } + suspend fun mapMessageToModelSync( + msg: TdApi.Message, + inboxLimit: Long, + outboxLimit: Long, + isChatOpen: Boolean = false, + isReply: Boolean = false + ): MessageModel { + val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit + val baseModel = mapMessageToModel(msg, isChatOpen, isReply) + return baseModel.copy(isRead = isRead) } - private fun mapMessageToModelFallback( + internal fun createMessageModel( msg: TdApi.Message, - isChatOpen: Boolean, - isReply: Boolean + senderName: String, + senderId: Long, + senderAvatar: String?, + isReadOverride: Boolean = false, + replyToMsgId: Long? = null, + replyToMsg: MessageModel? = null, + forwardInfo: ForwardInfo? = null, + views: Int? = null, + viewCount: Int? = null, + mediaAlbumId: Long = 0L, + sendingState: MessageSendingState? = null, + isChatOpen: Boolean = false, + readDate: Int = 0, + reactions: List = emptyList(), + isSenderVerified: Boolean = false, + threadId: Long? = null, + replyCount: Int = 0, + isReply: Boolean = false, + viaBotUserId: Long = 0L, + viaBotName: String? = null, + senderPersonalAvatar: String? = null, + senderCustomTitle: String? = null, + isSenderPremium: Boolean = false, + senderStatusEmojiId: Long = 0L, + senderStatusEmojiPath: String? = null ): MessageModel { - val (senderId, senderName, senderAvatar) = resolveFallbackSender(msg) - return createMessageModel( + val networkAutoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() + val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending + + val content = contentMapper.mapContent( msg = msg, + context = ContentMappingContext( + chatId = msg.chatId, + messageId = msg.id, + senderName = senderName, + networkAutoDownload = networkAutoDownload, + isActuallyUploading = isActuallyUploading + ) + ) + + val isServiceMessage = content is MessageContent.Service + val canEdit = msg.isOutgoing && !isServiceMessage + val canForward = !isServiceMessage + val canSave = !isServiceMessage + val hasInteraction = msg.interactionInfo != null + + return MessageModel( + id = msg.id, + date = contentMapper.resolveMessageDate(msg), + isOutgoing = msg.isOutgoing, senderName = senderName, + chatId = msg.chatId, + content = content, senderId = senderId, senderAvatar = senderAvatar, - isChatOpen = isChatOpen, - isReply = isReply + senderPersonalAvatar = senderPersonalAvatar, + senderCustomTitle = senderCustomTitle, + isRead = isReadOverride, + replyToMsgId = replyToMsgId, + replyToMsg = replyToMsg, + forwardInfo = forwardInfo, + views = views, + viewCount = viewCount, + mediaAlbumId = mediaAlbumId, + editDate = msg.editDate, + sendingState = sendingState, + readDate = readDate, + reactions = reactions, + isSenderVerified = isSenderVerified, + threadId = threadId, + replyCount = replyCount, + canBeEdited = canEdit, + canBeForwarded = canForward, + canBeDeletedOnlyForSelf = true, + canBeDeletedForAllUsers = msg.isOutgoing, + canBeSaved = canSave, + canGetMessageThread = msg.interactionInfo?.replyInfo != null, + canGetStatistics = hasInteraction, + canGetReadReceipts = hasInteraction, + canGetViewers = hasInteraction, + replyMarkup = if (isReply) null else msg.replyMarkup.toDomainReplyMarkup(), + viaBotUserId = viaBotUserId, + viaBotName = viaBotName, + isSenderPremium = isSenderPremium, + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = senderStatusEmojiPath ) } - suspend fun mapMessageToModel( + fun mapToEntity( msg: TdApi.Message, - isChatOpen: Boolean = false, - isReply: Boolean = false - ): MessageModel = coroutineScope { - withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { - var senderName = "User" - var senderAvatar: String? = null - var senderPersonalAvatar: String? = null - var senderCustomTitle: String? = null - var isSenderVerified = false - var isSenderPremium = false - var senderStatusEmojiId = 0L - var senderStatusEmojiPath: String? = null - val senderId: Long + getSenderName: ((Long) -> String?)? = null + ): org.monogram.data.db.model.MessageEntity { + return persistenceMapper.mapToEntity(msg, getSenderName) + } - when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> { - senderId = sender.userId - val cachedSnapshot = senderUserSnapshotCache[senderId] - if (cachedSnapshot != null) { - senderName = cachedSnapshot.name - senderAvatar = cachedSnapshot.avatar - senderPersonalAvatar = cachedSnapshot.personalAvatar - isSenderVerified = cachedSnapshot.isVerified - isSenderPremium = cachedSnapshot.isPremium - senderStatusEmojiId = cachedSnapshot.statusEmojiId - senderStatusEmojiPath = cachedSnapshot.statusEmojiPath - } else { - val user = try { - withTimeout(500) { userRepository.getUser(senderId) } - } catch (e: Exception) { - null - } - if (user != null) { - senderName = listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ") + internal fun extractCachedContent(content: TdApi.MessageContent): MessagePersistenceMapper.CachedMessageContent { + return persistenceMapper.extractCachedContent(content) + } - if (senderName.isBlank()) senderName = "User" + fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel { + return persistenceMapper.mapEntityToModel(entity) + } - senderAvatar = user.avatarPath.takeIf { isValidPath(it) } - senderPersonalAvatar = user.personalAvatarPath.takeIf { isValidPath(it) } - isSenderVerified = user.isVerified - isSenderPremium = user.isPremium - senderStatusEmojiId = user.statusEmojiId - senderStatusEmojiPath = user.statusEmojiPath + private suspend fun resolveReplyInfo( + msg: TdApi.Message, + isChatOpen: Boolean, + isReply: Boolean + ): Pair { + if (isReply || msg.replyTo == null) return null to null + val replyTo = msg.replyTo + if (replyTo !is TdApi.MessageReplyToMessage) return null to null + + val replyToMsgId = replyTo.messageId + val repliedMessage = try { + withTimeout(500) { + cache.getMessage(msg.chatId, replyToMsgId) + ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) } + } + } catch (_: Exception) { + null + } - senderUserSnapshotCache[senderId] = SenderUserSnapshot( - name = senderName, - avatar = senderAvatar, - personalAvatar = senderPersonalAvatar, - isVerified = isSenderVerified, - isPremium = isSenderPremium, - statusEmojiId = senderStatusEmojiId, - statusEmojiPath = senderStatusEmojiPath - ) - } - } + val replyToMsg = repliedMessage?.let { + mapMessageToModel( + msg = it, + isChatOpen = isChatOpen, + isReply = true + ).copy(replyToMsg = null, replyToMsgId = null) + } - val chat = cache.getChat(msg.chatId) - val canGetMember = when (chat?.type) { - is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true - is TdApi.ChatTypeBasicGroup -> true - is TdApi.ChatTypeSupergroup -> { - val supergroup = (chat.type as TdApi.ChatTypeSupergroup) - val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId) - !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false) - } - else -> false - } + return replyToMsgId to replyToMsg + } - if (canGetMember) { - val rankKey = "${msg.chatId}:$senderId" - val cachedRank = senderRankCache[rankKey] - if (cachedRank != null) { - senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL } - } else { - val member = try { - withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) } - } catch (e: Exception) { - null - } - senderCustomTitle = member?.rank - senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL - } - } - } + private suspend fun resolveForwardInfo(msg: TdApi.Message): ForwardInfo? { + val fwd = msg.forwardInfo ?: return null + val origin = fwd.origin + var originName = "Unknown" + var originPeerId = 0L + var originChatId: Long? = null + var originMessageId: Long? = null - is TdApi.MessageSenderChat -> { - senderId = sender.chatId - val cachedSnapshot = senderChatSnapshotCache[senderId] - if (cachedSnapshot != null) { - senderName = cachedSnapshot.name - senderAvatar = cachedSnapshot.avatar - } else { - val chat = try { - withTimeout(500) { - cache.getChat(senderId) ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - senderName = chat.title - val photo = chat.photo?.small - if (photo != null) { - senderAvatar = photo.local.path.takeIf { isValidPath(it) } - if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) { - fileApi.enqueueDownload( - photo.id, - 16, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } + when (origin) { + is TdApi.MessageOriginUser -> { + originPeerId = origin.senderUserId + val user = try { + withTimeout(500) { userRepository.getUser(originPeerId) } + } catch (_: Exception) { + null + } - senderChatSnapshotCache[senderId] = SenderChatSnapshot( - name = senderName, - avatar = senderAvatar - ) + if (user != null) { + val username = user.username?.takeIf { it.isNotBlank() } + val baseName = SenderNameResolver.fromPartsOrBlank(user.firstName, user.lastName) + originName = if (baseName.isNotBlank()) { + if (username != null) "$baseName (@$username)" else baseName + } else { + username?.let { "@$it" } ?: "Unknown" } } } - else -> senderId = 0L - } - - var replyToMsgId: Long? = null - var replyToMsg: MessageModel? = null - - if (!isReply && msg.replyTo != null) { - val replyTo = msg.replyTo - if (replyTo is TdApi.MessageReplyToMessage) { - replyToMsgId = replyTo.messageId - - val repliedMessage = try { + is TdApi.MessageOriginChat -> { + originPeerId = origin.senderChatId + val chat = try { withTimeout(500) { - cache.getMessage(msg.chatId, replyToMsgId) - ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) } + cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } } - } catch (e: Exception) { + } catch (_: Exception) { null } - if (repliedMessage != null) { - replyToMsg = - mapMessageToModel( - repliedMessage, - isChatOpen, - isReply = true - ).copy(replyToMsg = null, replyToMsgId = null) + if (chat != null) { + originName = chat.title } } - } - - var forwardInfo: ForwardInfo? = null - if (msg.forwardInfo != null) { - val fwd = msg.forwardInfo - val origin = fwd?.origin - var originName = "Unknown" - var originPeerId = 0L - var originChatId: Long? = null - var originMessageId: Long? = null - - when (origin) { - is TdApi.MessageOriginUser -> { - originPeerId = origin.senderUserId - val user = try { - withTimeout(500) { userRepository.getUser(originPeerId) } - } catch (e: Exception) { - null - } - - if (user != null) { - val first = user.firstName.takeIf { it.isNotBlank() } - val last = user.lastName?.takeIf { it.isNotBlank() } - val username = user.username?.takeIf { it.isNotBlank() } - - val baseName = listOfNotNull(first, last).joinToString(" ") - - originName = if (baseName.isNotBlank()) { - if (username != null) "$baseName (@$username)" else baseName - } else { - username?.let { "@$it" } ?: "Unknown" - } - } - } - - is TdApi.MessageOriginChat -> { - originPeerId = origin.senderChatId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - originName = chat.title - } - } - is TdApi.MessageOriginChannel -> { - originPeerId = origin.chatId - originChatId = origin.chatId - originMessageId = origin.messageId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - originName = chat.title + is TdApi.MessageOriginChannel -> { + originPeerId = origin.chatId + originChatId = origin.chatId + originMessageId = origin.messageId + val chat = try { + withTimeout(500) { + cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } } + } catch (_: Exception) { + null } - - is TdApi.MessageOriginHiddenUser -> { - originName = origin.senderName + if (chat != null) { + originName = chat.title } } - forwardInfo = - ForwardInfo(fwd?.date ?: 0, originPeerId, originName, originChatId, originMessageId) - } - val views = msg.interactionInfo?.viewCount - val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 - - val sendingState = when (val state = msg.sendingState) { - is TdApi.MessageSendingStatePending -> MessageSendingState.Pending - is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed( - state.error.code, - state.error.message - ) + is TdApi.MessageOriginHiddenUser -> { + originName = origin.senderName + } - else -> null + null -> Unit } - val reactions = - if (isReply) emptyList() else msg.interactionInfo?.reactions?.reactions?.map { reaction -> + return ForwardInfo( + date = fwd.date, + fromId = originPeerId, + fromName = originName, + originChatId = originChatId, + originMessageId = originMessageId + ) + } + + private suspend fun resolveReactions( + msg: TdApi.Message, + isReply: Boolean, + isChatOpen: Boolean + ): List { + if (isReply) return emptyList() + val reactionItems = msg.interactionInfo?.reactions?.reactions ?: return emptyList() + + return coroutineScope { + reactionItems.map { reaction -> async { val recentSenders = try { withTimeout(1000) { @@ -493,35 +348,37 @@ class MessageMapper( is TdApi.MessageSenderUser -> { val user = try { withTimeout(500) { userRepository.getUser(senderId.userId) } - } catch (e: Exception) { + } catch (_: Exception) { null } ReactionSender( id = senderId.userId, - name = listOfNotNull( - user?.firstName, - user?.lastName - ).joinToString(" "), - avatar = user?.avatarPath.takeIf { isValidPath(it) } + name = SenderNameResolver.fromPartsOrBlank( + firstName = user?.firstName, + lastName = user?.lastName + ), + avatar = user?.avatarPath.takeIf { fileHelper.isValidPath(it) } ) } is TdApi.MessageSenderChat -> { val chat = try { withTimeout(500) { - cache.getChat(senderId.chatId) ?: gateway.execute( - TdApi.GetChat( - senderId.chatId - ) - ).also { cache.putChat(it) } + cache.getChat(senderId.chatId) + ?: gateway.execute(TdApi.GetChat(senderId.chatId)) + .also { cache.putChat(it) } } - } catch (e: Exception) { + } catch (_: Exception) { null } ReactionSender( id = senderId.chatId, name = chat?.title ?: "", - avatar = chat?.photo?.small?.local?.path.takeIf { isValidPath(it) } + avatar = chat?.photo?.small?.local?.path.takeIf { + fileHelper.isValidPath( + it + ) + } ) } @@ -530,7 +387,7 @@ class MessageMapper( } }.awaitAll() } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } @@ -546,15 +403,17 @@ class MessageMapper( is TdApi.ReactionTypeCustomEmoji -> { val emojiId = type.customEmojiId - val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) } + var path = customEmojiLoader.getPathIfValid(emojiId) if (path == null) { - loadCustomEmoji( - emojiId, - msg.chatId, - msg.id, - isChatOpen && isNetworkAutoDownloadEnabled() + customEmojiLoader.loadIfNeeded( + emojiId = emojiId, + chatId = msg.chatId, + messageId = msg.id, + autoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() ) + path = customEmojiLoader.getPathIfValid(emojiId) } + MessageReactionModel( customEmojiId = emojiId, customEmojiPath = path, @@ -567,1594 +426,59 @@ class MessageMapper( else -> null } } - }?.awaitAll()?.filterNotNull() ?: emptyList() + }.awaitAll().filterNotNull() + } + } - val threadId = when (val topic = msg.topicId) { + private fun resolveThreadId(msg: TdApi.Message): Long? { + return when (val topic = msg.topicId) { is TdApi.MessageTopicForum -> topic.forumTopicId.toLong() is TdApi.MessageTopicThread -> topic.messageThreadId else -> null } + } - var viaBotName: String? = null - if (msg.viaBotUserId != 0L) { - val bot = try { - withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } - } catch (e: Exception) { - null - } - viaBotName = bot?.username ?: bot?.firstName + private suspend fun resolveViaBotName(msg: TdApi.Message): String? { + if (msg.viaBotUserId == 0L) return null + val bot = try { + withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } + } catch (_: Exception) { + null } + return bot?.username ?: bot?.firstName + } - createMessageModel( - msg, - senderName, - senderId, - senderAvatar, - isReadOverride = false, - replyToMsgId = replyToMsgId, - replyToMsg = replyToMsg, - forwardInfo = forwardInfo, - views = views, - viewCount = views, - mediaAlbumId = msg.mediaAlbumId, - sendingState = sendingState, - isChatOpen = isChatOpen, - readDate = 0, - reactions = reactions, - isSenderVerified = isSenderVerified, - threadId = threadId, - replyCount = replyCount, - isReply = isReply, - viaBotUserId = msg.viaBotUserId, - viaBotName = viaBotName, - senderPersonalAvatar = senderPersonalAvatar, - senderCustomTitle = senderCustomTitle, - isSenderPremium = isSenderPremium, - senderStatusEmojiId = senderStatusEmojiId, - senderStatusEmojiPath = senderStatusEmojiPath - ) - } ?: mapMessageToModelFallback(msg, isChatOpen, isReply) - } - - suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { - val chat = cache.getChat(chatId) - if (chat?.type !is TdApi.ChatTypePrivate) { - return 0 - } - - val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60) - if (messageDate < sevenDaysAgo) { - return 0 - } - - return try { - val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId)) - if (result is TdApi.MessageReadDateRead) { - result.readDate - } else { - 0 - } - } catch (e: Exception) { - 0 - } - } - - suspend fun mapMessageToModelSync( - msg: TdApi.Message, - inboxLimit: Long, - outboxLimit: Long, - isChatOpen: Boolean = false, - isReply: Boolean = false - ): MessageModel { - val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit - val baseModel = mapMessageToModel(msg, isChatOpen, isReply) - return baseModel.copy(isRead = isRead) - } - - private fun getUpdatedFile(file: TdApi.File): TdApi.File { - return cache.fileCache[file.id] ?: file - } - - private fun mapEntities( - entities: Array, - chatId: Long, - messageId: Long, - networkAutoDownload: Boolean - ): List { - return entities.map { entity -> - val type = when (val entityType = entity.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.Mention - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote - is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable - is TdApi.TextEntityTypeCustomEmoji -> { - val emojiId = entityType.customEmojiId - val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) } - if (path == null) { - scope.launch { - loadCustomEmoji(emojiId, chatId, messageId, networkAutoDownload) - } - } - MessageEntityType.CustomEmoji(emojiId, path) - } - - else -> MessageEntityType.Other(entityType.javaClass.simpleName) - } - MessageEntity(entity.offset, entity.length, type) - } - } - - private fun mapWebPage( - webPage: TdApi.LinkPreview?, - chatId: Long, - messageId: Long, - networkAutoDownload: Boolean - ): WebPage? { - if (webPage == null) return null - - var photoObj: TdApi.Photo? = null - var videoObj: TdApi.Video? = null - var audioObj: TdApi.Audio? = null - var documentObj: TdApi.Document? = null - var stickerObj: TdApi.Sticker? = null - var animationObj: TdApi.Animation? = null - var duration = 0 - - val linkPreviewType = when (val t = webPage.type) { - is TdApi.LinkPreviewTypePhoto -> { - photoObj = t.photo - WebPage.LinkPreviewType.Photo - } - - is TdApi.LinkPreviewTypeVideo -> { - videoObj = t.video - WebPage.LinkPreviewType.Video - } - - is TdApi.LinkPreviewTypeAnimation -> { - animationObj = t.animation - WebPage.LinkPreviewType.Animation - } - - is TdApi.LinkPreviewTypeAudio -> { - audioObj = t.audio - WebPage.LinkPreviewType.Audio - } - - is TdApi.LinkPreviewTypeDocument -> { - documentObj = t.document - WebPage.LinkPreviewType.Document - } - - is TdApi.LinkPreviewTypeSticker -> { - stickerObj = t.sticker - WebPage.LinkPreviewType.Sticker - } - - is TdApi.LinkPreviewTypeVideoNote -> WebPage.LinkPreviewType.VideoNote - is TdApi.LinkPreviewTypeVoiceNote -> WebPage.LinkPreviewType.VoiceNote - is TdApi.LinkPreviewTypeAlbum -> WebPage.LinkPreviewType.Album - is TdApi.LinkPreviewTypeArticle -> WebPage.LinkPreviewType.Article - is TdApi.LinkPreviewTypeApp -> WebPage.LinkPreviewType.App - is TdApi.LinkPreviewTypeExternalVideo -> { - duration = t.duration - WebPage.LinkPreviewType.ExternalVideo(t.url) - } - - is TdApi.LinkPreviewTypeExternalAudio -> { - duration = t.duration - WebPage.LinkPreviewType.ExternalAudio(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedVideo(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedAudio(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedAnimation(t.url) - } - is TdApi.LinkPreviewTypeUser -> WebPage.LinkPreviewType.User(0) - is TdApi.LinkPreviewTypeChat -> WebPage.LinkPreviewType.Chat(0) - is TdApi.LinkPreviewTypeStory -> WebPage.LinkPreviewType.Story(t.storyPosterChatId, t.storyId) - is TdApi.LinkPreviewTypeTheme -> WebPage.LinkPreviewType.Theme - is TdApi.LinkPreviewTypeBackground -> WebPage.LinkPreviewType.Background - is TdApi.LinkPreviewTypeInvoice -> WebPage.LinkPreviewType.Invoice - is TdApi.LinkPreviewTypeMessage -> WebPage.LinkPreviewType.Message - else -> WebPage.LinkPreviewType.Unknown - } - - fun processTdFile( - file: TdApi.File, - downloadType: TdMessageRemoteDataSource.DownloadType, - supportsStreaming: Boolean = false - ): TdApi.File { - val updatedFile = getUpdatedFile(file) - fileApi.registerFileForMessage(updatedFile.id, chatId, messageId) - - val autoDownload = when (downloadType) { - TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload - TdMessageRemoteDataSource.DownloadType.DEFAULT -> { - if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload - } - TdMessageRemoteDataSource.DownloadType.STICKER -> networkAutoDownload && appPreferences.autoDownloadStickers.value - TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> networkAutoDownload && appPreferences.autoDownloadVideoNotes.value - else -> networkAutoDownload - } - - if (!isValidPath(updatedFile.local.path) && autoDownload) { - fileApi.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false) - } - return updatedFile - } - - val photo = photoObj?.let { p -> - val size = p.sizes.firstOrNull() - if (size != null) { - val f = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT) - val bestPath = findBestAvailablePath(f, p.sizes) - - WebPage.Photo( - path = bestPath, - width = size.width, - height = size.height, - fileId = f.id, - minithumbnail = p.minithumbnail?.data - ) - } else null - } - - val video = videoObj?.let { v -> - val f = processTdFile(v.video, TdMessageRemoteDataSource.DownloadType.VIDEO, v.supportsStreaming) - WebPage.Video(f.local.path.takeIf { isValidPath(it) }, v.width, v.height, v.duration, f.id, v.supportsStreaming) - } - - val audio = audioObj?.let { a -> - val f = processTdFile(a.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT) - WebPage.Audio(a.audio.local.path.takeIf { isValidPath(it) }, a.duration, a.title, a.performer, f.id) - } - - val document = documentObj?.let { d -> - val f = processTdFile(d.document, TdMessageRemoteDataSource.DownloadType.DEFAULT) - WebPage.Document(d.document.local.path.takeIf { isValidPath(it) }, d.fileName, d.mimeType, f.size, f.id) - } - - val sticker = stickerObj?.let { s -> - val f = processTdFile(s.sticker, TdMessageRemoteDataSource.DownloadType.STICKER) - WebPage.Sticker(s.sticker.local.path.takeIf { isValidPath(it) }, s.width, s.height, s.emoji, f.id) - } - - val animation = animationObj?.let { anim -> - val f = processTdFile(anim.animation, TdMessageRemoteDataSource.DownloadType.GIF) - WebPage.Animation(anim.animation.local.path.takeIf { isValidPath(it) }, anim.width, anim.height, anim.duration, f.id) - } - - return WebPage( - url = webPage.url, - displayUrl = webPage.displayUrl, - type = linkPreviewType, - siteName = webPage.siteName, - title = webPage.title, - description = webPage.description?.text, - photo = photo, - embedUrl = null, - embedType = null, - embedWidth = 0, - embedHeight = 0, - duration = duration, - author = webPage.author, - video = video, - audio = audio, - document = document, - sticker = sticker, - animation = animation, - instantViewVersion = webPage.instantViewVersion - ) - } - - private fun mapReplyMarkup(markup: TdApi.ReplyMarkup?): ReplyMarkupModel? { - return when (markup) { - is TdApi.ReplyMarkupInlineKeyboard -> { - ReplyMarkupModel.InlineKeyboard( - rows = markup.rows.map { row -> - row.map { button -> - InlineKeyboardButtonModel( - text = button.text, - type = when (val type = button.type) { - is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url( - type.url - ) - - is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback( - type.data - ) - - is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp( - type.url - ) - - is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl( - type.url, - type.id - ) - - is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline( - query = type.query - ) - - is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy() - is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User( - type.userId - ) - - else -> InlineKeyboardButtonType.Unsupported - } - ) - } - } - ) - } - - is TdApi.ReplyMarkupShowKeyboard -> { - ReplyMarkupModel.ShowKeyboard( - rows = markup.rows.map { row -> - row.map { button -> - KeyboardButtonModel( - text = button.text, - type = when (val type = button.type) { - is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text - is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber - is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation - is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll( - type.forceQuiz, - type.forceRegular - ) - - is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp( - type.url - ) - - is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers( - type.id - ) - - is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat( - type.id - ) - - else -> KeyboardButtonType.Unsupported - } - ) - } - }, - isPersistent = markup.isPersistent, - resizeKeyboard = markup.resizeKeyboard, - oneTime = markup.oneTime, - isPersonal = markup.isPersonal, - inputFieldPlaceholder = markup.inputFieldPlaceholder - ) - } - - is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(markup.isPersonal) - is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply( - markup.isPersonal, - markup.inputFieldPlaceholder - ) - + private fun resolveSendingState(msg: TdApi.Message): MessageSendingState? { + return when (val state = msg.sendingState) { + is TdApi.MessageSendingStatePending -> MessageSendingState.Pending + is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed(state.error.code, state.error.message) else -> null } } - fun createMessageModel( - msg: TdApi.Message, - senderName: String, - senderId: Long, - senderAvatar: String?, - isReadOverride: Boolean = false, - replyToMsgId: Long? = null, - replyToMsg: MessageModel? = null, - forwardInfo: ForwardInfo? = null, - views: Int? = null, - viewCount: Int? = null, - mediaAlbumId: Long = 0L, - sendingState: MessageSendingState? = null, - isChatOpen: Boolean = false, - readDate: Int = 0, - reactions: List = emptyList(), - isSenderVerified: Boolean = false, - threadId: Long? = null, - replyCount: Int = 0, - isReply: Boolean = false, - viaBotUserId: Long = 0L, - viaBotName: String? = null, - senderPersonalAvatar: String? = null, - senderCustomTitle: String? = null, - isSenderPremium: Boolean = false, - senderStatusEmojiId: Long = 0L, - senderStatusEmojiPath: String? = null - ): MessageModel { - val networkAutoDownload = isChatOpen && isNetworkAutoDownloadEnabled() - val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending - - val content = when (val c = msg.content) { - is TdApi.MessageText -> { - val entities = mapEntities(c.text.entities, msg.chatId, msg.id, networkAutoDownload) - val webPage = mapWebPage(c.linkPreview, msg.chatId, msg.id, networkAutoDownload) - MessageContent.Text(c.text.text, entities, webPage) - } - - is TdApi.MessagePhoto -> { - - val sizes = c.photo.sizes - val photoSize = sizes.find { it.type == "x" } - ?: sizes.find { it.type == "m" } - ?: sizes.getOrNull(sizes.size / 2) - ?: sizes.lastOrNull() - val thumbnailSize = sizes.find { it.type == "m" } - ?: sizes.find { it.type == "s" } - ?: sizes.firstOrNull() - - val photoFile = photoSize?.photo?.let { getUpdatedFile(it) } - val thumbnailFile = thumbnailSize?.photo?.let { getUpdatedFile(it) } - - val path = findBestAvailablePath(photoFile, sizes) - val thumbnailPath = resolveLocalFilePath(thumbnailFile) - - if (photoFile != null) { - fileApi.registerFileForMessage(photoFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(photoFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - if (thumbnailFile != null) { - fileApi.registerFileForMessage(thumbnailFile.id, msg.chatId, msg.id) - if (thumbnailPath == null && networkAutoDownload) { - fileApi.enqueueDownload( - thumbnailFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - val isDownloading = photoFile?.local?.isDownloadingActive ?: false - val isQueued = photoFile?.let { fileApi.isFileQueued(it.id) } ?: false - val downloadProgress = if ((photoFile?.size ?: 0) > 0) { - photoFile!!.local.downloadedSize.toFloat() / photoFile.size.toFloat() - } else 0f - - MessageContent.Photo( - path = path, - thumbnailPath = thumbnailPath, - width = photoSize?.width ?: 0, - height = photoSize?.height ?: 0, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false), - uploadProgress = if ((photoFile?.size ?: 0) > 0) photoFile!!.remote.uploadedSize.toFloat() / photoFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = photoFile?.id ?: 0, - minithumbnail = c.photo.minithumbnail?.data - ) - } - - is TdApi.MessageVideo -> { - val video = c.video - val videoFile = getUpdatedFile(video.video) - val path = videoFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id) - - val thumbFile = video.thumbnail?.file?.let { getUpdatedFile(it) } - val thumbnailPath = resolveLocalFilePath(thumbFile) - - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (thumbnailPath == null && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - if (path == null && networkAutoDownload && video.supportsStreaming) { - fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO, 0, 0, false) - } - - val isDownloading = videoFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(videoFile.id) - val downloadProgress = if (videoFile.size > 0) { - videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - MessageContent.Video( - path = path, - thumbnailPath = thumbnailPath, - width = video.width, - height = video.height, - duration = video.duration, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && videoFile.remote.isUploadingActive, - uploadProgress = if (videoFile.size > 0) videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = videoFile.id, - minithumbnail = video.minithumbnail?.data, - supportsStreaming = video.supportsStreaming - ) - } - - is TdApi.MessageVoiceNote -> { - val voice = c.voiceNote - val voiceFile = getUpdatedFile(voice.voice) - val path = voiceFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(voiceFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(voiceFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - val isDownloading = voiceFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(voiceFile.id) - val downloadProgress = if (voiceFile.size > 0) { - voiceFile.local.downloadedSize.toFloat() / voiceFile.size.toFloat() - } else 0f - - MessageContent.Voice( - path = path, - duration = voice.duration, - waveform = voice.waveform, - isUploading = isActuallyUploading && voiceFile.remote.isUploadingActive, - uploadProgress = if (voiceFile.size > 0) voiceFile.remote.uploadedSize.toFloat() / voiceFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = voiceFile.id - ) - } - - is TdApi.MessageVideoNote -> { - val note = c.videoNote - val videoFile = getUpdatedFile(note.video) - val videoPath = videoFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id) - - if (videoPath == null && networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { - fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, 0, 0, false) - } - - val thumbFile = note.thumbnail?.file?.let { getUpdatedFile(it) } - val thumbPath = thumbFile?.local?.path?.takeIf { isValidPath(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (thumbPath == null && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - val isUploading = isActuallyUploading && videoFile.remote.isUploadingActive - val progress = if (videoFile.size > 0) { - videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - val isDownloading = videoFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(videoFile.id) - val downloadProgress = if (videoFile.size > 0) { - videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - MessageContent.VideoNote( - path = videoPath, - thumbnail = thumbPath, - duration = note.duration, - length = note.length, - isUploading = isUploading, - uploadProgress = progress, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = videoFile.id - ) - } - - is TdApi.MessageSticker -> { - val sticker = c.sticker - val stickerFile = getUpdatedFile(sticker.sticker) - val path = stickerFile.local.path.takeIf { isValidPath(it) } - - fileApi.registerFileForMessage(stickerFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload && appPreferences.autoDownloadStickers.value) { - fileApi.enqueueDownload(stickerFile.id, 1, TdMessageRemoteDataSource.DownloadType.STICKER, 0, 0, false) - } - - val format = when (sticker.format) { - is TdApi.StickerFormatWebp -> StickerFormat.STATIC - is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED - is TdApi.StickerFormatWebm -> StickerFormat.VIDEO - else -> StickerFormat.UNKNOWN - } - - val isDownloading = stickerFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(stickerFile.id) - val downloadProgress = if (stickerFile.size > 0) { - stickerFile.local.downloadedSize.toFloat() / stickerFile.size.toFloat() - } else 0f - - MessageContent.Sticker( - id = sticker.sticker.id.toLong(), - setId = sticker.setId, - path = path, - width = sticker.width, - height = sticker.height, - emoji = sticker.emoji, - format = format, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = stickerFile.id - ) - } - - is TdApi.MessageAnimation -> { - val animation = c.animation - val animationFile = getUpdatedFile(animation.animation) - val path = animationFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(animationFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(animationFile.id, 1, TdMessageRemoteDataSource.DownloadType.GIF, 0, 0, false) - } - - val thumbFile = animation.thumbnail?.file?.let { getUpdatedFile(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (!isValidPath(thumbFile.local.path) && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - val isDownloading = animationFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(animationFile.id) - val downloadProgress = if (animationFile.size > 0) { - animationFile.local.downloadedSize.toFloat() / animationFile.size.toFloat() - } else 0f - - MessageContent.Gif( - path = path, - width = animation.width, - height = animation.height, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && animationFile.remote.isUploadingActive, - uploadProgress = if (animationFile.size > 0) animationFile.remote.uploadedSize.toFloat() / animationFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = animationFile.id, - minithumbnail = animation.minithumbnail?.data - ) - } - - is TdApi.MessageAnimatedEmoji -> MessageContent.Text(c.emoji) - is TdApi.MessageDice -> { - val valueStr = if (c.value != 0) " (Result: ${c.value})" else "" - MessageContent.Text("${c.emoji}$valueStr") - } - - is TdApi.MessageDocument -> { - val doc = c.document - val docFile = getUpdatedFile(doc.document) - val path = docFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(docFile.id, msg.chatId, msg.id) - - val thumbFile = doc.thumbnail?.file?.let { getUpdatedFile(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (!isValidPath(thumbFile.local.path) && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - val isDownloading = docFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(docFile.id) - val downloadProgress = if (docFile.size > 0) { - docFile.local.downloadedSize.toFloat() / docFile.size.toFloat() - } else 0f - - MessageContent.Document( - path = path, - fileName = doc.fileName, - mimeType = doc.mimeType, - size = docFile.size, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && docFile.remote.isUploadingActive, - uploadProgress = if (docFile.size > 0) docFile.remote.uploadedSize.toFloat() / docFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = docFile.id - ) - } - - is TdApi.MessageAudio -> { - val audio = c.audio - val audioFile = getUpdatedFile(audio.audio) - val path = audioFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(audioFile.id, msg.chatId, msg.id) - - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload( - audioFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - - val isDownloading = audioFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(audioFile.id) - val downloadProgress = if (audioFile.size > 0) { - audioFile.local.downloadedSize.toFloat() / audioFile.size.toFloat() - } else 0f - - MessageContent.Audio( - path = path, - duration = audio.duration, - title = audio.title ?: "Unknown", - performer = audio.performer ?: "Unknown", - fileName = audio.fileName ?: "audio.mp3", - mimeType = audio.mimeType ?: "audio/mpeg", - size = audioFile.size, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && audioFile.remote.isUploadingActive, - uploadProgress = if (audioFile.size > 0) audioFile.remote.uploadedSize.toFloat() / audioFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = audioFile.id - ) - } - - is TdApi.MessageCall -> MessageContent.Text("📞 Call (${c.duration}s)") - is TdApi.MessageContact -> { - val contact = c.contact - MessageContent.Contact( - phoneNumber = contact.phoneNumber, - firstName = contact.firstName, - lastName = contact.lastName, - vcard = contact.vcard, - userId = contact.userId - ) - } - is TdApi.MessageLocation -> { - val loc = c.location - MessageContent.Location( - latitude = loc.latitude, - longitude = loc.longitude, - horizontalAccuracy = loc.horizontalAccuracy, - livePeriod = c.livePeriod, - heading = c.heading, - proximityAlertRadius = c.proximityAlertRadius - ) - } - - is TdApi.MessageVenue -> { - val v = c.venue - MessageContent.Venue( - latitude = v.location.latitude, - longitude = v.location.longitude, - title = v.title, - address = v.address, - provider = v.provider, - venueId = v.id, - venueType = v.type - ) - } - is TdApi.MessagePoll -> { - val poll = c.poll - val type = when (val pollType = poll.type) { - is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers) - is TdApi.PollTypeQuiz -> PollType.Quiz(pollType.correctOptionIds.firstOrNull() ?: -1, pollType.explanation?.text) - else -> PollType.Regular(poll.allowsMultipleAnswers) - } - MessageContent.Poll( - id = poll.id, - question = poll.question.text, - options = poll.options.map { option -> - PollOption( - text = option.text.text, - voterCount = option.voterCount, - votePercentage = option.votePercentage, - isChosen = option.isChosen, - isBeingChosen = false - ) - }, - totalVoterCount = poll.totalVoterCount, - isClosed = poll.isClosed, - isAnonymous = poll.isAnonymous, - type = type, - openPeriod = poll.openPeriod, - closeDate = poll.closeDate - ) - } - is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${c.game.title}") - is TdApi.MessageInvoice -> { - val productInfo = c.productInfo - MessageContent.Text("💳 Invoice: ${productInfo.title}") - } - is TdApi.MessageStory -> MessageContent.Text("📖 Story") - is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired") - is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired") - - is TdApi.MessageChatJoinByLink -> MessageContent.Service("$senderName has joined the group via invite link") - is TdApi.MessageChatAddMembers -> MessageContent.Service("$senderName added members") - is TdApi.MessageChatDeleteMember -> MessageContent.Service("$senderName left the chat") - is TdApi.MessagePinMessage -> MessageContent.Service("$senderName pinned a message") - is TdApi.MessageChatChangeTitle -> MessageContent.Service("$senderName changed group name to \"${c.title}\"") - is TdApi.MessageChatChangePhoto -> MessageContent.Service("$senderName changed group photo") - is TdApi.MessageChatDeletePhoto -> MessageContent.Service("$senderName removed group photo") - is TdApi.MessageScreenshotTaken -> MessageContent.Service("$senderName took a screenshot") - is TdApi.MessageContactRegistered -> MessageContent.Service("$senderName joined Telegram!") - is TdApi.MessageChatUpgradeTo -> MessageContent.Service("$senderName upgraded to supergroup") - is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created") - is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${c.title}\"") - is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${c.title}\"") - is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${c.currency} ${c.totalAmount}") - is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful") - is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent") - is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received") - is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${c.distance}m") - is TdApi.MessageForumTopicCreated -> MessageContent.Service("$senderName created topic \"${c.name}\"") - is TdApi.MessageForumTopicEdited -> MessageContent.Service("$senderName edited topic") - is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("$senderName toggled topic closed status") - is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("$senderName toggled topic hidden status") - is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("$senderName suggested a profile photo") - is TdApi.MessageCustomServiceAction -> MessageContent.Service(c.text) - is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${c.boostCount}") - is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${c.theme}") - is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${c.score}") - is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${c.startDate}") - is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started") - is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended") - is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed") - else -> MessageContent.Text("ℹ️ Unsupported message type: ${c.javaClass.simpleName}") - } - - val isServiceMessage = content is MessageContent.Service - - val canEdit = msg.isOutgoing && !isServiceMessage - val canForward = !isServiceMessage - val canSave = !isServiceMessage - - val hasInteraction = msg.interactionInfo != null - - return MessageModel( - id = msg.id, - date = resolveMessageDate(msg), - isOutgoing = msg.isOutgoing, - senderName = senderName, - chatId = msg.chatId, - content = content, - senderId = senderId, - senderAvatar = senderAvatar, - senderPersonalAvatar = senderPersonalAvatar, - senderCustomTitle = senderCustomTitle, - isRead = isReadOverride, - replyToMsgId = replyToMsgId, - replyToMsg = replyToMsg, - forwardInfo = forwardInfo, - views = views, - viewCount = views, - mediaAlbumId = msg.mediaAlbumId, - editDate = msg.editDate, - sendingState = sendingState, - readDate = readDate, - reactions = reactions, - isSenderVerified = isSenderVerified, - threadId = threadId, - replyCount = replyCount, - canBeEdited = canEdit, - canBeForwarded = canForward, - canBeDeletedOnlyForSelf = true, - canBeDeletedForAllUsers = msg.isOutgoing, - canBeSaved = canSave, - canGetMessageThread = msg.interactionInfo?.replyInfo != null, - canGetStatistics = hasInteraction, - canGetReadReceipts = hasInteraction, - canGetViewers = hasInteraction, - replyMarkup = if (isReply) null else mapReplyMarkup(msg.replyMarkup), - viaBotUserId = viaBotUserId, - viaBotName = viaBotName, - isSenderPremium = isSenderPremium, - senderStatusEmojiId = senderStatusEmojiId, - senderStatusEmojiPath = senderStatusEmojiPath - ) - } - - data class CachedMessageContent( - val type: String, - val text: String, - val meta: String?, - val fileId: Int = 0, - val path: String? = null, - val thumbnailPath: String? = null, - val minithumbnail: ByteArray? = null - ) - - private data class CachedReplyPreview( - val senderName: String, - val contentType: String, - val text: String - ) - - private data class CachedForwardOrigin( - val fromName: String, - val fromId: Long, - val originChatId: Long? = null, - val originMessageId: Long? = null - ) - - fun mapToEntity( + private fun mapMessageToModelFallback( msg: TdApi.Message, - getSenderName: ((Long) -> String?)? = null - ): org.monogram.data.db.model.MessageEntity { - val senderId = when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> sender.userId - is TdApi.MessageSenderChat -> sender.chatId - else -> 0L - } - val senderName = getSenderName?.invoke(senderId).orEmpty() - val content = extractCachedContent(msg.content) - val entitiesEncoded = encodeEntities(msg.content) - val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L - val replyToPreview = buildReplyPreview(msg) - val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin) - - return org.monogram.data.db.model.MessageEntity( - id = msg.id, - chatId = msg.chatId, - senderId = senderId, - senderName = senderName, - content = content.text, - contentType = content.type, - contentMeta = content.meta, - mediaFileId = content.fileId, - mediaPath = content.path, - mediaThumbnailPath = content.thumbnailPath, - minithumbnail = content.minithumbnail, - date = resolveMessageDate(msg), - isOutgoing = msg.isOutgoing, - isRead = false, - replyToMessageId = replyToMessageId, - replyToPreview = replyToPreview?.let(::encodeReplyPreview), - replyToPreviewType = replyToPreview?.contentType, - replyToPreviewText = replyToPreview?.text, - replyToPreviewSenderName = replyToPreview?.senderName, - replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0, - forwardFromName = forwardOrigin?.fromName, - forwardFromId = forwardOrigin?.fromId ?: 0L, - forwardOriginChatId = forwardOrigin?.originChatId, - forwardOriginMessageId = forwardOrigin?.originMessageId, - forwardDate = msg.forwardInfo?.date ?: 0, - editDate = msg.editDate, - mediaAlbumId = msg.mediaAlbumId, - entities = entitiesEncoded, - viewCount = msg.interactionInfo?.viewCount ?: 0, - forwardCount = msg.interactionInfo?.forwardCount ?: 0, - createdAt = System.currentTimeMillis() - ) - } - - fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent { - return when (content) { - is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null) - is TdApi.MessagePhoto -> { - val sizes = content.photo.sizes - val best = sizes.find { it.type == "x" } - ?: sizes.find { it.type == "m" } - ?: sizes.getOrNull(sizes.size / 2) - ?: sizes.lastOrNull() - val thumbnail = sizes.find { it.type == "m" } - ?: sizes.find { it.type == "s" } - val fileId = best?.photo?.id ?: 0 - val path = best?.photo?.local?.path?.takeIf { it.isNotBlank() } - val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { it.isNotBlank() } - CachedMessageContent( - "photo", - content.caption?.text.orEmpty(), - encodeMeta(best?.width ?: 0, best?.height ?: 0), - fileId = fileId, - path = path, - thumbnailPath = thumbnailPath, - minithumbnail = content.photo.minithumbnail?.data - ) - } - - is TdApi.MessageVideo -> { - val fileId = content.video.video.id - val path = content.video.video.local?.path?.takeIf { it.isNotBlank() } - CachedMessageContent( - "video", - content.caption?.text.orEmpty(), - encodeMeta( - content.video.width, - content.video.height, - content.video.duration, - content.video.thumbnail?.file?.local?.path.orEmpty(), - if (content.video.supportsStreaming) 1 else 0 - ), - fileId = fileId, - path = path, - thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { it.isNotBlank() }, - minithumbnail = content.video.minithumbnail?.data - ) - } - - is TdApi.MessageVoiceNote -> CachedMessageContent( - "voice", - content.caption?.text.orEmpty(), - encodeMeta(content.voiceNote.duration), - fileId = content.voiceNote.voice.id, - path = content.voiceNote.voice.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageVideoNote -> CachedMessageContent( - "video_note", - "", - encodeMeta( - content.videoNote.duration, - content.videoNote.length, - content.videoNote.thumbnail?.file?.local?.path.orEmpty() - ), - fileId = content.videoNote.video.id, - path = content.videoNote.video.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageSticker -> { - val format = when (content.sticker.format) { - is TdApi.StickerFormatWebp -> "webp" - is TdApi.StickerFormatTgs -> "tgs" - is TdApi.StickerFormatWebm -> "webm" - else -> "unknown" - } - CachedMessageContent( - "sticker", - content.sticker.emoji, - encodeMeta( - content.sticker.setId, - content.sticker.emoji, - content.sticker.width, - content.sticker.height, - format - ), - fileId = content.sticker.sticker.id, - path = content.sticker.sticker.local?.path?.takeIf { it.isNotBlank() } - ) - } - - is TdApi.MessageDocument -> CachedMessageContent( - "document", - content.caption?.text.orEmpty(), - encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size), - fileId = content.document.document.id, - path = content.document.document.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageAudio -> CachedMessageContent( - "audio", - content.caption?.text.orEmpty(), - encodeMeta( - content.audio.duration, - content.audio.title.orEmpty(), - content.audio.performer.orEmpty(), - content.audio.fileName.orEmpty() - ), - fileId = content.audio.audio.id, - path = content.audio.audio.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageAnimation -> CachedMessageContent( - "gif", - content.caption?.text.orEmpty(), - encodeMeta( - content.animation.width, - content.animation.height, - content.animation.duration, - content.animation.thumbnail?.file?.local?.path.orEmpty() - ), - fileId = content.animation.animation.id, - path = content.animation.animation.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessagePoll -> CachedMessageContent( - "poll", - content.poll.question.text, - encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0) - ) - - is TdApi.MessageContact -> CachedMessageContent( - "contact", - listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() } - .joinToString(" "), - encodeMeta( - content.contact.phoneNumber, - content.contact.firstName, - content.contact.lastName, - content.contact.userId - ) - ) - - is TdApi.MessageLocation -> CachedMessageContent( - "location", - "", - encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod) - ) - - is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null) - is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null) - is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null) - is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null) - is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null) - is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null) - is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null) - else -> CachedMessageContent("unsupported", "", null) - } - } - - fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel { - val meta = decodeMeta(entity.contentMeta) - val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank() - val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) { - resolveLegacyMediaFromMeta(entity.contentType, meta) - } else { - 0 to null - } - val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId - val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath - val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L } - val replyPreview = resolveReplyPreview(entity) - val replyPreviewModel = - if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel(entity, replyToMsgId, replyPreview) else null - - val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) } - val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) { - cache.getChat(entity.senderId) - } else { - null - } - - val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName) - val resolvedSenderAvatar = when { - cachedSenderUser != null -> resolveLocalFilePath(cachedSenderUser.profilePhoto?.small) - cachedSenderChat != null -> resolveLocalFilePath(cachedSenderChat.photo?.small) - else -> null - } - val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId) - ?.personalPhoto - ?.sizes - ?.firstOrNull() - ?.photo - ?.let { resolveLocalFilePath(it) } - - val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { - is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId - is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L - } - - val forwardInfo = entity.forwardFromName - ?.takeIf { it.isNotBlank() } - ?.let { fromName -> - ForwardInfo( - date = entity.forwardDate.takeIf { it > 0 } ?: entity.date, - fromId = entity.forwardFromId, - fromName = fromName, - originChatId = entity.forwardOriginChatId, - originMessageId = entity.forwardOriginMessageId - ) - } - - val content: MessageContent = when (entity.contentType) { - "text" -> MessageContent.Text(entity.content) - - "photo" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Photo( - path = resolveCachedPath(fileId, mediaPath), - thumbnailPath = entity.mediaThumbnailPath?.takeIf { isValidPath(it) }, - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId, - minithumbnail = entity.minithumbnail - ) - } - - "video" -> { - val fileId = mediaFileId - val supportsStreaming = if (usesLegacyEmbeddedMedia) { - (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1 - } else { - (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1 - } - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Video( - path = resolveCachedPath(fileId, mediaPath), - thumbnailPath = ( - entity.mediaThumbnailPath?.takeIf { isValidPath(it) } - ?: meta.getOrNull(3) - )?.takeIf { isValidPath(it) }, - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - duration = meta.getOrNull(2)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId, - supportsStreaming = supportsStreaming, - minithumbnail = entity.minithumbnail - ) - } - - "voice" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Voice( - path = resolveCachedPath(fileId, mediaPath), - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - fileId = fileId - ) - } - - "video_note" -> { - val fileId = mediaFileId - val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2) - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.VideoNote( - path = resolveCachedPath(fileId, mediaPath), - thumbnail = storedThumbPath?.takeIf { isValidPath(it) }, - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - length = meta.getOrNull(1)?.toIntOrNull() ?: 0, - fileId = fileId - ) - } - - "sticker" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Sticker( - id = 0L, - setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L, - path = resolveCachedPath(fileId, mediaPath), - width = meta.getOrNull(2)?.toIntOrNull() ?: 0, - height = meta.getOrNull(3)?.toIntOrNull() ?: 0, - emoji = entity.content, - fileId = fileId - ) - } - - "document" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Document( - path = resolveCachedPath(fileId, mediaPath), - fileName = meta.getOrNull(0).orEmpty(), - mimeType = meta.getOrNull(1).orEmpty(), - size = meta.getOrNull(2)?.toLongOrNull() ?: 0L, - caption = entity.content, - fileId = fileId - ) - } - - "audio" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Audio( - path = resolveCachedPath(fileId, mediaPath), - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - title = meta.getOrNull(1).orEmpty(), - performer = meta.getOrNull(2).orEmpty(), - fileName = meta.getOrNull(3).orEmpty(), - mimeType = "", - size = 0L, - caption = entity.content, - fileId = fileId - ) - } - - "gif" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Gif( - path = resolveCachedPath(fileId, mediaPath), - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId - ) - } - - "poll" -> MessageContent.Poll( - id = 0L, - question = entity.content, - options = emptyList(), - totalVoterCount = 0, - isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1, - isAnonymous = true, - type = PollType.Regular(false), - openPeriod = 0, - closeDate = 0 - ) - - "contact" -> MessageContent.Contact( - phoneNumber = meta.getOrNull(0).orEmpty(), - firstName = meta.getOrNull(1).orEmpty(), - lastName = meta.getOrNull(2).orEmpty(), - vcard = "", - userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L - ) - - "location" -> MessageContent.Location( - latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0, - longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0, - livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0 - ) - - "service" -> MessageContent.Service(entity.content) - else -> MessageContent.Text(entity.content) - } - - return MessageModel( - id = entity.id, - date = entity.date, - isOutgoing = entity.isOutgoing, - senderName = resolvedSenderName, - chatId = entity.chatId, - content = content, - senderId = entity.senderId, - senderAvatar = resolvedSenderAvatar, - senderPersonalAvatar = resolvedSenderPersonalAvatar, - isRead = entity.isRead, - replyToMsgId = replyToMsgId, - replyToMsg = replyPreviewModel, - forwardInfo = forwardInfo, - mediaAlbumId = entity.mediaAlbumId, - editDate = entity.editDate, - views = entity.viewCount, - viewCount = entity.viewCount, - replyCount = entity.replyCount, - isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, - isSenderPremium = cachedSenderUser?.isPremium ?: false, - senderStatusEmojiId = senderStatusEmojiId - ) - } - - private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? { - val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null - val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null - val replySenderName = when (val sender = replied.senderId) { - is TdApi.MessageSenderUser -> { - val user = cache.getUser(sender.userId) - listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() }) - .joinToString(" ") - } - - is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty() - else -> "" - } - val extracted = extractCachedContent(replied.content) - return CachedReplyPreview( - senderName = replySenderName, - contentType = extracted.type, - text = extracted.text.take(100) - ) - } - - private fun encodeReplyPreview(preview: CachedReplyPreview): String { - return "${preview.senderName}|${preview.contentType}|${preview.text}" - } - - private fun parseReplyPreview(raw: String?): CachedReplyPreview? { - if (raw.isNullOrBlank()) return null - val firstSeparator = raw.indexOf('|') - val secondSeparator = raw.indexOf('|', firstSeparator + 1) - if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null - - val senderName = raw.substring(0, firstSeparator) - val contentType = raw.substring(firstSeparator + 1, secondSeparator) - val text = raw.substring(secondSeparator + 1) - if (contentType.isBlank()) return null - - return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text) - } - - private fun resolveReplyPreview(entity: org.monogram.data.db.model.MessageEntity): CachedReplyPreview? { - val encodedPreview = parseReplyPreview(entity.replyToPreview) - val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName - val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType - val text = entity.replyToPreviewText ?: encodedPreview?.text ?: "" - - if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) { - return null - } - - return CachedReplyPreview( - senderName = senderName.orEmpty(), - contentType = contentType?.ifBlank { "text" } ?: "text", - text = text - ) - } - - private fun createReplyPreviewModel( - entity: org.monogram.data.db.model.MessageEntity, - replyToMsgId: Long, - preview: CachedReplyPreview + isChatOpen: Boolean, + isReply: Boolean ): MessageModel { - return MessageModel( - id = replyToMsgId, - date = entity.date, - isOutgoing = false, - senderName = preview.senderName.ifBlank { "Unknown" }, - chatId = entity.chatId, - content = mapReplyPreviewContent(preview), - senderId = 0L, - isRead = true + val sender = senderResolver.resolveFallbackSender(msg) + return createMessageModel( + msg = msg, + senderName = sender.senderName, + senderId = sender.senderId, + senderAvatar = sender.senderAvatar, + isChatOpen = isChatOpen, + isReply = isReply, + senderPersonalAvatar = sender.senderPersonalAvatar, + senderCustomTitle = sender.senderCustomTitle, + isSenderVerified = sender.isSenderVerified, + isSenderPremium = sender.isSenderPremium, + senderStatusEmojiId = sender.senderStatusEmojiId, + senderStatusEmojiPath = sender.senderStatusEmojiPath ) } - private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent { - return when (preview.contentType) { - "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text) - "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text) - "voice" -> MessageContent.Voice(path = null, duration = 0) - "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0) - "sticker" -> MessageContent.Sticker(id = 0L, setId = 0L, path = null, width = 0, height = 0, emoji = preview.text) - "document" -> MessageContent.Document(path = null, fileName = "", mimeType = "", size = 0L, caption = preview.text) - "audio" -> MessageContent.Audio( - path = null, - duration = 0, - title = "", - performer = "", - fileName = "", - mimeType = "", - size = 0L, - caption = preview.text - ) - "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text) - "poll" -> MessageContent.Poll( - id = 0L, - question = preview.text, - options = emptyList(), - totalVoterCount = 0, - isClosed = false, - isAnonymous = true, - type = PollType.Regular(false), - openPeriod = 0, - closeDate = 0 - ) - "contact" -> MessageContent.Contact( - phoneNumber = "", - firstName = preview.text, - lastName = "", - vcard = "", - userId = 0L - ) - "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0) - "service" -> MessageContent.Service(preview.text) - else -> MessageContent.Text(preview.text) - } - } - - private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin { - return when (origin) { - is TdApi.MessageOriginUser -> { - val user = cache.getUser(origin.senderUserId) - val name = listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() }) - .joinToString(" ") - .ifBlank { "User" } - CachedForwardOrigin(fromName = name, fromId = origin.senderUserId) - } - - is TdApi.MessageOriginChat -> CachedForwardOrigin( - fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat", - fromId = origin.senderChatId - ) - - is TdApi.MessageOriginChannel -> CachedForwardOrigin( - fromName = cache.getChat(origin.chatId)?.title ?: "Channel", - fromId = origin.chatId, - originChatId = origin.chatId, - originMessageId = origin.messageId - ) - - is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin( - fromName = origin.senderName.ifBlank { "Hidden user" }, - fromId = 0L - ) - - else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L) - } - } - - private fun encodeEntities(content: TdApi.MessageContent): String? { - val formatted = when (content) { - is TdApi.MessageText -> content.text - is TdApi.MessagePhoto -> content.caption - is TdApi.MessageVideo -> content.caption - is TdApi.MessageDocument -> content.caption - is TdApi.MessageAudio -> content.caption - is TdApi.MessageAnimation -> content.caption - is TdApi.MessageVoiceNote -> content.caption - else -> null - } ?: return null - - if (formatted.entities.isNullOrEmpty()) return null - - return buildString { - formatted.entities.forEachIndexed { index, entity -> - if (index > 0) append('|') - append(entity.offset).append(',').append(entity.length).append(',') - when (val type = entity.type) { - is TdApi.TextEntityTypeBold -> append("b") - is TdApi.TextEntityTypeItalic -> append("i") - is TdApi.TextEntityTypeUnderline -> append("u") - is TdApi.TextEntityTypeStrikethrough -> append("s") - is TdApi.TextEntityTypeSpoiler -> append("sp") - is TdApi.TextEntityTypeCode -> append("c") - is TdApi.TextEntityTypePre -> append("p") - is TdApi.TextEntityTypeUrl -> append("url") - is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url) - is TdApi.TextEntityTypeMention -> append("m") - is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId) - is TdApi.TextEntityTypeHashtag -> append("h") - is TdApi.TextEntityTypeBotCommand -> append("bc") - is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId) - is TdApi.TextEntityTypeEmailAddress -> append("em") - is TdApi.TextEntityTypePhoneNumber -> append("ph") - else -> append("?") - } - } - } - } - - private fun resolveMessageDate(msg: TdApi.Message): Int { - return when (val schedulingState = msg.schedulingState) { - is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate - else -> msg.date - } - } - - private suspend fun loadCustomEmoji(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) { - val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) - - if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { - val fileToUse = result.stickers.first().sticker - - fileIdToCustomEmojiId[fileToUse.id] = emojiId - fileApi.registerFileForMessage(fileToUse.id, chatId, messageId) - - if (!isValidPath(fileToUse.local.path)) { - if (autoDownload) { - fileApi.enqueueDownload(fileToUse.id, 32, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } else { - customEmojiPaths[emojiId] = fileToUse.local.path - } - } + private companion object { + private const val MESSAGE_MAP_TIMEOUT_MS = 2500L } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt new file mode 100644 index 00000000..c173a813 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt @@ -0,0 +1,72 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.* + +internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? { + return when (this) { + is TdApi.ReplyMarkupInlineKeyboard -> { + ReplyMarkupModel.InlineKeyboard( + rows = rows.map { row -> + row.map { button -> + InlineKeyboardButtonModel( + text = button.text, + type = when (val type = button.type) { + is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url(type.url) + is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback(type.data) + is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp(type.url) + is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl( + type.url, + type.id + ) + + is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline( + query = type.query + ) + + is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy() + is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User(type.userId) + else -> InlineKeyboardButtonType.Unsupported + } + ) + } + } + ) + } + + is TdApi.ReplyMarkupShowKeyboard -> { + ReplyMarkupModel.ShowKeyboard( + rows = rows.map { row -> + row.map { button -> + KeyboardButtonModel( + text = button.text, + type = when (val type = button.type) { + is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text + is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber + is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation + is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll( + type.forceQuiz, + type.forceRegular + ) + + is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp(type.url) + is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers(type.id) + is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat(type.id) + else -> KeyboardButtonType.Unsupported + } + ) + } + }, + isPersistent = isPersistent, + resizeKeyboard = resizeKeyboard, + oneTime = oneTime, + isPersonal = isPersonal, + inputFieldPlaceholder = inputFieldPlaceholder + ) + } + + is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(isPersonal) + is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(isPersonal, inputFieldPlaceholder) + else -> null + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt new file mode 100644 index 00000000..72534374 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt @@ -0,0 +1,15 @@ +package org.monogram.data.mapper + +internal object SenderNameResolver { + fun fromPartsOrBlank(firstName: String?, lastName: String?): String { + return listOfNotNull( + firstName?.takeIf { it.isNotBlank() }, + lastName?.takeIf { it.isNotBlank() } + ).joinToString(" ") + } + + fun fromParts(firstName: String?, lastName: String?, fallback: String = "User"): String { + val normalizedFallback = fallback.ifBlank { "User" } + return fromPartsOrBlank(firstName, lastName).ifBlank { normalizedFallback } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt index 5ff71c4e..e9fc03d1 100644 --- a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt @@ -12,7 +12,7 @@ fun TdApi.Sticker.toDomain(): StickerModel = StickerModel( width = width, height = height, emoji = emoji, - path = sticker.local.path.ifEmpty { null }, + path = sticker.local.path.takeIf { isValidFilePath(it) }, format = format.toDomain() ) @@ -27,7 +27,7 @@ fun TdApi.StickerSet.toDomain(): StickerSetModel = StickerSetModel( width = thumb.width, height = thumb.height, emoji = "", - path = thumb.file.local.path.ifEmpty { null }, + path = thumb.file.local.path.takeIf { isValidFilePath(it) }, format = stickers.firstOrNull()?.format.toDomain() ) }, diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt new file mode 100644 index 00000000..d113c63c --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt @@ -0,0 +1,120 @@ +package org.monogram.data.mapper + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.domain.repository.AppPreferencesProvider + +class TdFileHelper( + private val connectivityManager: ConnectivityManager, + private val fileApi: MessageFileApi, + private val appPreferences: AppPreferencesProvider, + private val cache: ChatCache +) { + fun isValidPath(path: String?): Boolean { + return isValidFilePath(path) + } + + fun getUpdatedFile(file: TdApi.File): TdApi.File { + return cache.fileCache[file.id] ?: file + } + + fun resolveLocalFilePath(file: TdApi.File?): String? { + if (file == null) return null + val directPath = file.local.path.takeIf { isValidPath(it) } + if (directPath != null) return directPath + return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) } + } + + fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? { + if (mainFile != null && isValidPath(mainFile.local.path)) { + return mainFile.local.path + } + + if (sizes != null) { + return sizes.sortedByDescending { it.width } + .map { getUpdatedFile(it.photo) } + .firstOrNull { isValidPath(it.local.path) } + ?.local?.path + } + + return null + } + + fun resolveCachedPath(fileId: Int, storedPath: String?): String? { + val fromStored = storedPath + ?.takeIf { it.isNotBlank() } + ?.takeIf { isValidPath(it) } + if (fromStored != null) return fromStored + + return fileId.takeIf { it != 0 } + ?.let { cache.fileCache[it]?.local?.path } + ?.takeIf { isValidPath(it) } + } + + fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { + if (fileId != 0) { + fileApi.registerFileForMessage(fileId, chatId, messageId) + } + } + + fun enqueueDownload( + fileId: Int, + priority: Int, + downloadType: TdMessageRemoteDataSource.DownloadType, + offset: Int = 0, + limit: Int = 0, + synchronous: Boolean = false + ) { + fileApi.enqueueDownload(fileId, priority, downloadType, offset.toLong(), limit.toLong(), synchronous) + } + + fun isFileQueued(fileId: Int): Boolean = fileApi.isFileQueued(fileId) + + fun computeDownloadProgress(file: TdApi.File): Float { + return if (file.size > 0) { + file.local.downloadedSize.toFloat() / file.size.toFloat() + } else { + 0f + } + } + + fun computeUploadProgress(file: TdApi.File): Float { + return if (file.size > 0) { + file.remote.uploadedSize.toFloat() / file.size.toFloat() + } else { + 0f + } + } + + fun isNetworkAutoDownloadEnabled(): Boolean { + return when (getCurrentNetworkType()) { + is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value + is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value + is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value + else -> appPreferences.autoDownloadWifi.value + } + } + + private fun getCurrentNetworkType(): TdApi.NetworkType { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + + return when { + capabilities == null -> TdApi.NetworkTypeNone() + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi() + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + if (connectivityManager.isDefaultNetworkActive && !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)) { + TdApi.NetworkTypeMobileRoaming() + } else { + TdApi.NetworkTypeMobile() + } + } + + else -> TdApi.NetworkTypeNone() + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt new file mode 100644 index 00000000..debacc5f --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt @@ -0,0 +1,58 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType + +internal fun TdApi.TextEntity.toMessageEntityOrNull( + mapUnsupportedToOther: Boolean = false, + mentionNameAsMention: Boolean = false, + customEmojiPathResolver: ((Long) -> String?)? = null, + onMissingCustomEmoji: ((Long) -> Unit)? = null +): MessageEntity? { + val mappedType = when (val entityType = type) { + is TdApi.TextEntityTypeBold -> MessageEntityType.Bold + is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic + is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline + is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough + is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler + is TdApi.TextEntityTypeCode -> MessageEntityType.Code + is TdApi.TextEntityTypePre -> MessageEntityType.Pre() + is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) + is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) + is TdApi.TextEntityTypeMention -> MessageEntityType.Mention + is TdApi.TextEntityTypeMentionName -> { + if (mentionNameAsMention) { + MessageEntityType.Mention + } else { + MessageEntityType.TextMention(entityType.userId) + } + } + + is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag + is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand + is TdApi.TextEntityTypeUrl -> MessageEntityType.Url + is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email + is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber + is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber + is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote + is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable + is TdApi.TextEntityTypeCustomEmoji -> { + val path = customEmojiPathResolver?.invoke(entityType.customEmojiId) + if (path == null) { + onMissingCustomEmoji?.invoke(entityType.customEmojiId) + } + MessageEntityType.CustomEmoji(entityType.customEmojiId, path) + } + + else -> { + if (mapUnsupportedToOther) { + MessageEntityType.Other(entityType.javaClass.simpleName) + } else { + null + } + } + } ?: return null + + return MessageEntity(offset = offset, length = length, type = mappedType) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt index 906d43db..b8804329 100644 --- a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt @@ -2,7 +2,6 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi import org.monogram.domain.models.MessageEntity -import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.RichText import org.monogram.domain.models.UpdateInfo @@ -45,27 +44,7 @@ fun TdApi.FormattedText.toChangelog(): List { } fun TdApi.TextEntity.toDomain(): MessageEntity? { - val type = when (val t = this.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(t.url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(t.userId) - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(t.customEmojiId) - else -> return null - } - return MessageEntity(this.offset, this.length, type) + return toMessageEntityOrNull() } fun TdApi.MessageDocument.toUpdateInfo(): UpdateInfo? { diff --git a/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt new file mode 100644 index 00000000..5fd50ab0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt @@ -0,0 +1,60 @@ +package org.monogram.data.mapper + +import android.text.format.DateUtils +import org.drinkless.tdlib.TdApi +import org.monogram.domain.repository.StringProvider +import java.text.SimpleDateFormat +import java.util.* + +internal fun formatChatUserStatus( + status: TdApi.UserStatus, + stringProvider: StringProvider, + isBot: Boolean = false +): String { + if (isBot) return stringProvider.getString("chat_mapper_bot") + return when (status) { + is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online") + is TdApi.UserStatusOffline -> { + val wasOnline = status.wasOnline.toLong() * 1000L + if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline") + val now = System.currentTimeMillis() + val diff = now - wasOnline + when { + diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now") + diff < 60 * 60 * 1000 -> { + val minutes = diff / (60 * 1000L) + if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1) + else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes) + } + + DateUtils.isToday(wasOnline) -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_at", format.format(date)) + } + + isYesterday(wasOnline) -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_yesterday", format.format(date)) + } + + else -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_date", format.format(date)) + } + } + } + + is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently") + is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week") + is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month") + is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline") + else -> "" + } +} + +private fun isYesterday(timestamp: Long): Boolean { + return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt index d4bfee65..b671664e 100644 --- a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi import org.monogram.domain.models.ThumbnailModel import org.monogram.domain.models.WallpaperModel import org.monogram.domain.models.WallpaperSettings +import org.monogram.domain.models.WallpaperType fun mapBackgrounds(backgrounds: Array): List { val defaultWallpapers = listOf( @@ -11,6 +12,7 @@ fun mapBackgrounds(backgrounds: Array): List { id = -1, slug = "default_blue", title = "Default Blue", + type = WallpaperType.FILL, pattern = false, documentId = 0, thumbnail = null, @@ -21,8 +23,11 @@ fun mapBackgrounds(backgrounds: Array): List { fourthBackgroundColor = null, intensity = null, rotation = 45, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ), + themeName = null, isDownloaded = true, localPath = null, isDefault = true @@ -38,10 +43,12 @@ fun TdApi.Background.toDomain(): WallpaperModel { id = this.id, slug = this.name, title = this.name, + type = this.type.toWallpaperType(), pattern = this.type is TdApi.BackgroundTypePattern, documentId = doc?.document?.id?.toLong() ?: 0L, thumbnail = doc?.thumbnail?.toDomain(), settings = this.type.toWallpaperSettings(), + themeName = this.type.toThemeName(), isDownloaded = file?.local?.isDownloadingCompleted == true, localPath = file?.local?.path?.ifEmpty { null }, isDefault = this.isDefault @@ -55,10 +62,36 @@ fun TdApi.Thumbnail.toDomain(): ThumbnailModel = ThumbnailModel( localPath = this.file.local.path ) +fun TdApi.BackgroundType.toWallpaperType(): WallpaperType = when (this) { + is TdApi.BackgroundTypeWallpaper -> WallpaperType.WALLPAPER + is TdApi.BackgroundTypePattern -> WallpaperType.PATTERN + is TdApi.BackgroundTypeFill -> WallpaperType.FILL + is TdApi.BackgroundTypeChatTheme -> WallpaperType.CHAT_THEME + else -> WallpaperType.WALLPAPER +} + +fun TdApi.BackgroundType.toThemeName(): String? = when (this) { + is TdApi.BackgroundTypeChatTheme -> themeName + else -> null +} + fun TdApi.BackgroundType.toWallpaperSettings(): WallpaperSettings? = when (this) { is TdApi.BackgroundTypePattern -> fill.toWallpaperSettings() - ?.copy(intensity = intensity, isInverted = isInverted) + ?.copy(intensity = intensity, isInverted = isInverted, isMoving = isMoving) is TdApi.BackgroundTypeFill -> fill.toWallpaperSettings() + is TdApi.BackgroundTypeWallpaper -> WallpaperSettings( + backgroundColor = null, + secondBackgroundColor = null, + thirdBackgroundColor = null, + fourthBackgroundColor = null, + intensity = null, + rotation = null, + isInverted = null, + isMoving = isMoving, + isBlurred = isBlurred + ) + + is TdApi.BackgroundTypeChatTheme -> null else -> null } @@ -70,7 +103,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = null, intensity = null, rotation = null, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) is TdApi.BackgroundFillGradient -> WallpaperSettings( backgroundColor = topColor, @@ -79,7 +114,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = null, intensity = null, rotation = rotationAngle, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) is TdApi.BackgroundFillFreeformGradient -> WallpaperSettings( backgroundColor = colors.getOrNull(0), @@ -88,7 +125,83 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = colors.getOrNull(3), intensity = null, rotation = null, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) else -> null -} \ No newline at end of file +} + +fun WallpaperModel.toInputBackground(): TdApi.InputBackground? = when (resolveWallpaperType()) { + WallpaperType.WALLPAPER -> when { + id > 0L -> TdApi.InputBackgroundRemote(id) + !localPath.isNullOrBlank() -> TdApi.InputBackgroundLocal(TdApi.InputFileLocal(localPath)) + else -> null + } + + WallpaperType.PATTERN, + WallpaperType.FILL, + WallpaperType.CHAT_THEME -> if (id > 0L) TdApi.InputBackgroundRemote(id) else null +} + +fun WallpaperModel.toBackgroundType(isBlurred: Boolean, isMoving: Boolean): TdApi.BackgroundType? = + when (resolveWallpaperType()) { + WallpaperType.WALLPAPER -> TdApi.BackgroundTypeWallpaper(isBlurred, isMoving) + + WallpaperType.PATTERN -> { + val wallpaperSettings = settings ?: return null + val fill = wallpaperSettings.toBackgroundFill() ?: return null + TdApi.BackgroundTypePattern( + fill, + wallpaperSettings.intensity ?: 50, + wallpaperSettings.isInverted == true, + isMoving + ) + } + + WallpaperType.FILL -> { + val wallpaperSettings = settings ?: return null + val fill = wallpaperSettings.toBackgroundFill() ?: return null + TdApi.BackgroundTypeFill(fill) + } + + WallpaperType.CHAT_THEME -> { + val name = themeName?.takeIf { it.isNotBlank() } ?: slug.takeIf { it.isNotBlank() } + name?.let { TdApi.BackgroundTypeChatTheme(it) } + } + } + +private fun WallpaperSettings.toBackgroundFill(): TdApi.BackgroundFill? { + val first = backgroundColor?.toTdColor() + val second = secondBackgroundColor?.toTdColor() + val third = thirdBackgroundColor?.toTdColor() + val fourth = fourthBackgroundColor?.toTdColor() + + val freeform = intArrayOfNotNull(first, second, third, fourth) + if (freeform.size >= 3) { + return TdApi.BackgroundFillFreeformGradient(freeform) + } + + if (first != null && second != null) { + return TdApi.BackgroundFillGradient(first, second, rotation ?: 0) + } + + if (first != null) { + return TdApi.BackgroundFillSolid(first) + } + + return null +} + +private fun WallpaperModel.resolveWallpaperType(): WallpaperType = when { + type == WallpaperType.PATTERN || pattern -> WallpaperType.PATTERN + type == WallpaperType.CHAT_THEME || slug.startsWith("emoji") -> WallpaperType.CHAT_THEME + type == WallpaperType.FILL -> WallpaperType.FILL + documentId != 0L || slug == "built-in" -> WallpaperType.WALLPAPER + else -> WallpaperType.FILL +} + +private fun intArrayOfNotNull(vararg values: Int?): IntArray = + values.filterNotNull().toIntArray() + +private fun Int.toTdColor(): Int = this and 0x00FFFFFF diff --git a/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt new file mode 100644 index 00000000..ab1a68e9 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt @@ -0,0 +1,267 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.domain.models.WebPage +import org.monogram.domain.repository.AppPreferencesProvider + +internal class WebPageMapper( + private val fileHelper: TdFileHelper, + private val appPreferences: AppPreferencesProvider +) { + fun map( + webPage: TdApi.LinkPreview?, + chatId: Long, + messageId: Long, + networkAutoDownload: Boolean + ): WebPage? { + if (webPage == null) return null + + var photoObj: TdApi.Photo? = null + var videoObj: TdApi.Video? = null + var audioObj: TdApi.Audio? = null + var documentObj: TdApi.Document? = null + var stickerObj: TdApi.Sticker? = null + var animationObj: TdApi.Animation? = null + var duration = 0 + + val linkPreviewType = when (val type = webPage.type) { + is TdApi.LinkPreviewTypePhoto -> { + photoObj = type.photo + WebPage.LinkPreviewType.Photo + } + + is TdApi.LinkPreviewTypeVideo -> { + videoObj = type.video + WebPage.LinkPreviewType.Video + } + + is TdApi.LinkPreviewTypeAnimation -> { + animationObj = type.animation + WebPage.LinkPreviewType.Animation + } + + is TdApi.LinkPreviewTypeAudio -> { + audioObj = type.audio + WebPage.LinkPreviewType.Audio + } + + is TdApi.LinkPreviewTypeDocument -> { + documentObj = type.document + WebPage.LinkPreviewType.Document + } + + is TdApi.LinkPreviewTypeSticker -> { + stickerObj = type.sticker + WebPage.LinkPreviewType.Sticker + } + + is TdApi.LinkPreviewTypeVideoNote -> { + WebPage.LinkPreviewType.VideoNote + } + + is TdApi.LinkPreviewTypeVoiceNote -> { + WebPage.LinkPreviewType.VoiceNote + } + + is TdApi.LinkPreviewTypeAlbum -> { + WebPage.LinkPreviewType.Album + } + + is TdApi.LinkPreviewTypeArticle -> { + WebPage.LinkPreviewType.Article + } + + is TdApi.LinkPreviewTypeApp -> { + WebPage.LinkPreviewType.App + } + + is TdApi.LinkPreviewTypeExternalVideo -> { + duration = type.duration + WebPage.LinkPreviewType.ExternalVideo(type.url) + } + + is TdApi.LinkPreviewTypeExternalAudio -> { + duration = type.duration + WebPage.LinkPreviewType.ExternalAudio(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedVideo(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedAudio(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedAnimation(type.url) + } + + is TdApi.LinkPreviewTypeUser -> { + WebPage.LinkPreviewType.User(0) + } + + is TdApi.LinkPreviewTypeChat -> { + WebPage.LinkPreviewType.Chat(0) + } + + is TdApi.LinkPreviewTypeStory -> { + WebPage.LinkPreviewType.Story(type.storyPosterChatId, type.storyId) + } + + is TdApi.LinkPreviewTypeTheme -> { + WebPage.LinkPreviewType.Theme + } + + is TdApi.LinkPreviewTypeBackground -> { + WebPage.LinkPreviewType.Background + } + + is TdApi.LinkPreviewTypeInvoice -> { + WebPage.LinkPreviewType.Invoice + } + + is TdApi.LinkPreviewTypeMessage -> { + WebPage.LinkPreviewType.Message + } + + else -> WebPage.LinkPreviewType.Unknown + } + + fun processTdFile( + file: TdApi.File, + downloadType: TdMessageRemoteDataSource.DownloadType, + supportsStreaming: Boolean = false + ): TdApi.File { + val updatedFile = fileHelper.getUpdatedFile(file) + fileHelper.registerCachedFile(updatedFile.id, chatId, messageId) + + val autoDownload = when (downloadType) { + TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload + TdMessageRemoteDataSource.DownloadType.DEFAULT -> { + if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload + } + + TdMessageRemoteDataSource.DownloadType.STICKER -> { + networkAutoDownload && appPreferences.autoDownloadStickers.value + } + + TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> { + networkAutoDownload && appPreferences.autoDownloadVideoNotes.value + } + + else -> networkAutoDownload + } + + if (!fileHelper.isValidPath(updatedFile.local.path) && autoDownload) { + fileHelper.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false) + } + + return updatedFile + } + + val photo = photoObj?.let { photoObject -> + val size = photoObject.sizes.firstOrNull() + if (size != null) { + val file = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT) + val bestPath = fileHelper.findBestAvailablePath(file, photoObject.sizes) + + WebPage.Photo( + path = bestPath, + width = size.width, + height = size.height, + fileId = file.id, + minithumbnail = photoObject.minithumbnail?.data + ) + } else { + null + } + } + + val video = videoObj?.let { videoObject -> + val file = processTdFile( + videoObject.video, + TdMessageRemoteDataSource.DownloadType.VIDEO, + videoObject.supportsStreaming + ) + WebPage.Video( + path = fileHelper.resolveLocalFilePath(file), + width = videoObject.width, + height = videoObject.height, + duration = videoObject.duration, + fileId = file.id, + supportsStreaming = videoObject.supportsStreaming + ) + } + + val audio = audioObj?.let { audioObject -> + val file = processTdFile(audioObject.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT) + WebPage.Audio( + path = fileHelper.resolveLocalFilePath(file), + duration = audioObject.duration, + title = audioObject.title, + performer = audioObject.performer, + fileId = file.id + ) + } + + val document = documentObj?.let { documentObject -> + val file = processTdFile(documentObject.document, TdMessageRemoteDataSource.DownloadType.DEFAULT) + WebPage.Document( + path = fileHelper.resolveLocalFilePath(file), + fileName = documentObject.fileName, + mimeType = documentObject.mimeType, + size = file.size, + fileId = file.id + ) + } + + val sticker = stickerObj?.let { stickerObject -> + val file = processTdFile(stickerObject.sticker, TdMessageRemoteDataSource.DownloadType.STICKER) + WebPage.Sticker( + path = fileHelper.resolveLocalFilePath(file), + width = stickerObject.width, + height = stickerObject.height, + emoji = stickerObject.emoji, + fileId = file.id + ) + } + + val animation = animationObj?.let { animationObject -> + val file = processTdFile(animationObject.animation, TdMessageRemoteDataSource.DownloadType.GIF) + WebPage.Animation( + path = fileHelper.resolveLocalFilePath(file), + width = animationObject.width, + height = animationObject.height, + duration = animationObject.duration, + fileId = file.id + ) + } + + return WebPage( + url = webPage.url, + displayUrl = webPage.displayUrl, + type = linkPreviewType, + siteName = webPage.siteName, + title = webPage.title, + description = webPage.description?.text, + photo = photo, + embedUrl = null, + embedType = null, + embedWidth = 0, + embedHeight = 0, + duration = duration, + author = webPage.author, + video = video, + audio = audio, + document = document, + sticker = sticker, + animation = animation, + instantViewVersion = webPage.instantViewVersion + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt new file mode 100644 index 00000000..3f4c382a --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt @@ -0,0 +1,593 @@ +package org.monogram.data.mapper.message + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.mapper.CustomEmojiLoader +import org.monogram.data.mapper.TdFileHelper +import org.monogram.data.mapper.WebPageMapper +import org.monogram.data.mapper.toMessageEntityOrNull +import org.monogram.domain.models.* +import org.monogram.domain.repository.AppPreferencesProvider + +internal data class ContentMappingContext( + val chatId: Long, + val messageId: Long, + val senderName: String, + val networkAutoDownload: Boolean, + val isActuallyUploading: Boolean +) + +internal class MessageContentMapper( + private val fileHelper: TdFileHelper, + private val appPreferences: AppPreferencesProvider, + private val customEmojiLoader: CustomEmojiLoader, + private val webPageMapper: WebPageMapper, + private val scope: CoroutineScope +) { + fun mapContent(msg: TdApi.Message, context: ContentMappingContext): MessageContent { + return when (val content = msg.content) { + is TdApi.MessageText -> { + val entities = mapEntities( + entities = content.text.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ) + val webPage = webPageMapper.map( + webPage = content.linkPreview, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ) + MessageContent.Text(content.text.text, entities, webPage) + } + + is TdApi.MessagePhoto -> { + val sizes = content.photo.sizes + val photoSize = sizes.find { it.type == "x" } + ?: sizes.find { it.type == "m" } + ?: sizes.getOrNull(sizes.size / 2) + ?: sizes.lastOrNull() + val thumbnailSize = sizes.find { it.type == "m" } + ?: sizes.find { it.type == "s" } + ?: sizes.firstOrNull() + + val photoFile = photoSize?.photo?.let(fileHelper::getUpdatedFile) + val thumbnailFile = thumbnailSize?.photo?.let(fileHelper::getUpdatedFile) + + val path = fileHelper.findBestAvailablePath(photoFile, sizes) + val thumbnailPath = fileHelper.resolveLocalFilePath(thumbnailFile) + + if (photoFile != null) { + fileHelper.registerCachedFile(photoFile.id, context.chatId, context.messageId) + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + photoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + if (thumbnailFile != null) { + fileHelper.registerCachedFile(thumbnailFile.id, context.chatId, context.messageId) + if (thumbnailPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbnailFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = photoFile?.local?.isDownloadingActive ?: false + val isQueued = photoFile?.let { fileHelper.isFileQueued(it.id) } ?: false + val downloadProgress = photoFile?.let(fileHelper::computeDownloadProgress) ?: 0f + + MessageContent.Photo( + path = path, + thumbnailPath = thumbnailPath, + width = photoSize?.width ?: 0, + height = photoSize?.height ?: 0, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false), + uploadProgress = photoFile?.let(fileHelper::computeUploadProgress) ?: 0f, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = photoFile?.id ?: 0, + minithumbnail = content.photo.minithumbnail?.data + ) + } + + is TdApi.MessageVideo -> { + val video = content.video + val videoFile = fileHelper.getUpdatedFile(video.video) + val path = fileHelper.resolveLocalFilePath(videoFile) + fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) + + val thumbFile = video.thumbnail?.file?.let(fileHelper::getUpdatedFile) + val thumbnailPath = fileHelper.resolveLocalFilePath(thumbFile) + + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (thumbnailPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + if (path == null && context.networkAutoDownload && video.supportsStreaming) { + fileHelper.enqueueDownload( + videoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.VIDEO, + 0, + 0, + false + ) + } + + val isDownloading = videoFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(videoFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(videoFile) + + MessageContent.Video( + path = path, + thumbnailPath = thumbnailPath, + width = video.width, + height = video.height, + duration = video.duration, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(videoFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = videoFile.id, + minithumbnail = video.minithumbnail?.data, + supportsStreaming = video.supportsStreaming + ) + } + + is TdApi.MessageVoiceNote -> { + val voice = content.voiceNote + val voiceFile = fileHelper.getUpdatedFile(voice.voice) + val path = fileHelper.resolveLocalFilePath(voiceFile) + fileHelper.registerCachedFile(voiceFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + voiceFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + + val isDownloading = voiceFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(voiceFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(voiceFile) + + MessageContent.Voice( + path = path, + duration = voice.duration, + waveform = voice.waveform, + isUploading = context.isActuallyUploading && voiceFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(voiceFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = voiceFile.id + ) + } + + is TdApi.MessageVideoNote -> { + val note = content.videoNote + val videoFile = fileHelper.getUpdatedFile(note.video) + val videoPath = fileHelper.resolveLocalFilePath(videoFile) + fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) + + if (videoPath == null && context.networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { + fileHelper.enqueueDownload( + videoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, + 0, + 0, + false + ) + } + + val thumbFile = note.thumbnail?.file?.let(fileHelper::getUpdatedFile) + val thumbPath = fileHelper.resolveLocalFilePath(thumbFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (thumbPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive + val uploadProgress = fileHelper.computeUploadProgress(videoFile) + val isDownloading = videoFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(videoFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(videoFile) + + MessageContent.VideoNote( + path = videoPath, + thumbnail = thumbPath, + duration = note.duration, + length = note.length, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = videoFile.id + ) + } + + is TdApi.MessageSticker -> { + val sticker = content.sticker + val stickerFile = fileHelper.getUpdatedFile(sticker.sticker) + val path = fileHelper.resolveLocalFilePath(stickerFile) + + fileHelper.registerCachedFile(stickerFile.id, context.chatId, context.messageId) + if (path == null && context.networkAutoDownload && appPreferences.autoDownloadStickers.value) { + fileHelper.enqueueDownload( + stickerFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.STICKER, + 0, + 0, + false + ) + } + + val format = when (sticker.format) { + is TdApi.StickerFormatWebp -> StickerFormat.STATIC + is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED + is TdApi.StickerFormatWebm -> StickerFormat.VIDEO + else -> StickerFormat.UNKNOWN + } + + val isDownloading = stickerFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(stickerFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(stickerFile) + + MessageContent.Sticker( + id = sticker.sticker.id.toLong(), + setId = sticker.setId, + path = path, + width = sticker.width, + height = sticker.height, + emoji = sticker.emoji, + format = format, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = stickerFile.id + ) + } + + is TdApi.MessageAnimation -> { + val animation = content.animation + val animationFile = fileHelper.getUpdatedFile(animation.animation) + val path = fileHelper.resolveLocalFilePath(animationFile) + fileHelper.registerCachedFile(animationFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + animationFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.GIF, + 0, + 0, + false + ) + } + + val thumbFile = animation.thumbnail?.file?.let(fileHelper::getUpdatedFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = animationFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(animationFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(animationFile) + + MessageContent.Gif( + path = path, + width = animation.width, + height = animation.height, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && animationFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(animationFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = animationFile.id, + minithumbnail = animation.minithumbnail?.data + ) + } + + is TdApi.MessageAnimatedEmoji -> MessageContent.Text(content.emoji) + is TdApi.MessageDice -> { + val valueStr = if (content.value != 0) " (Result: ${content.value})" else "" + MessageContent.Text("${content.emoji}$valueStr") + } + + is TdApi.MessageDocument -> { + val document = content.document + val documentFile = fileHelper.getUpdatedFile(document.document) + val path = fileHelper.resolveLocalFilePath(documentFile) + fileHelper.registerCachedFile(documentFile.id, context.chatId, context.messageId) + + val thumbFile = document.thumbnail?.file?.let(fileHelper::getUpdatedFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = documentFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(documentFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(documentFile) + + MessageContent.Document( + path = path, + fileName = document.fileName, + mimeType = document.mimeType, + size = documentFile.size, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && documentFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(documentFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = documentFile.id + ) + } + + is TdApi.MessageAudio -> { + val audio = content.audio + val audioFile = fileHelper.getUpdatedFile(audio.audio) + val path = fileHelper.resolveLocalFilePath(audioFile) + fileHelper.registerCachedFile(audioFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + audioFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + + val isDownloading = audioFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(audioFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(audioFile) + + MessageContent.Audio( + path = path, + duration = audio.duration, + title = audio.title ?: "Unknown", + performer = audio.performer ?: "Unknown", + fileName = audio.fileName ?: "audio.mp3", + mimeType = audio.mimeType ?: "audio/mpeg", + size = audioFile.size, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && audioFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(audioFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = audioFile.id + ) + } + + is TdApi.MessageCall -> MessageContent.Text("📞 Call (${content.duration}s)") + is TdApi.MessageContact -> { + val contact = content.contact + MessageContent.Contact( + phoneNumber = contact.phoneNumber, + firstName = contact.firstName, + lastName = contact.lastName, + vcard = contact.vcard, + userId = contact.userId + ) + } + + is TdApi.MessageLocation -> { + val location = content.location + MessageContent.Location( + latitude = location.latitude, + longitude = location.longitude, + horizontalAccuracy = location.horizontalAccuracy, + livePeriod = content.livePeriod, + heading = content.heading, + proximityAlertRadius = content.proximityAlertRadius + ) + } + + is TdApi.MessageVenue -> { + val venue = content.venue + MessageContent.Venue( + latitude = venue.location.latitude, + longitude = venue.location.longitude, + title = venue.title, + address = venue.address, + provider = venue.provider, + venueId = venue.id, + venueType = venue.type + ) + } + + is TdApi.MessagePoll -> { + val poll = content.poll + val pollType = when (val type = poll.type) { + is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers) + is TdApi.PollTypeQuiz -> { + PollType.Quiz(type.correctOptionIds.firstOrNull() ?: -1, type.explanation?.text) + } + + else -> PollType.Regular(poll.allowsMultipleAnswers) + } + + MessageContent.Poll( + id = poll.id, + question = poll.question.text, + options = poll.options.map { option -> + PollOption( + text = option.text.text, + voterCount = option.voterCount, + votePercentage = option.votePercentage, + isChosen = option.isChosen, + isBeingChosen = false + ) + }, + totalVoterCount = poll.totalVoterCount, + isClosed = poll.isClosed, + isAnonymous = poll.isAnonymous, + type = pollType, + openPeriod = poll.openPeriod, + closeDate = poll.closeDate + ) + } + + is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${content.game.title}") + is TdApi.MessageInvoice -> MessageContent.Text("💳 Invoice: ${content.productInfo.title}") + is TdApi.MessageStory -> MessageContent.Text("📖 Story") + is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired") + is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired") + + is TdApi.MessageChatJoinByLink -> MessageContent.Service("${context.senderName} has joined the group via invite link") + is TdApi.MessageChatAddMembers -> MessageContent.Service("${context.senderName} added members") + is TdApi.MessageChatDeleteMember -> MessageContent.Service("${context.senderName} left the chat") + is TdApi.MessagePinMessage -> MessageContent.Service("${context.senderName} pinned a message") + is TdApi.MessageChatChangeTitle -> MessageContent.Service("${context.senderName} changed group name to \"${content.title}\"") + is TdApi.MessageChatChangePhoto -> MessageContent.Service("${context.senderName} changed group photo") + is TdApi.MessageChatDeletePhoto -> MessageContent.Service("${context.senderName} removed group photo") + is TdApi.MessageScreenshotTaken -> MessageContent.Service("${context.senderName} took a screenshot") + is TdApi.MessageContactRegistered -> MessageContent.Service("${context.senderName} joined Telegram!") + is TdApi.MessageChatUpgradeTo -> MessageContent.Service("${context.senderName} upgraded to supergroup") + is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created") + is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${content.title}\"") + is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${content.title}\"") + is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${content.currency} ${content.totalAmount}") + is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful") + is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent") + is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received") + is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${content.distance}m") + is TdApi.MessageForumTopicCreated -> MessageContent.Service("${context.senderName} created topic \"${content.name}\"") + is TdApi.MessageForumTopicEdited -> MessageContent.Service("${context.senderName} edited topic") + is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("${context.senderName} toggled topic closed status") + is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("${context.senderName} toggled topic hidden status") + is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("${context.senderName} suggested a profile photo") + is TdApi.MessageCustomServiceAction -> MessageContent.Service(content.text) + is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${content.boostCount}") + is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${content.theme}") + is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${content.score}") + is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${content.startDate}") + is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started") + is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended") + is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed") + else -> MessageContent.Text("ℹ️ Unsupported message type: ${content.javaClass.simpleName}") + } + } + + fun mapEntities( + entities: Array, + chatId: Long, + messageId: Long, + networkAutoDownload: Boolean + ): List { + return entities.mapNotNull { entity -> + entity.toMessageEntityOrNull( + mapUnsupportedToOther = true, + mentionNameAsMention = true, + customEmojiPathResolver = { emojiId -> + customEmojiLoader.getPathIfValid(emojiId) + }, + onMissingCustomEmoji = { emojiId -> + scope.launch { + customEmojiLoader.loadIfNeeded(emojiId, chatId, messageId, networkAutoDownload) + } + } + ) + } + } + + fun resolveMessageDate(msg: TdApi.Message): Int { + return when (val schedulingState = msg.schedulingState) { + is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate + else -> msg.date + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt new file mode 100644 index 00000000..f82170ec --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt @@ -0,0 +1,746 @@ +package org.monogram.data.mapper.message + +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.mapper.SenderNameResolver +import org.monogram.data.mapper.TdFileHelper +import org.monogram.domain.models.ForwardInfo +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.PollType +import org.monogram.data.db.model.MessageEntity as MessageDbEntity + +internal class MessagePersistenceMapper( + private val cache: ChatCache, + private val fileHelper: TdFileHelper +) { + data class CachedMessageContent( + val type: String, + val text: String, + val meta: String?, + val fileId: Int = 0, + val path: String? = null, + val thumbnailPath: String? = null, + val minithumbnail: ByteArray? = null + ) + + private data class CachedReplyPreview( + val senderName: String, + val contentType: String, + val text: String + ) + + private data class CachedForwardOrigin( + val fromName: String, + val fromId: Long, + val originChatId: Long? = null, + val originMessageId: Long? = null + ) + + fun mapToEntity( + msg: TdApi.Message, + getSenderName: ((Long) -> String?)? = null + ): MessageDbEntity { + val senderId = when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> sender.userId + is TdApi.MessageSenderChat -> sender.chatId + else -> 0L + } + val senderName = getSenderName?.invoke(senderId).orEmpty() + val content = extractCachedContent(msg.content) + val entitiesEncoded = encodeEntities(msg.content) + val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L + val replyToPreview = buildReplyPreview(msg) + val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin) + + return MessageDbEntity( + id = msg.id, + chatId = msg.chatId, + senderId = senderId, + senderName = senderName, + content = content.text, + contentType = content.type, + contentMeta = content.meta, + mediaFileId = content.fileId, + mediaPath = content.path, + mediaThumbnailPath = content.thumbnailPath, + minithumbnail = content.minithumbnail, + date = resolveMessageDate(msg), + isOutgoing = msg.isOutgoing, + isRead = false, + replyToMessageId = replyToMessageId, + replyToPreview = replyToPreview?.let(::encodeReplyPreview), + replyToPreviewType = replyToPreview?.contentType, + replyToPreviewText = replyToPreview?.text, + replyToPreviewSenderName = replyToPreview?.senderName, + replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0, + forwardFromName = forwardOrigin?.fromName, + forwardFromId = forwardOrigin?.fromId ?: 0L, + forwardOriginChatId = forwardOrigin?.originChatId, + forwardOriginMessageId = forwardOrigin?.originMessageId, + forwardDate = msg.forwardInfo?.date ?: 0, + editDate = msg.editDate, + mediaAlbumId = msg.mediaAlbumId, + entities = entitiesEncoded, + viewCount = msg.interactionInfo?.viewCount ?: 0, + forwardCount = msg.interactionInfo?.forwardCount ?: 0, + createdAt = System.currentTimeMillis() + ) + } + + fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent { + return when (content) { + is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null) + is TdApi.MessagePhoto -> { + val sizes = content.photo.sizes + val best = sizes.find { it.type == "x" } + ?: sizes.find { it.type == "m" } + ?: sizes.getOrNull(sizes.size / 2) + ?: sizes.lastOrNull() + val thumbnail = sizes.find { it.type == "m" } + ?: sizes.find { it.type == "s" } + val fileId = best?.photo?.id ?: 0 + val path = best?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) } + val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) } + CachedMessageContent( + "photo", + content.caption.text, + encodeMeta(best?.width ?: 0, best?.height ?: 0), + fileId = fileId, + path = path, + thumbnailPath = thumbnailPath, + minithumbnail = content.photo.minithumbnail?.data + ) + } + + is TdApi.MessageVideo -> { + val fileId = content.video.video.id + val path = content.video.video.local.path.takeIf { fileHelper.isValidPath(it) } + CachedMessageContent( + "video", + content.caption.text, + encodeMeta( + content.video.width, + content.video.height, + content.video.duration, + content.video.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty(), + if (content.video.supportsStreaming) 1 else 0 + ), + fileId = fileId, + path = path, + thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { fileHelper.isValidPath(it) }, + minithumbnail = content.video.minithumbnail?.data + ) + } + + is TdApi.MessageVoiceNote -> CachedMessageContent( + "voice", + content.caption.text, + encodeMeta(content.voiceNote.duration), + fileId = content.voiceNote.voice.id, + path = content.voiceNote.voice.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageVideoNote -> CachedMessageContent( + "video_note", + "", + encodeMeta( + content.videoNote.duration, + content.videoNote.length, + content.videoNote.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty() + ), + fileId = content.videoNote.video.id, + path = content.videoNote.video.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageSticker -> { + val format = when (content.sticker.format) { + is TdApi.StickerFormatWebp -> "webp" + is TdApi.StickerFormatTgs -> "tgs" + is TdApi.StickerFormatWebm -> "webm" + else -> "unknown" + } + CachedMessageContent( + "sticker", + content.sticker.emoji, + encodeMeta( + content.sticker.setId, + content.sticker.emoji, + content.sticker.width, + content.sticker.height, + format + ), + fileId = content.sticker.sticker.id, + path = content.sticker.sticker.local.path.takeIf { fileHelper.isValidPath(it) } + ) + } + + is TdApi.MessageDocument -> CachedMessageContent( + "document", + content.caption.text, + encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size), + fileId = content.document.document.id, + path = content.document.document.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageAudio -> CachedMessageContent( + "audio", + content.caption.text, + encodeMeta( + content.audio.duration, + content.audio.title.orEmpty(), + content.audio.performer.orEmpty(), + content.audio.fileName.orEmpty() + ), + fileId = content.audio.audio.id, + path = content.audio.audio.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageAnimation -> CachedMessageContent( + "gif", + content.caption.text, + encodeMeta( + content.animation.width, + content.animation.height, + content.animation.duration, + content.animation.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty() + ), + fileId = content.animation.animation.id, + path = content.animation.animation.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessagePoll -> CachedMessageContent( + "poll", + content.poll.question.text, + encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0) + ) + + is TdApi.MessageContact -> CachedMessageContent( + "contact", + listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() } + .joinToString(" "), + encodeMeta( + content.contact.phoneNumber, + content.contact.firstName, + content.contact.lastName, + content.contact.userId + ) + ) + + is TdApi.MessageLocation -> CachedMessageContent( + "location", + "", + encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod) + ) + + is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null) + is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null) + is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null) + is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null) + is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null) + is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null) + is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null) + else -> CachedMessageContent("unsupported", "", null) + } + } + + fun mapEntityToModel(entity: MessageDbEntity): MessageModel { + val meta = decodeMeta(entity.contentMeta) + val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank() + val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) { + resolveLegacyMediaFromMeta(entity.contentType, meta) + } else { + 0 to null + } + val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId + val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath + val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L } + val replyPreview = resolveReplyPreview(entity) + val replyPreviewModel = + if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel( + entity, + replyToMsgId, + replyPreview + ) else null + + val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) } + val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) { + cache.getChat(entity.senderId) + } else { + null + } + + val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName) + val resolvedSenderAvatar = when { + cachedSenderUser != null -> fileHelper.resolveLocalFilePath(cachedSenderUser.profilePhoto?.small) + cachedSenderChat != null -> fileHelper.resolveLocalFilePath(cachedSenderChat.photo?.small) + else -> null + } + val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId) + ?.personalPhoto + ?.sizes + ?.firstOrNull() + ?.photo + ?.let { fileHelper.resolveLocalFilePath(it) } + + val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { + is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId + is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId + else -> 0L + } + + val forwardInfo = entity.forwardFromName + ?.takeIf { it.isNotBlank() } + ?.let { fromName -> + ForwardInfo( + date = entity.forwardDate.takeIf { it > 0 } ?: entity.date, + fromId = entity.forwardFromId, + fromName = fromName, + originChatId = entity.forwardOriginChatId, + originMessageId = entity.forwardOriginMessageId + ) + } + + val content: MessageContent = when (entity.contentType) { + "text" -> MessageContent.Text(entity.content) + + "photo" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Photo( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnailPath = entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) }, + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId, + minithumbnail = entity.minithumbnail + ) + } + + "video" -> { + val fileId = mediaFileId + val supportsStreaming = if (usesLegacyEmbeddedMedia) { + (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1 + } else { + (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1 + } + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Video( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnailPath = ( + entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) } + ?: meta.getOrNull(3) + )?.takeIf { fileHelper.isValidPath(it) }, + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + duration = meta.getOrNull(2)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId, + supportsStreaming = supportsStreaming, + minithumbnail = entity.minithumbnail + ) + } + + "voice" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Voice( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + fileId = fileId + ) + } + + "video_note" -> { + val fileId = mediaFileId + val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2) + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.VideoNote( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnail = storedThumbPath?.takeIf { fileHelper.isValidPath(it) }, + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + length = meta.getOrNull(1)?.toIntOrNull() ?: 0, + fileId = fileId + ) + } + + "sticker" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Sticker( + id = 0L, + setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L, + path = fileHelper.resolveCachedPath(fileId, mediaPath), + width = meta.getOrNull(2)?.toIntOrNull() ?: 0, + height = meta.getOrNull(3)?.toIntOrNull() ?: 0, + emoji = entity.content, + fileId = fileId + ) + } + + "document" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Document( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + fileName = meta.getOrNull(0).orEmpty(), + mimeType = meta.getOrNull(1).orEmpty(), + size = meta.getOrNull(2)?.toLongOrNull() ?: 0L, + caption = entity.content, + fileId = fileId + ) + } + + "audio" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Audio( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + title = meta.getOrNull(1).orEmpty(), + performer = meta.getOrNull(2).orEmpty(), + fileName = meta.getOrNull(3).orEmpty(), + mimeType = "", + size = 0L, + caption = entity.content, + fileId = fileId + ) + } + + "gif" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Gif( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId + ) + } + + "poll" -> MessageContent.Poll( + id = 0L, + question = entity.content, + options = emptyList(), + totalVoterCount = 0, + isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1, + isAnonymous = true, + type = PollType.Regular(false), + openPeriod = 0, + closeDate = 0 + ) + + "contact" -> MessageContent.Contact( + phoneNumber = meta.getOrNull(0).orEmpty(), + firstName = meta.getOrNull(1).orEmpty(), + lastName = meta.getOrNull(2).orEmpty(), + vcard = "", + userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L + ) + + "location" -> MessageContent.Location( + latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0, + longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0, + livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0 + ) + + "service" -> MessageContent.Service(entity.content) + else -> MessageContent.Text(entity.content) + } + + return MessageModel( + id = entity.id, + date = entity.date, + isOutgoing = entity.isOutgoing, + senderName = resolvedSenderName, + chatId = entity.chatId, + content = content, + senderId = entity.senderId, + senderAvatar = resolvedSenderAvatar, + senderPersonalAvatar = resolvedSenderPersonalAvatar, + isRead = entity.isRead, + replyToMsgId = replyToMsgId, + replyToMsg = replyPreviewModel, + forwardInfo = forwardInfo, + mediaAlbumId = entity.mediaAlbumId, + editDate = entity.editDate, + views = entity.viewCount, + viewCount = entity.viewCount, + replyCount = entity.replyCount, + isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, + isSenderPremium = cachedSenderUser?.isPremium ?: false, + senderStatusEmojiId = senderStatusEmojiId + ) + } + + private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String { + val user = cache.getUser(senderId) + if (user != null) { + return SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = fallback.ifBlank { "User" } + ) + } + + val chat = cache.getChat(senderId) + if (chat != null) { + return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + } + + return fallback.ifBlank { "User" } + } + + private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? { + val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null + val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null + val replySenderName = when (val sender = replied.senderId) { + is TdApi.MessageSenderUser -> { + val user = cache.getUser(sender.userId) + SenderNameResolver.fromParts( + firstName = user?.firstName, + lastName = user?.lastName, + fallback = "User" + ) + } + + is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty() + else -> "" + } + val extracted = extractCachedContent(replied.content) + return CachedReplyPreview( + senderName = replySenderName, + contentType = extracted.type, + text = extracted.text.take(100) + ) + } + + private fun encodeReplyPreview(preview: CachedReplyPreview): String { + return "${preview.senderName}|${preview.contentType}|${preview.text}" + } + + private fun parseReplyPreview(raw: String?): CachedReplyPreview? { + if (raw.isNullOrBlank()) return null + val firstSeparator = raw.indexOf('|') + val secondSeparator = raw.indexOf('|', firstSeparator + 1) + if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null + + val senderName = raw.substring(0, firstSeparator) + val contentType = raw.substring(firstSeparator + 1, secondSeparator) + val text = raw.substring(secondSeparator + 1) + if (contentType.isBlank()) return null + + return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text) + } + + private fun resolveReplyPreview(entity: MessageDbEntity): CachedReplyPreview? { + val encodedPreview = parseReplyPreview(entity.replyToPreview) + val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName + val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType + val text = entity.replyToPreviewText ?: encodedPreview?.text ?: "" + + if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) { + return null + } + + return CachedReplyPreview( + senderName = senderName.orEmpty(), + contentType = contentType?.ifBlank { "text" } ?: "text", + text = text + ) + } + + private fun createReplyPreviewModel( + entity: MessageDbEntity, + replyToMsgId: Long, + preview: CachedReplyPreview + ): MessageModel { + return MessageModel( + id = replyToMsgId, + date = entity.date, + isOutgoing = false, + senderName = preview.senderName.ifBlank { "Unknown" }, + chatId = entity.chatId, + content = mapReplyPreviewContent(preview), + senderId = 0L, + isRead = true + ) + } + + private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent { + return when (preview.contentType) { + "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text) + "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text) + "voice" -> MessageContent.Voice(path = null, duration = 0) + "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0) + "sticker" -> MessageContent.Sticker( + id = 0L, + setId = 0L, + path = null, + width = 0, + height = 0, + emoji = preview.text + ) + + "document" -> MessageContent.Document( + path = null, + fileName = "", + mimeType = "", + size = 0L, + caption = preview.text + ) + + "audio" -> MessageContent.Audio( + path = null, + duration = 0, + title = "", + performer = "", + fileName = "", + mimeType = "", + size = 0L, + caption = preview.text + ) + + "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text) + "poll" -> MessageContent.Poll( + id = 0L, + question = preview.text, + options = emptyList(), + totalVoterCount = 0, + isClosed = false, + isAnonymous = true, + type = PollType.Regular(false), + openPeriod = 0, + closeDate = 0 + ) + + "contact" -> MessageContent.Contact( + phoneNumber = "", + firstName = preview.text, + lastName = "", + vcard = "", + userId = 0L + ) + + "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0) + "service" -> MessageContent.Service(preview.text) + else -> MessageContent.Text(preview.text) + } + } + + private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin { + return when (origin) { + is TdApi.MessageOriginUser -> { + val user = cache.getUser(origin.senderUserId) + val name = SenderNameResolver.fromParts( + firstName = user?.firstName, + lastName = user?.lastName, + fallback = "User" + ) + CachedForwardOrigin(fromName = name, fromId = origin.senderUserId) + } + + is TdApi.MessageOriginChat -> CachedForwardOrigin( + fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat", + fromId = origin.senderChatId + ) + + is TdApi.MessageOriginChannel -> CachedForwardOrigin( + fromName = cache.getChat(origin.chatId)?.title ?: "Channel", + fromId = origin.chatId, + originChatId = origin.chatId, + originMessageId = origin.messageId + ) + + is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin( + fromName = origin.senderName.ifBlank { "Hidden user" }, + fromId = 0L + ) + + else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L) + } + } + + private fun encodeEntities(content: TdApi.MessageContent): String? { + val formatted = when (content) { + is TdApi.MessageText -> content.text + is TdApi.MessagePhoto -> content.caption + is TdApi.MessageVideo -> content.caption + is TdApi.MessageDocument -> content.caption + is TdApi.MessageAudio -> content.caption + is TdApi.MessageAnimation -> content.caption + is TdApi.MessageVoiceNote -> content.caption + else -> null + } ?: return null + + if (formatted.entities.isNullOrEmpty()) return null + + return buildString { + formatted.entities.forEachIndexed { index, entity -> + if (index > 0) append('|') + append(entity.offset).append(',').append(entity.length).append(',') + when (val type = entity.type) { + is TdApi.TextEntityTypeBold -> append("b") + is TdApi.TextEntityTypeItalic -> append("i") + is TdApi.TextEntityTypeUnderline -> append("u") + is TdApi.TextEntityTypeStrikethrough -> append("s") + is TdApi.TextEntityTypeSpoiler -> append("sp") + is TdApi.TextEntityTypeCode -> append("c") + is TdApi.TextEntityTypePre -> append("p") + is TdApi.TextEntityTypeUrl -> append("url") + is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url) + is TdApi.TextEntityTypeMention -> append("m") + is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId) + is TdApi.TextEntityTypeHashtag -> append("h") + is TdApi.TextEntityTypeBotCommand -> append("bc") + is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId) + is TdApi.TextEntityTypeEmailAddress -> append("em") + is TdApi.TextEntityTypePhoneNumber -> append("ph") + else -> append("?") + } + } + } + } + + private fun encodeMeta(vararg parts: Any?): String { + return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() } + } + + private fun decodeMeta(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|') + } + + private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair { + return when (contentType) { + "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) + "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2) + "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) + "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6) + "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5) + "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + else -> 0 to null + } + } + + private fun resolveMessageDate(msg: TdApi.Message): Int { + return when (val schedulingState = msg.schedulingState) { + is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate + else -> msg.date + } + } + + private companion object { + private const val META_SEPARATOR = '\u001F' + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt new file mode 100644 index 00000000..e25f046d --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt @@ -0,0 +1,279 @@ +package org.monogram.data.mapper.message + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withTimeout +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.mapper.SenderNameResolver +import org.monogram.data.mapper.TdFileHelper +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.UserRepository +import java.util.concurrent.ConcurrentHashMap + +internal data class ResolvedSender( + val senderId: Long, + val senderName: String, + val senderAvatar: String? = null, + val senderPersonalAvatar: String? = null, + val senderCustomTitle: String? = null, + val isSenderVerified: Boolean = false, + val isSenderPremium: Boolean = false, + val senderStatusEmojiId: Long = 0L, + val senderStatusEmojiPath: String? = null +) + +internal class MessageSenderResolver( + private val gateway: TelegramGateway, + private val userRepository: UserRepository, + private val chatInfoRepository: ChatInfoRepository, + private val cache: ChatCache, + private val fileHelper: TdFileHelper +) { + private data class SenderUserSnapshot( + val name: String, + val avatar: String?, + val personalAvatar: String?, + val isVerified: Boolean, + val isPremium: Boolean, + val statusEmojiId: Long, + val statusEmojiPath: String? + ) + + private data class SenderChatSnapshot( + val name: String, + val avatar: String? + ) + + private val senderUserSnapshotCache = ConcurrentHashMap() + private val senderChatSnapshotCache = ConcurrentHashMap() + private val senderRankCache = ConcurrentHashMap() + private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet() + + val senderUpdateFlow: Flow + get() = userRepository.anyUserUpdateFlow + + fun invalidateCache(userId: Long) { + if (userId <= 0L) return + senderUserSnapshotCache.remove(userId) + senderChatSnapshotCache.remove(userId) + senderRankCache.entries.removeIf { it.key.endsWith(":$userId") } + } + + fun resolveNameFromCache(senderId: Long, fallback: String): String { + val user = cache.getUser(senderId) + if (user != null) { + return SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = fallback.ifBlank { "User" } + ) + } + + val chat = cache.getChat(senderId) + if (chat != null) { + return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + } + + return fallback.ifBlank { "User" } + } + + fun resolveFallbackSender(msg: TdApi.Message): ResolvedSender { + return when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> { + val senderId = sender.userId + val snapshot = senderUserSnapshotCache[senderId] + if (snapshot != null) { + ResolvedSender( + senderId = senderId, + senderName = snapshot.name.ifBlank { "User" }, + senderAvatar = snapshot.avatar ?: snapshot.personalAvatar, + senderPersonalAvatar = snapshot.personalAvatar, + isSenderVerified = snapshot.isVerified, + isSenderPremium = snapshot.isPremium, + senderStatusEmojiId = snapshot.statusEmojiId, + senderStatusEmojiPath = snapshot.statusEmojiPath + ) + } else { + val user = cache.getUser(senderId) + val fallbackName = if (user != null) { + SenderNameResolver.fromParts(user.firstName, user.lastName, "User") + } else { + "User" + } + val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ?: user?.profilePhoto?.big?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar) + } + } + + is TdApi.MessageSenderChat -> { + val senderId = sender.chatId + val snapshot = senderChatSnapshotCache[senderId] + if (snapshot != null) { + ResolvedSender( + senderId = senderId, + senderName = snapshot.name.ifBlank { "User" }, + senderAvatar = snapshot.avatar + ) + } else { + val chat = cache.getChat(senderId) + val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User" + val avatar = chat?.photo?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar) + } + } + + else -> ResolvedSender(senderId = 0L, senderName = "User") + } + } + + suspend fun resolveSender(msg: TdApi.Message): ResolvedSender { + var senderName = "User" + var senderAvatar: String? = null + var senderPersonalAvatar: String? = null + var senderCustomTitle: String? = null + var isSenderVerified = false + var isSenderPremium = false + var senderStatusEmojiId = 0L + var senderStatusEmojiPath: String? = null + val senderId: Long + + when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> { + senderId = sender.userId + val cachedSnapshot = senderUserSnapshotCache[senderId] + if (cachedSnapshot != null) { + senderName = cachedSnapshot.name + senderAvatar = cachedSnapshot.avatar + senderPersonalAvatar = cachedSnapshot.personalAvatar + isSenderVerified = cachedSnapshot.isVerified + isSenderPremium = cachedSnapshot.isPremium + senderStatusEmojiId = cachedSnapshot.statusEmojiId + senderStatusEmojiPath = cachedSnapshot.statusEmojiPath + } else { + val user = try { + withTimeout(500) { userRepository.getUser(senderId) } + } catch (_: Exception) { + null + } + + if (user != null) { + senderName = SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = "User" + ) + + senderAvatar = user.avatarPath.takeIf { fileHelper.isValidPath(it) } + senderPersonalAvatar = user.personalAvatarPath.takeIf { fileHelper.isValidPath(it) } + isSenderVerified = user.isVerified + isSenderPremium = user.isPremium + senderStatusEmojiId = user.statusEmojiId + senderStatusEmojiPath = user.statusEmojiPath + + senderUserSnapshotCache[senderId] = SenderUserSnapshot( + name = senderName, + avatar = senderAvatar, + personalAvatar = senderPersonalAvatar, + isVerified = isSenderVerified, + isPremium = isSenderPremium, + statusEmojiId = senderStatusEmojiId, + statusEmojiPath = senderStatusEmojiPath + ) + } + } + + val chat = cache.getChat(msg.chatId) + val canGetMember = when (chat?.type) { + is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true + is TdApi.ChatTypeBasicGroup -> true + is TdApi.ChatTypeSupergroup -> { + val supergroup = chat.type as TdApi.ChatTypeSupergroup + val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId) + !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false) + } + + else -> false + } + + if (canGetMember) { + val rankKey = "${msg.chatId}:$senderId" + val cachedRank = senderRankCache[rankKey] + if (cachedRank != null) { + senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL } + } else { + val member = try { + withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) } + } catch (_: Exception) { + null + } + + senderCustomTitle = member?.rank + senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL + } + } + } + + is TdApi.MessageSenderChat -> { + senderId = sender.chatId + val cachedSnapshot = senderChatSnapshotCache[senderId] + if (cachedSnapshot != null) { + senderName = cachedSnapshot.name + senderAvatar = cachedSnapshot.avatar + } else { + val chat = try { + withTimeout(500) { + cache.getChat(senderId) + ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) } + } + } catch (_: Exception) { + null + } + + if (chat != null) { + senderName = chat.title + val photo = chat.photo?.small + if (photo != null) { + senderAvatar = photo.local.path.takeIf { fileHelper.isValidPath(it) } + if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) { + fileHelper.enqueueDownload( + photo.id, + 16, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + senderChatSnapshotCache[senderId] = SenderChatSnapshot( + name = senderName, + avatar = senderAvatar + ) + } + } + } + + else -> senderId = 0L + } + + return ResolvedSender( + senderId = senderId, + senderName = senderName, + senderAvatar = senderAvatar, + senderPersonalAvatar = senderPersonalAvatar, + senderCustomTitle = senderCustomTitle, + isSenderVerified = isSenderVerified, + isSenderPremium = isSenderPremium, + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = senderStatusEmojiPath + ) + } + + private companion object { + private const val NO_RANK_SENTINEL = "__NO_RANK__" + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt index 652e2d39..0e84c952 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt @@ -5,8 +5,7 @@ import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.UserEntity import org.monogram.data.db.model.UserFullInfoEntity -import org.monogram.data.mapper.isForcedVerifiedUser -import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.* import org.monogram.domain.models.* import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.ChatMembersFilter @@ -21,8 +20,8 @@ fun TdApi.User.toDomain( val personalAvatarPath = fullInfo?.personalPhoto?.let { personalPhoto -> val bestPhotoSize = personalPhoto.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: personalPhoto.sizes.lastOrNull() - personalPhoto.animation?.file?.local?.path?.ifEmpty { null } - ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } + personalPhoto.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } } val lastSeen = (status as? TdApi.UserStatusOffline) @@ -150,7 +149,7 @@ fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( isMember = isMember, restrictedUntilDate = restrictedUntilDate, - permissions = permissions.toDomain() + permissions = permissions.toDomainChatPermissions() ) is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left @@ -159,18 +158,16 @@ fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { } fun TdApi.Chat.toDomain(): ChatModel { - val isChannel = type is TdApi.ChatTypeSupergroup && - (type as TdApi.ChatTypeSupergroup).isChannel + val isChannel = type.isChannelType() return ChatModel( id = id, title = title, - avatarPath = photo?.small?.local?.path?.ifEmpty { null }, + avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, unreadCount = unreadCount, isMuted = notificationSettings.muteFor > 0, isChannel = isChannel, - isGroup = type is TdApi.ChatTypeBasicGroup || - (type is TdApi.ChatTypeSupergroup && !isChannel), - type = type.toDomain(), + isGroup = type.isGroupType(), + type = type.toDomainChatType(), lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" ) } @@ -214,7 +211,7 @@ fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( isMember, restrictedUntilDate, - permissions.toApi() + permissions.toTdApiChatPermissions() ) is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) @@ -222,17 +219,9 @@ fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { } } -private fun TdApi.ChatType.toDomain(): ChatType = when (this) { - is TdApi.ChatTypePrivate -> ChatType.PRIVATE - is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP - is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP - is TdApi.ChatTypeSecret -> ChatType.SECRET - else -> ChatType.PRIVATE -} - private fun TdApi.User.resolveAvatarPath(): String? { - val big = profilePhoto?.big?.local?.path?.ifEmpty { null } - val small = profilePhoto?.small?.local?.path?.ifEmpty { null } + val big = profilePhoto?.big?.local?.path?.takeIf { isValidFilePath(it) } + val small = profilePhoto?.small?.local?.path?.takeIf { isValidFilePath(it) } return big ?: small } @@ -289,60 +278,25 @@ private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { ) }, startPage = startPage?.let { - BusinessStartPageModel(it.title, it.message, it.sticker?.sticker?.local?.path) + BusinessStartPageModel( + title = it.title, + message = it.message, + stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } + ) }, nextOpenIn = nextOpenIn, nextCloseIn = nextCloseIn ) } -private fun TdApi.ChatPermissions.toDomain(): ChatPermissionsModel { - return ChatPermissionsModel( - canSendBasicMessages = canSendBasicMessages, - canSendAudios = canSendAudios, - canSendDocuments = canSendDocuments, - canSendPhotos = canSendPhotos, - canSendVideos = canSendVideos, - canSendVideoNotes = canSendVideoNotes, - canSendVoiceNotes = canSendVoiceNotes, - canSendPolls = canSendPolls, - canSendOtherMessages = canSendOtherMessages, - canAddLinkPreviews = canAddLinkPreviews, - canChangeInfo = canChangeInfo, - canInviteUsers = canInviteUsers, - canPinMessages = canPinMessages, - canCreateTopics = canCreateTopics - ) -} - -private fun ChatPermissionsModel.toApi(): TdApi.ChatPermissions { - return TdApi.ChatPermissions( - canSendBasicMessages, - canSendAudios, - canSendDocuments, - canSendPhotos, - canSendVideos, - canSendVideoNotes, - canSendVoiceNotes, - canSendPolls, - canSendOtherMessages, - canAddLinkPreviews, - canEditTag, - canChangeInfo, - canInviteUsers, - canPinMessages, - canCreateTopics - ) -} - fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { val businessLocation = businessInfo?.location val businessOpeningHours = businessInfo?.openingHours val businessStartPage = businessInfo?.startPage val birth = birthdate - val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } + val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.ifEmpty { null } + ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } return UserFullInfoEntity( userId = userId, 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 a4963fba..a3b263e1 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -20,6 +20,7 @@ import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper +import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.map import org.monogram.data.mapper.toDomain import org.monogram.domain.models.* @@ -37,6 +38,7 @@ class MessageRepositoryImpl( private val messageMapper: MessageMapper, private val messageRemoteDataSource: MessageRemoteDataSource, private val cache: ChatCache, + private val fileHelper: TdFileHelper, private val fileDataSource: FileDataSource, private val dispatcherProvider: DispatcherProvider, private val scope: CoroutineScope, @@ -789,7 +791,7 @@ class MessageRepositoryImpl( override suspend fun getFilePath(fileId: Int): String? { val result = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() return if (result is TdApi.File) { - result.local.path.ifEmpty { null } + result.local.path.takeIf { fileHelper.isValidPath(it) } } else { null } @@ -917,7 +919,7 @@ class MessageRepositoryImpl( if (thumbnail == null) return null val file = thumbnail.file val updated = cache.fileCache[file.id] ?: file - if (updated.local.path.isNotEmpty()) return updated.local.path + if (fileHelper.isValidPath(updated.local.path)) return updated.local.path scope.launch { fileDataSource.downloadFile(updated.id, 32, 0, 0, false) } @@ -1392,7 +1394,7 @@ class MessageRepositoryImpl( cachedUser.lastName?.takeIf { it.isNotBlank() } ).joinToString(" ").ifBlank { model.senderName } - val resolvedAvatar = resolveFilePath(cachedUser.profilePhoto?.small) + val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedUser.profilePhoto?.small) if (resolvedAvatar == null) { cachedUser.profilePhoto?.small?.id?.takeIf { it != 0 }?.let { avatarFileId -> messageRemoteDataSource.enqueueDownload(avatarFileId, priority = 16) @@ -1416,7 +1418,7 @@ class MessageRepositoryImpl( val cachedChat = cache.getChat(senderId) if (cachedChat != null) { val resolvedName = cachedChat.title.takeIf { it.isNotBlank() } ?: model.senderName - val resolvedAvatar = resolveFilePath(cachedChat.photo?.small) + val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedChat.photo?.small) return model.copy( senderName = resolvedName, senderAvatar = resolvedAvatar ?: model.senderAvatar @@ -1426,15 +1428,6 @@ class MessageRepositoryImpl( return model } - private fun resolveFilePath(file: TdApi.File?): String? { - if (file == null) return null - val directPath = file.local.path.takeIf { it.isNotBlank() && File(it).exists() } - if (directPath != null) return directPath - - val cachedPath = cache.fileCache[file.id]?.local?.path - return cachedPath?.takeIf { it.isNotBlank() && File(it).exists() } - } - private fun TextCompositionStyleModel.toEntity(): TextCompositionStyleEntity { return TextCompositionStyleEntity( name = name, diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index 4a910a0a..6e579c42 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -13,6 +13,7 @@ import org.monogram.data.datasource.remote.UserRemoteDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.toEntity import org.monogram.data.mapper.user.toTdApiChat import org.monogram.domain.repository.ProfilePhotoRepository @@ -134,7 +135,7 @@ class ProfilePhotoRepositoryImpl( photoInfo?.small ?: photoInfo?.big } ?: return null - val directPath = preferredFile.local.path.ifEmpty { null } + val directPath = preferredFile.local.path.takeIf { isValidFilePath(it) } if (directPath != null) { if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { fileQueue.enqueue( @@ -206,7 +207,7 @@ class ProfilePhotoRepositoryImpl( ensureFullRes: Boolean ): String? { val animationFile = photo.animation?.file - val animationPath = animationFile?.local?.path?.ifEmpty { null } + val animationPath = animationFile?.local?.path?.takeIf { isValidFilePath(it) } if (animationPath != null) return animationPath val bestPhotoFile = photo.sizes @@ -215,7 +216,7 @@ class ProfilePhotoRepositoryImpl( ?: photo.sizes.lastOrNull()?.photo ?: return null - val directPath = bestPhotoFile.local.path.ifEmpty { null } + val directPath = bestPhotoFile.local.path.takeIf { isValidFilePath(it) } if (directPath != null) return directPath if (!ensureFullRes) { @@ -226,7 +227,7 @@ class ProfilePhotoRepositoryImpl( ?: photo.sizes.find { it.type == "a" }?.photo ?: photo.sizes.firstOrNull()?.photo - val fallbackDirectPath = fallbackFile?.local?.path?.ifEmpty { null } + val fallbackDirectPath = fallbackFile?.local?.path?.takeIf { isValidFilePath(it) } if (fallbackDirectPath != null) return fallbackDirectPath val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id) @@ -252,7 +253,11 @@ class ProfilePhotoRepositoryImpl( private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null - return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + return if (file.local.isDownloadingCompleted) { + file.local.path.takeIf { isValidFilePath(it) } + } else { + null + } } companion object { diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index 4e67e638..853c34b6 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -6,12 +6,16 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.WallpaperDao import org.monogram.data.db.model.WallpaperEntity import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.mapBackgrounds +import org.monogram.data.mapper.toBackgroundType +import org.monogram.data.mapper.toDomain +import org.monogram.data.mapper.toInputBackground import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.WallpaperRepository @@ -75,6 +79,38 @@ class WallpaperRepositoryImpl( remote.downloadFile(fileId, 1) } + override suspend fun setDefaultWallpaper( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? { + val background = wallpaper.toInputBackground() + val type = wallpaper.toBackgroundType(isBlurred = isBlurred, isMoving = isMoving) + val result = remote.setDefaultBackground( + background = background, + type = type, + forDarkTheme = false + ) ?: return null + + wallpaperUpdates.emit(Unit) + return result.toDomain() + } + + override suspend fun uploadWallpaper( + filePath: String, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? { + val result = remote.setDefaultBackground( + background = TdApi.InputBackgroundLocal(TdApi.InputFileLocal(filePath)), + type = TdApi.BackgroundTypeWallpaper(isBlurred, isMoving), + forDarkTheme = false + ) ?: return null + + wallpaperUpdates.emit(Unit) + return result.toDomain() + } + private suspend fun saveWallpapersToDb(wallpapers: List) { withContext(dispatchers.io) { wallpaperDao.clearAll() diff --git a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt index a1ef60f3..1d8808f5 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.isValidFilePath import java.util.concurrent.ConcurrentHashMap internal class UserMediaResolver( @@ -25,7 +26,7 @@ internal class UserMediaResolver( val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { val file = result.stickers.first().sticker - if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { + if (file.local.isDownloadingCompleted && isValidFilePath(file.local.path)) { emojiPathCache[emojiId] = file.local.path file.local.path } else { @@ -37,7 +38,7 @@ internal class UserMediaResolver( (gateway.execute(TdApi.GetFile(file.id)) as? TdApi.File) ?.local ?.path - ?.takeIf { it.isNotEmpty() } + ?.takeIf { isValidFilePath(it) } }.getOrNull() if (refreshedPath != null) { emojiPathCache[emojiId] = refreshedPath @@ -55,10 +56,10 @@ internal class UserMediaResolver( suspend fun resolveAvatarPath(user: TdApi.User): String? { val bigPhoto = user.profilePhoto?.big val smallPhoto = user.profilePhoto?.small - val bigDirectPath = bigPhoto?.local?.path?.ifEmpty { null } + val bigDirectPath = bigPhoto?.local?.path?.takeIf { isValidFilePath(it) } if (bigDirectPath != null) return bigDirectPath - val smallDirectPath = smallPhoto?.local?.path?.ifEmpty { null } + val smallDirectPath = smallPhoto?.local?.path?.takeIf { isValidFilePath(it) } if (smallDirectPath != null) { val bigId = bigPhoto?.id?.takeIf { it != 0 } if (bigId != null && bigId != smallPhoto.id) { @@ -121,7 +122,11 @@ internal class UserMediaResolver( private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null - return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + return if (file.local.isDownloadingCompleted) { + file.local.path.takeIf { isValidFilePath(it) } + } else { + null + } } companion object { diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt index e0d003b6..0a32176b 100644 --- a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt +++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt @@ -11,6 +11,7 @@ import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.mapper.isValidFilePath import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel import java.io.File @@ -39,7 +40,7 @@ class StickerFileManager( val firstPath = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { fileUpdateHandler.fileDownloadCompleted .filter { it.first == fileId } - .mapNotNull { (_, path) -> path.takeIf(::isPathValid) } + .mapNotNull { (_, path) -> path.takeIf(::isValidFilePath) } .first() } @@ -125,7 +126,7 @@ class StickerFileManager( private suspend fun resolveAvailablePath(fileId: Long): String? { filePathsCache[fileId]?.let { path -> - if (isPathValid(path)) { + if (isValidFilePath(path)) { return path } filePathsCache.remove(fileId) @@ -134,7 +135,7 @@ class StickerFileManager( val dbPath = localDataSource.getPath(fileId) if (!dbPath.isNullOrEmpty()) { - if (isPathValid(dbPath)) { + if (isValidFilePath(dbPath)) { filePathsCache[fileId] = dbPath return dbPath } @@ -143,7 +144,7 @@ class StickerFileManager( val completedPath = fileUpdateHandler.fileDownloadCompleted .replayCache - .firstOrNull { it.first == fileId && isPathValid(it.second) } + .firstOrNull { it.first == fileId && isValidFilePath(it.second) } ?.second if (!completedPath.isNullOrEmpty()) { @@ -159,10 +160,6 @@ class StickerFileManager( fileQueue.enqueue(fileId.toInt(), priority, FileDownloadQueue.DownloadType.STICKER) } - private fun isPathValid(path: String): Boolean { - return path.isNotEmpty() && File(path).exists() - } - companion object { private const val TAG = "StickerFileManager" private const val DOWNLOAD_TIMEOUT_MS = 90_000L diff --git a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt index d7f3c8ec..0b79f7f3 100644 --- a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt @@ -7,15 +7,25 @@ data class WallpaperModel( val id: Long, val slug: String, val title: String, + val type: WallpaperType = WallpaperType.WALLPAPER, val pattern: Boolean, val documentId: Long, val thumbnail: ThumbnailModel?, val settings: WallpaperSettings?, + val themeName: String? = null, val isDownloaded: Boolean, val localPath: String?, val isDefault: Boolean = false ) +@Serializable +enum class WallpaperType { + WALLPAPER, + PATTERN, + FILL, + CHAT_THEME +} + @Serializable data class ThumbnailModel( val fileId: Int, @@ -32,5 +42,7 @@ data class WallpaperSettings( val fourthBackgroundColor: Int?, val intensity: Int?, val rotation: Int?, - val isInverted: Boolean? = null + val isInverted: Boolean? = null, + val isMoving: Boolean? = null, + val isBlurred: Boolean? = null ) diff --git a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt index 415db6f8..9900cf19 100644 --- a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt @@ -6,4 +6,15 @@ import org.monogram.domain.models.WallpaperModel interface WallpaperRepository { fun getWallpapers(): Flow> suspend fun downloadWallpaper(fileId: Int) -} \ No newline at end of file + suspend fun setDefaultWallpaper( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? + + suspend fun uploadWallpaper( + filePath: String, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt index f8de1cb7..d576b029 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt @@ -32,27 +32,28 @@ fun ChatContentBackground( isGrayscale = state.isWallpaperGrayscale ) } else if (state.wallpaper != null) { - if (File(state.wallpaper!!).exists()) { - var imageModifier = Modifier.fillMaxSize() + val file = File(state.wallpaper) + if (file.exists()) { + var imageModifier = modifier.fillMaxSize() if (state.isWallpaperBlurred && state.wallpaperBlurIntensity > 0) { imageModifier = imageModifier.blur((state.wallpaperBlurIntensity / 4f).dp) } AsyncImage( - model = File(state.wallpaper!!), + model = file, contentDescription = null, modifier = imageModifier, contentScale = ContentScale.Crop ) } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) } } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index a1c5d595..28d3d218 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -32,6 +32,7 @@ interface ChatSettingsComponent { fun onStickerSizeChanged(size: Float) fun onWallpaperChanged(wallpaper: String?) fun onWallpaperSelected(wallpaper: WallpaperModel) + fun onWallpaperUpload(path: String) fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) fun onWallpaperBlurIntensityChanged(intensity: Int) fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) @@ -98,6 +99,7 @@ interface ChatSettingsComponent { val isWallpaperMoving: Boolean = false, val wallpaperDimming: Int = 0, val isWallpaperGrayscale: Boolean = false, + val isWallpaperUploading: Boolean = false, val availableWallpapers: List = emptyList(), val selectedWallpaper: WallpaperModel? = null, val isPlayerGesturesEnabled: Boolean = true, @@ -612,6 +614,29 @@ class DefaultChatSettingsComponent( .launchIn(scope) } + private fun wallpaperPreferenceKey(wallpaper: WallpaperModel): String? = when { + wallpaper.slug.isNotEmpty() -> wallpaper.slug + !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath + else -> null + } + + private fun syncWallpaperOnServer( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ) { + scope.launch { + wallpaperRepository.setDefaultWallpaper( + wallpaper = wallpaper, + isBlurred = isBlurred, + isMoving = isMoving + )?.let { syncedWallpaper -> + wallpaperPreferenceKey(syncedWallpaper)?.let { appPreferences.setWallpaper(it) } + _state.update { it.copy(selectedWallpaper = syncedWallpaper) } + } + } + } + override fun onBackClicked() { onBack() } @@ -639,24 +664,50 @@ class DefaultChatSettingsComponent( override fun onWallpaperSelected(wallpaper: WallpaperModel) { _state.update { it.copy(selectedWallpaper = wallpaper) } + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + if (!wallpaper.isDownloaded && wallpaper.documentId != 0L) { scope.launch { wallpaperRepository.downloadWallpaper(wallpaper.documentId.toInt()) } } - val key = when { - wallpaper.slug.isNotEmpty() -> wallpaper.slug - !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath - else -> null - } + wallpaperPreferenceKey(wallpaper)?.let { appPreferences.setWallpaper(it) } + syncWallpaperOnServer(wallpaper, currentBlur, currentMoving) + } - key?.let { appPreferences.setWallpaper(it) } + override fun onWallpaperUpload(path: String) { + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + + appPreferences.setWallpaper(path) + _state.update { it.copy(isWallpaperUploading = true) } + + scope.launch { + val uploaded = wallpaperRepository.uploadWallpaper( + filePath = path, + isBlurred = currentBlur, + isMoving = currentMoving + ) + + if (uploaded != null) { + _state.update { it.copy(selectedWallpaper = uploaded) } + wallpaperPreferenceKey(uploaded)?.let { appPreferences.setWallpaper(it) } + if (!uploaded.isDownloaded && uploaded.documentId != 0L) { + wallpaperRepository.downloadWallpaper(uploaded.documentId.toInt()) + } + } + + _state.update { it.copy(isWallpaperUploading = false) } + } } override fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) { + val currentMoving = _state.value.isWallpaperMoving _state.update { it.copy(isWallpaperBlurred = isBlurred) } appPreferences.setWallpaperBlurred(isBlurred) + syncWallpaperOnServer(wallpaper, isBlurred, currentMoving) } override fun onWallpaperBlurIntensityChanged(intensity: Int) { @@ -664,8 +715,10 @@ class DefaultChatSettingsComponent( } override fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) { + val currentBlur = _state.value.isWallpaperBlurred _state.update { it.copy(isWallpaperMoving = isMoving) } appPreferences.setWallpaperMoving(isMoving) + syncWallpaperOnServer(wallpaper, currentBlur, isMoving) } override fun onWallpaperDimmingChanged(dimming: Int) { 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 224761fc..9364f2e7 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 @@ -1,7 +1,12 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.settings.chatSettings +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* @@ -22,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -43,11 +49,22 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.get import org.monogram.presentation.settings.chatSettings.components.ChatListPreview import org.monogram.presentation.settings.chatSettings.components.ChatSettingsPreview import org.monogram.presentation.settings.chatSettings.components.WallpaperItem +import java.io.File +import java.io.FileOutputStream @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatSettingsContent(component: ChatSettingsComponent) { val state by component.state.subscribeAsState() + val context = LocalContext.current + + val wallpaperPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + copyUriToTempWallpaperPath(context, uri)?.let(component::onWallpaperUpload) + } + } val blueColor = Color(0xFF4285F4) val greenColor = Color(0xFF34A853) @@ -274,6 +291,40 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(16.dp) ) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 4.dp) + .clickable(enabled = !state.isWallpaperUploading) { + wallpaperPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + Box( + modifier = Modifier + .size(80.dp, 120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center + ) { + if (state.isWallpaperUploading) { + CircularProgressIndicator( + modifier = Modifier.size(26.dp), + strokeWidth = 2.5.dp + ) + } else { + Icon( + imageVector = Icons.Rounded.Upload, + contentDescription = stringResource(R.string.upload_wallpaper_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + item { val isSelected = state.wallpaper == null @@ -1373,4 +1424,24 @@ private fun TimePickerDialogWrapper( content() } ) -} \ No newline at end of file +} + +private fun copyUriToTempWallpaperPath(context: Context, uri: Uri): String? = try { + if (uri.scheme == "file") return uri.path + + val mime = context.contentResolver.getType(uri).orEmpty() + val extension = when { + mime.contains("png") -> "png" + mime.contains("webp") -> "webp" + else -> "jpg" + } + + val file = File(context.cacheDir, "wallpaper_${System.nanoTime()}.$extension") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } ?: return null + + file.absolutePath +} catch (_: Exception) { + null +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt index 092c1fe3..794e5a49 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt @@ -5,7 +5,6 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import android.util.Log import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,8 +13,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -23,6 +24,7 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.models.WallpaperType import java.io.File @Composable @@ -137,16 +139,31 @@ fun WallpaperBackground( Box(modifier = modifier) { val settings = wallpaper.settings + val wallpaperType = remember( + wallpaper.type, + wallpaper.pattern, + wallpaper.slug, + wallpaper.documentId + ) { + wallpaper.resolveType() + } + + val colors = remember(settings) { + listOfNotNull( + settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } + ) + } - val hasColors = remember(settings) { - settings?.let { - it.backgroundColor != null || it.secondBackgroundColor != null || - it.thirdBackgroundColor != null || it.fourthBackgroundColor != null - } ?: false + val hasColors = remember(colors) { + colors.isNotEmpty() } - val isFullImage = remember(wallpaper) { - !wallpaper.pattern && !wallpaper.slug.startsWith("emoji") && (wallpaper.documentId != 0L || wallpaper.slug == "built-in") + val isFullImage = remember(wallpaperType, wallpaper.documentId, wallpaper.slug) { + wallpaperType == WallpaperType.WALLPAPER && + (wallpaper.documentId != 0L || wallpaper.slug == "built-in") } val isBackgroundDisabled = remember(isFullImage, hasColors) { @@ -156,49 +173,21 @@ fun WallpaperBackground( val shouldShowBackground = !isBackgroundDisabled || isChatSettings if (shouldShowBackground) { - val colors = remember(settings) { - listOfNotNull( - settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } - ) - } - val bgMod = Modifier.fillMaxSize() - if (colors.isNotEmpty()) { - if (colors.size == 1) { - Box(modifier = bgMod.background(colors[0])) - } else { - val rotation = settings?.rotation ?: 0 - Box( - modifier = bgMod.background( - Brush.linearGradient( - colors = colors, - start = Offset(0f, 0f), - end = when (rotation) { - 45 -> Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) - 90 -> Offset(Float.POSITIVE_INFINITY, 0f) - 135 -> Offset(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY) - 180 -> Offset(0f, Float.NEGATIVE_INFINITY) - 225 -> Offset(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY) - 270 -> Offset(Float.NEGATIVE_INFINITY, 0f) - 315 -> Offset(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY) - else -> Offset(0f, Float.POSITIVE_INFINITY) - } - ) - ) - ) - } - } else { - Box(modifier = bgMod.background(MaterialTheme.colorScheme.surface)) - } + val baseColor = colors.firstOrNull() ?: MaterialTheme.colorScheme.surface + Box(modifier = bgMod.background(baseColor)) } - val imagePath = if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { - wallpaper.localPath + val supportsImageLayer = wallpaperType == WallpaperType.WALLPAPER || wallpaperType == WallpaperType.PATTERN + + val imagePath = if (supportsImageLayer) { + if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { + wallpaper.localPath + } else { + wallpaper.thumbnail?.localPath + } } else { - wallpaper.thumbnail?.localPath + null } if (imagePath != null && File(imagePath).exists()) { @@ -227,7 +216,7 @@ fun WallpaperBackground( if (animatedBlur > 0f) it.blur((animatedBlur / 4f).dp) else it } - if (wallpaper.pattern) { + if (wallpaperType == WallpaperType.PATTERN) { val intensity = (settings?.intensity ?: 50) / 100f AsyncImage( model = file, @@ -245,9 +234,6 @@ fun WallpaperBackground( colorFilter = colorFilter ) } - } else if (wallpaper.slug.startsWith("emoji")) { - // TODO: Implement rendering with gradient and emojis - Log.d("WallpaperBackground", "Emoji wallpaper rendering not implemented for slug: ${wallpaper.slug}") } else if (!shouldShowBackground) { Box(modifier = Modifier .fillMaxSize() @@ -264,4 +250,12 @@ fun WallpaperBackground( ) } } -} \ No newline at end of file +} + +private fun WallpaperModel.resolveType(): WallpaperType = when { + type == WallpaperType.PATTERN || pattern -> WallpaperType.PATTERN + type == WallpaperType.CHAT_THEME || slug.startsWith("emoji") -> WallpaperType.CHAT_THEME + type == WallpaperType.FILL -> WallpaperType.FILL + documentId != 0L || slug == "built-in" -> WallpaperType.WALLPAPER + else -> WallpaperType.FILL +} diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 41a3940e..47711205 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -573,6 +573,7 @@ Restablecer Fondo de Pantalla del Chat Restablecer Fondo de Pantalla + Subir fondo de pantalla Estilo de Emoji Tema Modo Nocturno diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index d1c30d94..4ad42ab9 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -544,6 +544,7 @@ Վերակայել Չատի պաստառ Վերակայել պաստառը + Վերբեռնել պաստառ Էմոջիների ոճը Թեմա Գիշերային ռեժիմ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 6cb413bd..437a089c 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -574,6 +574,7 @@ Redefinir Papel de parede do chat Redefinir papel de parede + Carregar papel de parede Estilo de emoji Tema Modo noturno diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 61f22d30..4973ed2d 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -560,6 +560,7 @@ Сбросить Обои чата Сбросить обои + Загрузить обои Стиль эмодзи Тема Ночной режим diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 092a7498..674b1ef8 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -590,6 +590,7 @@ Obnoviť Tapeta chatu Obnoviť tapetu + Nahrať tapetu Štýl emoji Téma Nočný režim diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 612da2f9..6bca0472 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -560,6 +560,7 @@ Скинути Шпалери чату Скинути шпалери + Завантажити шпалери Стиль емодзі Тема Нічний режим diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 61a1e6da..7075eaaf 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -560,6 +560,7 @@ 重置 会话壁纸 重置壁纸 + 上传壁纸 表情风格 主题 夜间模式 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index cf71caef..030d3135 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -580,6 +580,7 @@ Reset Chat Wallpaper Reset Wallpaper + Upload Wallpaper Emoji Style Theme Night Mode From fa213c6805d5a851f5a4a2ddac6aeac1ff25d29b Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:35:44 +0300 Subject: [PATCH 36/53] Better mappings + memory logger (#204) --- app/src/main/java/org/monogram/app/App.kt | 33 + .../java/org/monogram/data/chats/ChatCache.kt | 8 + .../monogram/data/chats/ChatModelFactory.kt | 50 ++ .../remote/TdMessageRemoteDataSource.kt | 17 +- .../org/monogram/data/db/MonogramDatabase.kt | 2 +- .../monogram/data/db/MonogramMigrations.kt | 146 +++++ .../org/monogram/data/db/model/ChatEntity.kt | 11 + .../data/db/model/ChatFullInfoEntity.kt | 34 + .../org/monogram/data/db/model/UserEntity.kt | 26 + .../data/db/model/UserFullInfoEntity.kt | 55 ++ .../java/org/monogram/data/di/TdLibClient.kt | 4 +- .../java/org/monogram/data/di/dataModule.kt | 22 +- .../data/infra/DataMemoryPressureHandler.kt | 64 ++ .../monogram/data/infra/FileUpdateHandler.kt | 29 +- .../org/monogram/data/infra/OfflineWarmup.kt | 46 ++ .../monogram/data/infra/SynchronizedLruMap.kt | 40 ++ .../org/monogram/data/mapper/ChatMapper.kt | 44 ++ .../mapper/user/ChatFullInfoEntityMapper.kt | 271 ++++++++ .../data/mapper/user/ChatFullInfoMapper.kt | 292 +++++++++ .../data/mapper/user/ChatMemberMapper.kt | 107 ++++ .../data/mapper/user/EntityEncodingUtils.kt | 264 ++++++++ .../data/mapper/user/UserEntityMapper.kt | 161 ++++- .../mapper/user/UserFullInfoEntityMapper.kt | 338 ++++++++++ .../monogram/data/mapper/user/UserMapper.kt | 585 ++---------------- .../data/repository/BotRepositoryImpl.kt | 84 ++- .../repository/ChatsListRepositoryImpl.kt | 21 +- .../repository/ProfilePhotoRepositoryImpl.kt | 25 +- .../repository/WallpaperRepositoryImpl.kt | 10 +- .../monogram/domain/models/BotCommandModel.kt | 25 +- .../domain/models/ChatFullInfoModel.kt | 32 + .../org/monogram/domain/models/ChatModel.kt | 11 + .../domain/models/ProfileSecurityModels.kt | 110 ++++ .../org/monogram/domain/models/UserModel.kt | 13 +- .../presentation/di/coil/coilModule.kt | 10 +- .../features/profile/ProfileContent.kt | 9 + .../profile/components/ProfileHeader.kt | 53 +- .../components/ProfileHeaderTransformed.kt | 85 ++- .../profile/components/ProfileSections.kt | 105 +--- .../profile/components/ProfileTopBar.kt | 50 +- .../src/main/res/values-es/string.xml | 6 + .../src/main/res/values-hy/string.xml | 6 + .../src/main/res/values-pt-rBR/string.xml | 6 + .../src/main/res/values-ru-rRU/string.xml | 6 + .../src/main/res/values-sk/string.xml | 6 + .../src/main/res/values-uk/string.xml | 6 + .../src/main/res/values-zh-rCN/string.xml | 6 + presentation/src/main/res/values/string.xml | 6 + 47 files changed, 2652 insertions(+), 688 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt create mode 100644 data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt create mode 100644 domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt diff --git a/app/src/main/java/org/monogram/app/App.kt b/app/src/main/java/org/monogram/app/App.kt index 3ec92b19..9e70a00a 100644 --- a/app/src/main/java/org/monogram/app/App.kt +++ b/app/src/main/java/org/monogram/app/App.kt @@ -12,6 +12,7 @@ import org.koin.core.context.startKoin import org.maplibre.android.MapLibre import org.maplibre.android.WellKnownTileServer import org.monogram.app.di.appModule +import org.monogram.data.infra.DataMemoryPressureHandler import org.monogram.domain.managers.DistrManager import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider @@ -33,6 +34,19 @@ class App : Application(), SingletonImageLoader.Factory { checkPushAvailability() } + @Suppress("DEPRECATION") + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= TRIM_MEMORY_RUNNING_LOW) { + trimInMemoryCaches("onTrimMemory:$level") + } + } + + override fun onLowMemory() { + super.onLowMemory() + trimInMemoryCaches("onLowMemory") + } + private fun initCrashHandler() { val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -79,7 +93,26 @@ class App : Application(), SingletonImageLoader.Factory { } } + private fun trimInMemoryCaches(reason: String) { + if (!::container.isInitialized) return + runCatching { + get().clearDataCaches(reason) + }.onFailure { error -> + Log.w(TAG, "Failed to clear data caches for $reason", error) + } + + runCatching { + get().memoryCache?.clear() + }.onFailure { error -> + Log.w(TAG, "Failed to clear Coil memory cache for $reason", error) + } + } + override fun newImageLoader(context: PlatformContext): ImageLoader { return get() } + + companion object { + private const val TAG = "App" + } } diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt index a7385306..8186256a 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -147,10 +147,18 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { if (user.profilePhoto != null || existing.profilePhoto == null) { existing.profilePhoto = user.profilePhoto } + existing.accentColorId = user.accentColorId + existing.backgroundCustomEmojiId = user.backgroundCustomEmojiId + existing.profileAccentColorId = user.profileAccentColorId + existing.profileBackgroundCustomEmojiId = user.profileBackgroundCustomEmojiId existing.emojiStatus = user.emojiStatus existing.isPremium = user.isPremium existing.verificationStatus = user.verificationStatus existing.isSupport = user.isSupport + existing.restrictionInfo = user.restrictionInfo + existing.activeStoryState = user.activeStoryState + existing.restrictsNewChats = user.restrictsNewChats + existing.paidMessageStarCount = user.paidMessageStarCount existing.haveAccess = user.haveAccess existing.type = user.type existing.languageCode = user.languageCode diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 4523914b..4c495535 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -46,6 +46,17 @@ class ChatModelFactory( var isOnline = false var userStatus = "" var isVerified = isForcedVerifiedChat(chat.id) + var isScam = false + var isFake = false + var botVerificationIconCustomEmojiId = 0L + var restrictionReason: String? = null + var hasSensitiveContent = false + var activeStoryStateType: String? = null + var activeStoryId = 0 + var boostLevel = 0 + var hasForumTabs = false + var isAdministeredDirectMessagesGroup = false + var paidMessageStarCount = 0L var isForum = false var isBot = false var isMember = true @@ -93,6 +104,17 @@ class ChatModelFactory( supergroup?.let { memberCount = it.memberCount isVerified = (it.verificationStatus?.isVerified ?: false) || isForcedVerifiedChat(chat.id) + isScam = it.verificationStatus?.isScam ?: false + isFake = it.verificationStatus?.isFake ?: false + botVerificationIconCustomEmojiId = it.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L + restrictionReason = it.restrictionInfo?.restrictionReason?.ifEmpty { null } + hasSensitiveContent = it.restrictionInfo?.hasSensitiveContent ?: false + activeStoryStateType = it.activeStoryState.toTypeString() + activeStoryId = (it.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0 + boostLevel = it.boostLevel + hasForumTabs = it.hasForumTabs + isAdministeredDirectMessagesGroup = it.isAdministeredDirectMessagesGroup + paidMessageStarCount = it.paidMessageStarCount isForum = it.isForum isMember = it.status !is TdApi.ChatMemberStatusLeft isAdmin = it.status is TdApi.ChatMemberStatusAdministrator || @@ -133,6 +155,14 @@ class ChatModelFactory( if (isOnline) onlineCount = 1 userStatus = chatMapper.formatUserStatus(user.status, isBot) isVerified = (user.verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(user.id) + isScam = user.verificationStatus?.isScam ?: false + isFake = user.verificationStatus?.isFake ?: false + botVerificationIconCustomEmojiId = user.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L + restrictionReason = user.restrictionInfo?.restrictionReason?.ifEmpty { null } + hasSensitiveContent = user.restrictionInfo?.hasSensitiveContent ?: false + activeStoryStateType = user.activeStoryState.toTypeString() + activeStoryId = (user.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0 + paidMessageStarCount = user.paidMessageStarCount isSponsor = isSponsoredUser(user.id) username = user.usernames?.activeUsernames?.firstOrNull() usernames = user.usernames?.toDomain() @@ -239,6 +269,17 @@ class ChatModelFactory( isOnline = isOnline, userStatus = userStatus, isVerified = isVerified, + isScam = isScam, + isFake = isFake, + botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId, + restrictionReason = restrictionReason, + hasSensitiveContent = hasSensitiveContent, + activeStoryStateType = activeStoryStateType, + activeStoryId = activeStoryId, + boostLevel = boostLevel, + hasForumTabs = hasForumTabs, + isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup, + paidMessageStarCount = paidMessageStarCount, isSponsor = isSponsor, isForum = isForum, isBot = isBot, @@ -330,3 +371,12 @@ class ChatModelFactory( private const val USER_FULL_INFO_RETRY_TTL_MS = 5 * 60 * 1000L } } + +private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } +} 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 e3bbc16e..a22ee018 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 @@ -3,6 +3,7 @@ package org.monogram.data.datasource.remote import android.os.Build import android.util.Log import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import org.drinkless.tdlib.TdApi @@ -41,29 +42,29 @@ class TdMessageRemoteDataSource( override val messageEditedFlow = MutableSharedFlow() override val messageReadFlow = MutableSharedFlow( replay = 1, - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageUploadProgressFlow = MutableSharedFlow>() override val messageDownloadProgressFlow = MutableSharedFlow>() override val messageDownloadCancelledFlow = MutableSharedFlow() override val messageDeletedFlow = MutableSharedFlow>>( - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageIdUpdateFlow = MutableSharedFlow>( - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageDownloadCompletedFlow = MutableSharedFlow>() override val pinnedMessageFlow = MutableSharedFlow( extraBufferCapacity = 10, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val mediaUpdateFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 10, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST ) enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 012a2834..1df2fac1 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -25,7 +25,7 @@ import org.monogram.data.db.model.* SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 27, + version = 28, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index be1ff163..fe7a272e 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -18,4 +18,150 @@ object MonogramMigrations { ) } } + + val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.addColumn("users", "isScam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "isFake", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanBeEdited", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanJoinGroups", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanReadAllGroupMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeHasMainWebApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeHasTopics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeAllowsUsersToCreateTopics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanManageBots", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeIsInline", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeInlineQueryPlaceholder", "TEXT") + db.addColumn("users", "botTypeNeedLocation", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanConnectToBusiness", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanBeAddedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeActiveUserCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "userType", "TEXT NOT NULL DEFAULT 'UNKNOWN'") + db.addColumn("users", "restrictionReason", "TEXT") + db.addColumn("users", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "activeStoryStateType", "TEXT") + db.addColumn("users", "activeStoryId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "restrictsNewChats", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "backgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "profileBackgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "addedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("chats", "isScam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "isFake", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "restrictionReason", "TEXT") + db.addColumn("chats", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "activeStoryStateType", "TEXT") + db.addColumn("chats", "activeStoryId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "boostLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "hasForumTabs", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "isAdministeredDirectMessagesGroup", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("user_full_info", "botInfoShortDescription", "TEXT") + db.addColumn("user_full_info", "botInfoPhotoFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoPhotoPath", "TEXT") + db.addColumn("user_full_info", "botInfoAnimationFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoAnimationPath", "TEXT") + db.addColumn("user_full_info", "botInfoManagerBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoMenuButtonText", "TEXT") + db.addColumn("user_full_info", "botInfoMenuButtonUrl", "TEXT") + db.addColumn("user_full_info", "botInfoCommandsData", "TEXT") + db.addColumn("user_full_info", "botInfoPrivacyPolicyUrl", "TEXT") + db.addColumn("user_full_info", "botInfoDefaultGroupRightsData", "TEXT") + db.addColumn("user_full_info", "botInfoDefaultChannelRightsData", "TEXT") + db.addColumn("user_full_info", "botInfoAffiliateProgramData", "TEXT") + db.addColumn("user_full_info", "botInfoWebAppBackgroundLightColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppBackgroundDarkColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppHeaderLightColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppHeaderDarkColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn( + "user_full_info", + "botInfoVerificationParametersIconCustomEmojiId", + "INTEGER NOT NULL DEFAULT 0" + ) + db.addColumn("user_full_info", "botInfoVerificationParametersOrganizationName", "TEXT") + db.addColumn("user_full_info", "botInfoVerificationParametersDefaultCustomDescription", "TEXT") + db.addColumn( + "user_full_info", + "botInfoVerificationParametersCanSetCustomDescription", + "INTEGER NOT NULL DEFAULT 0" + ) + db.addColumn("user_full_info", "botInfoCanManageEmojiStatus", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoHasMediaPreviews", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoEditCommandsLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditDescriptionLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditDescriptionMediaLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditSettingsLinkType", "TEXT") + db.addColumn("user_full_info", "publicPhotoPath", "TEXT") + db.addColumn("user_full_info", "blockListType", "TEXT") + db.addColumn("user_full_info", "note", "TEXT") + db.addColumn("user_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationCustomDescription", "TEXT") + db.addColumn("user_full_info", "mainProfileTab", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioDuration", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "firstProfileAudioTitle", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioPerformer", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioFileName", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioMimeType", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "firstProfileAudioPath", "TEXT") + db.addColumn("user_full_info", "ratingLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingNextLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingNextLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("chat_full_info", "directMessagesChatId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botInfoData", "TEXT") + db.addColumn("chat_full_info", "blockListType", "TEXT") + db.addColumn("chat_full_info", "publicPhotoPath", "TEXT") + db.addColumn("chat_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationCustomDescription", "TEXT") + db.addColumn("chat_full_info", "mainProfileTab", "TEXT") + db.addColumn("chat_full_info", "firstProfileAudioData", "TEXT") + db.addColumn("chat_full_info", "ratingData", "TEXT") + db.addColumn("chat_full_info", "pendingRatingData", "TEXT") + db.addColumn("chat_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "slowModeDelayExpiresIn", "REAL NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canEnablePaidMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canEnablePaidReaction", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasHiddenMembers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canHideMembers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canGetStarRevenueStatistics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canToggleAggressiveAntiSpam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "isAllHistoryAvailable", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canHaveSponsoredMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasAggressiveAntiSpamEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasPaidMediaAllowed", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasPinnedStories", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "myBoostCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "unrestrictBoostCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "stickerSetId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "customEmojiStickerSetId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botCommandsData", "TEXT") + db.addColumn("chat_full_info", "upgradedFromBasicGroupId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "upgradedFromMaxMessageId", "INTEGER NOT NULL DEFAULT 0") + } + } + + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { + execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") + } } diff --git a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt index 67c65702..aadceb8e 100644 --- a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt @@ -54,6 +54,17 @@ data class ChatEntity( val typingAction: String? = null, val draftMessage: String? = null, val isVerified: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val boostLevel: Int = 0, + val hasForumTabs: Boolean = false, + val isAdministeredDirectMessagesGroup: Boolean = false, + val paidMessageStarCount: Long = 0L, val isSponsor: Boolean = false, val viewAsTopics: Boolean = false, val isForum: Boolean = false, diff --git a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt index 1a72d4a1..71f57fb2 100644 --- a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt @@ -13,17 +13,51 @@ data class ChatFullInfoEntity( val administratorCount: Int, val restrictedCount: Int, val bannedCount: Int, + val directMessagesChatId: Long = 0L, val commonGroupsCount: Int, val giftCount: Int = 0, val isBlocked: Boolean, val botInfo: String?, + val botInfoData: String? = null, + val blockListType: String? = null, + val publicPhotoPath: String? = null, + val usesUnofficialApp: Boolean = false, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val botVerificationBotUserId: Long = 0L, + val botVerificationIconCustomEmojiId: Long = 0L, + val botVerificationCustomDescription: String? = null, + val mainProfileTab: String? = null, + val firstProfileAudioData: String? = null, + val ratingData: String? = null, + val pendingRatingData: String? = null, + val pendingRatingDate: Int = 0, val slowModeDelay: Int, + val slowModeDelayExpiresIn: Double = 0.0, val locationAddress: String?, + val canEnablePaidMessages: Boolean = false, + val canEnablePaidReaction: Boolean = false, + val hasHiddenMembers: Boolean = false, + val canHideMembers: Boolean = false, val canSetStickerSet: Boolean, val canSetLocation: Boolean, val canGetMembers: Boolean, val canGetStatistics: Boolean, val canGetRevenueStatistics: Boolean = false, + val canGetStarRevenueStatistics: Boolean = false, + val canToggleAggressiveAntiSpam: Boolean = false, + val isAllHistoryAvailable: Boolean = false, + val canHaveSponsoredMessages: Boolean = false, + val hasAggressiveAntiSpamEnabled: Boolean = false, + val hasPaidMediaAllowed: Boolean = false, + val hasPinnedStories: Boolean = false, + val myBoostCount: Int = 0, + val unrestrictBoostCount: Int = 0, + val stickerSetId: Long = 0L, + val customEmojiStickerSetId: Long = 0L, + val botCommandsData: String? = null, + val upgradedFromBasicGroupId: Long = 0L, + val upgradedFromMaxMessageId: Long = 0L, val linkedChatId: Long, val note: String?, val canBeCalled: Boolean, diff --git a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt index 1f8959eb..65e1be7e 100644 --- a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt @@ -20,18 +20,44 @@ data class UserEntity( val personalAvatarPath: String? = null, val isPremium: Boolean, val isVerified: Boolean, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, val isSupport: Boolean = false, val isContact: Boolean = false, val isMutualContact: Boolean = false, val isCloseFriend: Boolean = false, + val botTypeCanBeEdited: Boolean = false, + val botTypeCanJoinGroups: Boolean = false, + val botTypeCanReadAllGroupMessages: Boolean = false, + val botTypeHasMainWebApp: Boolean = false, + val botTypeHasTopics: Boolean = false, + val botTypeAllowsUsersToCreateTopics: Boolean = false, + val botTypeCanManageBots: Boolean = false, + val botTypeIsInline: Boolean = false, + val botTypeInlineQueryPlaceholder: String? = null, + val botTypeNeedLocation: Boolean = false, + val botTypeCanConnectToBusiness: Boolean = false, + val botTypeCanBeAddedToAttachmentMenu: Boolean = false, + val botTypeActiveUserCount: Int = 0, + val userType: String = "UNKNOWN", + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val restrictsNewChats: Boolean = false, + val paidMessageStarCount: Long = 0L, val haveAccess: Boolean = true, val username: String?, val usernamesData: String? = null, val statusType: String = "OFFLINE", val accentColorId: Int = 0, + val backgroundCustomEmojiId: Long = 0L, val profileAccentColorId: Int = -1, + val profileBackgroundCustomEmojiId: Long = 0L, val statusEmojiId: Long = 0L, val languageCode: String? = null, + val addedToAttachmentMenu: Boolean = false, val lastSeen: Long, val createdAt: Long = System.currentTimeMillis() ) \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt index 7278a176..5f44a1ea 100644 --- a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt @@ -10,10 +10,39 @@ data class UserFullInfoEntity( val commonGroupsCount: Int, val giftCount: Int = 0, val botInfoDescription: String? = null, + val botInfoShortDescription: String? = null, + val botInfoPhotoFileId: Int = 0, + val botInfoPhotoPath: String? = null, + val botInfoAnimationFileId: Int = 0, + val botInfoAnimationPath: String? = null, + val botInfoManagerBotUserId: Long = 0L, + val botInfoMenuButtonText: String? = null, + val botInfoMenuButtonUrl: String? = null, + val botInfoCommandsData: String? = null, + val botInfoPrivacyPolicyUrl: String? = null, + val botInfoDefaultGroupRightsData: String? = null, + val botInfoDefaultChannelRightsData: String? = null, + val botInfoAffiliateProgramData: String? = null, + val botInfoWebAppBackgroundLightColor: Int = -1, + val botInfoWebAppBackgroundDarkColor: Int = -1, + val botInfoWebAppHeaderLightColor: Int = -1, + val botInfoWebAppHeaderDarkColor: Int = -1, + val botInfoVerificationParametersIconCustomEmojiId: Long = 0L, + val botInfoVerificationParametersOrganizationName: String? = null, + val botInfoVerificationParametersDefaultCustomDescription: String? = null, + val botInfoVerificationParametersCanSetCustomDescription: Boolean = false, + val botInfoCanManageEmojiStatus: Boolean = false, + val botInfoHasMediaPreviews: Boolean = false, + val botInfoEditCommandsLinkType: String? = null, + val botInfoEditDescriptionLinkType: String? = null, + val botInfoEditDescriptionMediaLinkType: String? = null, + val botInfoEditSettingsLinkType: String? = null, val personalChatId: Long = 0L, val birthdateDay: Int = 0, val birthdateMonth: Int = 0, val birthdateYear: Int = 0, + val publicPhotoPath: String? = null, + val blockListType: String? = null, val businessLocationAddress: String? = null, val businessLocationLatitude: Double = 0.0, val businessLocationLongitude: Double = 0.0, @@ -22,8 +51,34 @@ data class UserFullInfoEntity( val businessNextCloseIn: Int = 0, val businessStartPageTitle: String? = null, val businessStartPageMessage: String? = null, + val note: String? = null, val personalPhotoPath: String? = null, val isBlocked: Boolean, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val usesUnofficialApp: Boolean = false, + val botVerificationBotUserId: Long = 0L, + val botVerificationIconCustomEmojiId: Long = 0L, + val botVerificationCustomDescription: String? = null, + val mainProfileTab: String? = null, + val firstProfileAudioDuration: Int = 0, + val firstProfileAudioTitle: String? = null, + val firstProfileAudioPerformer: String? = null, + val firstProfileAudioFileName: String? = null, + val firstProfileAudioMimeType: String? = null, + val firstProfileAudioFileId: Int = 0, + val firstProfileAudioPath: String? = null, + val ratingLevel: Int = 0, + val ratingIsMaximumLevelReached: Boolean = false, + val ratingValue: Long = 0L, + val ratingCurrentLevelValue: Long = 0L, + val ratingNextLevelValue: Long = 0L, + val pendingRatingLevel: Int = 0, + val pendingRatingIsMaximumLevelReached: Boolean = false, + val pendingRatingValue: Long = 0L, + val pendingRatingCurrentLevelValue: Long = 0L, + val pendingRatingNextLevelValue: Long = 0L, + val pendingRatingDate: Int = 0, val canBeCalled: Boolean, val supportsVideoCalls: Boolean, val hasPrivateCalls: Boolean, diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index d4f3fba7..2dad8845 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -18,8 +18,8 @@ internal class TdLibClient { private val TAG = "TdLibClient" private val globalRetryAfterUntilMs = AtomicLong(0L) private val _updates = MutableSharedFlow( - replay = 10, - extraBufferCapacity = 1000, + replay = 3, + extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 2cb0be8f..057656f1 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.monogram.core.DispatcherProvider +import org.monogram.data.BuildConfig import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.PlayerDataSourceFactoryImpl @@ -124,7 +125,10 @@ val dataModule = module { "monogram_db" ) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(MonogramMigrations.MIGRATION_26_27) + .addMigrations( + MonogramMigrations.MIGRATION_26_27, + MonogramMigrations.MIGRATION_27_28 + ) .fallbackToDestructiveMigration(dropAllTables = true) .build() } @@ -542,6 +546,22 @@ val dataModule = module { ) } + single { + DataMemoryPressureHandler( + chatsListRepository = get(), + fileUpdateHandler = get() + ) + } + + if (BuildConfig.DEBUG) { + single(createdAtStart = true) { + DataMemoryDiagnostics( + scope = get(), + memoryPressureHandler = get() + ) + } + } + single { StickerFileManager( localDataSource = get(), diff --git a/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt new file mode 100644 index 00000000..d90dfe72 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt @@ -0,0 +1,64 @@ +package org.monogram.data.infra + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.monogram.data.BuildConfig +import org.monogram.data.repository.ChatsListRepositoryImpl + +class DataMemoryPressureHandler( + private val chatsListRepository: ChatsListRepositoryImpl, + private val fileUpdateHandler: FileUpdateHandler +) { + fun clearDataCaches(reason: String) { + chatsListRepository.clearMemoryCaches() + fileUpdateHandler.clearMemoryCaches() + if (BuildConfig.DEBUG) { + logSnapshot("after_clear:$reason") + } + } + + fun logSnapshot(reason: String) { + if (!BuildConfig.DEBUG) return + val runtime = Runtime.getRuntime() + val usedMb = (runtime.totalMemory() - runtime.freeMemory()) / MB + val maxMb = runtime.maxMemory() / MB + val chatSnapshot = chatsListRepository.memoryCacheSnapshot() + val fileSnapshot = fileUpdateHandler.memoryCacheSnapshot() + Log.d( + TAG, + "reason=$reason heap=${usedMb}MB/${maxMb}MB " + + "chatModelCache=${chatSnapshot.modelCacheSize} " + + "invalidatedModels=${chatSnapshot.invalidatedModelsSize} " + + "customEmojiPaths=${fileSnapshot.customEmojiPathsSize} " + + "fileToEmoji=${fileSnapshot.fileToEmojiSize}" + ) + } + + companion object { + private const val TAG = "DataMemoryPressure" + private const val MB = 1024L * 1024L + } +} + +class DataMemoryDiagnostics( + scope: CoroutineScope, + private val memoryPressureHandler: DataMemoryPressureHandler +) { + init { + if (BuildConfig.DEBUG) { + scope.launch { + while (isActive) { + delay(LOG_INTERVAL_MS) + memoryPressureHandler.logSnapshot("periodic") + } + } + } + } + + companion object { + private const val LOG_INTERVAL_MS = 60_000L + } +} diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index b97381b9..a2d74a63 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.UpdateDispatcher -import java.util.concurrent.ConcurrentHashMap class FileUpdateHandler( private val registry: FileMessageRegistry, @@ -15,8 +14,8 @@ class FileUpdateHandler( private val updates: UpdateDispatcher, private val scope: CoroutineScope ) { - val customEmojiPaths = ConcurrentHashMap() - val fileIdToCustomEmojiId = ConcurrentHashMap() + val customEmojiPaths = SynchronizedLruMap(CUSTOM_EMOJI_CACHE_SIZE) + val fileIdToCustomEmojiId = SynchronizedLruMap(FILE_TO_EMOJI_CACHE_SIZE) private val _downloadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val _downloadCompleted = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -104,4 +103,26 @@ class FileUpdateHandler( val emojiId = fileIdToCustomEmojiId[fileId] ?: return customEmojiPaths[emojiId] = path } -} \ No newline at end of file + + fun clearMemoryCaches() { + customEmojiPaths.clear() + fileIdToCustomEmojiId.clear() + } + + fun memoryCacheSnapshot(): MemoryCacheSnapshot { + return MemoryCacheSnapshot( + customEmojiPathsSize = customEmojiPaths.size(), + fileToEmojiSize = fileIdToCustomEmojiId.size() + ) + } + + data class MemoryCacheSnapshot( + val customEmojiPathsSize: Int, + val fileToEmojiSize: Int + ) + + companion object { + private const val CUSTOM_EMOJI_CACHE_SIZE = 512 + private const val FILE_TO_EMOJI_CACHE_SIZE = 512 + } +} diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index 9d3c9130..b1382243 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -260,6 +260,8 @@ class OfflineWarmup( else -> 0L } + val botType = type as? TdApi.UserTypeBot + return UserEntity( id = id, firstName = firstName, @@ -269,18 +271,44 @@ class OfflineWarmup( personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = verificationStatus?.isVerified ?: false, + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSupport = isSupport, isContact = isContact, isMutualContact = isMutualContact, isCloseFriend = isCloseFriend, + botTypeCanBeEdited = botType?.canBeEdited ?: false, + botTypeCanJoinGroups = botType?.canJoinGroups ?: false, + botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false, + botTypeHasMainWebApp = botType?.hasMainWebApp ?: false, + botTypeHasTopics = botType?.hasTopics ?: false, + botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false, + botTypeCanManageBots = botType?.canManageBots ?: false, + botTypeIsInline = botType?.isInline ?: false, + botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null }, + botTypeNeedLocation = botType?.needLocation ?: false, + botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false, + botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false, + botTypeActiveUserCount = botType?.activeUserCount ?: 0, + userType = type.toTypeString(), + restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null }, + hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false, + activeStoryStateType = activeStoryState.toTypeString(), + activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0, + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, haveAccess = haveAccess, username = usernames?.activeUsernames?.firstOrNull(), usernamesData = usernamesData, statusType = statusType, accentColorId = accentColorId, + backgroundCustomEmojiId = backgroundCustomEmojiId, profileAccentColorId = profileAccentColorId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, statusEmojiId = statusEmojiId, languageCode = languageCode.ifEmpty { null }, + addedToAttachmentMenu = addedToAttachmentMenu, lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, createdAt = System.currentTimeMillis() ) @@ -293,6 +321,24 @@ class OfflineWarmup( ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } } + private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } + } + + private fun TdApi.UserType?.toTypeString(): String { + return when (this) { + is TdApi.UserTypeRegular -> "REGULAR" + is TdApi.UserTypeBot -> "BOT" + is TdApi.UserTypeDeleted -> "DELETED" + else -> "UNKNOWN" + } + } + private companion object { private const val USER_WARMUP_LIMIT = 30 private const val USER_WARMUP_DELAY_MS = 75L diff --git a/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt new file mode 100644 index 00000000..d9d5210c --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt @@ -0,0 +1,40 @@ +package org.monogram.data.infra + +class SynchronizedLruMap( + private val maxSize: Int +) { + init { + require(maxSize > 0) { "maxSize must be > 0" } + } + + private val lock = Any() + private val map = object : LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > maxSize + } + } + + operator fun get(key: K): V? { + return synchronized(lock) { map[key] } + } + + operator fun set(key: K, value: V) { + synchronized(lock) { + map[key] = value + } + } + + fun containsKey(key: K): Boolean { + return synchronized(lock) { map.containsKey(key) } + } + + fun clear() { + synchronized(lock) { + map.clear() + } + } + + fun size(): Int { + return synchronized(lock) { map.size } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 3a0ed425..acea7b6e 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -18,6 +18,17 @@ class ChatMapper(private val stringProvider: StringProvider) { isOnline: Boolean, userStatus: String, isVerified: Boolean, + isScam: Boolean, + isFake: Boolean, + botVerificationIconCustomEmojiId: Long, + restrictionReason: String?, + hasSensitiveContent: Boolean, + activeStoryStateType: String?, + activeStoryId: Int, + boostLevel: Int, + hasForumTabs: Boolean, + isAdministeredDirectMessagesGroup: Boolean, + paidMessageStarCount: Long, isSponsor: Boolean, isForum: Boolean, isBot: Boolean, @@ -101,6 +112,17 @@ class ChatMapper(private val stringProvider: StringProvider) { }, blockList = chat.blockList != null, isVerified = isVerified || isForcedVerifiedChat(chat.id), + isScam = isScam, + isFake = isFake, + botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId, + restrictionReason = restrictionReason, + hasSensitiveContent = hasSensitiveContent, + activeStoryStateType = activeStoryStateType, + activeStoryId = activeStoryId, + boostLevel = boostLevel, + hasForumTabs = hasForumTabs, + isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup, + paidMessageStarCount = paidMessageStarCount, isSponsor = isSponsor, viewAsTopics = chat.viewAsTopics, isForum = isForum, @@ -161,6 +183,17 @@ class ChatMapper(private val stringProvider: StringProvider) { typingAction = entity.typingAction, draftMessage = entity.draftMessage, isVerified = entity.isVerified || isForcedVerifiedChat(entity.id), + isScam = entity.isScam, + isFake = entity.isFake, + botVerificationIconCustomEmojiId = entity.botVerificationIconCustomEmojiId, + restrictionReason = entity.restrictionReason, + hasSensitiveContent = entity.hasSensitiveContent, + activeStoryStateType = entity.activeStoryStateType, + activeStoryId = entity.activeStoryId, + boostLevel = entity.boostLevel, + hasForumTabs = entity.hasForumTabs, + isAdministeredDirectMessagesGroup = entity.isAdministeredDirectMessagesGroup, + paidMessageStarCount = entity.paidMessageStarCount, isSponsor = entity.isSponsor || (entity.privateUserId != 0L && isSponsoredUser(entity.privateUserId)), viewAsTopics = entity.viewAsTopics, isForum = entity.isForum, @@ -224,6 +257,17 @@ class ChatMapper(private val stringProvider: StringProvider) { typingAction = domain.typingAction, draftMessage = domain.draftMessage, isVerified = domain.isVerified || isForcedVerifiedChat(domain.id), + isScam = domain.isScam, + isFake = domain.isFake, + botVerificationIconCustomEmojiId = domain.botVerificationIconCustomEmojiId, + restrictionReason = domain.restrictionReason, + hasSensitiveContent = domain.hasSensitiveContent, + activeStoryStateType = domain.activeStoryStateType, + activeStoryId = domain.activeStoryId, + boostLevel = domain.boostLevel, + hasForumTabs = domain.hasForumTabs, + isAdministeredDirectMessagesGroup = domain.isAdministeredDirectMessagesGroup, + paidMessageStarCount = domain.paidMessageStarCount, isSponsor = domain.isSponsor, viewAsTopics = domain.viewAsTopics, isForum = domain.isForum, diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt new file mode 100644 index 00000000..20272a6e --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt @@ -0,0 +1,271 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.ChatEntity +import org.monogram.data.db.model.ChatFullInfoEntity +import org.monogram.domain.models.BotVerificationModel +import org.monogram.domain.models.ChatFullInfoModel + +fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { + return ChatFullInfoEntity( + chatId = chatId, + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = memberCount, + onlineCount = 0, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + commonGroupsCount = 0, + giftCount = giftCount, + isBlocked = false, + botInfo = null, + botInfoData = null, + blockListType = null, + publicPhotoPath = null, + usesUnofficialApp = false, + hasSponsoredMessagesEnabled = false, + needPhoneNumberPrivacyException = false, + botVerificationBotUserId = botVerification?.botUserId ?: 0L, + botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L, + botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null }, + mainProfileTab = mainProfileTab.toTypeString(), + firstProfileAudioData = null, + ratingData = null, + pendingRatingData = null, + pendingRatingDate = 0, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = location?.address?.ifEmpty { null }, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommandsData = encodeBotCommands(botCommands), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + linkedChatId = linkedChatId, + note = null, + canBeCalled = false, + supportsVideoCalls = false, + hasPrivateCalls = false, + hasPrivateForwards = false, + hasRestrictedVoiceAndVideoNoteMessages = false, + hasPostedToProfileStories = false, + setChatBackground = false, + incomingPaidMessageStarCount = 0, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount, + createdAt = System.currentTimeMillis() + ) +} + +fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { + return ChatFullInfoEntity( + chatId = chatId, + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = members.size, + onlineCount = 0, + administratorCount = 0, + restrictedCount = 0, + bannedCount = 0, + commonGroupsCount = 0, + giftCount = 0, + isBlocked = false, + botInfo = null, + slowModeDelay = 0, + locationAddress = null, + canSetStickerSet = false, + canSetLocation = false, + canGetMembers = false, + canGetStatistics = false, + canGetRevenueStatistics = false, + linkedChatId = 0, + note = null, + canBeCalled = false, + supportsVideoCalls = false, + hasPrivateCalls = false, + hasPrivateForwards = false, + hasRestrictedVoiceAndVideoNoteMessages = false, + hasPostedToProfileStories = false, + setChatBackground = false, + incomingPaidMessageStarCount = 0, + outgoingPaidMessageStarCount = 0, + createdAt = System.currentTimeMillis() + ) +} + +fun ChatEntity.toTdApiChat(): TdApi.Chat { + return TdApi.Chat().apply { + id = this@toTdApiChat.id + title = this@toTdApiChat.title + unreadCount = this@toTdApiChat.unreadCount + unreadMentionCount = this@toTdApiChat.unreadMentionCount + unreadReactionCount = this@toTdApiChat.unreadReactionCount + photo = avatarPath?.let { path -> + TdApi.ChatPhotoInfo().apply { + small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } + } + } + lastMessage = TdApi.Message().apply { + content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) } + date = lastMessageTime.toIntOrNull() ?: 0 + id = this@toTdApiChat.lastMessageId + isOutgoing = this@toTdApiChat.isLastMessageOutgoing + } + positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null)) + notificationSettings = TdApi.ChatNotificationSettings().apply { + muteFor = if (isMuted) Int.MAX_VALUE else 0 + } + type = when (this@toTdApiChat.type) { + "PRIVATE" -> TdApi.ChatTypePrivate().apply { + userId = + if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId + ?: 0L) + } + + "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply { + basicGroupId = this@toTdApiChat.basicGroupId + } + + "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel) + "SECRET" -> TdApi.ChatTypeSecret().apply { + secretChatId = this@toTdApiChat.secretChatId + } + + else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId } + } + isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread + hasProtectedContent = this@toTdApiChat.hasProtectedContent + isTranslatable = this@toTdApiChat.isTranslatable + viewAsTopics = this@toTdApiChat.viewAsTopics + accentColorId = this@toTdApiChat.accentColorId + profileAccentColorId = this@toTdApiChat.profileAccentColorId + backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId + messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime + canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf + canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers + canBeReported = this@toTdApiChat.canBeReported + lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId + lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId + replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId + messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) } + blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null + permissions = TdApi.ChatPermissions( + this@toTdApiChat.permissionCanSendBasicMessages, + this@toTdApiChat.permissionCanSendAudios, + this@toTdApiChat.permissionCanSendDocuments, + this@toTdApiChat.permissionCanSendPhotos, + this@toTdApiChat.permissionCanSendVideos, + this@toTdApiChat.permissionCanSendVideoNotes, + this@toTdApiChat.permissionCanSendVoiceNotes, + this@toTdApiChat.permissionCanSendPolls, + this@toTdApiChat.permissionCanSendOtherMessages, + this@toTdApiChat.permissionCanAddLinkPreviews, + this@toTdApiChat.permissionCanEditTag, + this@toTdApiChat.permissionCanChangeInfo, + this@toTdApiChat.permissionCanInviteUsers, + this@toTdApiChat.permissionCanPinMessages, + this@toTdApiChat.permissionCanCreateTopics + ) + clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}" + } +} + +fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel { + val botVerificationModel = if ( + botVerificationBotUserId != 0L || + botVerificationIconCustomEmojiId != 0L || + !botVerificationCustomDescription.isNullOrEmpty() + ) { + BotVerificationModel( + botUserId = botVerificationBotUserId, + iconCustomEmojiId = botVerificationIconCustomEmojiId, + customDescription = botVerificationCustomDescription + ) + } else { + null + } + + return ChatFullInfoModel( + description = description, + inviteLink = inviteLink, + memberCount = memberCount, + onlineCount = onlineCount, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + commonGroupsCount = commonGroupsCount, + giftCount = giftCount, + isBlocked = isBlocked, + botInfo = botInfo, + botInfoModel = null, + blockListType = blockListType, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + linkedChatId = linkedChatId, + businessInfo = null, + publicPhotoPath = publicPhotoPath, + note = note, + usesUnofficialApp = usesUnofficialApp, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + botVerification = botVerificationModel, + mainProfileTab = mainProfileTab.toProfileTabType(), + firstProfileAudio = decodeProfileAudio(firstProfileAudioData), + rating = decodeUserRating(ratingData), + pendingRating = decodeUserRating(pendingRatingData), + pendingRatingDate = pendingRatingDate, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = locationAddress, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommands = decodeBotCommands(botCommandsData), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + incomingPaidMessageStarCount = incomingPaidMessageStarCount, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt new file mode 100644 index 00000000..7bffb8a8 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt @@ -0,0 +1,292 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.isChannelType +import org.monogram.data.mapper.isGroupType +import org.monogram.data.mapper.isValidFilePath +import org.monogram.data.mapper.toDomainChatType +import org.monogram.domain.models.* + +fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel { + val birthdate = birthdate?.let { date -> + BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null) + } + return ChatFullInfoModel( + description = bio?.text?.ifEmpty { null }, + commonGroupsCount = groupInCommonCount, + giftCount = giftCount, + birthdate = birthdate, + isBlocked = blockList != null, + blockListType = blockList.toTypeString(), + botInfo = botInfo?.description?.ifEmpty { null }, + botInfoModel = botInfo?.toDomain(), + canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, + linkedChatId = personalChatId, + businessInfo = businessInfo?.toDomain(), + publicPhotoPath = publicPhoto.resolveChatPhotoPath(), + usesUnofficialApp = usesUnofficialApp, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + botVerification = botVerification?.toDomain(), + mainProfileTab = mainProfileTab?.toDomain(), + firstProfileAudio = firstProfileAudio?.toDomain(), + rating = rating?.toDomain(), + pendingRating = pendingRating?.toDomain(), + pendingRatingDate = pendingRatingDate, + note = note?.text?.ifEmpty { null }, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + incomingPaidMessageStarCount = incomingPaidMessageStarCount, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} + +fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat( + supergroup: TdApi.Supergroup? +): ChatFullInfoModel { + val link = inviteLink?.inviteLink + ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" } + return ChatFullInfoModel( + description = description.ifEmpty { null }, + inviteLink = link, + memberCount = memberCount, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = location?.address?.ifEmpty { null }, + giftCount = giftCount, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + linkedChatId = linkedChatId, + botVerification = botVerification?.toDomain(), + mainProfileTab = mainProfileTab?.toDomain(), + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommands = botCommands?.map { it.toDomain() } ?: emptyList(), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} + +fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel { + return ChatFullInfoModel( + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = members.size + ) +} + +fun TdApi.Chat.toDomain(): ChatModel { + val isChannel = type.isChannelType() + return ChatModel( + id = id, + title = title, + avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, + unreadCount = unreadCount, + isMuted = notificationSettings.muteFor > 0, + isChannel = isChannel, + isGroup = type.isGroupType(), + type = type.toDomainChatType(), + lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" + ) +} + +private fun TdApi.BotVerification.toDomain(): BotVerificationModel { + return BotVerificationModel( + botUserId = botUserId, + iconCustomEmojiId = iconCustomEmojiId, + customDescription = customDescription.text.ifEmpty { null } + ) +} + +private fun TdApi.BotVerificationParameters.toDomain(): BotVerificationParametersModel { + return BotVerificationParametersModel( + iconCustomEmojiId = iconCustomEmojiId, + organizationName = organizationName.ifEmpty { null }, + defaultCustomDescription = defaultCustomDescription?.text?.ifEmpty { null }, + canSetCustomDescription = canSetCustomDescription + ) +} + +private fun TdApi.UserRating.toDomain(): UserRatingModel { + return UserRatingModel( + level = level, + isMaximumLevelReached = isMaximumLevelReached, + rating = rating, + currentLevelRating = currentLevelRating, + nextLevelRating = nextLevelRating + ) +} + +private fun TdApi.Audio.toDomain(): ProfileAudioModel { + val filePath = audio.local.path.takeIf { isValidFilePath(it) } + return ProfileAudioModel( + duration = duration, + title = title.ifEmpty { null }, + performer = performer.ifEmpty { null }, + fileName = fileName.ifEmpty { null }, + mimeType = mimeType.ifEmpty { null }, + fileId = audio.id, + filePath = filePath + ) +} + +private fun TdApi.ProfileTab.toDomain(): ProfileTabType { + return when (this) { + is TdApi.ProfileTabPosts -> ProfileTabType.POSTS + is TdApi.ProfileTabGifts -> ProfileTabType.GIFTS + is TdApi.ProfileTabMedia -> ProfileTabType.MEDIA + is TdApi.ProfileTabFiles -> ProfileTabType.FILES + is TdApi.ProfileTabLinks -> ProfileTabType.LINKS + is TdApi.ProfileTabMusic -> ProfileTabType.MUSIC + is TdApi.ProfileTabVoice -> ProfileTabType.VOICE + is TdApi.ProfileTabGifs -> ProfileTabType.GIFS + else -> ProfileTabType.UNKNOWN + } +} + +private fun TdApi.BotCommand.toDomain(): BotCommandModel { + return BotCommandModel( + command = command, + description = description + ) +} + +private fun TdApi.BotCommands.toDomain(): SupergroupBotCommandsModel { + return SupergroupBotCommandsModel( + botUserId = botUserId, + commands = commands?.map { it.toDomain() } ?: emptyList() + ) +} + +private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel { + return ChatAdministratorRightsModel( + canManageChat = canManageChat, + canChangeInfo = canChangeInfo, + canPostMessages = canPostMessages, + canEditMessages = canEditMessages, + canDeleteMessages = canDeleteMessages, + canInviteUsers = canInviteUsers, + canRestrictMembers = canRestrictMembers, + canPinMessages = canPinMessages, + canManageTopics = canManageTopics, + canPromoteMembers = canPromoteMembers, + canManageVideoChats = canManageVideoChats, + canPostStories = canPostStories, + canEditStories = canEditStories, + canDeleteStories = canDeleteStories, + canManageDirectMessages = canManageDirectMessages, + canManageTags = canManageTags, + isAnonymous = isAnonymous + ) +} + +private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel { + return AffiliateProgramInfoModel( + commissionPerMille = parameters.commissionPerMille, + monthCount = parameters.monthCount, + endDate = endDate, + dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount, + dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount + ) +} + +private fun TdApi.BotInfo.toDomain(): BotInfoModel { + return BotInfoModel( + commands = commands?.map { it.toDomain() } ?: emptyList(), + menuButton = when (val button = menuButton) { + is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(button.text, button.url) + null -> BotMenuButtonModel.Commands + else -> BotMenuButtonModel.Default + }, + shortDescription = shortDescription.ifEmpty { null }, + description = description.ifEmpty { null }, + photoFileId = photo?.sizes?.lastOrNull()?.photo?.id ?: 0, + photoPath = photo?.resolvePhotoPath(), + animationFileId = animation?.animation?.id ?: 0, + animationPath = animation?.animation?.local?.path?.takeIf { isValidFilePath(it) }, + managerBotUserId = managerBotUserId, + privacyPolicyUrl = privacyPolicyUrl.ifEmpty { null }, + defaultGroupAdministratorRights = defaultGroupAdministratorRights?.toDomain(), + defaultChannelAdministratorRights = defaultChannelAdministratorRights?.toDomain(), + affiliateProgram = affiliateProgram?.toDomain(), + webAppBackgroundLightColor = webAppBackgroundLightColor, + webAppBackgroundDarkColor = webAppBackgroundDarkColor, + webAppHeaderLightColor = webAppHeaderLightColor, + webAppHeaderDarkColor = webAppHeaderDarkColor, + verificationParameters = verificationParameters?.toDomain(), + canGetRevenueStatistics = canGetRevenueStatistics, + canManageEmojiStatus = canManageEmojiStatus, + hasMediaPreviews = hasMediaPreviews, + editCommandsLinkType = editCommandsLink?.javaClass?.simpleName, + editDescriptionLinkType = editDescriptionLink?.javaClass?.simpleName, + editDescriptionMediaLinkType = editDescriptionMediaLink?.javaClass?.simpleName, + editSettingsLinkType = editSettingsLink?.javaClass?.simpleName + ) +} + +private fun TdApi.Photo.resolvePhotoPath(): String? { + val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull() + return bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } +} + +private fun TdApi.ChatPhoto?.resolveChatPhotoPath(): String? { + if (this == null) return null + val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull() + return animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } +} + +private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { + return BusinessInfoModel( + location = location?.let { + BusinessLocationModel( + it.location!!.latitude, + it.location!!.longitude, + it.address + ) + }, + openingHours = openingHours?.let { + BusinessOpeningHoursModel( + it.timeZoneId, + it.openingHours.map { interval -> + BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute) + } + ) + }, + startPage = startPage?.let { + BusinessStartPageModel( + title = it.title, + message = it.message, + stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } + ) + }, + nextOpenIn = nextOpenIn, + nextCloseIn = nextCloseIn + ) +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt new file mode 100644 index 00000000..d85bfc45 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt @@ -0,0 +1,107 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.toDomainChatPermissions +import org.monogram.data.mapper.toTdApiChatPermissions +import org.monogram.domain.models.GroupMemberModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.domain.repository.ChatMembersFilter + +fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel { + val rank = when (this.status) { + is TdApi.ChatMemberStatusCreator -> "Owner" + is TdApi.ChatMemberStatusAdministrator -> "Admin" + else -> null + } + return GroupMemberModel( + user = user, + rank = rank, + status = this.status.toDomain() + ) +} + +fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { + return when (this) { + is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator + is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator( + customTitle = "", + canBeEdited = canBeEdited, + canManageChat = rights.canManageChat, + canChangeInfo = rights.canChangeInfo, + canPostMessages = rights.canPostMessages, + canEditMessages = rights.canEditMessages, + canDeleteMessages = rights.canDeleteMessages, + canInviteUsers = rights.canInviteUsers, + canRestrictMembers = rights.canRestrictMembers, + canPinMessages = rights.canPinMessages, + canManageTopics = rights.canManageTopics, + canPromoteMembers = rights.canPromoteMembers, + canManageVideoChats = rights.canManageVideoChats, + canPostStories = rights.canPostStories, + canEditStories = rights.canEditStories, + canDeleteStories = rights.canDeleteStories, + canManageDirectMessages = rights.canManageDirectMessages, + isAnonymous = rights.isAnonymous + ) + + is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( + isMember = isMember, + restrictedUntilDate = restrictedUntilDate, + permissions = permissions.toDomainChatPermissions() + ) + + is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) + is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left + else -> ChatMemberStatus.Member + } +} + +fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter { + return when (this) { + is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent() + is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators() + is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned() + is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted() + is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots() + is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query) + } +} + +fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { + return when (this) { + is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember() + is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator( + canBeEdited, + TdApi.ChatAdministratorRights( + canManageChat, + canChangeInfo, + canPostMessages, + canEditMessages, + canDeleteMessages, + canInviteUsers, + canRestrictMembers, + canPinMessages, + canManageTopics, + canPromoteMembers, + canManageVideoChats, + canPostStories, + canEditStories, + canDeleteStories, + canManageDirectMessages, + false, + isAnonymous + ) + ) + + is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( + isMember, + restrictedUntilDate, + permissions.toTdApiChatPermissions() + ) + + is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() + is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) + is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true) + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt new file mode 100644 index 00000000..e750e83b --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt @@ -0,0 +1,264 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.* + +internal fun encodeChatAdministratorRights(rights: TdApi.ChatAdministratorRights?): String? { + if (rights == null) return null + return listOf( + rights.canManageChat, + rights.canChangeInfo, + rights.canPostMessages, + rights.canEditMessages, + rights.canDeleteMessages, + rights.canInviteUsers, + rights.canRestrictMembers, + rights.canPinMessages, + rights.canManageTopics, + rights.canPromoteMembers, + rights.canManageVideoChats, + rights.canPostStories, + rights.canEditStories, + rights.canDeleteStories, + rights.canManageDirectMessages, + rights.canManageTags, + rights.isAnonymous + ).joinToString("|") { if (it) "1" else "0" } +} + +internal fun decodeChatAdministratorRights(data: String?): TdApi.ChatAdministratorRights? { + if (data.isNullOrBlank()) return null + val values = data.split('|') + fun bit(index: Int): Boolean = values.getOrNull(index) == "1" + return TdApi.ChatAdministratorRights( + bit(0), + bit(1), + bit(2), + bit(3), + bit(4), + bit(5), + bit(6), + bit(7), + bit(8), + bit(9), + bit(10), + bit(11), + bit(12), + bit(13), + bit(14), + bit(15), + bit(16) + ) +} + +internal fun encodeAffiliateProgramInfo(affiliateProgram: TdApi.AffiliateProgramInfo?): String? { + if (affiliateProgram == null) return null + val params = affiliateProgram.parameters + val amount = affiliateProgram.dailyRevenuePerUserAmount + return listOf( + params.commissionPerMille.toString(), + params.monthCount.toString(), + affiliateProgram.endDate.toString(), + amount.starCount.toString(), + amount.nanostarCount.toString() + ).joinToString("|") +} + +internal fun decodeAffiliateProgramInfo(data: String?): TdApi.AffiliateProgramInfo? { + if (data.isNullOrBlank()) return null + val values = data.split('|') + val commissionPerMille = values.getOrNull(0)?.toIntOrNull() ?: return null + val monthCount = values.getOrNull(1)?.toIntOrNull() ?: return null + val endDate = values.getOrNull(2)?.toIntOrNull() ?: return null + val starCount = values.getOrNull(3)?.toLongOrNull() ?: return null + val nanostarCount = values.getOrNull(4)?.toIntOrNull() ?: return null + return TdApi.AffiliateProgramInfo( + TdApi.AffiliateProgramParameters(commissionPerMille, monthCount), + endDate, + TdApi.StarAmount(starCount, nanostarCount) + ) +} + +internal fun encodeProfileAudio(audio: ProfileAudioModel?): String? { + if (audio == null) return null + return listOf( + audio.duration.toString(), + audio.title.orEmpty().escapeStorage(), + audio.performer.orEmpty().escapeStorage(), + audio.fileName.orEmpty().escapeStorage(), + audio.mimeType.orEmpty().escapeStorage(), + audio.fileId.toString(), + audio.filePath.orEmpty().escapeStorage() + ).joinToString("|") +} + +internal fun decodeProfileAudio(data: String?): ProfileAudioModel? { + if (data.isNullOrBlank()) return null + val parts = data.split('|') + return ProfileAudioModel( + duration = parts.getOrNull(0)?.toIntOrNull() ?: 0, + title = parts.getOrNull(1)?.unescapeStorage()?.ifEmpty { null }, + performer = parts.getOrNull(2)?.unescapeStorage()?.ifEmpty { null }, + fileName = parts.getOrNull(3)?.unescapeStorage()?.ifEmpty { null }, + mimeType = parts.getOrNull(4)?.unescapeStorage()?.ifEmpty { null }, + fileId = parts.getOrNull(5)?.toIntOrNull() ?: 0, + filePath = parts.getOrNull(6)?.unescapeStorage()?.ifEmpty { null } + ) +} + +internal fun encodeUserRating(rating: UserRatingModel?): String? { + if (rating == null) return null + return listOf( + rating.level.toString(), + if (rating.isMaximumLevelReached) "1" else "0", + rating.rating.toString(), + rating.currentLevelRating.toString(), + rating.nextLevelRating.toString() + ).joinToString("|") +} + +internal fun decodeUserRating(data: String?): UserRatingModel? { + if (data.isNullOrBlank()) return null + val parts = data.split('|') + return UserRatingModel( + level = parts.getOrNull(0)?.toIntOrNull() ?: 0, + isMaximumLevelReached = parts.getOrNull(1) == "1", + rating = parts.getOrNull(2)?.toLongOrNull() ?: 0L, + currentLevelRating = parts.getOrNull(3)?.toLongOrNull() ?: 0L, + nextLevelRating = parts.getOrNull(4)?.toLongOrNull() ?: 0L + ) +} + +internal fun encodeBotCommands(commands: Array?): String? { + if (commands.isNullOrEmpty()) return null + return commands.joinToString("\n") { botCommands -> + val serializedCommands = (botCommands.commands ?: emptyArray()).joinToString(";") { command -> + "${command.command.escapeStorage()},${command.description.escapeStorage()}" + } + "${botCommands.botUserId}:$serializedCommands" + } +} + +internal fun encodeBotInfoCommands(commands: Array?): String? { + if (commands.isNullOrEmpty()) return null + return commands.joinToString(";") { command -> + "${command.command.escapeStorage()},${command.description.escapeStorage()}" + } +} + +internal fun decodeBotInfoCommands(data: String?): Array { + if (data.isNullOrBlank()) return emptyArray() + return data.split(';').mapNotNull { item -> + val commandSeparator = item.indexOf(',') + if (commandSeparator < 0) return@mapNotNull null + val command = item.substring(0, commandSeparator).unescapeStorage() + val description = item.substring(commandSeparator + 1).unescapeStorage() + TdApi.BotCommand(command, description) + }.toTypedArray() +} + +internal fun decodeBotCommands(data: String?): List { + if (data.isNullOrBlank()) return emptyList() + return data.split('\n').mapNotNull { line -> + val separator = line.indexOf(':') + if (separator <= 0) return@mapNotNull null + val botUserId = line.substring(0, separator).toLongOrNull() ?: return@mapNotNull null + val commandsRaw = line.substring(separator + 1) + val commands = if (commandsRaw.isBlank()) { + emptyList() + } else { + commandsRaw.split(';').mapNotNull { item -> + val commandSeparator = item.indexOf(',') + if (commandSeparator < 0) return@mapNotNull null + val command = item.substring(0, commandSeparator).unescapeStorage() + val description = item.substring(commandSeparator + 1).unescapeStorage() + BotCommandModel(command, description) + } + } + SupergroupBotCommandsModel(botUserId = botUserId, commands = commands) + } +} + +internal fun TdApi.ProfileTab?.toTypeString(): String? { + return when (this) { + is TdApi.ProfileTabPosts -> "POSTS" + is TdApi.ProfileTabGifts -> "GIFTS" + is TdApi.ProfileTabMedia -> "MEDIA" + is TdApi.ProfileTabFiles -> "FILES" + is TdApi.ProfileTabLinks -> "LINKS" + is TdApi.ProfileTabMusic -> "MUSIC" + is TdApi.ProfileTabVoice -> "VOICE" + is TdApi.ProfileTabGifs -> "GIFS" + else -> null + } +} + +internal fun String?.toTdApiProfileTab(): TdApi.ProfileTab? { + return when (this) { + "POSTS" -> TdApi.ProfileTabPosts() + "GIFTS" -> TdApi.ProfileTabGifts() + "MEDIA" -> TdApi.ProfileTabMedia() + "FILES" -> TdApi.ProfileTabFiles() + "LINKS" -> TdApi.ProfileTabLinks() + "MUSIC" -> TdApi.ProfileTabMusic() + "VOICE" -> TdApi.ProfileTabVoice() + "GIFS" -> TdApi.ProfileTabGifs() + else -> null + } +} + +internal fun String?.toProfileTabType(): ProfileTabType? { + return when (this) { + "POSTS" -> ProfileTabType.POSTS + "GIFTS" -> ProfileTabType.GIFTS + "MEDIA" -> ProfileTabType.MEDIA + "FILES" -> ProfileTabType.FILES + "LINKS" -> ProfileTabType.LINKS + "MUSIC" -> ProfileTabType.MUSIC + "VOICE" -> ProfileTabType.VOICE + "GIFS" -> ProfileTabType.GIFS + else -> null + } +} + +internal fun String?.toTdApiChatPhoto(): TdApi.ChatPhoto? { + if (this.isNullOrBlank()) return null + return TdApi.ChatPhoto().apply { + sizes = arrayOf( + TdApi.PhotoSize().apply { + type = "x" + width = 0 + height = 0 + photo = TdApi.File().apply { + local = TdApi.LocalFile().apply { path = this@toTdApiChatPhoto } + } + } + ) + } +} + +internal fun TdApi.BlockList?.toTypeString(): String? { + return when (this) { + is TdApi.BlockListMain -> "MAIN" + is TdApi.BlockListStories -> "STORIES" + else -> null + } +} + +internal fun String.escapeStorage(): String { + return this + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("|", "\\p") + .replace(",", "\\c") + .replace(";", "\\s") +} + +internal fun String.unescapeStorage(): String { + return this + .replace("\\s", ";") + .replace("\\c", ",") + .replace("\\p", "|") + .replace("\\n", "\n") + .replace("\\\\", "\\") +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt index d309e9ae..51620923 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt @@ -28,6 +28,8 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { else -> 0L } + val botType = type as? TdApi.UserTypeBot + return UserEntity( id = id, firstName = firstName, @@ -38,18 +40,44 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = verificationStatus?.isVerified ?: false, + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSupport = isSupport, isContact = isContact, isMutualContact = isMutualContact, isCloseFriend = isCloseFriend, + botTypeCanBeEdited = botType?.canBeEdited ?: false, + botTypeCanJoinGroups = botType?.canJoinGroups ?: false, + botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false, + botTypeHasMainWebApp = botType?.hasMainWebApp ?: false, + botTypeHasTopics = botType?.hasTopics ?: false, + botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false, + botTypeCanManageBots = botType?.canManageBots ?: false, + botTypeIsInline = botType?.isInline ?: false, + botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null }, + botTypeNeedLocation = botType?.needLocation ?: false, + botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false, + botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false, + botTypeActiveUserCount = botType?.activeUserCount ?: 0, + userType = type.toTypeString(), + restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null }, + hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false, + activeStoryStateType = activeStoryState.toTypeString(), + activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0, + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, haveAccess = haveAccess, username = usernames?.activeUsernames?.firstOrNull(), usernamesData = usernamesData, statusType = statusType, accentColorId = accentColorId, + backgroundCustomEmojiId = backgroundCustomEmojiId, profileAccentColorId = profileAccentColorId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, statusEmojiId = statusEmojiId, languageCode = languageCode.ifEmpty { null }, + addedToAttachmentMenu = addedToAttachmentMenu, lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, createdAt = System.currentTimeMillis() ) @@ -60,4 +88,135 @@ fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? { ?: personalPhoto?.sizes?.lastOrNull() return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } -} \ No newline at end of file +} + +fun UserEntity.toTdApi(): TdApi.User { + return TdApi.User().apply { + id = this@toTdApi.id + firstName = this@toTdApi.firstName + lastName = this@toTdApi.lastName ?: "" + phoneNumber = this@toTdApi.phoneNumber ?: "" + isPremium = this@toTdApi.isPremium + isSupport = this@toTdApi.isSupport + isContact = this@toTdApi.isContact + isMutualContact = this@toTdApi.isMutualContact + isCloseFriend = this@toTdApi.isCloseFriend + haveAccess = this@toTdApi.haveAccess + languageCode = this@toTdApi.languageCode ?: "" + accentColorId = this@toTdApi.accentColorId + backgroundCustomEmojiId = this@toTdApi.backgroundCustomEmojiId + profileAccentColorId = this@toTdApi.profileAccentColorId + profileBackgroundCustomEmojiId = this@toTdApi.profileBackgroundCustomEmojiId + verificationStatus = if ( + isVerified || + isScam || + isFake || + botVerificationIconCustomEmojiId != 0L + ) { + TdApi.VerificationStatus( + isVerified, + isScam, + isFake, + botVerificationIconCustomEmojiId + ) + } else { + null + } + val (active, disabled, editable, collectible) = decodeUsernames( + this@toTdApi.usernamesData, + this@toTdApi.username + ) + usernames = TdApi.Usernames(active, disabled, editable, collectible) + emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let { + TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0) + } + status = when (this@toTdApi.statusType) { + "ONLINE" -> TdApi.UserStatusOnline(0) + "RECENTLY" -> TdApi.UserStatusRecently() + "LAST_WEEK" -> TdApi.UserStatusLastWeek() + "LAST_MONTH" -> TdApi.UserStatusLastMonth() + else -> TdApi.UserStatusOffline(lastSeen.toInt()) + } + type = when (this@toTdApi.userType) { + "REGULAR" -> TdApi.UserTypeRegular() + "BOT" -> TdApi.UserTypeBot( + botTypeCanBeEdited, + botTypeCanJoinGroups, + botTypeCanReadAllGroupMessages, + botTypeHasMainWebApp, + botTypeHasTopics, + botTypeAllowsUsersToCreateTopics, + botTypeCanManageBots, + botTypeIsInline, + botTypeInlineQueryPlaceholder.orEmpty(), + botTypeNeedLocation, + botTypeCanConnectToBusiness, + botTypeCanBeAddedToAttachmentMenu, + botTypeActiveUserCount + ) + + "DELETED" -> TdApi.UserTypeDeleted() + else -> TdApi.UserTypeUnknown() + } + restrictionInfo = if (!restrictionReason.isNullOrBlank() || hasSensitiveContent) { + TdApi.RestrictionInfo( + restrictionReason.orEmpty(), + hasSensitiveContent + ) + } else { + null + } + activeStoryState = when (activeStoryStateType) { + "LIVE" -> TdApi.ActiveStoryStateLive(activeStoryId) + "UNREAD" -> TdApi.ActiveStoryStateUnread() + "READ" -> TdApi.ActiveStoryStateRead() + else -> null + } + restrictsNewChats = this@toTdApi.restrictsNewChats + paidMessageStarCount = this@toTdApi.paidMessageStarCount + addedToAttachmentMenu = this@toTdApi.addedToAttachmentMenu + profilePhoto = avatarPath?.let { path -> + TdApi.ProfilePhoto().apply { + small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } + } + } + } +} + +private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames { + if (data.isNullOrEmpty()) { + val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray() + return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray()) + } + val parts = data.split("\n", limit = 4) + val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + val editable = parts.getOrNull(2).orEmpty() + val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + return QuadUsernames(active, disabled, editable, collectible) +} + +private data class QuadUsernames( + val active: Array, + val disabled: Array, + val editable: String, + val collectible: Array +) + +private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } +} + +private fun TdApi.UserType?.toTypeString(): String { + return when (this) { + is TdApi.UserTypeRegular -> "REGULAR" + is TdApi.UserTypeBot -> "BOT" + is TdApi.UserTypeDeleted -> "DELETED" + else -> "UNKNOWN" + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt new file mode 100644 index 00000000..ab52de08 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt @@ -0,0 +1,338 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.UserFullInfoEntity +import org.monogram.data.mapper.isValidFilePath + +fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { + val businessLocation = businessInfo?.location + val businessOpeningHours = businessInfo?.openingHours + val businessStartPage = businessInfo?.startPage + val birth = birthdate + val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val publicPhotoPath = publicPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: (publicPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: publicPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val botInfoPhotoPath = botInfo?.photo?.let { photo -> + val bestPhotoSize = photo.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: photo.sizes.lastOrNull() + bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } + } + val botInfoPhotoFileId = botInfo?.photo?.sizes?.lastOrNull()?.photo?.id ?: 0 + val botInfoAnimationFileId = botInfo?.animation?.animation?.id ?: 0 + val botInfoAnimationPath = botInfo?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) } + val botVerification = botVerification + val firstProfileAudio = firstProfileAudio + val rating = rating + val pendingRating = pendingRating + val botInfoVerificationParams = botInfo?.verificationParameters + + return UserFullInfoEntity( + userId = userId, + bio = bio?.text?.ifEmpty { null }, + commonGroupsCount = groupInCommonCount, + giftCount = giftCount, + botInfoDescription = botInfo?.description?.ifEmpty { null }, + botInfoShortDescription = botInfo?.shortDescription?.ifEmpty { null }, + botInfoPhotoFileId = botInfoPhotoFileId, + botInfoPhotoPath = botInfoPhotoPath, + botInfoAnimationFileId = botInfoAnimationFileId, + botInfoAnimationPath = botInfoAnimationPath, + botInfoManagerBotUserId = botInfo?.managerBotUserId ?: 0L, + botInfoMenuButtonText = botInfo?.menuButton?.text?.ifEmpty { null }, + botInfoMenuButtonUrl = botInfo?.menuButton?.url?.ifEmpty { null }, + botInfoCommandsData = encodeBotInfoCommands(botInfo?.commands), + botInfoPrivacyPolicyUrl = botInfo?.privacyPolicyUrl?.ifEmpty { null }, + botInfoDefaultGroupRightsData = encodeChatAdministratorRights(botInfo?.defaultGroupAdministratorRights), + botInfoDefaultChannelRightsData = encodeChatAdministratorRights(botInfo?.defaultChannelAdministratorRights), + botInfoAffiliateProgramData = encodeAffiliateProgramInfo(botInfo?.affiliateProgram), + botInfoWebAppBackgroundLightColor = botInfo?.webAppBackgroundLightColor ?: -1, + botInfoWebAppBackgroundDarkColor = botInfo?.webAppBackgroundDarkColor ?: -1, + botInfoWebAppHeaderLightColor = botInfo?.webAppHeaderLightColor ?: -1, + botInfoWebAppHeaderDarkColor = botInfo?.webAppHeaderDarkColor ?: -1, + botInfoVerificationParametersIconCustomEmojiId = botInfoVerificationParams?.iconCustomEmojiId ?: 0L, + botInfoVerificationParametersOrganizationName = botInfoVerificationParams?.organizationName?.ifEmpty { null }, + botInfoVerificationParametersDefaultCustomDescription = botInfoVerificationParams?.defaultCustomDescription?.text?.ifEmpty { null }, + botInfoVerificationParametersCanSetCustomDescription = botInfoVerificationParams?.canSetCustomDescription + ?: false, + botInfoCanManageEmojiStatus = botInfo?.canManageEmojiStatus ?: false, + botInfoHasMediaPreviews = botInfo?.hasMediaPreviews ?: false, + botInfoEditCommandsLinkType = botInfo?.editCommandsLink?.javaClass?.simpleName, + botInfoEditDescriptionLinkType = botInfo?.editDescriptionLink?.javaClass?.simpleName, + botInfoEditDescriptionMediaLinkType = botInfo?.editDescriptionMediaLink?.javaClass?.simpleName, + botInfoEditSettingsLinkType = botInfo?.editSettingsLink?.javaClass?.simpleName, + personalChatId = personalChatId, + birthdateDay = birth?.day ?: 0, + birthdateMonth = birth?.month ?: 0, + birthdateYear = birth?.year ?: 0, + publicPhotoPath = publicPhotoPath, + blockListType = blockList.toTypeString(), + businessLocationAddress = businessLocation?.address?.ifEmpty { null }, + businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0, + businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0, + businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId, + businessNextOpenIn = businessInfo?.nextOpenIn ?: 0, + businessNextCloseIn = businessInfo?.nextCloseIn ?: 0, + businessStartPageTitle = businessStartPage?.title?.ifEmpty { null }, + businessStartPageMessage = businessStartPage?.message?.ifEmpty { null }, + note = note?.text?.ifEmpty { null }, + personalPhotoPath = personalPhotoPath, + isBlocked = blockList != null, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + usesUnofficialApp = usesUnofficialApp, + botVerificationBotUserId = botVerification?.botUserId ?: 0L, + botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L, + botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null }, + mainProfileTab = mainProfileTab.toTypeString(), + firstProfileAudioDuration = firstProfileAudio?.duration ?: 0, + firstProfileAudioTitle = firstProfileAudio?.title?.ifEmpty { null }, + firstProfileAudioPerformer = firstProfileAudio?.performer?.ifEmpty { null }, + firstProfileAudioFileName = firstProfileAudio?.fileName?.ifEmpty { null }, + firstProfileAudioMimeType = firstProfileAudio?.mimeType?.ifEmpty { null }, + firstProfileAudioFileId = firstProfileAudio?.audio?.id ?: 0, + firstProfileAudioPath = firstProfileAudio?.audio?.local?.path?.takeIf { isValidFilePath(it) }, + ratingLevel = rating?.level ?: 0, + ratingIsMaximumLevelReached = rating?.isMaximumLevelReached ?: false, + ratingValue = rating?.rating ?: 0L, + ratingCurrentLevelValue = rating?.currentLevelRating ?: 0L, + ratingNextLevelValue = rating?.nextLevelRating ?: 0L, + pendingRatingLevel = pendingRating?.level ?: 0, + pendingRatingIsMaximumLevelReached = pendingRating?.isMaximumLevelReached ?: false, + pendingRatingValue = pendingRating?.rating ?: 0L, + pendingRatingCurrentLevelValue = pendingRating?.currentLevelRating ?: 0L, + pendingRatingNextLevelValue = pendingRating?.nextLevelRating ?: 0L, + pendingRatingDate = pendingRatingDate, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, + createdAt = System.currentTimeMillis() + ) +} + +fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo { + return TdApi.UserFullInfo().apply { + bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) } + groupInCommonCount = commonGroupsCount + giftCount = this@toTdApi.giftCount + personalChatId = this@toTdApi.personalChatId + birthdate = if (birthdateDay > 0 && birthdateMonth > 0) { + TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear) + } else { + null + } + botInfo = if ( + botInfoDescription != null || + botInfoShortDescription != null || + botInfoManagerBotUserId != 0L || + botInfoPrivacyPolicyUrl != null + ) { + TdApi.BotInfo().apply { + shortDescription = botInfoShortDescription.orEmpty() + description = botInfoDescription.orEmpty() + photo = if (botInfoPhotoFileId != 0 || !botInfoPhotoPath.isNullOrEmpty()) { + TdApi.Photo().apply { + sizes = arrayOf( + TdApi.PhotoSize().apply { + type = "x" + width = 0 + height = 0 + photo = TdApi.File().apply { + id = botInfoPhotoFileId + local = TdApi.LocalFile().apply { path = botInfoPhotoPath.orEmpty() } + } + } + ) + } + } else { + null + } + animation = if (botInfoAnimationFileId != 0 || !botInfoAnimationPath.isNullOrEmpty()) { + TdApi.Animation().apply { + animation = TdApi.File().apply { + id = botInfoAnimationFileId + local = TdApi.LocalFile().apply { path = botInfoAnimationPath.orEmpty() } + } + } + } else { + null + } + managerBotUserId = botInfoManagerBotUserId + menuButton = if (!botInfoMenuButtonText.isNullOrEmpty() || !botInfoMenuButtonUrl.isNullOrEmpty()) { + TdApi.BotMenuButton( + botInfoMenuButtonText.orEmpty(), + botInfoMenuButtonUrl.orEmpty() + ) + } else { + null + } + commands = decodeBotInfoCommands(botInfoCommandsData) + privacyPolicyUrl = botInfoPrivacyPolicyUrl.orEmpty() + defaultGroupAdministratorRights = decodeChatAdministratorRights(botInfoDefaultGroupRightsData) + defaultChannelAdministratorRights = decodeChatAdministratorRights(botInfoDefaultChannelRightsData) + affiliateProgram = decodeAffiliateProgramInfo(botInfoAffiliateProgramData) + webAppBackgroundLightColor = botInfoWebAppBackgroundLightColor + webAppBackgroundDarkColor = botInfoWebAppBackgroundDarkColor + webAppHeaderLightColor = botInfoWebAppHeaderLightColor + webAppHeaderDarkColor = botInfoWebAppHeaderDarkColor + verificationParameters = if ( + botInfoVerificationParametersIconCustomEmojiId != 0L || + !botInfoVerificationParametersOrganizationName.isNullOrEmpty() || + !botInfoVerificationParametersDefaultCustomDescription.isNullOrEmpty() + ) { + TdApi.BotVerificationParameters( + botInfoVerificationParametersIconCustomEmojiId, + botInfoVerificationParametersOrganizationName.orEmpty(), + botInfoVerificationParametersDefaultCustomDescription?.let { + TdApi.FormattedText(it, emptyArray()) + }, + botInfoVerificationParametersCanSetCustomDescription + ) + } else { + null + } + canGetRevenueStatistics = canGetRevenueStatistics + canManageEmojiStatus = botInfoCanManageEmojiStatus + hasMediaPreviews = botInfoHasMediaPreviews + editCommandsLink = null + editDescriptionLink = null + editDescriptionMediaLink = null + editSettingsLink = null + } + } else { + null + } + businessInfo = if ( + businessLocationAddress != null || + businessOpeningHoursTimeZone != null || + businessStartPageTitle != null || + businessStartPageMessage != null + ) { + TdApi.BusinessInfo().apply { + location = businessLocationAddress?.let { address -> + TdApi.BusinessLocation( + TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0), + address + ) + } + openingHours = businessOpeningHoursTimeZone?.let { tz -> + TdApi.BusinessOpeningHours(tz, emptyArray()) + } + startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) { + TdApi.BusinessStartPage( + businessStartPageTitle.orEmpty(), + businessStartPageMessage.orEmpty(), + null + ) + } else { + null + } + nextOpenIn = businessNextOpenIn + nextCloseIn = businessNextCloseIn + } + } else { + null + } + personalPhoto = personalPhotoPath.toTdApiChatPhoto() + photo = null + publicPhoto = publicPhotoPath.toTdApiChatPhoto() + blockList = when (blockListType) { + "STORIES" -> TdApi.BlockListStories() + "MAIN" -> TdApi.BlockListMain() + else -> if (isBlocked) TdApi.BlockListMain() else null + } + canBeCalled = this@toTdApi.canBeCalled + supportsVideoCalls = this@toTdApi.supportsVideoCalls + hasPrivateCalls = this@toTdApi.hasPrivateCalls + hasPrivateForwards = this@toTdApi.hasPrivateForwards + hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages + hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories + hasSponsoredMessagesEnabled = this@toTdApi.hasSponsoredMessagesEnabled + needPhoneNumberPrivacyException = this@toTdApi.needPhoneNumberPrivacyException + setChatBackground = this@toTdApi.setChatBackground + usesUnofficialApp = this@toTdApi.usesUnofficialApp + incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount + outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount + botVerification = if ( + botVerificationBotUserId != 0L || + botVerificationIconCustomEmojiId != 0L || + !botVerificationCustomDescription.isNullOrEmpty() + ) { + TdApi.BotVerification( + botVerificationBotUserId, + botVerificationIconCustomEmojiId, + TdApi.FormattedText(botVerificationCustomDescription.orEmpty(), emptyArray()) + ) + } else { + null + } + mainProfileTab = this@toTdApi.mainProfileTab.toTdApiProfileTab() + firstProfileAudio = if ( + firstProfileAudioFileId != 0 || + !firstProfileAudioPath.isNullOrEmpty() || + firstProfileAudioDuration > 0 || + !firstProfileAudioTitle.isNullOrEmpty() || + !firstProfileAudioPerformer.isNullOrEmpty() + ) { + TdApi.Audio( + firstProfileAudioDuration, + firstProfileAudioTitle.orEmpty(), + firstProfileAudioPerformer.orEmpty(), + firstProfileAudioFileName.orEmpty(), + firstProfileAudioMimeType.orEmpty(), + null, + null, + emptyArray(), + TdApi.File().apply { + id = firstProfileAudioFileId + local = TdApi.LocalFile().apply { path = firstProfileAudioPath.orEmpty() } + } + ) + } else { + null + } + rating = if ( + ratingLevel != 0 || + ratingIsMaximumLevelReached || + ratingValue != 0L || + ratingCurrentLevelValue != 0L || + ratingNextLevelValue != 0L + ) { + TdApi.UserRating( + ratingLevel, + ratingIsMaximumLevelReached, + ratingValue, + ratingCurrentLevelValue, + ratingNextLevelValue + ) + } else { + null + } + pendingRating = if ( + pendingRatingLevel != 0 || + pendingRatingIsMaximumLevelReached || + pendingRatingValue != 0L || + pendingRatingCurrentLevelValue != 0L || + pendingRatingNextLevelValue != 0L + ) { + TdApi.UserRating( + pendingRatingLevel, + pendingRatingIsMaximumLevelReached, + pendingRatingValue, + pendingRatingCurrentLevelValue, + pendingRatingNextLevelValue + ) + } else { + null + } + pendingRatingDate = this@toTdApi.pendingRatingDate + note = this@toTdApi.note?.let { TdApi.FormattedText(it, emptyArray()) } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt index 0e84c952..20afafd0 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt @@ -1,14 +1,10 @@ package org.monogram.data.mapper.user import org.drinkless.tdlib.TdApi -import org.monogram.data.db.model.ChatEntity -import org.monogram.data.db.model.ChatFullInfoEntity -import org.monogram.data.db.model.UserEntity -import org.monogram.data.db.model.UserFullInfoEntity -import org.monogram.data.mapper.* +import org.monogram.data.mapper.isForcedVerifiedUser +import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.isValidFilePath import org.monogram.domain.models.* -import org.monogram.domain.repository.ChatMemberStatus -import org.monogram.domain.repository.ChatMembersFilter fun TdApi.User.toDomain( fullInfo: TdApi.UserFullInfo? = null, @@ -36,9 +32,17 @@ fun TdApi.User.toDomain( personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = (verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(id), + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSponsor = isSponsoredUser(id), isSupport = isSupport, type = type.toDomain(), + botTypeInfo = (type as? TdApi.UserTypeBot)?.toDomain(), + restrictionInfo = restrictionInfo?.toDomain(), + activeStoryState = activeStoryState?.toDomain(), + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, statusEmojiId = emojiStatusId, statusEmojiPath = customEmojiPath, username = username, @@ -50,175 +54,13 @@ fun TdApi.User.toDomain( isCloseFriend = isCloseFriend, haveAccess = haveAccess, languageCode = languageCode, - lastSeen = lastSeen + lastSeen = lastSeen, + backgroundCustomEmojiId = backgroundCustomEmojiId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, + addedToAttachmentMenu = addedToAttachmentMenu ) } -fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel { - val rank = when (this.status) { - is TdApi.ChatMemberStatusCreator -> "Owner" - is TdApi.ChatMemberStatusAdministrator -> "Admin" - else -> null - } - return GroupMemberModel( - user = user, - rank = rank, - status = this.status.toDomain() - ) -} - -fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel { - val birthdate = birthdate?.let { date -> - BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null) - } - return ChatFullInfoModel( - description = bio?.text?.ifEmpty { null }, - commonGroupsCount = groupInCommonCount, - giftCount = giftCount, - birthdate = birthdate, - isBlocked = blockList != null, - botInfo = botInfo?.description?.ifEmpty { null }, - canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, - linkedChatId = personalChatId, - businessInfo = businessInfo?.let { businessInfo!!.toDomain() }, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground - ) -} - -fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat( - supergroup: TdApi.Supergroup? -): ChatFullInfoModel { - val link = inviteLink?.inviteLink - ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" } - return ChatFullInfoModel( - description = description.ifEmpty { null }, - inviteLink = link, - memberCount = memberCount, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - slowModeDelay = slowModeDelay, - locationAddress = location?.address?.ifEmpty { null }, - giftCount = giftCount, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId - ) -} - -fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel { - return ChatFullInfoModel( - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = members.size - ) -} - -fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { - return when (this) { - is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator - is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator( - customTitle = "", - canBeEdited = canBeEdited, - canManageChat = rights.canManageChat, - canChangeInfo = rights.canChangeInfo, - canPostMessages = rights.canPostMessages, - canEditMessages = rights.canEditMessages, - canDeleteMessages = rights.canDeleteMessages, - canInviteUsers = rights.canInviteUsers, - canRestrictMembers = rights.canRestrictMembers, - canPinMessages = rights.canPinMessages, - canManageTopics = rights.canManageTopics, - canPromoteMembers = rights.canPromoteMembers, - canManageVideoChats = rights.canManageVideoChats, - canPostStories = rights.canPostStories, - canEditStories = rights.canEditStories, - canDeleteStories = rights.canDeleteStories, - canManageDirectMessages = rights.canManageDirectMessages, - isAnonymous = rights.isAnonymous - ) - is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( - isMember = isMember, - restrictedUntilDate = restrictedUntilDate, - permissions = permissions.toDomainChatPermissions() - ) - is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) - is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left - else -> ChatMemberStatus.Member - } -} - -fun TdApi.Chat.toDomain(): ChatModel { - val isChannel = type.isChannelType() - return ChatModel( - id = id, - title = title, - avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, - unreadCount = unreadCount, - isMuted = notificationSettings.muteFor > 0, - isChannel = isChannel, - isGroup = type.isGroupType(), - type = type.toDomainChatType(), - lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" - ) -} - -fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter { - return when (this) { - is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent() - is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators() - is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned() - is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted() - is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots() - is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query) - } -} - -fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { - return when (this) { - is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember() - is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator( - canBeEdited, - TdApi.ChatAdministratorRights( - canManageChat, - canChangeInfo, - canPostMessages, - canEditMessages, - canDeleteMessages, - canInviteUsers, - canRestrictMembers, - canPinMessages, - canManageTopics, - canPromoteMembers, - canManageVideoChats, - canPostStories, - canEditStories, - canDeleteStories, - canManageDirectMessages, - false, - isAnonymous - ) - ) - is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( - isMember, - restrictedUntilDate, - permissions.toTdApiChatPermissions() - ) - is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() - is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) - is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true) - } -} - private fun TdApi.User.resolveAvatarPath(): String? { val big = profilePhoto?.big?.local?.path?.takeIf { isValidFilePath(it) } val small = profilePhoto?.small?.local?.path?.takeIf { isValidFilePath(it) } @@ -260,383 +102,36 @@ private fun TdApi.UserStatus?.toDomain(): UserStatusType { } } -private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { - return BusinessInfoModel( - location = location?.let { - BusinessLocationModel( - it.location!!.latitude, - it.location!!.longitude, - it.address - ) - }, - openingHours = openingHours?.let { - BusinessOpeningHoursModel( - it.timeZoneId, - it.openingHours.map { interval -> - BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute) - } - ) - }, - startPage = startPage?.let { - BusinessStartPageModel( - title = it.title, - message = it.message, - stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } - ) - }, - nextOpenIn = nextOpenIn, - nextCloseIn = nextCloseIn +private fun TdApi.UserTypeBot.toDomain(): UserTypeBotInfoModel { + return UserTypeBotInfoModel( + canBeEdited = canBeEdited, + canJoinGroups = canJoinGroups, + canReadAllGroupMessages = canReadAllGroupMessages, + hasMainWebApp = hasMainWebApp, + hasTopics = hasTopics, + allowsUsersToCreateTopics = allowsUsersToCreateTopics, + canManageBots = canManageBots, + isInline = isInline, + inlineQueryPlaceholder = inlineQueryPlaceholder.ifEmpty { null }, + needLocation = needLocation, + canConnectToBusiness = canConnectToBusiness, + canBeAddedToAttachmentMenu = canBeAddedToAttachmentMenu, + activeUserCount = activeUserCount ) } -fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { - val businessLocation = businessInfo?.location - val businessOpeningHours = businessInfo?.openingHours - val businessStartPage = businessInfo?.startPage - val birth = birthdate - val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } - ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } - - return UserFullInfoEntity( - userId = userId, - bio = bio?.text?.ifEmpty { null }, - commonGroupsCount = groupInCommonCount, - giftCount = giftCount, - botInfoDescription = botInfo?.description?.ifEmpty { null }, - personalChatId = personalChatId, - birthdateDay = birth?.day ?: 0, - birthdateMonth = birth?.month ?: 0, - birthdateYear = birth?.year ?: 0, - businessLocationAddress = businessLocation?.address?.ifEmpty { null }, - businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0, - businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0, - businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId, - businessNextOpenIn = businessInfo?.nextOpenIn ?: 0, - businessNextCloseIn = businessInfo?.nextCloseIn ?: 0, - businessStartPageTitle = businessStartPage?.title?.ifEmpty { null }, - businessStartPageMessage = businessStartPage?.message?.ifEmpty { null }, - personalPhotoPath = personalPhotoPath, - isBlocked = blockList != null, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground, - canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, - createdAt = System.currentTimeMillis() +private fun TdApi.RestrictionInfo.toDomain(): RestrictionInfoModel { + return RestrictionInfoModel( + restrictionReason = restrictionReason.ifEmpty { null }, + hasSensitiveContent = hasSensitiveContent ) } -fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { - return ChatFullInfoEntity( - chatId = chatId, - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = memberCount, - onlineCount = 0, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - commonGroupsCount = 0, - giftCount = giftCount, - isBlocked = false, - botInfo = null, - slowModeDelay = slowModeDelay, - locationAddress = location?.address?.ifEmpty { null }, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId, - note = null, - canBeCalled = false, - supportsVideoCalls = false, - hasPrivateCalls = false, - hasPrivateForwards = false, - hasRestrictedVoiceAndVideoNoteMessages = false, - hasPostedToProfileStories = false, - setChatBackground = false, - incomingPaidMessageStarCount = 0, - outgoingPaidMessageStarCount = outgoingPaidMessageStarCount, - createdAt = System.currentTimeMillis() - ) -} - -fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { - return ChatFullInfoEntity( - chatId = chatId, - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = members.size, - onlineCount = 0, - administratorCount = 0, - restrictedCount = 0, - bannedCount = 0, - commonGroupsCount = 0, - giftCount = 0, - isBlocked = false, - botInfo = null, - slowModeDelay = 0, - locationAddress = null, - canSetStickerSet = false, - canSetLocation = false, - canGetMembers = false, - canGetStatistics = false, - canGetRevenueStatistics = false, - linkedChatId = 0, - note = null, - canBeCalled = false, - supportsVideoCalls = false, - hasPrivateCalls = false, - hasPrivateForwards = false, - hasRestrictedVoiceAndVideoNoteMessages = false, - hasPostedToProfileStories = false, - setChatBackground = false, - incomingPaidMessageStarCount = 0, - outgoingPaidMessageStarCount = 0, - createdAt = System.currentTimeMillis() - ) -} - -fun UserEntity.toTdApi(): TdApi.User { - return TdApi.User().apply { - id = this@toTdApi.id - firstName = this@toTdApi.firstName - lastName = this@toTdApi.lastName ?: "" - phoneNumber = this@toTdApi.phoneNumber ?: "" - isPremium = this@toTdApi.isPremium - isSupport = this@toTdApi.isSupport - isContact = this@toTdApi.isContact - isMutualContact = this@toTdApi.isMutualContact - isCloseFriend = this@toTdApi.isCloseFriend - haveAccess = this@toTdApi.haveAccess - languageCode = this@toTdApi.languageCode ?: "" - accentColorId = this@toTdApi.accentColorId - profileAccentColorId = this@toTdApi.profileAccentColorId - verificationStatus = if (isVerified) TdApi.VerificationStatus(true, false, false, 0L) else null - val (active, disabled, editable, collectible) = decodeUsernames( - this@toTdApi.usernamesData, - this@toTdApi.username - ) - usernames = TdApi.Usernames(active, disabled, editable, collectible) - emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let { - TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0) - } - status = when (this@toTdApi.statusType) { - "ONLINE" -> TdApi.UserStatusOnline(0) - "RECENTLY" -> TdApi.UserStatusRecently() - "LAST_WEEK" -> TdApi.UserStatusLastWeek() - "LAST_MONTH" -> TdApi.UserStatusLastMonth() - else -> TdApi.UserStatusOffline(lastSeen.toInt()) - } - profilePhoto = avatarPath?.let { path -> - TdApi.ProfilePhoto().apply { - small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - } - } -} - -fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo { - return TdApi.UserFullInfo().apply { - bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) } - groupInCommonCount = commonGroupsCount - giftCount = this@toTdApi.giftCount - personalChatId = this@toTdApi.personalChatId - birthdate = if (birthdateDay > 0 && birthdateMonth > 0) { - TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear) - } else { - null - } - botInfo = botInfoDescription?.let { text -> - TdApi.BotInfo().apply { - description = text - canGetRevenueStatistics = canGetRevenueStatistics - } - } - businessInfo = if ( - businessLocationAddress != null || - businessOpeningHoursTimeZone != null || - businessStartPageTitle != null || - businessStartPageMessage != null - ) { - TdApi.BusinessInfo().apply { - location = businessLocationAddress?.let { address -> - TdApi.BusinessLocation( - TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0), - address - ) - } - openingHours = businessOpeningHoursTimeZone?.let { tz -> - TdApi.BusinessOpeningHours(tz, emptyArray()) - } - startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) { - TdApi.BusinessStartPage( - businessStartPageTitle.orEmpty(), - businessStartPageMessage.orEmpty(), - null - ) - } else { - null - } - nextOpenIn = businessNextOpenIn - nextCloseIn = businessNextCloseIn - } - } else { - null - } - personalPhoto = personalPhotoPath?.let { path -> - TdApi.ChatPhoto().apply { - sizes = arrayOf( - TdApi.PhotoSize().apply { - type = "x" - width = 0 - height = 0 - photo = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - ) - } - } - blockList = if (isBlocked) TdApi.BlockListMain() else null - canBeCalled = this@toTdApi.canBeCalled - supportsVideoCalls = this@toTdApi.supportsVideoCalls - hasPrivateCalls = this@toTdApi.hasPrivateCalls - hasPrivateForwards = this@toTdApi.hasPrivateForwards - hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages - hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories - setChatBackground = this@toTdApi.setChatBackground - incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount - outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount - } -} - -private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames { - if (data.isNullOrEmpty()) { - val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray() - return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray()) - } - val parts = data.split("\n", limit = 4) - val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - val editable = parts.getOrNull(2).orEmpty() - val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - return QuadUsernames(active, disabled, editable, collectible) -} - -private data class QuadUsernames( - val active: Array, - val disabled: Array, - val editable: String, - val collectible: Array -) - -fun ChatEntity.toTdApiChat(): TdApi.Chat { - return TdApi.Chat().apply { - id = this@toTdApiChat.id - title = this@toTdApiChat.title - unreadCount = this@toTdApiChat.unreadCount - unreadMentionCount = this@toTdApiChat.unreadMentionCount - unreadReactionCount = this@toTdApiChat.unreadReactionCount - photo = avatarPath?.let { path -> - TdApi.ChatPhotoInfo().apply { - small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - } - lastMessage = TdApi.Message().apply { - content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) } - date = lastMessageTime.toIntOrNull() ?: 0 - id = this@toTdApiChat.lastMessageId - isOutgoing = this@toTdApiChat.isLastMessageOutgoing - } - positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null)) - notificationSettings = TdApi.ChatNotificationSettings().apply { - muteFor = if (isMuted) Int.MAX_VALUE else 0 - } - type = when (this@toTdApiChat.type) { - "PRIVATE" -> TdApi.ChatTypePrivate().apply { - userId = if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId ?: 0L) - } - "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply { - basicGroupId = this@toTdApiChat.basicGroupId - } - "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel) - "SECRET" -> TdApi.ChatTypeSecret().apply { - secretChatId = this@toTdApiChat.secretChatId - } - else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId } - } - isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread - hasProtectedContent = this@toTdApiChat.hasProtectedContent - isTranslatable = this@toTdApiChat.isTranslatable - viewAsTopics = this@toTdApiChat.viewAsTopics - accentColorId = this@toTdApiChat.accentColorId - profileAccentColorId = this@toTdApiChat.profileAccentColorId - backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId - messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime - canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf - canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers - canBeReported = this@toTdApiChat.canBeReported - lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId - lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId - replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId - messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) } - blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null - permissions = TdApi.ChatPermissions( - this@toTdApiChat.permissionCanSendBasicMessages, - this@toTdApiChat.permissionCanSendAudios, - this@toTdApiChat.permissionCanSendDocuments, - this@toTdApiChat.permissionCanSendPhotos, - this@toTdApiChat.permissionCanSendVideos, - this@toTdApiChat.permissionCanSendVideoNotes, - this@toTdApiChat.permissionCanSendVoiceNotes, - this@toTdApiChat.permissionCanSendPolls, - this@toTdApiChat.permissionCanSendOtherMessages, - this@toTdApiChat.permissionCanAddLinkPreviews, - this@toTdApiChat.permissionCanEditTag, - this@toTdApiChat.permissionCanChangeInfo, - this@toTdApiChat.permissionCanInviteUsers, - this@toTdApiChat.permissionCanPinMessages, - this@toTdApiChat.permissionCanCreateTopics - ) - clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}" +private fun TdApi.ActiveStoryState.toDomain(): ActiveStoryStateModel { + return when (this) { + is TdApi.ActiveStoryStateLive -> ActiveStoryStateModel(ActiveStoryStateType.LIVE, storyId) + is TdApi.ActiveStoryStateUnread -> ActiveStoryStateModel(ActiveStoryStateType.UNREAD, 0) + is TdApi.ActiveStoryStateRead -> ActiveStoryStateModel(ActiveStoryStateType.READ, 0) + else -> ActiveStoryStateModel(ActiveStoryStateType.UNKNOWN, 0) } } - -fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel { - return ChatFullInfoModel( - description = description, - inviteLink = inviteLink, - memberCount = memberCount, - onlineCount = onlineCount, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - commonGroupsCount = commonGroupsCount, - giftCount = giftCount, - isBlocked = isBlocked, - botInfo = botInfo, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId, - businessInfo = null, - note = note, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground, - slowModeDelay = slowModeDelay, - locationAddress = locationAddress, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - incomingPaidMessageStarCount = incomingPaidMessageStarCount, - outgoingPaidMessageStarCount = outgoingPaidMessageStarCount - ) -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt index a4661e4c..5fcc1789 100644 --- a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt @@ -2,9 +2,8 @@ package org.monogram.data.repository import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.remote.UserRemoteDataSource -import org.monogram.domain.models.BotCommandModel -import org.monogram.domain.models.BotInfoModel -import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.data.mapper.isValidFilePath +import org.monogram.domain.models.* import org.monogram.domain.repository.BotRepository class BotRepositoryImpl( @@ -20,13 +19,84 @@ class BotRepositoryImpl( override suspend fun getBotInfo(botId: Long): BotInfoModel? { val fullInfo = remote.getBotFullInfo(botId) ?: return null - val commands = fullInfo.botInfo?.commands?.map { + val info = fullInfo.botInfo + val commands = info?.commands?.map { BotCommandModel(it.command, it.description) } ?: emptyList() - val menuButton = when (val btn = fullInfo.botInfo?.menuButton) { + val menuButton = when (val btn = info?.menuButton) { is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(btn.text, btn.url) else -> BotMenuButtonModel.Default } - return BotInfoModel(commands, menuButton) + val bestPhoto = info?.photo?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: info?.photo?.sizes?.lastOrNull() + val photoPath = bestPhoto?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val animationPath = info?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) } + + return BotInfoModel( + commands = commands, + menuButton = menuButton, + shortDescription = info?.shortDescription?.ifEmpty { null }, + description = info?.description?.ifEmpty { null }, + photoFileId = bestPhoto?.photo?.id ?: 0, + photoPath = photoPath, + animationFileId = info?.animation?.animation?.id ?: 0, + animationPath = animationPath, + managerBotUserId = info?.managerBotUserId ?: 0L, + privacyPolicyUrl = info?.privacyPolicyUrl?.ifEmpty { null }, + defaultGroupAdministratorRights = info?.defaultGroupAdministratorRights?.toDomain(), + defaultChannelAdministratorRights = info?.defaultChannelAdministratorRights?.toDomain(), + affiliateProgram = info?.affiliateProgram?.toDomain(), + webAppBackgroundLightColor = info?.webAppBackgroundLightColor ?: -1, + webAppBackgroundDarkColor = info?.webAppBackgroundDarkColor ?: -1, + webAppHeaderLightColor = info?.webAppHeaderLightColor ?: -1, + webAppHeaderDarkColor = info?.webAppHeaderDarkColor ?: -1, + verificationParameters = info?.verificationParameters?.let { + BotVerificationParametersModel( + iconCustomEmojiId = it.iconCustomEmojiId, + organizationName = it.organizationName.ifEmpty { null }, + defaultCustomDescription = it.defaultCustomDescription?.text?.ifEmpty { null }, + canSetCustomDescription = it.canSetCustomDescription + ) + }, + canGetRevenueStatistics = info?.canGetRevenueStatistics ?: false, + canManageEmojiStatus = info?.canManageEmojiStatus ?: false, + hasMediaPreviews = info?.hasMediaPreviews ?: false, + editCommandsLinkType = info?.editCommandsLink?.javaClass?.simpleName, + editDescriptionLinkType = info?.editDescriptionLink?.javaClass?.simpleName, + editDescriptionMediaLinkType = info?.editDescriptionMediaLink?.javaClass?.simpleName, + editSettingsLinkType = info?.editSettingsLink?.javaClass?.simpleName + ) } -} \ No newline at end of file +} + +private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel { + return ChatAdministratorRightsModel( + canManageChat = canManageChat, + canChangeInfo = canChangeInfo, + canPostMessages = canPostMessages, + canEditMessages = canEditMessages, + canDeleteMessages = canDeleteMessages, + canInviteUsers = canInviteUsers, + canRestrictMembers = canRestrictMembers, + canPinMessages = canPinMessages, + canManageTopics = canManageTopics, + canPromoteMembers = canPromoteMembers, + canManageVideoChats = canManageVideoChats, + canPostStories = canPostStories, + canEditStories = canEditStories, + canDeleteStories = canDeleteStories, + canManageDirectMessages = canManageDirectMessages, + canManageTags = canManageTags, + isAnonymous = isAnonymous + ) +} + +private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel { + return AffiliateProgramInfoModel( + commissionPerMille = parameters.commissionPerMille, + monthCount = parameters.monthCount, + endDate = endDate, + dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount, + dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount + ) +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index df356557..64848ef5 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -20,6 +20,7 @@ import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.ConnectionManager import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.SynchronizedLruMap import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.MessageMapper import org.monogram.domain.models.ChatModel @@ -194,7 +195,7 @@ class ChatsListRepositoryImpl( private val initialChatListLimit = 50 private var currentLimit = initialChatListLimit - private val modelCache = ConcurrentHashMap() + private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() private var lastList: List? = null private var lastListFolderId: Int = -1 @@ -701,6 +702,23 @@ class ChatsListRepositoryImpl( } } + fun clearMemoryCaches() { + modelCache.clear() + invalidatedModels.clear() + } + + fun memoryCacheSnapshot(): MemoryCacheSnapshot { + return MemoryCacheSnapshot( + modelCacheSize = modelCache.size(), + invalidatedModelsSize = invalidatedModels.size + ) + } + + data class MemoryCacheSnapshot( + val modelCacheSize: Int, + val invalidatedModelsSize: Int + ) + private fun fetchUser(userId: Long) { if (userId == 0L) return if (cache.pendingUsers.add(userId)) { @@ -731,5 +749,6 @@ class ChatsListRepositoryImpl( companion object { private const val TAG = "ChatsListRepository" private const val REBUILD_THROTTLE_MS = 250L + private const val MODEL_CACHE_SIZE = 256 } } diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index 6e579c42..6a46f51b 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -4,7 +4,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching @@ -58,22 +59,22 @@ class ProfilePhotoRepositoryImpl( return listOfNotNull(currentPath) } - override fun getUserProfilePhotosFlow(userId: Long): Flow> = flow { + override fun getUserProfilePhotosFlow(userId: Long): Flow> = channelFlow { if (userId <= 0) { - emit(emptyList()) - return@flow + send(emptyList()) + return@channelFlow } - emit(getUserProfilePhotos(userId)) - updates.file.collect { emit(getUserProfilePhotos(userId)) } + send(getUserProfilePhotos(userId)) + updates.file.collectLatest { send(getUserProfilePhotos(userId)) } } - override fun getChatProfilePhotosFlow(chatId: Long): Flow> = flow { + override fun getChatProfilePhotosFlow(chatId: Long): Flow> = channelFlow { if (chatId == 0L) { - emit(emptyList()) - return@flow + send(emptyList()) + return@channelFlow } - emit(getChatProfilePhotos(chatId)) - updates.file.collect { emit(getChatProfilePhotos(chatId)) } + send(getChatProfilePhotos(chatId)) + updates.file.collectLatest { send(getChatProfilePhotos(chatId)) } } private suspend fun loadChatPhotoHistoryPaths( @@ -266,4 +267,4 @@ class ProfilePhotoRepositoryImpl( private const val FULL_RES_DOWNLOAD_PRIORITY = 32 private const val FILE_DOWNLOAD_TIMEOUT_MS = 15_000L } -} \ No newline at end of file +} diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index 853c34b6..f8a51347 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -1,6 +1,7 @@ package org.monogram.data.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -27,13 +28,16 @@ class WallpaperRepositoryImpl( private val scope: CoroutineScope ) : WallpaperRepository { - private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) + private val wallpaperUpdates = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val wallpapers = MutableStateFlow>(emptyList()) init { scope.launch { - updates.file.collect { - wallpaperUpdates.emit(Unit) + updates.file.collectLatest { + wallpaperUpdates.tryEmit(Unit) } } diff --git a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt index a513fb19..bf4a7f72 100644 --- a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt @@ -13,7 +13,30 @@ sealed interface BotMenuButtonModel { data class BotInfoModel( val commands: List, - val menuButton: BotMenuButtonModel + val menuButton: BotMenuButtonModel, + val shortDescription: String? = null, + val description: String? = null, + val photoFileId: Int = 0, + val photoPath: String? = null, + val animationFileId: Int = 0, + val animationPath: String? = null, + val managerBotUserId: Long = 0L, + val privacyPolicyUrl: String? = null, + val defaultGroupAdministratorRights: ChatAdministratorRightsModel? = null, + val defaultChannelAdministratorRights: ChatAdministratorRightsModel? = null, + val affiliateProgram: AffiliateProgramInfoModel? = null, + val webAppBackgroundLightColor: Int = -1, + val webAppBackgroundDarkColor: Int = -1, + val webAppHeaderLightColor: Int = -1, + val webAppHeaderDarkColor: Int = -1, + val verificationParameters: BotVerificationParametersModel? = null, + val canGetRevenueStatistics: Boolean = false, + val canManageEmojiStatus: Boolean = false, + val hasMediaPreviews: Boolean = false, + val editCommandsLinkType: String? = null, + val editDescriptionLinkType: String? = null, + val editDescriptionMediaLinkType: String? = null, + val editSettingsLinkType: String? = null ) data class InlineQueryResultModel( diff --git a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt index f863f9a4..18d502ba 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt @@ -13,16 +13,41 @@ data class ChatFullInfoModel( val giftCount: Int = 0, val isBlocked: Boolean = false, val botInfo: String? = null, + val botInfoModel: BotInfoModel? = null, + val blockListType: String? = null, val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, val locationAddress: String? = null, + val directMessagesChatId: Long = 0L, val canSetStickerSet: Boolean = false, val canSetLocation: Boolean = false, val canGetMembers: Boolean = false, val canGetStatistics: Boolean = false, val canGetRevenueStatistics: Boolean = false, + val canGetStarRevenueStatistics: Boolean = false, + val canEnablePaidMessages: Boolean = false, + val canEnablePaidReaction: Boolean = false, + val hasHiddenMembers: Boolean = false, + val canHideMembers: Boolean = false, + val canToggleAggressiveAntiSpam: Boolean = false, + val isAllHistoryAvailable: Boolean = false, + val canHaveSponsoredMessages: Boolean = false, + val hasAggressiveAntiSpamEnabled: Boolean = false, + val hasPaidMediaAllowed: Boolean = false, + val hasPinnedStories: Boolean = false, val linkedChatId: Long = 0L, val businessInfo: BusinessInfoModel? = null, + val publicPhotoPath: String? = null, val note: String? = null, + val usesUnofficialApp: Boolean = false, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val botVerification: BotVerificationModel? = null, + val mainProfileTab: ProfileTabType? = null, + val firstProfileAudio: ProfileAudioModel? = null, + val rating: UserRatingModel? = null, + val pendingRating: UserRatingModel? = null, + val pendingRatingDate: Int = 0, val canBeCalled: Boolean = false, val supportsVideoCalls: Boolean = false, val hasPrivateCalls: Boolean = false, @@ -30,6 +55,13 @@ data class ChatFullInfoModel( val hasRestrictedVoiceAndVideoNoteMessages: Boolean = false, val hasPostedToProfileStories: Boolean = false, val setChatBackground: Boolean = false, + val myBoostCount: Int = 0, + val unrestrictBoostCount: Int = 0, + val stickerSetId: Long = 0L, + val customEmojiStickerSetId: Long = 0L, + val botCommands: List = emptyList(), + val upgradedFromBasicGroupId: Long = 0L, + val upgradedFromMaxMessageId: Long = 0L, val incomingPaidMessageStarCount: Long = 0L, val outgoingPaidMessageStarCount: Long = 0L, ) \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index 63dcc4a9..b03eec09 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -63,6 +63,17 @@ data class ChatModel( val isBot: Boolean = false, val isMember: Boolean = true, val isArchived: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val boostLevel: Int = 0, + val hasForumTabs: Boolean = false, + val isAdministeredDirectMessagesGroup: Boolean = false, + val paidMessageStarCount: Long = 0L, ) @Serializable diff --git a/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt new file mode 100644 index 00000000..ef4652fd --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt @@ -0,0 +1,110 @@ +package org.monogram.domain.models + +data class RestrictionInfoModel( + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false +) + +enum class ActiveStoryStateType { + LIVE, + UNREAD, + READ, + UNKNOWN +} + +data class ActiveStoryStateModel( + val type: ActiveStoryStateType = ActiveStoryStateType.UNKNOWN, + val storyId: Int = 0 +) + +data class BotVerificationModel( + val botUserId: Long = 0L, + val iconCustomEmojiId: Long = 0L, + val customDescription: String? = null +) + +data class BotVerificationParametersModel( + val iconCustomEmojiId: Long = 0L, + val organizationName: String? = null, + val defaultCustomDescription: String? = null, + val canSetCustomDescription: Boolean = false +) + +enum class ProfileTabType { + POSTS, + GIFTS, + MEDIA, + FILES, + LINKS, + MUSIC, + VOICE, + GIFS, + UNKNOWN +} + +data class ProfileAudioModel( + val duration: Int = 0, + val title: String? = null, + val performer: String? = null, + val fileName: String? = null, + val mimeType: String? = null, + val fileId: Int = 0, + val filePath: String? = null +) + +data class UserRatingModel( + val level: Int = 0, + val isMaximumLevelReached: Boolean = false, + val rating: Long = 0L, + val currentLevelRating: Long = 0L, + val nextLevelRating: Long = 0L +) + +data class ChatAdministratorRightsModel( + val canManageChat: Boolean = false, + val canChangeInfo: Boolean = false, + val canPostMessages: Boolean = false, + val canEditMessages: Boolean = false, + val canDeleteMessages: Boolean = false, + val canInviteUsers: Boolean = false, + val canRestrictMembers: Boolean = false, + val canPinMessages: Boolean = false, + val canManageTopics: Boolean = false, + val canPromoteMembers: Boolean = false, + val canManageVideoChats: Boolean = false, + val canPostStories: Boolean = false, + val canEditStories: Boolean = false, + val canDeleteStories: Boolean = false, + val canManageDirectMessages: Boolean = false, + val canManageTags: Boolean = false, + val isAnonymous: Boolean = false +) + +data class AffiliateProgramInfoModel( + val commissionPerMille: Int = 0, + val monthCount: Int = 0, + val endDate: Int = 0, + val dailyRevenuePerUserStarCount: Long = 0L, + val dailyRevenuePerUserNanostarCount: Int = 0 +) + +data class UserTypeBotInfoModel( + val canBeEdited: Boolean = false, + val canJoinGroups: Boolean = false, + val canReadAllGroupMessages: Boolean = false, + val hasMainWebApp: Boolean = false, + val hasTopics: Boolean = false, + val allowsUsersToCreateTopics: Boolean = false, + val canManageBots: Boolean = false, + val isInline: Boolean = false, + val inlineQueryPlaceholder: String? = null, + val needLocation: Boolean = false, + val canConnectToBusiness: Boolean = false, + val canBeAddedToAttachmentMenu: Boolean = false, + val activeUserCount: Int = 0 +) + +data class SupergroupBotCommandsModel( + val botUserId: Long = 0L, + val commands: List = emptyList() +) diff --git a/domain/src/main/java/org/monogram/domain/models/UserModel.kt b/domain/src/main/java/org/monogram/domain/models/UserModel.kt index 019c302b..f312e9ea 100644 --- a/domain/src/main/java/org/monogram/domain/models/UserModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/UserModel.kt @@ -14,6 +14,9 @@ data class UserModel( val lastSeen: Long = 0L, val isPremium: Boolean = false, val isVerified: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, val isSponsor: Boolean = false, val isSupport: Boolean = false, val userStatus: UserStatusType = UserStatusType.OFFLINE, @@ -23,8 +26,16 @@ data class UserModel( val isMutualContact: Boolean = false, val isCloseFriend: Boolean = false, val type: UserTypeEnum = UserTypeEnum.REGULAR, + val botTypeInfo: UserTypeBotInfoModel? = null, + val restrictionInfo: RestrictionInfoModel? = null, + val activeStoryState: ActiveStoryStateModel? = null, + val restrictsNewChats: Boolean = false, + val paidMessageStarCount: Long = 0L, val haveAccess: Boolean = true, - val languageCode: String? = null + val languageCode: String? = null, + val backgroundCustomEmojiId: Long = 0L, + val profileBackgroundCustomEmojiId: Long = 0L, + val addedToAttachmentMenu: Boolean = false ) enum class UserStatusType { diff --git a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt index e9962e58..35901c01 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt @@ -1,13 +1,21 @@ package org.monogram.presentation.di.coil +import android.content.Context import coil3.ImageLoader +import coil3.memory.MemoryCache import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.svg.SvgDecoder import org.koin.dsl.module val coilModule = module { single { - ImageLoader.Builder(get()) + val context = get() + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.15) + .build() + } .components { add(LottieDecoder.Factory()) add(SvgDecoder.Factory()) 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 ef0340f4..a4858512 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 @@ -110,6 +110,9 @@ fun ProfileContent(component: ProfileComponent) { } val isOnline = user?.type != UserTypeEnum.BOT && user?.userStatus == UserStatusType.ONLINE + val isBot = user?.type == UserTypeEnum.BOT || chat?.isBot == true + val isScam = user?.isScam == true || chat?.isScam == true + val isFake = user?.isFake == true || chat?.isFake == true val collapsedColor = MaterialTheme.colorScheme.surface val expandedColor = MaterialTheme.colorScheme.background @@ -170,6 +173,9 @@ fun ProfileContent(component: ProfileComponent) { chatModel = chat, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, canSearch = false, canShare = canShareTopBar, canEdit = canEditTopBar, @@ -268,6 +274,9 @@ fun ProfileContent(component: ProfileComponent) { isOnline = isOnline, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, statusEmojiPath = user?.statusEmojiPath, progress = progress, contentPadding = PaddingValues( diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt index df5c1436..1149f819 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt @@ -37,6 +37,8 @@ fun ProfileHeader( isSponsor: Boolean, statusEmojiPath: String?, isBot: Boolean, + isScam: Boolean, + isFake: Boolean, onAvatarClick: () -> Unit ) { val displayPath = profilePhotos.firstOrNull() ?: avatarPath @@ -120,17 +122,27 @@ fun ProfileHeader( } if (isBot) { Spacer(Modifier.width(6.dp)) - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = stringResource(R.string.label_bot_badge), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } + ProfileStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + if (isScam) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + if (isFake) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -143,3 +155,22 @@ fun ProfileHeader( ) } } + +@Composable +private fun ProfileStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + color = contentColor + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt index e96d4e2b..440cf845 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.Icon 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 @@ -23,6 +24,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -30,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UserModel +import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarHeader import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -45,6 +48,9 @@ fun ProfileHeaderTransformed( isOnline: Boolean, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, statusEmojiPath: String?, progress: Float, contentPadding: PaddingValues, @@ -149,23 +155,49 @@ fun ProfileHeaderTransformed( ) } - userModel?.let { user -> - if (!user.statusEmojiPath.isNullOrEmpty()) { - Spacer(modifier = Modifier.width(6.dp)) - StickerImage( - path = user.statusEmojiPath, - modifier = Modifier.size(26.dp), - animate = false - ) - } else if (user.isPremium) { - Spacer(modifier = Modifier.width(6.dp)) - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = Color(0xFF31A6FD) - ) - } + val displayedStatusEmojiPath = statusEmojiPath ?: userModel?.statusEmojiPath + if (!displayedStatusEmojiPath.isNullOrEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + StickerImage( + path = displayedStatusEmojiPath, + modifier = Modifier.size(26.dp), + animate = false + ) + } else if (userModel?.isPremium == true) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = Color(0xFF31A6FD) + ) + } + + if (isBot) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -202,3 +234,22 @@ fun ProfileHeaderTransformed( ) } } + +@Composable +private fun HeaderStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index a122cd6d..121f0f9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -1,31 +1,13 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.profile.components import android.content.ClipData import android.content.Intent -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith +import androidx.compose.animation.* import androidx.compose.foundation.background 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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -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.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -34,55 +16,9 @@ import androidx.compose.material.icons.automirrored.rounded.Login import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material.icons.rounded.AlternateEmail -import androidx.compose.material.icons.rounded.AssignmentTurnedIn -import androidx.compose.material.icons.rounded.BarChart -import androidx.compose.material.icons.rounded.Cake -import androidx.compose.material.icons.rounded.Collections -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.ForwardToInbox -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.MicOff -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.Payments -import androidx.compose.material.icons.rounded.PersonAdd -import androidx.compose.material.icons.rounded.Phone -import androidx.compose.material.icons.rounded.Portrait -import androidx.compose.material.icons.rounded.RocketLaunch -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material.icons.rounded.Shield -import androidx.compose.material.icons.rounded.Timer -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.ShapeDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -104,7 +40,7 @@ import org.monogram.presentation.core.ui.* import org.monogram.presentation.core.util.CountryManager import org.monogram.presentation.core.util.OperatorManager import org.monogram.presentation.features.profile.ProfileComponent -import java.util.Calendar +import java.util.* @Composable fun ProfileInfoSectionSkeleton( @@ -589,6 +525,33 @@ fun ProfileInfoSection( } } + if (!isGroupOrChannel && fullInfo?.usesUnofficialApp == true) { + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Security, + title = stringResource(R.string.unofficial_app_title), + subtitle = stringResource(R.string.unofficial_app_subtitle), + iconColor = Color(0xFFFF9800), + position = pos, + onClick = { } + ) + } + } + + fullInfo?.botVerification?.let { botVerification -> + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Verified, + title = stringResource(R.string.bot_verification_title), + subtitle = botVerification.customDescription + ?: stringResource(R.string.bot_verification_subtitle), + iconColor = MaterialTheme.colorScheme.primary, + position = pos, + onClick = { } + ) + } + } + user?.phoneNumber?.takeIf { it.isNotEmpty() }?.let { phone -> val formattedPhone = remember(phone) { CountryManager.formatPhoneNumber(phone) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt index 71e4c6a7..774b8f8d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -42,6 +41,9 @@ fun ProfileTopBar( chatModel: ChatModel?, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, canSearch: Boolean = false, canShare: Boolean = false, canEdit: Boolean = false, @@ -109,6 +111,33 @@ fun ProfileTopBar( ) } + if (isBot) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + userModel?.let { user -> if (!user.statusEmojiPath.isNullOrEmpty()) { Spacer(modifier = Modifier.width(4.dp)) @@ -287,3 +316,22 @@ fun ProfileTopBar( } } } + +@Composable +private fun TopBarStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 47711205..672f2ce0 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -1298,6 +1298,12 @@ No se encontraron enlaces No se encontraron GIFs BOT + ESTAFA + FALSO + Usa app no oficial + Esta cuenta está usando un cliente no oficial de Telegram + Verificación del bot + Verificado por un bot de terceros Cerrado ID diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 4ad42ab9..85b22b7c 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -1175,6 +1175,12 @@ Հղումներ չկան GIF-եր չկան ԲՈՏ + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Փակ է ID diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 437a089c..105cf8e4 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -1326,6 +1326,12 @@ Nenhum link encontrado Nenhum GIF encontrado BOT + GOLPE + FALSO + Usa app não oficial + Esta conta está usando um cliente não oficial do Telegram + Verificação do bot + Verificado por um bot de terceiros Fechado ID diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 4973ed2d..5f326f41 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -1260,6 +1260,12 @@ Ссылки не найдены GIF не найдены БОТ + СКАМ + ФЕЙК + Использует неофициальное приложение + Этот аккаунт использует неофициальный клиент Telegram + Проверка бота + Подтверждено сторонним ботом Закрыто ID diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 674b1ef8..e8071d05 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -1324,6 +1324,12 @@ Žiadne odkazy sa nenašli Žiadne GIFy sa nenašli BOT + SCAM + FAKE + Používa neoficiálnu aplikáciu + Tento účet používa neoficiálneho klienta Telegramu + Overenie bota + Overené botom tretej strany Zatvorené ID diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 6bca0472..acf87fba 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -1260,6 +1260,12 @@ Посилань не знайдено GIF не знайдено БОТ + ШАХРАЙСТВО + ФЕЙК + Використовує неофіційний застосунок + Цей акаунт використовує неофіційний клієнт Telegram + Перевірка бота + Підтверджено стороннім ботом Зачинено ID diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 7075eaaf..957ddd7b 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -1247,6 +1247,12 @@ 未找到链接 未找到 GIF 机器人 + 诈骗 + 冒充 + 使用非官方应用 + 该账号正在使用非官方 Telegram 客户端 + 机器人验证 + 由第三方机器人验证 已关闭 ID diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 030d3135..369d7463 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1339,6 +1339,12 @@ No links found No GIFs found BOT + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Closed ID From 06fe5837712de7921e9962c8755a107dc4134209 Mon Sep 17 00:00:00 2001 From: Artemiy <53598473+aliveoutside@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:36:30 +0300 Subject: [PATCH 37/53] photo: better crop (#188) better crop in pic editor (no casino) --- .../editor/photo/PhotoEditorScreen.kt | 479 ++++++++++++------ .../editor/photo/PhotoEditorUtils.kt | 121 +++-- .../editor/photo/components/CropOverlay.kt | 102 ---- .../photo/components/TransformControls.kt | 174 +++++-- .../editor/photo/crop/CropEditorState.kt | 145 ++++++ .../editor/photo/crop/CropGeometry.kt | 339 +++++++++++++ .../editor/photo/crop/CropOverlay.kt | 257 ++++++++++ .../editor/photo/crop/CropGeometryTest.kt | 125 +++++ 8 files changed, 1390 insertions(+), 352 deletions(-) delete mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt 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/currentChat/editor/photo/PhotoEditorScreen.kt index 69606a65..ffa71781 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/currentChat/editor/photo/PhotoEditorScreen.kt @@ -1,7 +1,8 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.editor.photo +import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector @@ -36,9 +38,12 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers 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 java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { @@ -50,6 +55,9 @@ enum class EditorTool(val labelRes: Int, val icon: ImageVector) { ERASER(R.string.photo_editor_tool_eraser, Icons.Rounded.CleaningServices) } +private const val MinImageScale = 0.5f +private const val MaxImageScale = 10f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PhotoEditorScreen( @@ -59,8 +67,9 @@ fun PhotoEditorScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val density = LocalDensity.current - var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var currentTool by remember { mutableStateOf(EditorTool.TRANSFORM) } val paths = remember { mutableStateListOf() } val pathsRedo = remember { mutableStateListOf() } @@ -70,6 +79,7 @@ fun PhotoEditorScreen( var brushSize by remember { mutableFloatStateOf(15f) } var currentFilter by remember { mutableStateOf(null) } + var imageRotation by remember { mutableFloatStateOf(0f) } var imageScale by remember { mutableFloatStateOf(1f) } var imageOffset by remember { mutableStateOf(Offset.Zero) } @@ -81,12 +91,171 @@ fun PhotoEditorScreen( var isSaving by remember { mutableStateOf(false) } var showDiscardDialog by remember { mutableStateOf(false) } + val imageSize by produceState(initialValue = IntSize.Zero, key1 = imagePath) { + value = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(imagePath, options) + IntSize(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } + } + + + val pivot by remember(canvasSize) { + derivedStateOf { Offset(canvasSize.width / 2f, canvasSize.height / 2f) } + } + + val cropState = rememberCropEditorState( + canvasSize = canvasSize, + imageSize = imageSize, + transformPivot = pivot, + imageScale = imageScale, + imageRotation = imageRotation, + imageOffset = imageOffset + ) + + + fun fillAreaAfterResize() { + val crop = cropState.cropRect + if (crop.width <= 0f || crop.height <= 0f || canvasSize.width <= 0 || canvasSize.height <= 0) return + + val currentAspect = crop.width / crop.height + val targetCropRect = calculateTargetFillRect(canvasSize, currentAspect) + if (targetCropRect == Rect.Zero) return + + + val scaleFactor = maxOf( + targetCropRect.width / crop.width, + targetCropRect.height / crop.height + ) + val targetScale = (imageScale * scaleFactor).coerceIn(MinImageScale, MaxImageScale) + val z = if (imageScale != 0f) targetScale / imageScale else 1f + + + + + + val targetOffset = Offset( + x = (targetCropRect.center.x - pivot.x) - z * (crop.center.x - pivot.x - imageOffset.x), + y = (targetCropRect.center.y - pivot.y) - z * (crop.center.y - pivot.y - imageOffset.y) + ) + + val targetImageBounds = calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + val safeTargetCropRect = constrainCropRectToImage( + currentCropRect = crop, + candidateRect = targetCropRect, + visibleBounds = targetImageBounds, + minCropSizePx = cropState.minCropSizePx, + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + + + scope.launch { + val startCrop = crop + val startScale = imageScale + val startOffset = imageOffset + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(200)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (safeTargetCropRect.left - startCrop.left) * t, + top = startCrop.top + (safeTargetCropRect.top - startCrop.top) * t, + right = startCrop.right + (safeTargetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (safeTargetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } + + val shouldConstrain by remember(currentTool) { + derivedStateOf { currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE } + } + + fun applyTransform(centroid: Offset, pan: Offset, zoom: Float) { + val effectiveMinScale = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + minimumScaleToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + currentScale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = pivot + ).coerceAtLeast(MinImageScale) + } else { + MinImageScale + } + + val newScale = (imageScale * zoom).coerceIn(effectiveMinScale, MaxImageScale) + val actualZoom = if (imageScale != 0f) newScale / imageScale else 1f + + val offsetAfterZoom = offsetForZoomAroundAnchor(imageOffset, pivot, centroid, actualZoom) + val newOffset = offsetAfterZoom + pan + + imageScale = newScale + imageOffset = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + clampOffsetToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = newScale, + rotationDegrees = imageRotation, + offset = newOffset, + pivot = pivot + ) + } else { + newOffset + } + } + + fun applyRotation(newRotation: Float) { + val deltaAngle = newRotation - imageRotation + + val anchor = if (cropState.cropRect != Rect.Zero) cropState.cropRect.center else pivot + val newOffset = offsetForRotationAroundAnchor(imageOffset, pivot, anchor, deltaAngle) + + imageRotation = newRotation + + + if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + val (fittedScale, fittedOffset) = fitContentInBounds( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = imageScale, + rotationDegrees = newRotation, + offset = newOffset, + pivot = pivot, + minScale = MinImageScale, + maxScale = MaxImageScale + ) + imageScale = fittedScale + imageOffset = fittedOffset + } else { + imageOffset = newOffset + } + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || textElements.isNotEmpty() || currentFilter != null || - imageRotation != 0f || + (cropState.cropRect != Rect.Zero && cropState.cropRect != cropState.defaultCropRect) || + normalizeRotationDegrees(imageRotation) != 0f || imageScale != 1f || imageOffset != Offset.Zero } @@ -120,7 +289,9 @@ fun PhotoEditorScreen( textElements, currentFilter, canvasSize, - imageRotation, + cropState.cropRect, + pivot, + normalizeRotationDegrees(imageRotation), imageScale, imageOffset ) @@ -151,9 +322,7 @@ fun PhotoEditorScreen( tonalElevation = 3.dp, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { - Column( - modifier = Modifier.navigationBarsPadding() - ) { + Column(modifier = Modifier.navigationBarsPadding()) { AnimatedContent( targetState = currentTool, label = "ToolOptions", @@ -162,24 +331,22 @@ fun PhotoEditorScreen( Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 100.dp), + .heightIn(min = 84.dp), contentAlignment = Alignment.Center ) { when (tool) { EditorTool.TRANSFORM -> { TransformControls( rotation = imageRotation, - scale = imageScale, - onRotationChange = { imageRotation = it }, - onScaleChange = { imageScale = it }, + onRotationChange = { newRotation -> applyRotation(newRotation) }, onReset = { imageRotation = 0f imageScale = 1f imageOffset = Offset.Zero + cropState.reset() } ) } - EditorTool.DRAW, EditorTool.ERASER -> { DrawControls( isEraser = tool == EditorTool.ERASER, @@ -189,7 +356,6 @@ fun PhotoEditorScreen( onSizeChange = { brushSize = it } ) } - EditorTool.FILTER -> { FilterControls( imagePath = imagePath, @@ -197,7 +363,6 @@ fun PhotoEditorScreen( onFilterSelect = { currentFilter = it } ) } - EditorTool.TEXT -> { Button( onClick = { @@ -211,7 +376,6 @@ fun PhotoEditorScreen( Text(stringResource(R.string.photo_editor_action_add_text)) } } - else -> { Text( stringResource(R.string.photo_editor_label_select_tool), @@ -223,10 +387,7 @@ fun PhotoEditorScreen( } } - NavigationBar( - containerColor = Color.Transparent, - tonalElevation = 0.dp - ) { + NavigationBar(containerColor = Color.Transparent, tonalElevation = 0.dp) { EditorTool.entries.forEach { tool -> val label = stringResource(tool.labelRes) NavigationBarItem( @@ -255,180 +416,168 @@ fun PhotoEditorScreen( .background(Color.Black) .onGloballyPositioned { canvasSize = it.size } ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = imageScale, - scaleY = imageScale, - rotationZ = imageRotation, - translationX = imageOffset.x, - translationY = imageOffset.y - ) - .pointerInput(currentTool) { - if (currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE) { - detectTransformGestures { _, pan, zoom, rotation -> - imageScale *= zoom - imageRotation += rotation - imageOffset += pan - } - } - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(File(imagePath)) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } - ) - - Canvas( + Box(modifier = Modifier.fillMaxSize()) { + + Box( modifier = Modifier .fillMaxSize() + .graphicsLayer( + scaleX = imageScale, + scaleY = imageScale, + rotationZ = imageRotation, + translationX = imageOffset.x, + translationY = imageOffset.y, + transformOrigin = TransformOrigin.Center + ) .pointerInput(currentTool) { - if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { - detectDragGestures( - onDragStart = { offset -> - val path = Path().apply { moveTo(offset.x, offset.y) } - paths.add( - DrawnPath( - path = path, - color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, - strokeWidth = brushSize, - isEraser = currentTool == EditorTool.ERASER - ) - ) - pathsRedo.clear() - }, - onDrag = { change, _ -> - change.consume() - val index = paths.lastIndex - if (index == -1) return@detectDragGestures - - val currentPathData = paths[index] - val x1 = change.previousPosition.x - val y1 = change.previousPosition.y - val x2 = change.position.x - val y2 = change.position.y - - currentPathData.path.quadraticTo( - x1, y1, (x1 + x2) / 2, (y1 + y2) / 2 - ) - - paths.add(paths.removeAt(index)) - } - ) + if (currentTool == EditorTool.NONE) { + detectTransformGestures { centroid, pan, zoom, _ -> + applyTransform(centroid, pan, zoom) + } } } ) { - paths.forEach { pathData -> - drawPath( - path = pathData.path, - color = pathData.color, - alpha = pathData.alpha, - style = Stroke( - width = pathData.strokeWidth, - cap = StrokeCap.Round, - join = StrokeJoin.Round - ), - blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver - ) - } - } - - textElements.forEach { element -> - val density = LocalDensity.current - - var currentOffset by remember(element.id) { - mutableStateOf( - if (element.offset == Offset.Zero) Offset( - canvasSize.width / 2f, - canvasSize.height / 2f - ) else element.offset - ) - } - var currentScale by remember(element.id) { mutableStateOf(element.scale) } - var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + AsyncImage( + model = ImageRequest.Builder(context).data(File(imagePath)).build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } + ) - Box( + Canvas( modifier = Modifier - .offset( - x = with(density) { currentOffset.x.toDp() }, - y = with(density) { currentOffset.y.toDp() } + .fillMaxSize() + .pointerInput(currentTool) { + if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { + detectDragGestures( + onDragStart = { offset -> + val path = Path().apply { moveTo(offset.x, offset.y) } + paths.add( + DrawnPath( + path = path, + color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, + strokeWidth = brushSize, + isEraser = currentTool == EditorTool.ERASER + ) + ) + pathsRedo.clear() + }, + onDrag = { change, _ -> + change.consume() + val index = paths.lastIndex + if (index == -1) return@detectDragGestures + val cur = paths[index] + val x1 = change.previousPosition.x + val y1 = change.previousPosition.y + val x2 = change.position.x + val y2 = change.position.y + cur.path.quadraticTo(x1, y1, (x1 + x2) / 2, (y1 + y2) / 2) + paths.add(paths.removeAt(index)) + } + ) + } + } + ) { + paths.forEach { pathData -> + drawPath( + path = pathData.path, + color = pathData.color, + alpha = pathData.alpha, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver ) - .graphicsLayer( - scaleX = currentScale, - scaleY = currentScale, - rotationZ = currentRotation, - translationX = -with(density) { 100.dp.toPx() }, - translationY = -with(density) { 25.dp.toPx() } + } + } + + textElements.forEach { element -> + var currentOffset by remember(element.id) { + mutableStateOf( + if (element.offset == Offset.Zero) Offset(canvasSize.width / 2f, canvasSize.height / 2f) + else element.offset ) - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTransformGestures( - onGesture = { _, pan, zoom, rotation -> + } + var currentScale by remember(element.id) { mutableStateOf(element.scale) } + var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + + Box( + modifier = Modifier + .offset( + x = with(density) { currentOffset.x.toDp() }, + y = with(density) { currentOffset.y.toDp() } + ) + .graphicsLayer( + scaleX = currentScale, + scaleY = currentScale, + rotationZ = currentRotation, + translationX = -with(density) { 100.dp.toPx() }, + translationY = -with(density) { 25.dp.toPx() } + ) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTransformGestures { _, pan, zoom, rotation -> currentOffset += pan currentScale *= zoom currentRotation += rotation } - ) + } } - } - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTapGestures(onTap = { - if (currentTool == EditorTool.TEXT) { - editingTextElement = element - selectedColor = element.color - showTextDialog = true - } - }) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTapGestures(onTap = { + if (currentTool == EditorTool.TEXT) { + editingTextElement = element + selectedColor = element.color + showTextDialog = true + } + }) + } + } + ) { + LaunchedEffect(currentOffset, currentScale, currentRotation) { + val idx = textElements.indexOfFirst { it.id == element.id } + if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { + textElements[idx] = textElements[idx].copy( + offset = currentOffset, scale = currentScale, rotation = currentRotation + ) } } - ) { - LaunchedEffect(currentOffset, currentScale, currentRotation) { - val idx = textElements.indexOfFirst { it.id == element.id } - if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { - textElements[idx] = textElements[idx].copy( - offset = currentOffset, - scale = currentScale, - rotation = currentRotation - ) - } - } - - Text( - text = element.text, - color = element.color, - style = MaterialTheme.typography.headlineLarge.copy( - shadow = Shadow( - color = Color.Black, - offset = Offset(2f, 2f), - blurRadius = 4f + Text( + text = element.text, + color = element.color, + style = MaterialTheme.typography.headlineLarge.copy( + shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f) ) ) - ) - - if (currentTool == EditorTool.TEXT) { - Box( - modifier = Modifier - .matchParentSize() - .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) - ) + if (currentTool == EditorTool.TEXT) { + Box( + modifier = Modifier + .matchParentSize() + .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + ) + } } } } } + if (currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero) { + CropScrim(cropRect = cropState.cropRect) + } + AnimatedVisibility( - visible = currentTool == EditorTool.TRANSFORM, + visible = currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero, enter = fadeIn(), exit = fadeOut() ) { - CropOverlay() + CropOverlay( + cropRect = cropState.cropRect, + bounds = cropState.currentImageBounds, + minCropSizePx = cropState.minCropSizePx, + onCropRectChange = cropState.updateCropRect, + onContentTransform = { centroid, pan, zoom -> applyTransform(centroid, pan, zoom) }, + onResizeEnded = { fillAreaAfterResize() } + ) } if (isSaving) { 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/currentChat/editor/photo/PhotoEditorUtils.kt index 344bb578..1b534410 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/currentChat/editor/photo/PhotoEditorUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.* import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix @@ -17,6 +18,8 @@ import java.io.FileOutputStream import java.util.* import android.graphics.Canvas as AndroidCanvas import android.graphics.Paint as AndroidPaint +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation data class DrawnPath( val path: Path, @@ -114,11 +117,17 @@ suspend fun saveImage( textElements: List, filter: ImageFilter?, canvasSize: IntSize, + cropRect: Rect, + transformPivot: Offset = cropRect.center, imageRotation: Float = 0f, imageScale: Float = 1f, imageOffset: Offset = Offset.Zero ): String? = withContext(Dispatchers.IO) { try { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || cropRect.width <= 0f || cropRect.height <= 0f) { + return@withContext null + } + val options = BitmapFactory.Options().apply { inMutable = true } var bitmap = BitmapFactory.decodeFile(originalPath, options) ?: return@withContext null @@ -150,66 +159,84 @@ suspend fun saveImage( dx = (screenW - (bitmapW * baseScale)) / 2f } - val resultBitmap = Bitmap.createBitmap(canvasSize.width, canvasSize.height, Bitmap.Config.ARGB_8888) + val exportScale = if (baseScale > 0f) 1f / baseScale else 1f + val resultBitmap = createBitmap( + (cropRect.width * exportScale).toInt().coerceAtLeast(1), + (cropRect.height * exportScale).toInt().coerceAtLeast(1) + ) val canvas = AndroidCanvas(resultBitmap) + val transformPivotX = transformPivot.x * exportScale + val transformPivotY = transformPivot.y * exportScale + val scaledCropLeft = cropRect.left * exportScale + val scaledCropTop = cropRect.top * exportScale + val scaledImageOffset = Offset(imageOffset.x * exportScale, imageOffset.y * exportScale) + + canvas.translate(-scaledCropLeft, -scaledCropTop) + + canvas.withTranslation(scaledImageOffset.x + transformPivotX, scaledImageOffset.y + transformPivotY) { + rotate(imageRotation) + scale(imageScale, imageScale) + translate(-transformPivotX, -transformPivotY) + + val imagePaint = AndroidPaint().apply { + isAntiAlias = true + if (filter != null) { + colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + } + } + val destRect = RectF( + dx * exportScale, + dy * exportScale, + dx * exportScale + bitmapW, + dy * exportScale + bitmapH + ) + drawBitmap(bitmap, null, destRect, imagePaint) - canvas.save() - canvas.translate(imageOffset.x + screenW / 2f, imageOffset.y + screenH / 2f) - canvas.rotate(imageRotation) - canvas.scale(imageScale, imageScale) - canvas.translate(-screenW / 2f, -screenH / 2f) + val pathPaint = AndroidPaint().apply { + isAntiAlias = true + style = AndroidPaint.Style.STROKE + strokeCap = AndroidPaint.Cap.ROUND + strokeJoin = AndroidPaint.Join.ROUND + } - val imagePaint = AndroidPaint().apply { - isAntiAlias = true - if (filter != null) { - colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + paths.forEach { pathData -> + if (pathData.isEraser) { + pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } else { + pathPaint.xfermode = null + pathPaint.color = pathData.color.toArgb() + pathPaint.alpha = (pathData.alpha * 255).toInt() + } + pathPaint.strokeWidth = pathData.strokeWidth * exportScale + val scaledPath = android.graphics.Path(pathData.path.asAndroidPath()) + scaledPath.transform( + android.graphics.Matrix().apply { setScale(exportScale, exportScale) } + ) + drawPath(scaledPath, pathPaint) } - } - val destRect = RectF(dx, dy, dx + bitmapW * baseScale, dy + bitmapH * baseScale) - canvas.drawBitmap(bitmap, null, destRect, imagePaint) - - val pathPaint = AndroidPaint().apply { - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - paths.forEach { pathData -> - if (pathData.isEraser) { - pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - } else { - pathPaint.xfermode = null - pathPaint.color = pathData.color.toArgb() - pathPaint.alpha = (pathData.alpha * 255).toInt() + val textPaint = AndroidPaint().apply { + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD } - pathPaint.strokeWidth = pathData.strokeWidth - canvas.drawPath(pathData.path.asAndroidPath(), pathPaint) - } - val textPaint = AndroidPaint().apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } + textElements.forEach { element -> + textPaint.color = element.color.toArgb() + textPaint.textSize = 64f * element.scale * exportScale - textElements.forEach { element -> - textPaint.color = element.color.toArgb() - textPaint.textSize = 64f * element.scale + withTranslation(element.offset.x * exportScale, element.offset.y * exportScale) { + rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) - canvas.save() - canvas.translate(element.offset.x, element.offset.y) - canvas.rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) + val textWidth = textPaint.measureText(element.text) + val fontMetrics = textPaint.fontMetrics + val textHeight = fontMetrics.descent - fontMetrics.ascent - val textWidth = textPaint.measureText(element.text) - val fontMetrics = textPaint.fontMetrics - val textHeight = fontMetrics.descent - fontMetrics.ascent + drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) + } + } - canvas.drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) - canvas.restore() } - canvas.restore() - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { out -> resultBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt deleted file mode 100644 index e9841bfd..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -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.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -@Composable -fun CropOverlay( - modifier: Modifier = Modifier -) { - Canvas( - modifier = modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - ) { - val width = size.width - val height = size.height - - val padding = 32.dp.toPx() - val cropWidth = width - padding * 2 - val cropHeight = height - padding * 2 - - val rect = Rect( - offset = Offset(padding, padding), - size = Size(cropWidth, cropHeight) - ) - - drawRect( - color = Color.Black.copy(alpha = 0.7f), - size = size - ) - - drawRect( - color = Color.Transparent, - topLeft = rect.topLeft, - size = rect.size, - blendMode = BlendMode.Clear - ) - - val strokeWidth = 1.dp.toPx() - val gridColor = Color.White.copy(alpha = 0.5f) - - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width / 3, rect.top), - end = Offset(rect.left + rect.width / 3, rect.bottom), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width * 2 / 3, rect.top), - end = Offset(rect.left + rect.width * 2 / 3, rect.bottom), - strokeWidth = strokeWidth - ) - - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height / 3), - end = Offset(rect.right, rect.top + rect.height / 3), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height * 2 / 3), - end = Offset(rect.right, rect.top + rect.height * 2 / 3), - strokeWidth = strokeWidth - ) - - val cornerLen = 24.dp.toPx() - val cornerStroke = 3.dp.toPx() - val cornerColor = Color.White - - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.topRight, rect.topRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.topRight, rect.topRight.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawRect( - color = Color.White.copy(alpha = 0.8f), - topLeft = rect.topLeft, - size = rect.size, - style = Stroke(width = 1.dp.toPx()) - ) - } -} \ No newline at end of file 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/currentChat/editor/photo/components/TransformControls.kt index 99f663fe..553ee2b4 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/currentChat/editor/photo/components/TransformControls.kt @@ -1,79 +1,177 @@ package org.monogram.presentation.features.chats.currentChat.editor.photo.components +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RotateLeft -import androidx.compose.material.icons.rounded.RotateRight import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R +import kotlin.math.absoluteValue +import kotlin.math.floor +import kotlin.math.roundToInt @Composable fun TransformControls( rotation: Float, - scale: Float, onRotationChange: (Float) -> Unit, - onScaleChange: (Float) -> Unit, onReset: () -> Unit ) { + val normalizedRotation = normalizeRotationDegrees(rotation) + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + verticalAlignment = Alignment.Bottom ) { - FilledTonalIconButton(onClick = { onRotationChange(rotation - 90f) }) { - Icon( - Icons.Rounded.RotateLeft, - contentDescription = stringResource(R.string.photo_editor_action_rotate_left) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${normalizedRotation.roundToInt()}°", + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RotationWheel( + angle = rotation, + onAngleChange = onRotationChange, + modifier = Modifier.fillMaxWidth() ) } - OutlinedButton( + Spacer(modifier = Modifier.width(12.dp)) + + OutlinedIconButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 16.dp) + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) ) { - Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.photo_editor_action_reset)) - } - - FilledTonalIconButton(onClick = { onRotationChange(rotation + 90f) }) { Icon( - Icons.Rounded.RotateRight, - contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) ) } } + } +} - Spacer(modifier = Modifier.height(12.dp)) +@Composable +private fun RotationWheel( + angle: Float, + onAngleChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val onSurface = MaterialTheme.colorScheme.onSurface + val primary = MaterialTheme.colorScheme.primary + var visualAngle by remember { mutableFloatStateOf(angle) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + LaunchedEffect(angle) { + visualAngle = closestEquivalentAngle(angle, visualAngle) + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.large + ) + .pointerInput(Unit) { + var dragAngle = visualAngle + detectDragGestures( + onDragStart = { + dragAngle = visualAngle + } + ) { change, dragAmount -> + change.consume() + dragAngle -= dragAmount.x * 0.1f + visualAngle = dragAngle + onAngleChange(dragAngle) + } + } + .padding(horizontal = 12.dp, vertical = 8.dp) ) { - Text( - stringResource(R.string.photo_editor_label_zoom), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(48.dp) - ) - Slider( - value = scale, - onValueChange = onScaleChange, - valueRange = 0.5f..3f, - modifier = Modifier.weight(1f) - ) + Canvas(modifier = Modifier.fillMaxWidth().height(42.dp)) { + val centerX = size.width / 2f + val bottom = size.height + val tickSpacing = 6f + val minorStep = 5 + val majorStep = 45 + val currentTick = visualAngle / minorStep + val centerTick = floor(currentTick).toInt() + val visibleTickCount = (size.width / tickSpacing / 2f).roundToInt() + 4 + + for (relativeTick in -visibleTickCount..visibleTickCount) { + val tickIndex = centerTick + relativeTick + val tickAngle = tickIndex * minorStep + val x = centerX + (tickIndex - currentTick) * tickSpacing + if (x < 0f || x > size.width) continue + + val distanceRatio = ((x - centerX).absoluteValue / centerX).coerceIn(0f, 1f) + val alpha = 1f - distanceRatio * 0.8f + val isMajor = tickAngle % majorStep == 0 + val isMedium = tickAngle % 15 == 0 + val tickHeight = when { + isMajor -> size.height * 0.62f + isMedium -> size.height * 0.45f + else -> size.height * 0.28f + } + val strokeWidth = when { + isMajor -> 3f + isMedium -> 2.5f + else -> 1.5f + } + + drawLine( + color = onSurface.copy(alpha = alpha), + start = Offset(x, bottom - tickHeight), + end = Offset(x, bottom), + strokeWidth = strokeWidth + ) + } + + drawLine( + color = primary, + start = Offset(centerX, 0f), + end = Offset(centerX, bottom), + strokeWidth = 4f + ) + } } } } + +internal fun normalizeRotationDegrees(value: Float): Float { + var normalized = value % 360f + if (normalized > 180f) normalized -= 360f + if (normalized < -180f) normalized += 360f + return normalized +} + +private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { + val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() + return normalizedAngle + turns * 360f +} 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/currentChat/editor/photo/crop/CropEditorState.kt new file mode 100644 index 00000000..ad405297 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt @@ -0,0 +1,145 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +@Stable +class CropEditorState internal constructor( + val minCropSizePx: Float, + val imageBounds: Rect, + val currentImageBounds: Rect, + val defaultCropRect: Rect, + val cropRect: Rect, + val updateCropRect: (Rect) -> Unit, + val setCropRect: (Rect) -> Unit, + val reset: () -> Unit +) + +fun calculateTargetFillRect(canvasSize: IntSize, aspectRatio: Float): Rect { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || aspectRatio <= 0f) return Rect.Zero + val cw = canvasSize.width.toFloat() + val ch = canvasSize.height.toFloat() + val centerX = cw / 2f + val centerY = ch / 2f + + val w: Float + val h: Float + if (ch * aspectRatio > cw) { + w = cw + h = cw / aspectRatio + } else { + h = ch + w = ch * aspectRatio + } + return Rect(centerX - w / 2f, centerY - h / 2f, centerX + w / 2f, centerY + h / 2f) +} + +@Composable +fun rememberCropEditorState( + canvasSize: IntSize, + imageSize: IntSize, + transformPivot: Offset, + imageScale: Float, + imageRotation: Float, + imageOffset: Offset +): CropEditorState { + val density = LocalDensity.current + val minCropSizePx = remember(density) { with(density) { 96.dp.toPx() } } + + val imageBounds by remember(canvasSize, imageSize) { + derivedStateOf { calculateCropRect(canvasSize, imageSize) } + } + val defaultCropRect by remember(imageBounds) { + derivedStateOf { imageBounds } + } + + var cropRect by remember { mutableStateOf(Rect.Zero) } + var previousImageBounds by remember { mutableStateOf(Rect.Zero) } + + val currentImageBounds by remember(imageBounds, imageScale, imageRotation, imageOffset, transformPivot) { + derivedStateOf { + if (imageBounds != Rect.Zero) { + calculateScalarTransformedBounds( + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + } else { + imageBounds + } + } + } + + LaunchedEffect(imageBounds) { + if (imageBounds == Rect.Zero) { + cropRect = Rect.Zero + previousImageBounds = Rect.Zero + } else if (cropRect == Rect.Zero || previousImageBounds == Rect.Zero) { + cropRect = imageBounds + previousImageBounds = imageBounds + } else if (previousImageBounds != imageBounds) { + cropRect = constrainCropRect( + cropRect = remapRectToBounds(cropRect, previousImageBounds, imageBounds), + bounds = imageBounds, + minCropSizePx = minCropSizePx + ) + previousImageBounds = imageBounds + } + } + + return CropEditorState( + minCropSizePx = minCropSizePx, + imageBounds = imageBounds, + currentImageBounds = currentImageBounds, + defaultCropRect = defaultCropRect, + cropRect = cropRect, + updateCropRect = { candidate -> + cropRect = constrainCropRectToImage( + currentCropRect = cropRect, + candidateRect = candidate, + visibleBounds = currentImageBounds, + minCropSizePx = minCropSizePx, + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + }, + setCropRect = { rect -> + cropRect = rect + }, + reset = { + cropRect = defaultCropRect + } + ) +} + +private fun remapRectToBounds(rect: Rect, fromBounds: Rect, toBounds: Rect): Rect { + if (rect == Rect.Zero || fromBounds.width <= 0f || fromBounds.height <= 0f) return toBounds + + val leftFraction = (rect.left - fromBounds.left) / fromBounds.width + val topFraction = (rect.top - fromBounds.top) / fromBounds.height + val rightFraction = (rect.right - fromBounds.left) / fromBounds.width + val bottomFraction = (rect.bottom - fromBounds.top) / fromBounds.height + + return Rect( + left = toBounds.left + toBounds.width * leftFraction, + top = toBounds.top + toBounds.height * topFraction, + right = toBounds.left + toBounds.width * rightFraction, + bottom = toBounds.top + toBounds.height * bottomFraction + ) +} 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/currentChat/editor/photo/crop/CropGeometry.kt new file mode 100644 index 00000000..d742f860 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt @@ -0,0 +1,339 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +private const val GeometryEpsilon = 0.001f + +private fun contentToScreen( + p: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val dx = p.x - pivot.x + val dy = p.y - pivot.y + return Offset( + x = pivot.x + scale * (dx * cosR - dy * sinR) + offset.x, + y = pivot.y + scale * (dx * sinR + dy * cosR) + offset.y + ) +} + +private fun screenToContent( + screen: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val sx = (screen.x - pivot.x - offset.x) / scale + val sy = (screen.y - pivot.y - offset.y) / scale + // R(-rotation) = transpose of R(rotation) + return Offset( + x = pivot.x + sx * cosR + sy * sinR, + y = pivot.y - sx * sinR + sy * cosR + ) +} + +private fun projectRectCornersToContentBounds( + rect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + if (rect.isEmpty || scale <= 0f) return Rect.Zero + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (corner in corners) { + val contentPoint = screenToContent(corner, scale, cosR, sinR, offset, pivot) + minX = min(minX, contentPoint.x) + minY = min(minY, contentPoint.y) + maxX = max(maxX, contentPoint.x) + maxY = max(maxY, contentPoint.y) + } + + return Rect(minX, minY, maxX, maxY) +} + +private fun rectCorners(rect: Rect): Array = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft +) + +private fun Rect.containsWithTolerance(point: Offset, epsilon: Float = GeometryEpsilon): Boolean { + return point.x >= left - epsilon && point.x <= right + epsilon && + point.y >= top - epsilon && point.y <= bottom + epsilon +} + +private fun lerpRect(start: Rect, end: Rect, fraction: Float): Rect { + return Rect( + left = start.left + (end.left - start.left) * fraction, + top = start.top + (end.top - start.top) * fraction, + right = start.right + (end.right - start.right) * fraction, + bottom = start.bottom + (end.bottom - start.bottom) * fraction + ) +} + +fun calculateScalarTransformedBounds( + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + baseBounds.topLeft, baseBounds.topRight, + baseBounds.bottomRight, baseBounds.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (p in corners) { + val s = contentToScreen(p, scale, cosR, sinR, offset, pivot) + minX = min(minX, s.x); minY = min(minY, s.y) + maxX = max(maxX, s.x); maxY = max(maxY, s.y) + } + return Rect(minX, minY, maxX, maxY) +} + +internal fun isCropRectCoveredByImage( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Boolean { + if (baseBounds.isEmpty || cropRect.isEmpty || scale <= 0f) return false + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + return rectCorners(cropRect).all { corner -> + baseBounds.containsWithTolerance( + point = screenToContent( + screen = corner, + scale = scale, + cosR = cosR, + sinR = sinR, + offset = offset, + pivot = pivot + ) + ) + } +} + +internal fun constrainCropRectToImage( + currentCropRect: Rect, + candidateRect: Rect, + visibleBounds: Rect, + minCropSizePx: Float, + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val constrainedCandidate = constrainCropRect( + cropRect = candidateRect, + bounds = visibleBounds, + minCropSizePx = minCropSizePx + ) + + if (baseBounds.isEmpty || constrainedCandidate.isEmpty || scale <= 0f) return constrainedCandidate + if (isCropRectCoveredByImage(baseBounds, constrainedCandidate, scale, rotationDegrees, offset, pivot)) { + return constrainedCandidate + } + if (!isCropRectCoveredByImage(baseBounds, currentCropRect, scale, rotationDegrees, offset, pivot)) { + return currentCropRect + } + + var low = 0f + var high = 1f + repeat(20) { + val mid = (low + high) / 2f + val interpolated = lerpRect(currentCropRect, constrainedCandidate, mid) + if (isCropRectCoveredByImage(baseBounds, interpolated, scale, rotationDegrees, offset, pivot)) { + low = mid + } else { + high = mid + } + } + + return lerpRect(currentCropRect, constrainedCandidate, low) +} + +fun offsetForZoomAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + zoom: Float +): Offset { + return Offset( + x = (1f - zoom) * (anchor.x - pivot.x) + zoom * currentOffset.x, + y = (1f - zoom) * (anchor.y - pivot.y) + zoom * currentOffset.y + ) +} + +fun offsetForRotationAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + deltaAngleDegrees: Float +): Offset { + val rad = Math.toRadians(deltaAngleDegrees.toDouble()) + val cosD = cos(rad).toFloat() + val sinD = sin(rad).toFloat() + + val vx = anchor.x - pivot.x - currentOffset.x + val vy = anchor.y - pivot.y - currentOffset.y + val rvx = vx * cosD - vy * sinD + val rvy = vx * sinD + vy * cosD + + return Offset( + x = anchor.x - pivot.x - rvx, + y = anchor.y - pivot.y - rvy + ) +} + +fun clampOffsetToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Offset { + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return offset + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + var cdx = 0f + var cdy = 0f + + if (cropContentBounds.left < baseBounds.left) { + cdx = baseBounds.left - cropContentBounds.left + } else if (cropContentBounds.right > baseBounds.right) { + cdx = baseBounds.right - cropContentBounds.right + } + + if (cropContentBounds.top < baseBounds.top) { + cdy = baseBounds.top - cropContentBounds.top + } else if (cropContentBounds.bottom > baseBounds.bottom) { + cdy = baseBounds.bottom - cropContentBounds.bottom + } + + if (cdx == 0f && cdy == 0f) return offset + + val screenDx = -scale * (cdx * cosR - cdy * sinR) + val screenDy = -scale * (cdx * sinR + cdy * cosR) + + return Offset(offset.x + screenDx, offset.y + screenDy) +} + +fun fitContentInBounds( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset, + minScale: Float = 0.5f, + maxScale: Float = 30f +): Pair { + if (baseBounds.isEmpty || cropRect.isEmpty) return Pair(scale, offset) + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return Pair(scale, offset) + + val cropContentW = cropContentBounds.width + val cropContentH = cropContentBounds.height + var newScale = scale + + if (cropContentW > baseBounds.width || cropContentH > baseBounds.height) { + val scaleX = if (baseBounds.width > 0f) cropContentW / baseBounds.width else 1f + val scaleY = if (baseBounds.height > 0f) cropContentH / baseBounds.height else 1f + val correction = max(scaleX, scaleY) + newScale = (scale * correction).coerceIn(minScale, maxScale) + } + + val newOffset = if (newScale != scale) { + val zoomFactor = newScale / scale + offsetForZoomAroundAnchor(offset, pivot, cropRect.center, zoomFactor) + } else { + offset + } + + val clampedOffset = clampOffsetToCoverCrop(baseBounds, cropRect, newScale, rotationDegrees, newOffset, pivot) + return Pair(newScale, clampedOffset) +} + +fun minimumScaleToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + currentScale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Float { + if (baseBounds.isEmpty || cropRect.isEmpty || currentScale <= 0f) return currentScale + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = currentScale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return currentScale + + val contentSpanX = cropContentBounds.width + val contentSpanY = cropContentBounds.height + + var minScale = 0f + if (baseBounds.width > 0f && contentSpanX > 0f) { + minScale = max(minScale, currentScale * contentSpanX / baseBounds.width) + } + if (baseBounds.height > 0f && contentSpanY > 0f) { + minScale = max(minScale, currentScale * contentSpanY / baseBounds.height) + } + return minScale +} 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/currentChat/editor/photo/crop/CropOverlay.kt new file mode 100644 index 00000000..1a884fd2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt @@ -0,0 +1,257 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +fun calculateCropRect(bounds: IntSize, imageSize: IntSize): Rect { + if (bounds.width <= 0 || bounds.height <= 0 || imageSize.width <= 0 || imageSize.height <= 0) { + return Rect.Zero + } + val imageAspect = imageSize.width.toFloat() / imageSize.height.toFloat() + val canvasAspect = bounds.width.toFloat() / bounds.height.toFloat() + return if (imageAspect > canvasAspect) { + val fittedHeight = bounds.width / imageAspect + val top = (bounds.height - fittedHeight) / 2f + Rect(0f, top, bounds.width.toFloat(), top + fittedHeight) + } else { + val fittedWidth = bounds.height * imageAspect + val left = (bounds.width - fittedWidth) / 2f + Rect(left, 0f, left + fittedWidth, bounds.height.toFloat()) + } +} + +fun constrainCropRect(cropRect: Rect, bounds: Rect, minCropSizePx: Float): Rect { + val b = Rect( + left = minOf(bounds.left, bounds.right), + top = minOf(bounds.top, bounds.bottom), + right = maxOf(bounds.left, bounds.right), + bottom = maxOf(bounds.top, bounds.bottom) + ) + if (b.width <= 0f || b.height <= 0f) return cropRect + val minW = minCropSizePx.coerceAtMost(b.width) + val minH = minCropSizePx.coerceAtMost(b.height) + val w = cropRect.width.coerceIn(minW, b.width) + val h = cropRect.height.coerceIn(minH, b.height) + val l = cropRect.left.coerceIn(b.left, (b.right - w).coerceAtLeast(b.left)) + val t = cropRect.top.coerceIn(b.top, (b.bottom - h).coerceAtLeast(b.top)) + return Rect(l, t, l + w, t + h) +} + + + +private enum class CropHandle { + NONE, MOVE, + TOP_LEFT, TOP, TOP_RIGHT, RIGHT, + BOTTOM_RIGHT, BOTTOM, BOTTOM_LEFT, LEFT +} + + + +@Composable +fun CropScrim(cropRect: Rect, modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + drawRect(Color.Black.copy(alpha = 0.7f), size = size) + drawRect(Color.Transparent, topLeft = cropRect.topLeft, size = cropRect.size, blendMode = BlendMode.Clear) + } +} + + + +@Composable +fun CropOverlay( + cropRect: Rect, + bounds: Rect, + minCropSizePx: Float, + onCropRectChange: (Rect) -> Unit, + onContentTransform: (centroid: Offset, pan: Offset, zoom: Float) -> Unit = { _, _, _ -> }, + onResizeEnded: () -> Unit = {}, + onDragStateChange: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier +) { + val currentCropRect by rememberUpdatedState(cropRect) + val currentOnCropRectChange by rememberUpdatedState(onCropRectChange) + val currentOnContentTransform by rememberUpdatedState(onContentTransform) + val currentOnResizeEnded by rememberUpdatedState(onResizeEnded) + val currentOnDragStateChange by rememberUpdatedState(onDragStateChange) + val handleTouchRadiusPx = 28.dp + val cornerHandleZonePx = 44.dp + val sideHandleLengthPx = 36.dp + val sideTouchInsetPx = 24.dp + + + var isResizing by remember { mutableStateOf(false) } + + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + + .pointerInput(bounds, minCropSizePx) { + val handleTouchRadius = handleTouchRadiusPx.toPx() + val cornerHandleZone = cornerHandleZonePx.toPx() + val sideTouchInset = sideTouchInsetPx.toPx() + + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + val activeHandle = pickCropHandle( + down.position, currentCropRect, + handleTouchRadius, cornerHandleZone, sideTouchInset + ) + + if (activeHandle == CropHandle.NONE || activeHandle == CropHandle.MOVE) { + return@awaitEachGesture + } + + var dragRect = currentCropRect + down.consume() + isResizing = true + currentOnDragStateChange(true) + + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val primary = event.changes.firstOrNull { it.id == down.id } + ?: event.changes.firstOrNull { it.pressed } + if (primary == null || !primary.pressed) break + + val drag = primary.position - primary.previousPosition + if (drag == Offset.Zero) continue + primary.consume() + + dragRect = resizeCropRect(dragRect, activeHandle, drag, bounds, minCropSizePx) + currentOnCropRectChange(dragRect) + } + } finally { + isResizing = false + currentOnResizeEnded() + currentOnDragStateChange(false) + } + } + } + + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (!isResizing) { + currentOnContentTransform(centroid, pan, zoom) + } + } + } + ) { + + val strokeWidth = 1.dp.toPx() + val gridColor = Color.White.copy(alpha = 0.5f) + drawLine(gridColor, Offset(cropRect.left + cropRect.width / 3, cropRect.top), Offset(cropRect.left + cropRect.width / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.top), Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height / 3), Offset(cropRect.right, cropRect.top + cropRect.height / 3), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height * 2 / 3), Offset(cropRect.right, cropRect.top + cropRect.height * 2 / 3), strokeWidth) + + + val cornerLen = 24.dp.toPx() + val cornerStroke = 3.dp.toPx() + val white = Color.White + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(y = cropRect.bottom - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(y = cropRect.bottom - cornerLen), cornerStroke) + + + val sideLen = sideHandleLengthPx.toPx() + val sideStroke = 4.dp.toPx() + val cx = cropRect.left + cropRect.width / 2f + val cy = cropRect.top + cropRect.height / 2f + val half = sideLen / 2f + drawLine(white, Offset(cx - half, cropRect.top), Offset(cx + half, cropRect.top), sideStroke) + drawLine(white, Offset(cx - half, cropRect.bottom), Offset(cx + half, cropRect.bottom), sideStroke) + drawLine(white, Offset(cropRect.left, cy - half), Offset(cropRect.left, cy + half), sideStroke) + drawLine(white, Offset(cropRect.right, cy - half), Offset(cropRect.right, cy + half), sideStroke) + + + drawRect(Color.White.copy(alpha = 0.8f), cropRect.topLeft, cropRect.size, style = Stroke(1.dp.toPx())) + } +} + + + +private fun pickCropHandle( + point: Offset, crop: Rect, + touchRadius: Float, cornerZone: Float, sideInset: Float +): CropHandle { + val topBand = (crop.top - sideInset)..(crop.top + sideInset) + val bottomBand = (crop.bottom - sideInset)..(crop.bottom + sideInset) + val leftBand = (crop.left - sideInset)..(crop.left + sideInset) + val rightBand = (crop.right - sideInset)..(crop.right + sideInset) + val inH = point.x in crop.left..crop.right + val inV = point.y in crop.top..crop.bottom + return when { + inCorner(point, crop, CropHandle.TOP_LEFT, touchRadius, cornerZone) -> CropHandle.TOP_LEFT + inCorner(point, crop, CropHandle.TOP_RIGHT, touchRadius, cornerZone) -> CropHandle.TOP_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_RIGHT, touchRadius, cornerZone) -> CropHandle.BOTTOM_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_LEFT, touchRadius, cornerZone) -> CropHandle.BOTTOM_LEFT + inH && point.y in topBand -> CropHandle.TOP + inV && point.x in rightBand -> CropHandle.RIGHT + inH && point.y in bottomBand -> CropHandle.BOTTOM + inV && point.x in leftBand -> CropHandle.LEFT + inH && inV -> CropHandle.MOVE + else -> CropHandle.NONE + } +} + +private fun resizeCropRect(crop: Rect, handle: CropHandle, drag: Offset, bounds: Rect, minSize: Float): Rect { + if (bounds.width <= 0f || bounds.height <= 0f) return crop + val minW = minSize.coerceAtMost(bounds.width) + val minH = minSize.coerceAtMost(bounds.height) + var l = crop.left; var t = crop.top; var r = crop.right; var b = crop.bottom + when (handle) { + CropHandle.MOVE -> { /* not used here */ } + CropHandle.TOP_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP -> { t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right) } + CropHandle.BOTTOM_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM -> { b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW) } + CropHandle.NONE -> {} + } + return Rect(l, t, r, b) +} + +private fun inCorner(point: Offset, crop: Rect, handle: CropHandle, radius: Float, zone: Float): Boolean { + val r = when (handle) { + CropHandle.TOP_LEFT -> Rect(crop.left - radius, crop.top - radius, crop.left + zone, crop.top + zone) + CropHandle.TOP_RIGHT -> Rect(crop.right - zone, crop.top - radius, crop.right + radius, crop.top + zone) + CropHandle.BOTTOM_RIGHT -> Rect(crop.right - zone, crop.bottom - zone, crop.right + radius, crop.bottom + radius) + CropHandle.BOTTOM_LEFT -> Rect(crop.left - radius, crop.bottom - zone, crop.left + zone, crop.bottom + radius) + else -> return false + } + return point.x in r.left..r.right && point.y in r.top..r.bottom +} 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/currentChat/editor/photo/crop/CropGeometryTest.kt new file mode 100644 index 00000000..954317e3 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -0,0 +1,125 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CropGeometryTest { + @Test + fun `fitContentInBounds restores crop coverage for rotated image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val cropRect = Rect(left = 15f, top = 15f, right = 85f, bottom = 85f) + val initialScale = 1.2f + val initialOffset = Offset(50f, -40f) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center + ) + ) + + val (newScale, newOffset) = fitContentInBounds( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center, + minScale = 0.5f, + maxScale = 10f + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = newScale, + rotationDegrees = 35f, + offset = newOffset, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `isCropRectCoveredByImage rejects crop in rotated bounding box corner`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val cropRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = 65f, + bottom = 65f + ) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `constrainCropRectToImage pulls invalid rotated crop back inside image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val currentCropRect = Rect(left = 35f, top = 35f, right = 65f, bottom = 65f) + val candidateRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = currentCropRect.right, + bottom = currentCropRect.bottom + ) + + val constrained = constrainCropRectToImage( + currentCropRect = currentCropRect, + candidateRect = candidateRect, + visibleBounds = visibleBounds, + minCropSizePx = 16f, + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + + assertTrue(constrained.left > candidateRect.left + EPSILON) + assertTrue(constrained.top > candidateRect.top + EPSILON) + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = constrained, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { + return calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + } + + private companion object { + const val EPSILON = 0.001f + } +} From 40cdb49af9acf7b916774431e4a1601b0f135534 Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:24:38 +0400 Subject: [PATCH 38/53] feat(photo-editor): add rotate to crop --- .../editor/photo/PhotoEditorScreen.kt | 23 ++++++++++ .../photo/components/TransformControls.kt | 44 ++++++++++++++----- .../photo/components/TransformControlsTest.kt | 21 +++++++++ .../editor/photo/crop/CropGeometryTest.kt | 24 ++++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt 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/currentChat/editor/photo/PhotoEditorScreen.kt index ffa71781..390d8370 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/currentChat/editor/photo/PhotoEditorScreen.kt @@ -249,6 +249,28 @@ fun PhotoEditorScreen( } } + fun rotateClockwise() { + val targetRotation = rotateClockwiseToNextRightAngle(imageRotation) + + imageRotation = targetRotation + imageScale = 1f + imageOffset = Offset.Zero + + cropState.setCropRect( + if (cropState.imageBounds == Rect.Zero) { + Rect.Zero + } else { + calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = 1f, + rotationDegrees = targetRotation, + offset = Offset.Zero, + pivot = pivot + ) + } + ) + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || @@ -339,6 +361,7 @@ fun PhotoEditorScreen( TransformControls( rotation = imageRotation, onRotationChange = { newRotation -> applyRotation(newRotation) }, + onRotateClockwise = { rotateClockwise() }, onReset = { imageRotation = 0f imageScale = 1f 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/currentChat/editor/photo/components/TransformControls.kt index 553ee2b4..b9c1fd21 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/currentChat/editor/photo/components/TransformControls.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Rotate90DegreesCw import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -23,6 +24,7 @@ import kotlin.math.roundToInt fun TransformControls( rotation: Float, onRotationChange: (Float) -> Unit, + onRotateClockwise: () -> Unit, onReset: () -> Unit ) { val normalizedRotation = normalizeRotationDegrees(rotation) @@ -57,17 +59,34 @@ fun TransformControls( Spacer(modifier = Modifier.width(12.dp)) - OutlinedIconButton( - onClick = onReset, - colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.photo_editor_action_reset) - ) + OutlinedIconButton( + onClick = onRotateClockwise, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Rotate90DegreesCw, + contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + ) + } + + OutlinedIconButton( + onClick = onReset, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) + ) + } } } } @@ -171,6 +190,11 @@ internal fun normalizeRotationDegrees(value: Float): Float { return normalized } +internal fun rotateClockwiseToNextRightAngle(value: Float): Float { + val snappedRotation = (value / 90f).roundToInt() * 90f + return normalizeRotationDegrees(snappedRotation + 90f) +} + private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() return normalizedAngle + turns * 360f 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/currentChat/editor/photo/components/TransformControlsTest.kt new file mode 100644 index 00000000..aad6e58a --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt @@ -0,0 +1,21 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransformControlsTest { + @Test + fun `rotateClockwiseToNextRightAngle advances exact quarter turn`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(0f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(90f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(180f), 0.001f) + } + + @Test + fun `rotateClockwiseToNextRightAngle snaps tilted image before rotating`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(10f), 0.001f) + assertEquals(90f, rotateClockwiseToNextRightAngle(-10f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(100f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(179f), 0.001f) + } +} 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/currentChat/editor/photo/crop/CropGeometryTest.kt index 954317e3..1aa60c9f 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/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -109,6 +109,30 @@ class CropGeometryTest { ) } + @Test + fun `quarter turn transformed bounds stay covered by image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 200f, bottom = 100f) + val pivot = baseBounds.center + val cropRect = calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + ) + } + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { return calculateScalarTransformedBounds( baseBounds = baseBounds, From 9382dfd40d510b2237c2ed8ac04edc2ef6123ce4 Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:38:38 +0400 Subject: [PATCH 39/53] feat(photo-editor): smoother rotate animation --- .../editor/photo/PhotoEditorScreen.kt | 94 +++++++++++++------ .../photo/components/TransformControls.kt | 8 +- .../photo/components/TransformControlsTest.kt | 8 ++ 3 files changed, 78 insertions(+), 32 deletions(-) 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/currentChat/editor/photo/PhotoEditorScreen.kt index 390d8370..01c133b8 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/currentChat/editor/photo/PhotoEditorScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.presentation.R @@ -112,6 +113,55 @@ fun PhotoEditorScreen( imageRotation = imageRotation, imageOffset = imageOffset ) + var transformAnimationJob by remember { mutableStateOf(null) } + + fun animateTransformTo( + targetCropRect: Rect, + targetRotation: Float, + targetScale: Float, + targetOffset: Offset, + durationMillis: Int = 180 + ) { + val startCrop = cropState.cropRect + val startRotation = imageRotation + val startScale = imageScale + val startOffset = imageOffset + + if ( + startCrop == targetCropRect && + startRotation == targetRotation && + startScale == targetScale && + startOffset == targetOffset + ) { + cropState.setCropRect(targetCropRect) + imageRotation = targetRotation + imageScale = targetScale + imageOffset = targetOffset + return + } + + transformAnimationJob?.cancel() + transformAnimationJob = scope.launch { + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(durationMillis)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (targetCropRect.left - startCrop.left) * t, + top = startCrop.top + (targetCropRect.top - startCrop.top) * t, + right = startCrop.right + (targetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (targetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageRotation = startRotation + (targetRotation - startRotation) * t + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } fun fillAreaAfterResize() { @@ -159,28 +209,13 @@ fun PhotoEditorScreen( ) - scope.launch { - val startCrop = crop - val startScale = imageScale - val startOffset = imageOffset - val anim = androidx.compose.animation.core.Animatable(0f) - anim.animateTo(1f, androidx.compose.animation.core.tween(200)) { - val t = value - cropState.setCropRect( - Rect( - left = startCrop.left + (safeTargetCropRect.left - startCrop.left) * t, - top = startCrop.top + (safeTargetCropRect.top - startCrop.top) * t, - right = startCrop.right + (safeTargetCropRect.right - startCrop.right) * t, - bottom = startCrop.bottom + (safeTargetCropRect.bottom - startCrop.bottom) * t - ) - ) - imageScale = startScale + (targetScale - startScale) * t - imageOffset = Offset( - x = startOffset.x + (targetOffset.x - startOffset.x) * t, - y = startOffset.y + (targetOffset.y - startOffset.y) * t - ) - } - } + animateTransformTo( + targetCropRect = safeTargetCropRect, + targetRotation = imageRotation, + targetScale = targetScale, + targetOffset = targetOffset, + durationMillis = 200 + ) } val shouldConstrain by remember(currentTool) { @@ -250,14 +285,10 @@ fun PhotoEditorScreen( } fun rotateClockwise() { - val targetRotation = rotateClockwiseToNextRightAngle(imageRotation) - - imageRotation = targetRotation - imageScale = 1f - imageOffset = Offset.Zero + val targetRotation = rotateClockwiseAnimationTarget(imageRotation) - cropState.setCropRect( - if (cropState.imageBounds == Rect.Zero) { + animateTransformTo( + targetCropRect = if (cropState.imageBounds == Rect.Zero) { Rect.Zero } else { calculateScalarTransformedBounds( @@ -267,7 +298,10 @@ fun PhotoEditorScreen( offset = Offset.Zero, pivot = pivot ) - } + }, + targetRotation = targetRotation, + targetScale = 1f, + targetOffset = Offset.Zero ) } 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/currentChat/editor/photo/components/TransformControls.kt index b9c1fd21..c4fa9966 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/currentChat/editor/photo/components/TransformControls.kt @@ -190,9 +190,13 @@ internal fun normalizeRotationDegrees(value: Float): Float { return normalized } -internal fun rotateClockwiseToNextRightAngle(value: Float): Float { +internal fun rotateClockwiseAnimationTarget(value: Float): Float { val snappedRotation = (value / 90f).roundToInt() * 90f - return normalizeRotationDegrees(snappedRotation + 90f) + return snappedRotation + 90f +} + +internal fun rotateClockwiseToNextRightAngle(value: Float): Float { + return normalizeRotationDegrees(rotateClockwiseAnimationTarget(value)) } private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { 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/currentChat/editor/photo/components/TransformControlsTest.kt index aad6e58a..04df4f1c 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/currentChat/editor/photo/components/TransformControlsTest.kt @@ -4,6 +4,14 @@ import org.junit.Assert.assertEquals import org.junit.Test class TransformControlsTest { + @Test + fun `rotateClockwiseAnimationTarget always moves clockwise to next quarter turn`() { + assertEquals(90f, rotateClockwiseAnimationTarget(10f), 0.001f) + assertEquals(90f, rotateClockwiseAnimationTarget(-10f), 0.001f) + assertEquals(270f, rotateClockwiseAnimationTarget(179f), 0.001f) + assertEquals(450f, rotateClockwiseAnimationTarget(350f), 0.001f) + } + @Test fun `rotateClockwiseToNextRightAngle advances exact quarter turn`() { assertEquals(90f, rotateClockwiseToNextRightAngle(0f), 0.001f) From d3cb673e84d0a0c10bfb0c6979835df31bff34b3 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:43:24 +0300 Subject: [PATCH 40/53] better notifications info caching --- .../monogram/data/chats/ChatModelFactory.kt | 13 +- .../org/monogram/data/db/MonogramDatabase.kt | 4 +- .../monogram/data/db/MonogramMigrations.kt | 25 +++ .../data/db/dao/NotificationExceptionDao.kt | 40 ++++ .../db/model/NotificationExceptionEntity.kt | 22 ++ .../java/org/monogram/data/di/TdLibClient.kt | 6 +- .../monogram/data/di/TdNotificationManager.kt | 24 ++- .../java/org/monogram/data/di/dataModule.kt | 5 +- .../org/monogram/data/infra/OfflineWarmup.kt | 4 +- .../NotificationSettingsRepositoryImpl.kt | 199 +++++++++++++++++- .../notifications/NotificationsComponent.kt | 31 ++- 11 files changed, 348 insertions(+), 25 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt create mode 100644 data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 4c495535..8bf1710a 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -2,6 +2,8 @@ package org.monogram.data.chats import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching @@ -29,6 +31,7 @@ class ChatModelFactory( private val fetchUser: (Long) -> Unit ) { private val missingUserFullInfoUntilMs = ConcurrentHashMap() + private val userFullInfoSemaphore = Semaphore(permits = 3) fun mapChatToModel( chat: TdApi.Chat, @@ -180,6 +183,10 @@ class ChatModelFactory( if (!isUserFullInfoTemporarilyMissing(type.userId)) { lazyLoad(cache.pendingUserFullInfo, type.userId) { if (type.userId == 0L) return@lazyLoad + cache.userFullInfoCache[type.userId]?.let { + triggerUpdate(chat.id) + return@lazyLoad + } val cachedInfo = coRunCatching { userFullInfoDao.getUserFullInfo(type.userId)?.toTdApi() }.getOrNull() @@ -189,7 +196,11 @@ class ChatModelFactory( triggerUpdate(chat.id) return@lazyLoad } - val result = coRunCatching { gateway.execute(TdApi.GetUserFullInfo(type.userId)) }.getOrNull() + val result = userFullInfoSemaphore.withPermit { + cache.userFullInfoCache[type.userId] ?: coRunCatching { + gateway.execute(TdApi.GetUserFullInfo(type.userId)) + }.getOrNull() + } if (result != null) { cache.putUserFullInfo(type.userId, result) coRunCatching { userFullInfoDao.insertUserFullInfo(result.toEntity(type.userId)) } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 1df2fac1..7dbbe36c 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -20,12 +20,13 @@ import org.monogram.data.db.model.* AttachBotEntity::class, KeyValueEntity::class, NotificationSettingEntity::class, + NotificationExceptionEntity::class, WallpaperEntity::class, StickerPathEntity::class, SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 28, + version = 29, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { @@ -42,6 +43,7 @@ abstract class MonogramDatabase : RoomDatabase() { abstract fun attachBotDao(): AttachBotDao abstract fun keyValueDao(): KeyValueDao abstract fun notificationSettingDao(): NotificationSettingDao + abstract fun notificationExceptionDao(): NotificationExceptionDao abstract fun wallpaperDao(): WallpaperDao abstract fun stickerPathDao(): StickerPathDao abstract fun sponsorDao(): SponsorDao diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index fe7a272e..fa64968f 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -161,6 +161,31 @@ object MonogramMigrations { } } + val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `notification_exceptions` ( + `chatId` INTEGER NOT NULL, + `scope` TEXT NOT NULL, + `title` TEXT NOT NULL, + `avatarPath` TEXT, + `personalAvatarPath` TEXT, + `isMuted` INTEGER NOT NULL, + `isGroup` INTEGER NOT NULL, + `isChannel` INTEGER NOT NULL, + `type` TEXT NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`chatId`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_notification_exceptions_scope` ON `notification_exceptions` (`scope`)" + ) + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt new file mode 100644 index 00000000..4f7826dd --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt @@ -0,0 +1,40 @@ +package org.monogram.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.monogram.data.db.model.NotificationExceptionEntity + +@Dao +interface NotificationExceptionDao { + @Query("SELECT * FROM notification_exceptions WHERE scope = :scope ORDER BY updatedAt DESC") + suspend fun getByScope(scope: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: NotificationExceptionEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("DELETE FROM notification_exceptions WHERE scope = :scope") + suspend fun deleteByScope(scope: String) + + @Query("DELETE FROM notification_exceptions WHERE chatId = :chatId") + suspend fun deleteByChatId(chatId: Long) + + @Query("UPDATE notification_exceptions SET isMuted = :isMuted, updatedAt = :updatedAt WHERE chatId = :chatId") + suspend fun updateMute(chatId: Long, isMuted: Boolean, updatedAt: Long = System.currentTimeMillis()) + + @Transaction + suspend fun replaceForScope(scope: String, entities: List) { + deleteByScope(scope) + if (entities.isNotEmpty()) { + insertAll(entities) + } + } + + @Query("DELETE FROM notification_exceptions") + suspend fun clearAll() +} diff --git a/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt new file mode 100644 index 00000000..4fdd3f5b --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt @@ -0,0 +1,22 @@ +package org.monogram.data.db.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "notification_exceptions", + indices = [Index(value = ["scope"])] +) +data class NotificationExceptionEntity( + @PrimaryKey val chatId: Long, + val scope: String, + val title: String, + val avatarPath: String?, + val personalAvatarPath: String?, + val isMuted: Boolean, + val isGroup: Boolean, + val isChannel: Boolean, + val type: String, + val updatedAt: Long = System.currentTimeMillis() +) diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index 2dad8845..c31624c2 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -81,8 +81,12 @@ internal class TdLibClient { if (result.code == 429 && retries < 3) { retries++ val retryAfterMs = parseRetryAfterMs(result.message) - updateGlobalRetryWindow(retryAfterMs) Log.w(TAG, "Rate limited for $function, retrying in ${retryAfterMs}ms (attempt $retries)") + if (function is TdApi.GetUserFullInfo) { + delay(retryAfterMs) + } else { + updateGlobalRetryWindow(retryAfterMs) + } continue } diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index 31b46b38..4c036272 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -222,10 +222,9 @@ class TdNotificationManager( coRunCatching { val result = gateway.execute(TdApi.GetChatNotificationSettingsExceptions(scope, true)) if (result is TdApi.Chats) { - result.chatIds.forEach { chatId -> - getChat(chatId) { chat -> - updateChatNotificationSettings(chat.id, chat.notificationSettings) - } + for (chatId in result.chatIds.distinct()) { + val chat = getChatSuspend(chatId) ?: continue + updateChatNotificationSettings(chat.id, chat.notificationSettings) } } }.onFailure { @@ -760,12 +759,19 @@ class TdNotificationManager( return } scope.launch { - try { - val result = gateway.execute(TdApi.GetChat(chatId)) - chatCache[chatId] = result - callback(result) - } catch (_: Exception) { + getChatSuspend(chatId)?.let(callback) + } + } + + private suspend fun getChatSuspend(chatId: Long): TdApi.Chat? { + chatCache[chatId]?.let { return it } + + return try { + gateway.execute(TdApi.GetChat(chatId)).also { chat -> + chatCache[chat.id] = chat } + } catch (_: Exception) { + null } } diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 057656f1..74b47f39 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -127,7 +127,8 @@ val dataModule = module { .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .addMigrations( MonogramMigrations.MIGRATION_26_27, - MonogramMigrations.MIGRATION_27_28 + MonogramMigrations.MIGRATION_27_28, + MonogramMigrations.MIGRATION_28_29 ) .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -145,6 +146,7 @@ val dataModule = module { single { get().attachBotDao() } single { get().keyValueDao() } single { get().notificationSettingDao() } + single { get().notificationExceptionDao() } single { get().wallpaperDao() } single { get().stickerPathDao() } single { get().sponsorDao() } @@ -408,6 +410,7 @@ val dataModule = module { remote = get(), cache = get(), chatsRemote = get(), + notificationExceptionDao = get(), updates = get(), scope = get(), dispatchers = get() diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index b1382243..0830bb81 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -340,8 +340,8 @@ class OfflineWarmup( } private companion object { - private const val USER_WARMUP_LIMIT = 30 - private const val USER_WARMUP_DELAY_MS = 75L + private const val USER_WARMUP_LIMIT = 15 + private const val USER_WARMUP_DELAY_MS = 150L private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 private const val SEVEN_DAYS_MS = 7L * ONE_DAY_MS } diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt index 923b0eef..c3ff6166 100644 --- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -1,31 +1,44 @@ package org.monogram.data.repository -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.db.dao.NotificationExceptionDao +import org.monogram.data.db.model.NotificationExceptionEntity import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.toApi import org.monogram.data.mapper.user.toDomain import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ChatType import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope +import java.util.concurrent.ConcurrentHashMap class NotificationSettingsRepositoryImpl( private val remote: SettingsRemoteDataSource, private val cache: SettingsCacheDataSource, private val chatsRemote: ChatsRemoteDataSource, + private val notificationExceptionDao: NotificationExceptionDao, private val updates: UpdateDispatcher, private val scope: CoroutineScope, private val dispatchers: DispatcherProvider ) : NotificationSettingsRepository { + private val exceptionsCache = ConcurrentHashMap>() + private val exceptionsCacheMutex = Mutex() + init { scope.launch { updates.newChat.collect { update -> cache.putChat(update.chat) + syncChatWithExceptionsCache(update.chat) } } @@ -35,6 +48,7 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.title = update.title } + syncChatWithExceptionsCache(chat) } } } @@ -45,6 +59,7 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.photo = update.photo } + syncChatWithExceptionsCache(chat) } } } @@ -55,6 +70,13 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.notificationSettings = update.notificationSettings } + syncChatWithExceptionsCache(chat) + } ?: run { + if (update.notificationSettings.isException(compareSound = true)) { + invalidateExceptionsCache() + } else { + removeFromExceptionsCache(update.chatId) + } } } } @@ -73,14 +95,22 @@ class NotificationSettingsRepositoryImpl( remote.setScopeNotificationSettings(scope.toApi(), settings) } - override suspend fun getExceptions(scope: TdNotificationScope): List = coroutineScope { - val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) - chats?.chatIds?.map { chatId -> - async(dispatchers.io) { - cache.getChat(chatId)?.toDomain() - ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }?.toDomain() + override suspend fun getExceptions(scope: TdNotificationScope): List { + exceptionsCache[scope]?.let { return it } + + return exceptionsCacheMutex.withLock { + exceptionsCache[scope]?.let { return@withLock it } + + loadExceptionsFromRoom(scope)?.let { roomCached -> + exceptionsCache[scope] = roomCached + return@withLock roomCached } - }?.awaitAll()?.filterNotNull() ?: emptyList() + + val remoteLoaded = loadExceptionsFromApi(scope) + exceptionsCache[scope] = remoteLoaded + persistScopeToRoom(scope, remoteLoaded) + remoteLoaded + } } override suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) { @@ -92,6 +122,7 @@ class NotificationSettingsRepositoryImpl( useDefaultMuteStories = true } remote.setChatNotificationSettings(chatId, settings) + updateCachedChatMute(chatId, isMuted = !enabled) } override suspend fun resetChatNotificationSettings(chatId: Long) { @@ -102,5 +133,157 @@ class NotificationSettingsRepositoryImpl( useDefaultMuteStories = true } remote.setChatNotificationSettings(chatId, settings) + removeFromExceptionsCache(chatId) + } + + private suspend fun loadExceptionsFromRoom(scope: TdNotificationScope): List? = + withContext(dispatchers.io) { + val cached = notificationExceptionDao.getByScope(scope.name) + if (cached.isEmpty()) null else cached.map { it.toDomainChatModel() } + } + + private suspend fun loadExceptionsFromApi(scope: TdNotificationScope): List = + withContext(dispatchers.io) { + val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) + val result = mutableListOf() + + chats?.chatIds?.distinct()?.forEach { chatId -> + val chat = cache.getChat(chatId) + ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) } + + chat?.toDomain()?.let(result::add) + } + + result + } + + private suspend fun persistScopeToRoom(scope: TdNotificationScope, chats: List) { + withContext(dispatchers.io) { + notificationExceptionDao.replaceForScope( + scope = scope.name, + entities = chats.map { it.toExceptionEntity(scope) } + ) + } + } + + private fun syncChatWithExceptionsCache(chat: TdApi.Chat) { + val notificationScope = chat.toNotificationScope() ?: return + val isException = chat.notificationSettings.isException(compareSound = true) + val mappedChat = if (isException) chat.toDomain() else null + + exceptionsCache[notificationScope]?.let { existing -> + val updated = if (isException && mappedChat != null) { + if (existing.any { it.id == chat.id }) { + existing.map { cached -> + if (cached.id == chat.id) mappedChat else cached + } + } else { + existing + mappedChat + } + } else { + existing.filterNot { it.id == chat.id } + } + + exceptionsCache[notificationScope] = updated + } + + scope.launch(dispatchers.io) { + if (isException && mappedChat != null) { + notificationExceptionDao.insert(mappedChat.toExceptionEntity(notificationScope)) + } else { + notificationExceptionDao.deleteByChatId(chat.id) + } + } + } + + private fun updateCachedChatMute(chatId: Long, isMuted: Boolean) { + if (exceptionsCache.isNotEmpty()) { + exceptionsCache.keys.forEach { notificationScope -> + val existing = exceptionsCache[notificationScope] ?: return@forEach + exceptionsCache[notificationScope] = existing.map { chat -> + if (chat.id == chatId) chat.copy(isMuted = isMuted) else chat + } + } + } + + scope.launch(dispatchers.io) { + notificationExceptionDao.updateMute(chatId = chatId, isMuted = isMuted) + } + } + + private fun removeFromExceptionsCache(chatId: Long) { + if (exceptionsCache.isNotEmpty()) { + exceptionsCache.keys.forEach { notificationScope -> + val existing = exceptionsCache[notificationScope] ?: return@forEach + exceptionsCache[notificationScope] = existing.filterNot { it.id == chatId } + } + } + + scope.launch(dispatchers.io) { + notificationExceptionDao.deleteByChatId(chatId) + } + } + + private fun invalidateExceptionsCache() { + exceptionsCache.clear() + scope.launch(dispatchers.io) { + notificationExceptionDao.clearAll() + } + } + + private fun TdApi.Chat.toNotificationScope(): TdNotificationScope? = when (val chatType = type) { + is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS + is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS + is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS + else -> null + } + + private fun TdApi.ChatNotificationSettings.isException(compareSound: Boolean): Boolean { + if (!useDefaultMuteFor) return true + if (!useDefaultShowPreview) return true + if (!useDefaultMuteStories) return true + if (!useDefaultShowStoryPoster) return true + if (!useDefaultDisablePinnedMessageNotifications) return true + if (!useDefaultDisableMentionNotifications) return true + + if (compareSound) { + if (!useDefaultSound) return true + if (!useDefaultStorySound) return true + } + + return false + } + + private fun ChatModel.toExceptionEntity(scope: TdNotificationScope): NotificationExceptionEntity { + return NotificationExceptionEntity( + chatId = id, + scope = scope.name, + title = title, + avatarPath = avatarPath, + personalAvatarPath = personalAvatarPath, + isMuted = isMuted, + isGroup = isGroup, + isChannel = isChannel, + type = type.name + ) + } + + private fun NotificationExceptionEntity.toDomainChatModel(): ChatModel { + return ChatModel( + id = chatId, + title = title, + unreadCount = 0, + avatarPath = avatarPath, + personalAvatarPath = personalAvatarPath, + isMuted = isMuted, + isGroup = isGroup, + isChannel = isChannel, + type = type.toDomainChatType() + ) + } + + private fun String.toDomainChatType(): ChatType { + return runCatching { ChatType.valueOf(this) } + .getOrDefault(ChatType.PRIVATE) } } \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 643c32de..075ba5bf 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -309,7 +309,11 @@ class DefaultNotificationsComponent( notificationSettingsRepository.setChatNotificationSettings(chatId, enabled) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.map { chat -> + if (chat.id == chatId) chat.copy(isMuted = !enabled) else chat + } + } } } } @@ -319,7 +323,30 @@ class DefaultNotificationsComponent( notificationSettingsRepository.resetChatNotificationSettings(chatId) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.filterNot { it.id == chatId } + } + } + } + } + + private fun updateExceptionsState( + scope: TdNotificationScope, + transform: (List) -> List + ) { + _state.update { state -> + when (scope) { + TdNotificationScope.PRIVATE_CHATS -> state.copy( + privateExceptions = state.privateExceptions?.let(transform) + ) + + TdNotificationScope.GROUPS -> state.copy( + groupExceptions = state.groupExceptions?.let(transform) + ) + + TdNotificationScope.CHANNELS -> state.copy( + channelExceptions = state.channelExceptions?.let(transform) + ) } } } From 7a8ccd073d0a0eb5b796f2a425623f2e07d086f6 Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:27:15 +0400 Subject: [PATCH 41/53] feat(chat): smoother mic/send button animation --- .../inputbar/ChatInputBarComposerSection.kt | 4 +- .../components/inputbar/InputBarSendButton.kt | 106 ++++++++++++------ 2 files changed, 74 insertions(+), 36 deletions(-) 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 index 327658fd..ce1b8000 100644 --- 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 @@ -166,8 +166,8 @@ fun ChatInputBarComposerSection( ) { AnimatedVisibility( visible = !voiceRecorder.isRecording, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally() + enter = fadeIn(tween(250)) + expandHorizontally(tween(250)), + exit = fadeOut(tween(200)) + shrinkHorizontally(tween(200)) ) { InputBarLeadingIcons( editingMessage = editingMessage, 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/currentChat/components/inputbar/InputBarSendButton.kt index 4b1e6a77..52f668b5 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/currentChat/components/inputbar/InputBarSendButton.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.Crossfade +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -59,6 +60,17 @@ fun InputBarSendButton( var isVoiceRecordingActive by remember { mutableStateOf(false) } val isRecordingMode = isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canSendVoice + val backgroundColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(250), + label = "BackgroundColor" + ) + val contentColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(250), + label = "ContentColor" + ) + if (canWriteText || canSendVoice) { val sendIcon = when { pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send @@ -73,16 +85,12 @@ fun InputBarSendButton( Box( modifier = Modifier + .size(48.dp) + .background(color = backgroundColor, shape = CircleShape) + .clip(CircleShape) .then( if (isRecordingMode) { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .pointerInput(isVideoMessageMode) { + Modifier.pointerInput(isVideoMessageMode) { awaitEachGesture { try { awaitFirstDown() @@ -147,39 +155,69 @@ fun InputBarSendButton( } } } else { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .combinedClickable( - onClick = { - if (isSendEnabled) { - onSendWithOptions(MessageSendOptions()) - } - }, - onLongClick = { - if (canShowOptions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onShowSendOptionsMenu() - } + Modifier.combinedClickable( + onClick = { + if (isSendEnabled) { + onSendWithOptions(MessageSendOptions()) } - ) + }, + onLongClick = { + if (canShowOptions) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onShowSendOptionsMenu() + } + } + ) } ), contentAlignment = Alignment.Center ) { - Crossfade(targetState = sendIcon, label = "IconAnimation") { icon -> + AnimatedContent( + targetState = sendIcon, + transitionSpec = { + val enteringSend = targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check + val leavingSend = initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check + + when { + enteringSend && !leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { -it / 2 }) + } + + !enteringSend && leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { -it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { it / 2 }) + } + + else -> { + (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( + fadeOut( + animationSpec = tween(200) + ) + scaleOut(targetScale = 0.8f) + ) + } + }.using(SizeTransform(clip = false)) + }, + label = "IconAnimation" + ) { icon -> Icon( imageVector = icon, contentDescription = null, - tint = if (isSendEnabled || isVoiceRecordingActive) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + tint = contentColor, + modifier = Modifier.size(24.dp) ) } } From 71e87a86f01f30561d363d4abdcc0fa61b698074 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:31:40 +0300 Subject: [PATCH 42/53] fixed runtime update user info in chat --- .../repository/user/UserRepositoryImpl.kt | 4 ++ .../chats/currentChat/DefaultChatComponent.kt | 1 + .../chats/currentChat/impl/MessageLoading.kt | 50 +++++++++++++++++-- .../currentChat/impl/MessageOperations.kt | 6 +++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index e0b958c6..7f50e2f8 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -126,6 +126,7 @@ class UserRepositoryImpl( } return try { deferred.await()?.let { user -> + handleUserIdUpdated(user.id) mapUserModel(user, userLocal.getUserFullInfo(userId)) } } finally { @@ -164,6 +165,9 @@ class UserRepositoryImpl( } return try { val fullInfo = deferred.await() + if (fullInfo != null) { + handleUserIdUpdated(userId) + } mapUserModel(user, fullInfo) } finally { fullInfoRequests.remove(userId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index d4ab0c21..ef64f106 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -69,6 +69,7 @@ class DefaultChatComponent( internal val reactionUpdateSuppressedUntil = ConcurrentHashMap() internal val remappedMessageIds = ConcurrentHashMap() internal val mediaDownloadRetryCount = ConcurrentHashMap() + internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal var lastLoadedOlderId: Long = 0L internal var lastLoadedNewerId: Long = 0L 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/currentChat/impl/MessageLoading.kt index c3bed7b9..301d7f2c 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/currentChat/impl/MessageLoading.kt @@ -15,6 +15,7 @@ import java.io.File private const val PAGE_SIZE = 50 private const val MAX_DOWNLOAD_RETRIES = 3 +private const val DEFAULT_SENDER_NAME = "User" private fun isUsableAvatarPath(path: String?): Boolean { if (path.isNullOrBlank()) return false @@ -57,6 +58,33 @@ private fun mergeSenderVisuals(previous: MessageModel, incoming: MessageModel): ) } +private fun MessageModel.needsSenderRefresh(): Boolean { + if (senderId <= 0L) return false + val hasPlaceholderName = senderName.isBlank() || senderName == DEFAULT_SENDER_NAME + val hasNoAvatar = senderAvatar.isNullOrBlank() && senderPersonalAvatar.isNullOrBlank() + return hasPlaceholderName || hasNoAvatar +} + +internal fun DefaultChatComponent.requestSenderRefreshIfNeeded(message: MessageModel) { + if (!message.needsSenderRefresh()) return + requestSenderRefresh(message.senderId) +} + +internal fun DefaultChatComponent.requestSenderRefresh(senderId: Long) { + if (senderId <= 0L) return + if (!pendingSenderRefreshes.add(senderId)) return + + scope.launch { + try { + repositoryMessage.invalidateSenderCache(senderId) + val user = userRepository.getUser(senderId) ?: return@launch + refreshMessagesForSender(senderId, user) + } finally { + pendingSenderRefreshes.remove(senderId) + } + } +} + private fun reactionsSemanticEqual( current: List, incoming: List @@ -247,6 +275,7 @@ internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) } private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { @@ -291,6 +320,7 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { } val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) + refreshCachedSenderProfiles(messages) if (!isOldestLoaded) { delay(100) loadMoreMessages() @@ -316,6 +346,7 @@ private suspend fun DefaultChatComponent.loadAroundMessage( ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) delay(100) loadMoreMessages() loadNewerMessages() @@ -389,6 +420,7 @@ internal fun DefaultChatComponent.loadMoreMessages() { if (olderMessages.isNotEmpty()) { updateMessages(olderMessages) + refreshCachedSenderProfiles(olderMessages) } val afterSize = _state.value.messages.size @@ -461,6 +493,7 @@ internal fun DefaultChatComponent.loadNewerMessages() { if (newerMessages.isNotEmpty()) { updateMessages(newerMessages) + refreshCachedSenderProfiles(newerMessages) lastLoadedNewerId = anchorId } @@ -535,6 +568,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { _state.value.currentTopicId == null || message.threadId == _state.value.currentTopicId if (isCorrectThread) { updateMessages(listOf(message)) + requestSenderRefreshIfNeeded(message) _state.update { state -> state.copy( isLatestLoaded = if (message.isOutgoing || state.isAtBottom) true else state.isLatestLoaded @@ -592,6 +626,11 @@ internal fun DefaultChatComponent.setupMessageCollectors() { ) } } + + val isCurrentThread = _state.value.currentTopicId == null || newMessage.threadId == _state.value.currentTopicId + if (isCurrentThread) { + requestSenderRefreshIfNeeded(newMessage) + } } } .launchIn(scope) @@ -855,6 +894,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { updateInlineResultsWithFile(messageId.toInt(), path) } + if (path.isNotEmpty() && messageId == downloadedFileId.toLong()) { + refreshCachedSenderProfiles(_state.value.messages) + } + fileIdToRetry?.let { if (it != 0) { val suppressed = AutoDownloadSuppression.isSuppressed(it) @@ -1006,17 +1049,16 @@ private fun DefaultChatComponent.observeSenderUpdates() { .launchIn(scope) } -private suspend fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { +private fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { val senderIds = messages.asSequence() + .filter { it.needsSenderRefresh() } .map { it.senderId } .filter { it > 0L } .distinct() .toList() senderIds.forEach { senderId -> - repositoryMessage.invalidateSenderCache(senderId) - val user = userRepository.getUser(senderId) ?: return@forEach - refreshMessagesForSender(senderId, user) + requestSenderRefresh(senderId) } } 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/currentChat/impl/MessageOperations.kt index 98795df8..1eeea807 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/currentChat/impl/MessageOperations.kt @@ -18,6 +18,12 @@ internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { repositoryMessage.markAllMentionsAsRead(chatId) repositoryMessage.markAllReactionsAsRead(chatId) } + + _state.value.messages + .firstOrNull { it.id == messageId } + ?.let { visibleMessage -> + requestSenderRefreshIfNeeded(visibleMessage) + } } } From b2e50b039a12d55234867481b374e7c849bf177d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:52:12 +0300 Subject: [PATCH 43/53] thread send message fix + caption message fix --- .../remote/TdMessageRemoteDataSource.kt | 32 ++++++++++------- .../data/repository/MessageRepositoryImpl.kt | 34 ++++++++++++++++--- 2 files changed, 49 insertions(+), 17 deletions(-) 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 a22ee018..4bdde980 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 @@ -482,7 +482,7 @@ class TdMessageRemoteDataSource( this.clearDraft = true } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -507,7 +507,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -540,7 +540,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -571,7 +571,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -595,7 +595,7 @@ class TdMessageRemoteDataSource( this.height = 512 } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -620,7 +620,7 @@ class TdMessageRemoteDataSource( this.animation = TdApi.InputFileId(gifId.toInt()) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -645,7 +645,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -684,7 +684,7 @@ class TdMessageRemoteDataSource( } }.toTypedArray() val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessageAlbum().apply { this.chatId = chatId this.topicId = topicId @@ -828,6 +828,16 @@ class TdMessageRemoteDataSource( return TdApi.TextEntity(start, safeLength, tdType) } + private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { + if (threadId == null || threadId == 0L) return null + val chat = cache.getChat(chatId) ?: getChat(chatId) + return if (chat?.viewAsTopics == true) { + TdApi.MessageTopicForum(threadId.toInt()) + } else { + TdApi.MessageTopicThread(threadId) + } + } + private fun MessageSendOptions.toTdMessageSendOptions(): TdApi.MessageSendOptions { return TdApi.MessageSendOptions().apply { this.disableNotification = silent @@ -928,7 +938,7 @@ class TdMessageRemoteDataSource( val req = TdApi.SendChatAction().apply { this.chatId = chatId this.action = action - this.topicId = if (messageThreadId != 0L) TdApi.MessageTopicThread(messageThreadId) else null + this.topicId = resolveTopicId(chatId, messageThreadId.takeIf { it != 0L }) } return safeExecute(req) } @@ -1087,9 +1097,7 @@ class TdMessageRemoteDataSource( val request = TdApi.SetChatDraftMessage().apply { this.chatId = chatId this.draftMessage = draft - if (threadId != null && threadId != 0L) { - this.topicId = TdApi.MessageTopicThread(threadId) - } + this.topicId = resolveTopicId(chatId, threadId) } safeExecute(request) } 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 a3b263e1..5f03d3e0 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -113,6 +113,20 @@ class MessageRepositoryImpl( is TdApi.UpdateMessageContent -> { val extracted = messageMapper.extractCachedContent(update.newContent) + + if (update.newContent is TdApi.MessagePhoto && extracted.text.isBlank()) { + val refreshed = messageRemoteDataSource.getMessage(update.chatId, update.messageId) + if (refreshed != null) { + chatLocalDataSource.insertMessage( + messageMapper.mapToEntity( + refreshed, + ::resolveSenderName + ) + ) + return + } + } + chatLocalDataSource.updateMessageContent( messageId = update.messageId, content = extracted.text, @@ -1073,11 +1087,7 @@ class MessageRepositoryImpl( TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null) { - TdApi.MessageTopicForum(threadId.toInt()) - } else { - null - } + val topicId = resolveTopicId(chatId, threadId) gateway.execute( TdApi.SendInlineQueryResultMessage( @@ -1092,6 +1102,20 @@ class MessageRepositoryImpl( ) } + private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { + if (threadId == null || threadId == 0L) return null + + val chat = cache.getChat(chatId) + ?: coRunCatching { gateway.execute(TdApi.GetChat(chatId)) }.getOrNull() + ?.also { cache.putChat(it) } + + return if (chat?.viewAsTopics == true) { + TdApi.MessageTopicForum(threadId.toInt()) + } else { + TdApi.MessageTopicThread(threadId) + } + } + override suspend fun getChatEventLog( chatId: Long, query: String, From 15621a5b6e3333b806d8feb1b63162c360ede917 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:56:59 +0300 Subject: [PATCH 44/53] fix #174 --- .../features/chats/currentChat/ChatContent.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 2df3cd4a..5814bae9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -490,7 +490,14 @@ fun ChatContent( state = state, component = component, contentAlpha = contentAlpha, - onBack = { keyboardController?.hide(); component.onBackClicked() }, + onBack = { + keyboardController?.hide() + if (state.currentTopicId != null) { + component.onTopicClick(0) + } else { + component.onBackClicked() + } + }, onOpenMenu = { keyboardController?.hide() focusManager.clearFocus(force = true) @@ -1162,7 +1169,7 @@ fun ChatContent( 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.onBackClicked() + else if (state.currentTopicId != null) component.onTopicClick(0) } } } From 193347c7ded6e08b4566222da498bda4ce31ed7d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:37:44 +0300 Subject: [PATCH 45/53] slow mode, mute etc. support --- .../chats/currentChat/ChatComponent.kt | 4 + .../features/chats/currentChat/ChatContent.kt | 4 + .../currentChat/components/ChatInputBar.kt | 620 +++++++++++++----- .../inputbar/ChatInputBarComposerSection.kt | 7 + .../inputbar/InputBarLeadingIcons.kt | 31 +- .../components/inputbar/InputBarSendButton.kt | 156 +++-- .../inputbar/InputTextFieldContainer.kt | 56 +- .../chats/currentChat/impl/ChatInfo.kt | 36 +- .../stickers/ui/menu/StickerEmojiMenu.kt | 22 +- 9 files changed, 687 insertions(+), 249 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index ef86b211..c4ed1475 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -196,6 +196,10 @@ interface ChatComponent { val canWrite: Boolean = false, val isAdmin: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val memberCount: Int = 0, val onlineCount: Int = 0, val unreadCount: Int = 0, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 5814bae9..dc40d055 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -517,6 +517,10 @@ fun ChatContent( isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed ?: false, permissions = state.permissions, + slowModeDelay = state.slowModeDelay, + slowModeDelayExpiresIn = state.slowModeDelayExpiresIn, + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate, isAdmin = state.isAdmin, isChannel = state.isChannel, isBot = state.isBot, 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/currentChat/components/ChatInputBar.kt index bb8d8b91..81ed4653 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/currentChat/components/ChatInputBar.kt @@ -6,9 +6,14 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.* @@ -19,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay import org.monogram.domain.models.* import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.StickerRepository @@ -28,7 +34,9 @@ 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.* import org.monogram.presentation.features.gallery.GalleryScreen +import java.text.DateFormat import java.util.* +import kotlin.math.ceil @Immutable data class ChatInputBarState( @@ -38,6 +46,10 @@ data class ChatInputBarState( val pendingMediaPaths: List = emptyList(), val isClosed: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val isAdmin: Boolean = false, val isChannel: Boolean = false, val isBot: Boolean = false, @@ -88,6 +100,12 @@ data class ChatInputBarActions( val onSendScheduledNow: (MessageModel) -> Unit = {}, ) +private enum class InputBarMode { + Composer, + SlowMode, + Restricted +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatInputBar( @@ -101,10 +119,6 @@ fun ChatInputBar( return } - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - val canWriteText = remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) } @@ -113,9 +127,18 @@ fun ChatInputBar( state.isAdmin, state.permissions.canSendPhotos, state.permissions.canSendVideos, - state.permissions.canSendDocuments + state.permissions.canSendDocuments, + state.permissions.canSendAudios ) { - if (state.isChannel) true else (state.isAdmin || (state.permissions.canSendPhotos || state.permissions.canSendVideos || state.permissions.canSendDocuments)) + if (state.isChannel) { + true + } else { + state.isAdmin || + state.permissions.canSendPhotos || + state.permissions.canSendVideos || + state.permissions.canSendDocuments || + state.permissions.canSendAudios + } } val canSendStickers = remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) @@ -123,6 +146,16 @@ fun ChatInputBar( val canSendVoice = remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) } + val canSendVideoNotes = remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { + if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) + } + val canSendAnything = remember(canWriteText, canSendMedia, canSendStickers, canSendVoice, canSendVideoNotes) { + canWriteText || canSendMedia || canSendStickers || canSendVoice || canSendVideoNotes + } + + val context = LocalContext.current + val emojiStyle by appPreferences.emojiStyle.collectAsState() + val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } var textValue by remember { mutableStateOf(TextFieldValue(state.draftText)) } var isStickerMenuVisible by remember { mutableStateOf(false) } @@ -196,9 +229,54 @@ fun ChatInputBar( } } + LaunchedEffect(canSendStickers) { + if (!canSendStickers && isStickerMenuVisible) { + isStickerMenuVisible = false + } + } + + LaunchedEffect(canSendVideoNotes, canSendVoice) { + if (!canSendVideoNotes && isVideoMessageMode) { + isVideoMessageMode = false + } + if (!canSendVoice && canSendVideoNotes) { + isVideoMessageMode = true + } + } + var lastEditingMessageId by remember { mutableStateOf(null) } - val voiceRecorder = rememberVoiceRecorder(onRecordingFinished = actions.onSendVoice) + var slowModeRemainingSeconds by remember { + mutableIntStateOf(0) + } + LaunchedEffect(state.slowModeDelay, state.slowModeDelayExpiresIn, state.isAdmin) { + slowModeRemainingSeconds = if (!state.isAdmin && state.slowModeDelay > 0) { + ceil(state.slowModeDelayExpiresIn).toInt().coerceAtLeast(0) + } else { + 0 + } + } + LaunchedEffect(slowModeRemainingSeconds, state.slowModeDelay, state.isAdmin) { + if (!state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0) { + delay(1000) + slowModeRemainingSeconds = (slowModeRemainingSeconds - 1).coerceAtLeast(0) + } + } + val isSlowModeActive = remember(state.isAdmin, state.slowModeDelay, slowModeRemainingSeconds) { + !state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0 + } + + fun activateSlowModeCooldown() { + if (!state.isAdmin && state.slowModeDelay > 0) { + slowModeRemainingSeconds = state.slowModeDelay + } + } + + val voiceRecorder = rememberVoiceRecorder { path, duration, waveform -> + if (!canSendVoice || isSlowModeActive) return@rememberVoiceRecorder + actions.onSendVoice(path, duration, waveform) + activateSlowModeCooldown() + } val maxMessageLength = remember(state.pendingMediaPaths, state.isPremiumUser) { if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 } @@ -209,11 +287,25 @@ fun ChatInputBar( if (isOverMessageLimit) return@sendWithOptions val isTextEmpty = textValue.text.isBlank() val captionEntities = extractEntities(textValue.annotatedString, knownCustomEmojis) + val isScheduling = it.scheduleDate != null + var sentInstantMessage = false + + val canSendNow = when { + state.pendingMediaPaths.isNotEmpty() && canSendMedia -> true + state.editingMessage != null -> false + canWriteText && !isTextEmpty -> true + else -> false + } + + if (isSlowModeActive && canSendNow && !isScheduling) { + return@sendWithOptions + } if (state.pendingMediaPaths.isNotEmpty() && canSendMedia) { actions.onSendMedia(state.pendingMediaPaths, textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling } else if (state.editingMessage != null && canWriteText) { if (!isTextEmpty) { actions.onSaveEdit(textValue.text, captionEntities) @@ -222,6 +314,11 @@ fun ChatInputBar( actions.onSend(textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling + } + + if (sentInstantMessage) { + activateSlowModeCooldown() } if (it.scheduleDate != null) { @@ -421,6 +518,26 @@ fun ChatInputBar( if (granted) showCamera = true } + val inputBarMode = remember( + canSendAnything, + isSlowModeActive, + textValue.text, + state.pendingMediaPaths, + state.editingMessage, + voiceRecorder.isRecording + ) { + when { + !canSendAnything -> InputBarMode.Restricted + isSlowModeActive && + textValue.text.isBlank() && + state.pendingMediaPaths.isEmpty() && + state.editingMessage == null && + !voiceRecorder.isRecording -> InputBarMode.SlowMode + + else -> InputBarMode.Composer + } + } + if (showCamera) { CameraScreen( onImageCaptured = { uri -> @@ -434,157 +551,203 @@ fun ChatInputBar( ) } else { Box { - ChatInputBarComposerSection( - editingMessage = state.editingMessage, - replyMessage = state.replyMessage, - pendingMediaPaths = state.pendingMediaPaths, - mentionSuggestions = state.mentionSuggestions, - filteredCommands = filteredCommands, - currentInlineBotUsername = state.currentInlineBotUsername, - isInlineBotLoading = state.isInlineBotLoading, - inlineBotResults = state.inlineBotResults, - isBot = state.isBot, - botMenuButton = state.botMenuButton, - botCommands = state.botCommands, - scheduledMessagesCount = state.scheduledMessages.size, - textValue = textValue, - onTextValueChange = { textValue = it }, - knownCustomEmojis = knownCustomEmojis, - emojiFontFamily = emojiFontFamily, - focusRequester = focusRequester, - canWriteText = canWriteText, - canSendMedia = canSendMedia, - canSendStickers = canSendStickers, - canSendVoice = canSendVoice, - isStickerMenuVisible = isStickerMenuVisible, - closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, - isKeyboardVisible = isKeyboardVisible, - transitionHoldBottomInset = transitionHoldBottomInset, - stickerMenuHeight = stickerMenuHeight, - voiceRecorder = voiceRecorder, - isGifSearchFocused = isGifSearchFocused, - showFullScreenEditor = showFullScreenEditor, - currentMessageLength = currentMessageLength, - maxMessageLength = maxMessageLength, - isOverMessageLimit = isOverMessageLimit, - isVideoMessageMode = isVideoMessageMode, - replyMarkup = state.replyMarkup, - showSendOptionsSheet = showSendOptionsSheet, - stickerRepository = stickerRepository, - onCancelEdit = actions.onCancelEdit, - onCancelReply = actions.onCancelReply, - onCancelMedia = actions.onCancelMedia, - onMediaOrderChange = actions.onMediaOrderChange, - onMediaClick = actions.onMediaClick, - onMentionClick = { user -> - textValue = applyMentionSuggestion(textValue, user) - }, - onMentionQueryClear = { actions.onMentionQueryChange(null) }, - onInlineResultClick = { resultId -> - actions.onSendInlineResult(resultId) - textValue = TextFieldValue("") + AnimatedContent( + targetState = inputBarMode, + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + slideInVertically(animationSpec = tween(220)) { it / 4 }) + .togetherWith( + fadeOut(animationSpec = tween(150)) + slideOutVertically(animationSpec = tween(150)) { it / 4 } + ) }, - onInlineSwitchPmClick = { text -> - state.currentInlineBotUsername?.let { username -> - actions.onInlineSwitchPm(username, text) - } - }, - onLoadMoreInlineResults = actions.onLoadMoreInlineResults, - onCommandClick = { command -> - actions.onSend("/$command", emptyList(), MessageSendOptions()) - textValue = TextFieldValue("") - }, - onAttachClick = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showGallery = true - }, - onStickerMenuToggle = { - if (isStickerMenuVisible) { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = true - closeStickerMenuWithoutSlide = true - isStickerMenuVisible = false - focusRequester.requestFocus() - } else { - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - if (isKeyboardVisible) { - openStickerMenuAfterKeyboardClosed = true + label = "InputBarModeTransition" + ) { mode -> + when (mode) { + InputBarMode.Composer -> ChatInputBarComposerSection( + editingMessage = state.editingMessage, + replyMessage = state.replyMessage, + pendingMediaPaths = state.pendingMediaPaths, + mentionSuggestions = state.mentionSuggestions, + filteredCommands = filteredCommands, + currentInlineBotUsername = state.currentInlineBotUsername, + isInlineBotLoading = state.isInlineBotLoading, + inlineBotResults = state.inlineBotResults, + isBot = state.isBot, + botMenuButton = state.botMenuButton, + botCommands = state.botCommands, + scheduledMessagesCount = state.scheduledMessages.size, + textValue = textValue, + onTextValueChange = { textValue = it }, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + canWriteText = canWriteText, + canSendMedia = canSendMedia, + canSendStickers = canSendStickers, + canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, + isStickerMenuVisible = isStickerMenuVisible, + closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, + isKeyboardVisible = isKeyboardVisible, + transitionHoldBottomInset = transitionHoldBottomInset, + 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, + onCancelMedia = actions.onCancelMedia, + onMediaOrderChange = actions.onMediaOrderChange, + onMediaClick = actions.onMediaClick, + onMentionClick = { user -> + textValue = applyMentionSuggestion(textValue, user) + }, + onMentionQueryClear = { actions.onMentionQueryChange(null) }, + onInlineResultClick = { resultId -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onSendInlineResult(resultId) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onInlineSwitchPmClick = { text -> + state.currentInlineBotUsername?.let { username -> + actions.onInlineSwitchPm(username, text) + } + }, + onLoadMoreInlineResults = actions.onLoadMoreInlineResults, + onCommandClick = { command -> + if (isSlowModeActive || !canWriteText) return@ChatInputBarComposerSection + actions.onSend("/$command", emptyList(), MessageSendOptions()) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onAttachClick = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showGallery = true + }, + onStickerMenuToggle = { + if (isStickerMenuVisible) { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = true + closeStickerMenuWithoutSlide = true + isStickerMenuVisible = false + focusRequester.requestFocus() + } else { + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + if (isKeyboardVisible) { + openStickerMenuAfterKeyboardClosed = true + hideKeyboardAndClearFocus() + } else { + openStickerMenuAfterKeyboardClosed = false + isStickerMenuVisible = true + focusManager.clearFocus() + } + } + }, + onShowBotCommands = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false hideKeyboardAndClearFocus() - } else { + actions.onShowBotCommands() + }, + onOpenMiniApp = actions.onOpenMiniApp, + onInputFocus = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + if (isStickerMenuVisible) { + closeStickerMenuWithoutSlide = true + } + isStickerMenuVisible = false + }, + onOpenFullScreenEditor = { showFullScreenEditor = true }, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true + }, + onSendWithOptions = sendWithOptions, + onShowSendOptionsMenu = { openStickerMenuAfterKeyboardClosed = false - isStickerMenuVisible = true - focusManager.clearFocus() + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showSendOptionsSheet = true + actions.onRefreshScheduledMessages() + }, + onCameraClick = { + hideKeyboardAndClearFocus() + actions.onCameraClick() + }, + onVideoModeToggle = { + if (canSendVideoNotes) { + isVideoMessageMode = !isVideoMessageMode + } + }, + onVoiceStart = { + hideKeyboardAndClearFocus() + voiceRecorder.startRecording() + }, + onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, + onVoiceLock = { voiceRecorder.lockRecording() }, + onSendSilent = { + showSendOptionsSheet = false + sendWithOptions(MessageSendOptions(silent = true)) + }, + onScheduleMessage = { + showSendOptionsSheet = false + pendingScheduleDateMillis = null + showScheduleDatePicker = true + }, + onOpenScheduledMessagesFromPopup = { + showSendOptionsSheet = false + showScheduledMessagesSheet = true + actions.onRefreshScheduledMessages() + }, + onDismissSendOptions = { showSendOptionsSheet = false }, + onStickerClick = { stickerPath -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onStickerClick(stickerPath) + activateSlowModeCooldown() + }, + onGifClick = { gif -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onGifClick(gif) + activateSlowModeCooldown() + }, + onGifSearchFocusedChange = { isGifSearchFocused = it }, + onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick + ) + + InputBarMode.SlowMode -> SlowModeInputBar( + remainingSeconds = slowModeRemainingSeconds, + scheduledMessagesCount = state.scheduledMessages.size, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true } - } - }, - onShowBotCommands = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - actions.onShowBotCommands() - }, - onOpenMiniApp = actions.onOpenMiniApp, - onInputFocus = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - if (isStickerMenuVisible) { - closeStickerMenuWithoutSlide = true - } - isStickerMenuVisible = false - }, - onOpenFullScreenEditor = { showFullScreenEditor = true }, - onOpenScheduledMessages = { - actions.onRefreshScheduledMessages() - showScheduledMessagesSheet = true - }, - onSendWithOptions = sendWithOptions, - onShowSendOptionsMenu = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showSendOptionsSheet = true - actions.onRefreshScheduledMessages() - }, - onCameraClick = { - hideKeyboardAndClearFocus() - actions.onCameraClick() - }, - onVideoModeToggle = { isVideoMessageMode = !isVideoMessageMode }, - onVoiceStart = { - hideKeyboardAndClearFocus() - voiceRecorder.startRecording() - }, - onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, - onVoiceLock = { voiceRecorder.lockRecording() }, - onSendSilent = { - showSendOptionsSheet = false - sendWithOptions(MessageSendOptions(silent = true)) - }, - onScheduleMessage = { - showSendOptionsSheet = false - pendingScheduleDateMillis = null - showScheduleDatePicker = true - }, - onOpenScheduledMessagesFromPopup = { - showSendOptionsSheet = false - showScheduledMessagesSheet = true - actions.onRefreshScheduledMessages() - }, - onDismissSendOptions = { showSendOptionsSheet = false }, - onStickerClick = actions.onStickerClick, - onGifClick = actions.onGifClick, - onGifSearchFocusedChange = { isGifSearchFocused = it }, - onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick - ) + ) + + InputBarMode.Restricted -> RestrictedInputBar( + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate + ) + } + } FullScreenEditorSheet( visible = showFullScreenEditor, @@ -746,3 +909,152 @@ private fun ClosedTopicBar() { ) } } + +@Composable +private fun SlowModeInputBar( + remainingSeconds: Int, + scheduledMessagesCount: Int, + onOpenScheduledMessages: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.slow_mode_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + AnimatedContent( + targetState = remainingSeconds.coerceAtLeast(0), + transitionSpec = { + (fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)) { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(120)) + slideOutVertically(animationSpec = tween(120)) { -it / 2 } + ) + }, + label = "SlowModeRemaining" + ) { seconds -> + Text( + text = formatSlowModeDuration(seconds), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (scheduledMessagesCount > 0) { + IconButton(onClick = onOpenScheduledMessages) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = stringResource(R.string.action_scheduled_messages), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun RestrictedInputBar( + isCurrentUserRestricted: Boolean, + restrictedUntilDate: Int +) { + val restrictionDetails = remember(isCurrentUserRestricted, restrictedUntilDate) { + if (!isCurrentUserRestricted) { + null + } else if (restrictedUntilDate <= 0) { + RestrictionDetails.Permanent + } else { + RestrictionDetails.Until(formatRestrictedUntilDate(restrictedUntilDate)) + } + } + + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.input_error_not_allowed), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + AnimatedVisibility( + visible = restrictionDetails != null, + enter = fadeIn(animationSpec = tween(220)) + expandVertically(animationSpec = tween(220)), + exit = fadeOut(animationSpec = tween(140)) + shrinkVertically(animationSpec = tween(140)) + ) { + val detailsText = when (restrictionDetails) { + is RestrictionDetails.Until -> stringResource( + R.string.logs_restricted_until, + restrictionDetails.value + ) + + RestrictionDetails.Permanent -> stringResource(R.string.logs_restricted_permanently) + null -> "" + } + + Text( + text = detailsText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +private sealed interface RestrictionDetails { + data class Until(val value: String) : RestrictionDetails + data object Permanent : RestrictionDetails +} + +private fun formatRestrictedUntilDate(epochSeconds: Int): String { + val formatter = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + return formatter.format(Date(epochSeconds.toLong() * 1000L)) +} + +private fun formatSlowModeDuration(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} 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 index ce1b8000..d3982133 100644 --- 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 @@ -45,6 +45,7 @@ fun ChatInputBarComposerSection( canSendMedia: Boolean, canSendStickers: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, isStickerMenuVisible: Boolean, closeStickerMenuWithoutSlide: Boolean, isKeyboardVisible: Boolean, @@ -57,6 +58,8 @@ fun ChatInputBarComposerSection( maxMessageLength: Int, isOverMessageLimit: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, replyMarkup: ReplyMarkupModel?, showSendOptionsSheet: Boolean, stickerRepository: StickerRepository, @@ -248,8 +251,11 @@ fun ChatInputBarComposerSection( isOverCharLimit = isOverMessageLimit, canWriteText = canWriteText, canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, canSendMedia = canSendMedia, isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds, onSendWithOptions = onSendWithOptions, onShowSendOptionsMenu = onShowSendOptionsMenu, onCameraClick = onCameraClick, @@ -329,6 +335,7 @@ fun ChatInputBarComposerSection( onGifSelected = onGifClick, onSearchFocused = onGifSearchFocusedChange, panelHeight = stickerMenuHeight, + canSendStickers = canSendStickers, stickerRepository = stickerRepository ) } 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/currentChat/components/inputbar/InputBarLeadingIcons.kt index e1f548fc..9179d9d8 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/currentChat/components/inputbar/InputBarLeadingIcons.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import androidx.compose.animation.* import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -21,15 +22,27 @@ fun InputBarLeadingIcons( canSendMedia: Boolean, onAttachClick: () -> Unit ) { - if (editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia) { - IconButton(onClick = onAttachClick) { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = stringResource(R.string.cd_attach), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + val canAttachMedia = editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia + + AnimatedContent( + targetState = canAttachMedia, + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.85f)).togetherWith( + fadeOut() + scaleOut(targetScale = 0.85f) + ).using(SizeTransform(clip = false)) + }, + label = "AttachIconVisibility" + ) { showAttach -> + if (showAttach) { + IconButton(onClick = onAttachClick) { + Icon( + imageVector = Icons.Outlined.AddCircleOutline, + contentDescription = stringResource(R.string.cd_attach), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Spacer(modifier = Modifier.width(12.dp)) } - } else if (!canSendMedia) { - Spacer(modifier = Modifier.width(12.dp)) } } 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/currentChat/components/inputbar/InputBarSendButton.kt index 52f668b5..09459e4b 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/currentChat/components/inputbar/InputBarSendButton.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,8 +43,11 @@ fun InputBarSendButton( isOverCharLimit: Boolean, canWriteText: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, canSendMedia: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, onSendWithOptions: (MessageSendOptions) -> Unit, onShowSendOptionsMenu: () -> Unit, onCameraClick: () -> Unit, @@ -54,34 +58,48 @@ fun InputBarSendButton( ) { val haptic = LocalHapticFeedback.current val isTextEmpty = textValue.text.isBlank() + val canSendContent = canWriteText || (pendingMediaPaths.isNotEmpty() && canSendMedia) + val isSlowModeBlocked = isSlowModeActive && editingMessage == null val isSendEnabled = - (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && canWriteText && !isOverCharLimit + (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && + canSendContent && + !isOverCharLimit && + !isSlowModeBlocked var isVoiceRecordingActive by remember { mutableStateOf(false) } - val isRecordingMode = isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canSendVoice + val effectiveVideoMode = when { + !canSendVideoNotes -> false + !canSendVoice -> true + else -> isVideoMessageMode + } + val canUseRecording = canSendVoice || canSendVideoNotes + val canToggleRecordingMode = canSendVoice && canSendVideoNotes + val isRecordingMode = + isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canUseRecording && !isSlowModeBlocked val backgroundColor by animateColorAsState( - targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, animationSpec = tween(250), label = "BackgroundColor" ) val contentColor by animateColorAsState( - targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = tween(250), label = "ContentColor" ) - if (canWriteText || canSendVoice) { + if (canWriteText || canSendVoice || canSendVideoNotes) { val sendIcon = when { pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send editingMessage != null -> Icons.Default.Check !isTextEmpty -> Icons.AutoMirrored.Filled.Send - isVideoMessageMode -> Icons.Default.Videocam + effectiveVideoMode -> Icons.Default.Videocam else -> Icons.Outlined.Mic } val canShowOptions = editingMessage == null && canWriteText && (!isTextEmpty || (pendingMediaPaths.isNotEmpty() && canSendMedia)) && - !isOverCharLimit + !isOverCharLimit && + !isSlowModeBlocked Box( modifier = Modifier @@ -90,7 +108,7 @@ fun InputBarSendButton( .clip(CircleShape) .then( if (isRecordingMode) { - Modifier.pointerInput(isVideoMessageMode) { + Modifier.pointerInput(effectiveVideoMode, canToggleRecordingMode) { awaitEachGesture { try { awaitFirstDown() @@ -101,7 +119,7 @@ fun InputBarSendButton( } if (up == null) { - if (isVideoMessageMode) { + if (effectiveVideoMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onCameraClick() waitForUpOrCancellation() @@ -143,8 +161,10 @@ fun InputBarSendButton( } } } else { - onVideoModeToggle() - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + if (canToggleRecordingMode) { + onVideoModeToggle() + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } } } finally { if (isVoiceRecordingActive) { @@ -172,54 +192,78 @@ fun InputBarSendButton( ), contentAlignment = Alignment.Center ) { - AnimatedContent( - targetState = sendIcon, - transitionSpec = { - val enteringSend = targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check - val leavingSend = initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check - - when { - enteringSend && !leavingSend -> { - (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( - initialScale = 0.4f, - animationSpec = tween(220, delayMillis = 50) - ) + slideInVertically { it / 2 }) - .togetherWith( - fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.4f, - animationSpec = tween(180) - ) + slideOutVertically { -it / 2 }) - } + if (isSlowModeBlocked) { + Text( + text = formatSlowModeCountdown(slowModeRemainingSeconds), + style = MaterialTheme.typography.labelSmall, + color = contentColor + ) + } else { + AnimatedContent( + targetState = sendIcon, + transitionSpec = { + val enteringSend = + targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check + val leavingSend = + initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check - !enteringSend && leavingSend -> { - (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( - initialScale = 0.4f, - animationSpec = tween(220, delayMillis = 50) - ) + slideInVertically { -it / 2 }) - .togetherWith( - fadeOut(animationSpec = tween(180)) + scaleOut( - targetScale = 0.4f, - animationSpec = tween(180) - ) + slideOutVertically { it / 2 }) - } + when { + enteringSend && !leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { -it / 2 }) + } - else -> { - (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( - fadeOut( - animationSpec = tween(200) - ) + scaleOut(targetScale = 0.8f) - ) - } - }.using(SizeTransform(clip = false)) - }, - label = "IconAnimation" - ) { icon -> - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(24.dp) - ) + !enteringSend && leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { -it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { it / 2 }) + } + + else -> { + (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( + fadeOut( + animationSpec = tween(200) + ) + scaleOut(targetScale = 0.8f) + ) + } + }.using(SizeTransform(clip = false)) + }, + label = "IconAnimation" + ) { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } } } } } + +private fun formatSlowModeCountdown(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} 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/currentChat/components/inputbar/InputTextFieldContainer.kt index 2fbab909..1a1fb50d 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/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -62,29 +62,41 @@ fun InputTextFieldContainer( modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) ) { val showBotActions = remember(isBot, textValue.text) { isBot && textValue.text.isEmpty() } - if (showBotActions) { - BotInputActions( - botMenuButton = botMenuButton, - botCommands = botCommands, - canSendStickers = canSendStickers, - isStickerMenuVisible = isStickerMenuVisible, - onStickerMenuToggle = onStickerMenuToggle, - onShowBotCommands = onShowBotCommands, - onOpenMiniApp = onOpenMiniApp - ) - } else if (canSendStickers) { - IconButton( - onClick = onStickerMenuToggle, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + AnimatedContent( + targetState = showBotActions to canSendStickers, + transitionSpec = { + (fadeIn() + expandHorizontally()).togetherWith(fadeOut() + shrinkHorizontally()) + }, + label = "InputActionsVisibility" + ) { (showBotActionsState, canSendStickersState) -> + when { + showBotActionsState -> { + BotInputActions( + botMenuButton = botMenuButton, + botCommands = botCommands, + canSendStickers = canSendStickersState, + isStickerMenuVisible = isStickerMenuVisible, + onStickerMenuToggle = onStickerMenuToggle, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp + ) + } + + canSendStickersState -> { + IconButton( + onClick = onStickerMenuToggle, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> Spacer(modifier = Modifier.width(8.dp)) } - } else { - Spacer(modifier = Modifier.width(8.dp)) } InputTextField( 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/currentChat/impl/ChatInfo.kt index b2b01b23..3bc2b5fa 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/currentChat/impl/ChatInfo.kt @@ -32,6 +32,19 @@ internal fun DefaultChatComponent.loadChatInfo() { } } } + + runCatching { chatInfoRepository.getChatFullInfo(chatId) } + .getOrNull() + ?.let { fullInfo -> + _state.update { + it.copy( + slowModeDelay = fullInfo.slowModeDelay, + slowModeDelayExpiresIn = fullInfo.slowModeDelayExpiresIn + ) + } + } + + refreshCurrentUserRestrictionState() } chatListRepository.chatListFlow @@ -65,6 +78,14 @@ internal fun DefaultChatComponent.loadChatInfo() { } .launchIn(scope) + chatListRepository.chatListFlow + .map { chats -> chats.find { it.id == chatId } } + .filterNotNull() + .map { chat -> chat.permissions to chat.isMember } + .distinctUntilChanged() + .onEach { refreshCurrentUserRestrictionState() } + .launchIn(scope) + forumTopicsRepository.forumTopicsFlow .filter { it.first == chatId } .onEach { (_, topics) -> @@ -203,4 +224,17 @@ internal fun DefaultChatComponent.handleConfirmRestrict( ) _state.update { it.copy(restrictUserId = null) } } -} \ No newline at end of file +} + +private suspend fun DefaultChatComponent.refreshCurrentUserRestrictionState() { + val me = runCatching { userRepository.getMe() }.getOrNull() ?: return + val status = runCatching { chatInfoRepository.getChatMember(chatId, me.id)?.status }.getOrNull() + val restrictedStatus = status as? ChatMemberStatus.Restricted + + _state.update { + it.copy( + isCurrentUserRestricted = restrictedStatus != null, + restrictedUntilDate = restrictedStatus?.restrictedUntilDate ?: 0 + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt index 1a8e3c47..58b6ba96 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt @@ -31,12 +31,14 @@ fun StickerEmojiMenu( onGifSelected: (GifModel) -> Unit, panelHeight: Dp = 400.dp, emojiOnlyMode: Boolean = false, + canSendStickers: Boolean = true, onSearchFocused: (Boolean) -> Unit = {}, stickerRepository: StickerRepository ) { - var selectedTab by remember(emojiOnlyMode) { mutableIntStateOf(if (emojiOnlyMode) 1 else 0) } + val stickersAndGifsAllowed = !emojiOnlyMode && canSendStickers + var selectedTab by remember(stickersAndGifsAllowed) { mutableIntStateOf(if (stickersAndGifsAllowed) 0 else 1) } var isSearchMode by remember { mutableStateOf(false) } - val tabs = if (emojiOnlyMode) { + val tabs = if (!stickersAndGifsAllowed) { listOf(Triple(stringResource(R.string.sticker_menu_tab_emojis), Icons.Outlined.EmojiEmotions, 1)) } else { listOf( @@ -46,6 +48,12 @@ fun StickerEmojiMenu( ) } + LaunchedEffect(stickersAndGifsAllowed) { + if (!stickersAndGifsAllowed && selectedTab != 1) { + selectedTab = 1 + } + } + Surface( modifier = if (isSearchMode) { Modifier @@ -63,14 +71,14 @@ fun StickerEmojiMenu( Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { when (selectedTab) { - 0 -> StickersView( + 0 -> if (stickersAndGifsAllowed) StickersView( onStickerSelected = onStickerSelected, onSearchFocused = { focused -> isSearchMode = focused onSearchFocused(focused) }, contentPadding = PaddingValues(bottom = 76.dp) - ) + ) else Unit 1 -> EmojisGrid( onEmojiSelected = onEmojiSelected, @@ -82,7 +90,7 @@ fun StickerEmojiMenu( contentPadding = PaddingValues(bottom = 76.dp) ) - 2 -> GifsView( + 2 -> if (stickersAndGifsAllowed) GifsView( onGifSelected = onGifSelected, onSearchFocused = { focused -> isSearchMode = focused @@ -90,12 +98,12 @@ fun StickerEmojiMenu( }, contentPadding = PaddingValues(bottom = 76.dp), stickerRepository = stickerRepository - ) + ) else Unit } } AnimatedVisibility( - visible = !isSearchMode && !emojiOnlyMode, + visible = !isSearchMode && tabs.size > 1, enter = fadeIn(animationSpec = tween(180)) + slideInVertically(animationSpec = tween(220)) { it / 4 }, exit = fadeOut(animationSpec = tween(130)) + From 4c23aac134fe8baf8b8149f3227665998b4295ee Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:43:48 +0300 Subject: [PATCH 46/53] fix disable drag-to-back in chats --- .../org/monogram/app/components/MobileLayout.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/monogram/app/components/MobileLayout.kt b/app/src/main/java/org/monogram/app/components/MobileLayout.kt index 02fa8480..40f04f0d 100644 --- a/app/src/main/java/org/monogram/app/components/MobileLayout.kt +++ b/app/src/main/java/org/monogram/app/components/MobileLayout.kt @@ -29,11 +29,20 @@ import org.monogram.presentation.root.RootComponent @Composable fun MobileLayout(root: RootComponent) { val stack by root.childStack.subscribeAsState() + val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState() val coroutineScope = rememberCoroutineScope() val dragOffsetX = remember { Animatable(0f) } val previous = stack.items.dropLast(1).lastOrNull()?.instance var swipeBackInProgress by remember { mutableStateOf(false) } var widthPx by remember { mutableFloatStateOf(0f) } + val canUseDragToBack = + isDragToBackEnabled && stack.active.instance is RootComponent.Child.ChatDetailChild + + LaunchedEffect(canUseDragToBack) { + if (!canUseDragToBack && dragOffsetX.value > 0f) { + dragOffsetX.snapTo(0f) + } + } if (dragOffsetX.value > 0 && previous != null) { Box(modifier = Modifier.fillMaxSize()) { @@ -57,8 +66,8 @@ fun MobileLayout(root: RootComponent) { widthPx = it.width.toFloat() } .then( - if (stack.active.instance is RootComponent.Child.ChatDetailChild) { - Modifier.pointerInput(Unit) { + if (canUseDragToBack) { + Modifier.pointerInput(canUseDragToBack) { var isDragging = false detectHorizontalDragGestures( onDragStart = { offset -> From 6d4df006b2f3cb2d86c82cfbcfaa99797f42790e Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 08:07:23 +0300 Subject: [PATCH 47/53] disable fast reply in pinned messages list --- .../features/chats/currentChat/ChatContent.kt | 20 ------------------- .../pins/PinnedMessagesListSheet.kt | 4 ++++ 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index dc40d055..3116be69 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -444,26 +444,6 @@ fun ChatContent( .background(MaterialTheme.colorScheme.background) .onGloballyPositioned { containerSize = it.size } ) { - /*if (isDragToBackEnabled && !isTablet && !isCustomBackHandlingEnabled && dragOffsetX.value > 0 && previousChild != null) { - Box( - modifier = Modifier.fillMaxSize() - ) { - renderChild(previousChild) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Color.Black.copy( - alpha = 0.3f * (1f - (dragOffsetX.value / containerSize.width.toFloat()).coerceIn( - 0f, - 1f - )) - ) - ) - ) - } - }*/ - Box( modifier = Modifier .fillMaxSize() 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/currentChat/components/pins/PinnedMessagesListSheet.kt index d7d4dd4f..446302b8 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -128,6 +128,7 @@ fun PinnedMessagesListSheet( letterSpacing = state.letterSpacing, bubbleRadius = state.bubbleRadius, stickerSize = state.stickerSize, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -145,6 +146,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } @@ -172,6 +174,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -189,6 +192,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } From 1fabd249d6683adfa4ef1c0b827b69965cc6fb73 Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 10:07:21 +0300 Subject: [PATCH 48/53] disable fast reply for closed topics --- .../chats/currentChat/chatContent/ChatContentList.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 14861e44..d22cdcfe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -494,6 +494,7 @@ private fun MessageBubbleSwitcher( isAnyViewerOpen: Boolean = false ) { val isChannel = state.isChannel && state.currentTopicId == null + val isTopicClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed?: false when (item) { is GroupedMessageItem.Single -> { @@ -696,7 +697,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && !isTopicClosed, onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -764,7 +765,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && !isTopicClosed, onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, From 7bfa626662f3281dc3963815355c1b96cf32b5d0 Mon Sep 17 00:00:00 2001 From: andr0d1v Date: Tue, 7 Apr 2026 11:46:06 +0300 Subject: [PATCH 49/53] fix: fast reply in closed topics for admins --- .../features/chats/currentChat/chatContent/ChatContentList.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index d22cdcfe..09182a0c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -697,7 +697,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && !isTopicClosed, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -765,7 +765,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode && !isTopicClosed, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, From e82334750f3d4b56e4d9bb95d4852c7afed574e4 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Tue, 7 Apr 2026 13:37:03 +0300 Subject: [PATCH 50/53] fix compile --- .../chats/currentChat/components/pins/PinnedMessagesListSheet.kt | 1 - 1 file changed, 1 deletion(-) 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/currentChat/components/pins/PinnedMessagesListSheet.kt index 446302b8..804f9a1a 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -128,7 +128,6 @@ fun PinnedMessagesListSheet( letterSpacing = state.letterSpacing, bubbleRadius = state.bubbleRadius, stickerSize = state.stickerSize, - canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { From d7874b217abf4d33f14dc23f4bcd895477efee3c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:20:53 +0300 Subject: [PATCH 51/53] fix chat settings ui --- .../chatSettings/ChatSettingsContent.kt | 25 +++++++++++++------ .../src/main/res/values-es/string.xml | 1 - .../src/main/res/values-hy/string.xml | 1 - .../src/main/res/values-pt-rBR/string.xml | 1 - .../src/main/res/values-ru-rRU/string.xml | 5 ++-- .../src/main/res/values-sk/string.xml | 1 - .../src/main/res/values-uk/string.xml | 1 - .../src/main/res/values-zh-rCN/string.xml | 1 - presentation/src/main/res/values/string.xml | 1 - 9 files changed, 19 insertions(+), 18 deletions(-) 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 9364f2e7..ecd2f415 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 @@ -1094,12 +1094,20 @@ private fun AppearanceSliderItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) Spacer(modifier = Modifier.width(8.dp)) Surface( @@ -1117,13 +1125,14 @@ private fun AppearanceSliderItem( } TextButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp) + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(32.dp) ) { - Text( - stringResource(R.string.reset_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold + Icon( + imageVector = Icons.Rounded.RestartAlt, + contentDescription = stringResource(R.string.photo_editor_action_reset), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) ) } } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 672f2ce0..88f570c5 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -570,7 +570,6 @@ Espaciado de letras del mensaje Redondeo de burbuja Tamaño del sticker - Restablecer Fondo de Pantalla del Chat Restablecer Fondo de Pantalla Subir fondo de pantalla diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 85b22b7c..6d8d6d3a 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -541,7 +541,6 @@ Տեքստի չափը Տառերի հեռավորությունը Հաղորդագրության կլորացումը - Վերակայել Չատի պաստառ Վերակայել պաստառը Վերբեռնել պաստառ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 105cf8e4..072a4c06 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -571,7 +571,6 @@ Espaçamento entre letras Arredondamento das bolhas Tamanho das figurinhas - Redefinir Papel de parede do chat Redefinir papel de parede Carregar papel de parede diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 5f326f41..2e24ee89 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -553,11 +553,10 @@ Настройки чатов Внешний вид - Размер текста сообщений - Межбуквенный интервал сообщений + Размер текста + Межбуквенный интервал Скругление блоков Размер стикеров - Сбросить Обои чата Сбросить обои Загрузить обои diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index e8071d05..16488a7a 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -587,7 +587,6 @@ Veľkosť textu správy Zaoblenie bublín Veľkosť nálepiek - Obnoviť Tapeta chatu Obnoviť tapetu Nahrať tapetu diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index acf87fba..9567abc3 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -557,7 +557,6 @@ Міжлітерний інтервал повідомлень Скруглення блоків Розмір стікерів - Скинути Шпалери чату Скинути шпалери Завантажити шпалери diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 957ddd7b..62c0e7d8 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -557,7 +557,6 @@ 字元間距 气泡圆角 贴纸大小 - 重置 会话壁纸 重置壁纸 上传壁纸 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 369d7463..47e880d4 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -577,7 +577,6 @@ Message letter spacing Bubble rounding Sticker size - Reset Chat Wallpaper Reset Wallpaper Upload Wallpaper From 7221654f0baf58901ef812496d84f964bce98c95 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:58:31 +0300 Subject: [PATCH 52/53] pins redesign + fix #73 --- .../chats/currentChat/ChatComponent.kt | 1 + .../features/chats/currentChat/ChatContent.kt | 4 +- .../chats/currentChat/ChatStoreFactory.kt | 82 ++------- .../pins/PinnedMessagesListSheet.kt | 164 +++++++++++++++--- .../chats/currentChat/impl/PinnedMessages.kt | 15 +- 5 files changed, 166 insertions(+), 100 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index c4ed1475..2087a697 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -218,6 +218,7 @@ interface ChatComponent { val pinnedMessage: MessageModel? = null, val allPinnedMessages: List = emptyList(), val showPinnedMessagesList: Boolean = false, + val isLoadingPinnedMessages: Boolean = false, val pinnedMessageCount: Int = 0, val pinnedMessageIndex: Int = 0, val scrollToMessageId: Long? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 3116be69..8117543e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -994,9 +994,9 @@ fun ChatContent( PinnedMessagesListSheet( state = state, onDismiss = { component.onDismissPinnedMessages() }, - onMessageClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + onMessageClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onUnpin = { component.onUnpinMessage(it) }, - onReplyClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + onReplyClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onReactionClick = { id, r -> component.onSendReaction(id, r) }, downloadUtils = component.downloadUtils ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt index 3597411e..a338601e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt @@ -7,70 +7,7 @@ import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.flow.update import org.monogram.presentation.features.chats.currentChat.ChatStore.Intent import org.monogram.presentation.features.chats.currentChat.ChatStore.Label -import org.monogram.presentation.features.chats.currentChat.impl.handleAcceptMiniAppTOS -import org.monogram.presentation.features.chats.currentChat.impl.handleAddToAdBlockWhitelist -import org.monogram.presentation.features.chats.currentChat.impl.handleAddToGifs -import org.monogram.presentation.features.chats.currentChat.impl.handleBlockUser -import org.monogram.presentation.features.chats.currentChat.impl.handleBotCommandClick -import org.monogram.presentation.features.chats.currentChat.impl.handleCancelDownloadFile -import org.monogram.presentation.features.chats.currentChat.impl.handleClearHistory -import org.monogram.presentation.features.chats.currentChat.impl.handleClearMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleClearSelection -import org.monogram.presentation.features.chats.currentChat.impl.handleClosePoll -import org.monogram.presentation.features.chats.currentChat.impl.handleCommentsClick -import org.monogram.presentation.features.chats.currentChat.impl.handleConfirmRestrict -import org.monogram.presentation.features.chats.currentChat.impl.handleCopyLink -import org.monogram.presentation.features.chats.currentChat.impl.handleCopySelectedMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteChat -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteSelectedMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleDismissInvoice -import org.monogram.presentation.features.chats.currentChat.impl.handleDismissMiniAppTOS -import org.monogram.presentation.features.chats.currentChat.impl.handleDownloadFile -import org.monogram.presentation.features.chats.currentChat.impl.handleDownloadHighRes -import org.monogram.presentation.features.chats.currentChat.impl.handleDraftChange -import org.monogram.presentation.features.chats.currentChat.impl.handleInlineQueryChange -import org.monogram.presentation.features.chats.currentChat.impl.handleJoinChat -import org.monogram.presentation.features.chats.currentChat.impl.handleKeyboardButtonClick -import org.monogram.presentation.features.chats.currentChat.impl.handleLoadMoreInlineResults -import org.monogram.presentation.features.chats.currentChat.impl.handleMentionQueryChange -import org.monogram.presentation.features.chats.currentChat.impl.handleMessageVisible -import org.monogram.presentation.features.chats.currentChat.impl.handleOpenInvoice -import org.monogram.presentation.features.chats.currentChat.impl.handleOpenMiniApp -import org.monogram.presentation.features.chats.currentChat.impl.handlePinMessage -import org.monogram.presentation.features.chats.currentChat.impl.handlePinnedMessageClick -import org.monogram.presentation.features.chats.currentChat.impl.handlePollOptionClick -import org.monogram.presentation.features.chats.currentChat.impl.handleRemoveFromAdBlockWhitelist -import org.monogram.presentation.features.chats.currentChat.impl.handleReplyMarkupButtonClick -import org.monogram.presentation.features.chats.currentChat.impl.handleReportMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleReportReasonSelected -import org.monogram.presentation.features.chats.currentChat.impl.handleRetractVote -import org.monogram.presentation.features.chats.currentChat.impl.handleSaveEditedMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleSendAlbum -import org.monogram.presentation.features.chats.currentChat.impl.handleSendGif -import org.monogram.presentation.features.chats.currentChat.impl.handleSendGifFile -import org.monogram.presentation.features.chats.currentChat.impl.handleSendInlineResult -import org.monogram.presentation.features.chats.currentChat.impl.handleSendMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleSendPhoto -import org.monogram.presentation.features.chats.currentChat.impl.handleSendReaction -import org.monogram.presentation.features.chats.currentChat.impl.handleSendScheduledNow -import org.monogram.presentation.features.chats.currentChat.impl.handleSendSticker -import org.monogram.presentation.features.chats.currentChat.impl.handleSendVideo -import org.monogram.presentation.features.chats.currentChat.impl.handleSendVoice -import org.monogram.presentation.features.chats.currentChat.impl.handleShowVoters -import org.monogram.presentation.features.chats.currentChat.impl.handleStickerClick -import org.monogram.presentation.features.chats.currentChat.impl.handleToggleMessageSelection -import org.monogram.presentation.features.chats.currentChat.impl.handleToggleMute -import org.monogram.presentation.features.chats.currentChat.impl.handleTopicClick -import org.monogram.presentation.features.chats.currentChat.impl.handleUnblockUser -import org.monogram.presentation.features.chats.currentChat.impl.handleUnpinMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleVideoRecorded -import org.monogram.presentation.features.chats.currentChat.impl.loadAllPinnedMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadMoreMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadNewerMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadScheduledMessages -import org.monogram.presentation.features.chats.currentChat.impl.scrollToBottomInternal -import org.monogram.presentation.features.chats.currentChat.impl.scrollToMessageInternal +import org.monogram.presentation.features.chats.currentChat.impl.* class ChatStoreFactory( private val storeFactory: StoreFactory, @@ -167,10 +104,23 @@ class ChatStoreFactory( is Intent.PinnedMessageClick -> component.handlePinnedMessageClick(intent.message) is Intent.ShowAllPinnedMessages -> { - component._state.update { it.copy(showPinnedMessagesList = true) } + component._state.update { + it.copy( + showPinnedMessagesList = true, + isLoadingPinnedMessages = true + ) + } component.loadAllPinnedMessages() } - is Intent.DismissPinnedMessages -> component._state.update { it.copy(showPinnedMessagesList = false) } + + is Intent.DismissPinnedMessages -> { + component._state.update { + it.copy( + showPinnedMessagesList = false, + isLoadingPinnedMessages = false + ) + } + } is Intent.ScrollToMessageConsumed -> component._state.update { it.copy(scrollToMessageId = null) } is Intent.ScrollToBottom -> component.scrollToBottomInternal() is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId) 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/currentChat/components/pins/PinnedMessagesListSheet.kt index 804f9a1a..a1aca31e 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -10,19 +10,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.ChatComponent 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.* +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 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,6 +44,9 @@ fun PinnedMessagesListSheet( ) { val messages = state.allPinnedMessages val groupedMessages = remember(messages) { groupMessagesByAlbum(messages.distinctBy { it.id }) } + val isLoadingPinnedMessages = state.isLoadingPinnedMessages && messages.isEmpty() + val displayedPinnedCount = maxOf(state.pinnedMessageCount, messages.size) + val shimmerBrush = rememberShimmerBrush() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( @@ -53,33 +62,65 @@ fun PinnedMessagesListSheet( .fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.pinned_messages), + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = pluralStringResource(R.plurals.pinned_count, messages.size, messages.size), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.Bold, + maxLines = 2, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis ) + Spacer(modifier = Modifier.height(2.dp)) + if (isLoadingPinnedMessages) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .width(108.dp) + .height(18.dp) + .background( + brush = shimmerBrush, + shape = RoundedCornerShape(9.dp) + ) + ) + } else { + Text( + text = pluralStringResource( + R.plurals.pinned_count, + displayedPinnedCount, + displayedPinnedCount + ), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) - LazyColumn( - modifier = Modifier - .weight(1f) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), - contentPadding = PaddingValues(8.dp) - ) { + if (isLoadingPinnedMessages) { + PinnedMessagesLoadingSkeleton( + brush = shimmerBrush, + isChannel = state.isChannel, + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)) + ) + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), + contentPadding = PaddingValues(8.dp) + ) { itemsIndexed(groupedMessages, key = { _, item -> when (item) { is GroupedMessageItem.Single -> "pin_${item.message.id}" @@ -201,15 +242,86 @@ fun PinnedMessagesListSheet( } } - Box(modifier = Modifier.padding(16.dp)) { - Button( - onClick = onDismiss, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) + } + } + } +} + +private data class PinnedSkeletonConfig( + val isOutgoing: Boolean, + val bubbleWidth: Float, + val lineWidths: List +) + +@Composable +private fun PinnedMessagesLoadingSkeleton( + brush: Brush, + isChannel: Boolean, + modifier: Modifier = Modifier +) { + val items = listOf( + PinnedSkeletonConfig(false, 0.82f, listOf(0.92f, 0.64f)), + PinnedSkeletonConfig(true, 0.58f, listOf(0.8f)), + PinnedSkeletonConfig(false, 0.74f, listOf(0.88f, 0.7f)), + PinnedSkeletonConfig(true, 0.62f, listOf(0.86f, 0.6f)), + PinnedSkeletonConfig(false, 0.68f, listOf(0.76f)), + PinnedSkeletonConfig(true, 0.8f, listOf(0.9f, 0.62f)), + PinnedSkeletonConfig(false, 0.56f, listOf(0.72f)) + ) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(items) { _, item -> + val outgoing = !isChannel && item.isOutgoing + val bubbleColor = if (outgoing) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.65f) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = bubbleColor, + modifier = Modifier.fillMaxWidth(item.bubbleWidth) ) { - Text(text = stringResource(R.string.pinned_close), fontSize = 16.sp, fontWeight = FontWeight.Bold) + Column( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 9.dp, bottom = 7.dp) + ) { + item.lineWidths.forEachIndexed { index, width -> + Box( + modifier = Modifier + .fillMaxWidth(width) + .height(14.dp) + .background( + brush = brush, + shape = RoundedCornerShape(5.dp) + ) + ) + if (index != item.lineWidths.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + } + } + + Spacer(modifier = Modifier.height(7.dp)) + + Box( + modifier = Modifier + .align(Alignment.End) + .width(if (outgoing) 44.dp else 32.dp) + .height(10.dp) + .background( + brush = brush, + shape = RoundedCornerShape(3.dp) + ) + ) + } } } } 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/currentChat/impl/PinnedMessages.kt index 1ae23a2b..223b501d 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/currentChat/impl/PinnedMessages.kt @@ -35,13 +35,18 @@ internal fun DefaultChatComponent.loadPinnedMessage() { internal fun DefaultChatComponent.loadAllPinnedMessages() { scope.launch { val threadId = _state.value.currentTopicId + _state.update { it.copy(isLoadingPinnedMessages = true) } try { val pinnedMessages = repositoryMessage.getAllPinnedMessages(chatId, threadId) _state.update { - it.copy(allPinnedMessages = pinnedMessages) + it.copy( + allPinnedMessages = pinnedMessages, + isLoadingPinnedMessages = false + ) } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error loading all pinned messages", e) + _state.update { it.copy(isLoadingPinnedMessages = false) } } } } @@ -109,6 +114,9 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { val threadId = _state.value.currentTopicId val messages = repositoryMessage.getMessagesAround(chatId, message.id, 50, threadId) if (messages.isNotEmpty()) { + updateMessages(messages, replace = true) + lastLoadedOlderId = 0L + lastLoadedNewerId = 0L _state.update { it.copy( scrollToMessageId = message.id, @@ -118,11 +126,6 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { isOldestLoaded = false ) } - updateMessages(messages, replace = true) - lastLoadedOlderId = 0L - lastLoadedNewerId = 0L - loadMoreMessages() - loadNewerMessages() } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error jumping to message", e) From 4809dce5754ff2ab61393bfc3e1f5246cadd59a7 Mon Sep 17 00:00:00 2001 From: Andro_Dev Date: Tue, 7 Apr 2026 18:26:03 +0300 Subject: [PATCH 53/53] . --- .../pins/PinnedMessagesListSheet.kt | 92 +------------------ 1 file changed, 3 insertions(+), 89 deletions(-) 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/currentChat/components/pins/PinnedMessagesListSheet.kt index 88458456..8565d675 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/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -247,94 +247,6 @@ fun PinnedMessagesListSheet( null -> null } -<<<<<<< feat/fast-reply - Box(modifier = Modifier.animateItem()) { - if (state.isChannel) { - if (item is GroupedMessageItem.Single) { - ChannelMessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, - downloadUtils = downloadUtils - ) - } else if (item is GroupedMessageItem.Album) { - AlbumMessageBubbleContainer( - messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = false, - isChannel = true, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - canReply = false, - downloadUtils = downloadUtils - ) - } - } else { - if (item is GroupedMessageItem.Single) { - MessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - 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, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - 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 = state.isGroup, - isChannel = false, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - canReply = false, - downloadUtils = downloadUtils - ) -======= val newerMsg = when (val newerItem = groupedMessages.getOrNull(index - 1)) { is GroupedMessageItem.Single -> newerItem.message is GroupedMessageItem.Album -> newerItem.messages.first() @@ -344,7 +256,6 @@ fun PinnedMessagesListSheet( if (shouldShowDate(msg, olderMsg)) { DateSeparator(msg.date) Spacer(modifier = Modifier.height(8.dp)) ->>>>>>> develop } Box(modifier = Modifier.animateItem()) { @@ -384,6 +295,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } @@ -411,6 +323,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -428,6 +341,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) }