diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt deleted file mode 100644 index f3f5fa7e41..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageList.kt +++ /dev/null @@ -1,421 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -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.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEach -import androidx.paging.LoadState -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey -import com.getcode.model.ID -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.MessageStatus -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.messagecontents.MessageContentActionHandler -import com.getcode.ui.components.chat.messagecontents.MessageContextAction -import com.getcode.ui.components.chat.messagecontents.SelectedReaction -import com.getcode.ui.components.chat.utils.ChatItem -import com.getcode.ui.components.chat.utils.MessageReaction -import com.getcode.ui.components.chat.utils.MessageTip -import com.getcode.ui.components.text.markup.Markup -import com.getcode.util.formatDateRelatively -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -sealed interface MessageListEvent { - data class AdvancePointer(val messageId: ID) : MessageListEvent - data class OpenMessageActions(val messageId: ID, val actions: List) : MessageListEvent - data class OnMarkupEvent(val markup: Markup.Interactive) : MessageListEvent - data class ReplyToMessage(val message: ChatItem.Message) : MessageListEvent - data class ViewOriginalMessage(val messageId: ID, val originalMessageId: ID) : MessageListEvent - data object UnreadStateHandled : MessageListEvent - data class TipMessage(val message: ChatItem.Message) : MessageListEvent - data class ViewUserProfile(val userId: ID): MessageListEvent - data class AddReaction(val messageId: ID, val emoji: String): MessageListEvent - data class RemoveReaction(val originalMessageId: ID): MessageListEvent - data class ShowMessageReactions( - val tips: List, - val reactions: List, - val startingWith: SelectedReaction, - ) : MessageListEvent -} - -data class MessageListPointer( - val current: ChatItem.Message, - val previous: ChatItem.Message?, - val next: ChatItem.Message? -) - -data class MessageListPointerResult( - val isPreviousGrouped: Boolean, - val isNextGrouped: Boolean, -) - -@Composable -fun MessageList( - modifier: Modifier = Modifier, - listState: LazyListState = rememberLazyListState(), - contentStyle: TextStyle = MessageNodeDefaults.ContentStyle, - messages: LazyPagingItems, - handleMessagePointers: (MessageListPointer) -> MessageListPointerResult = { (current, previous, next) -> - val prevGrouped = previous?.chatMessageId == current.chatMessageId - val nextGrouped = next?.chatMessageId == current.chatMessageId - MessageListPointerResult(prevGrouped, nextGrouped) - }, - footer: (@Composable () -> Unit)? = null, - dispatch: (MessageListEvent) -> Unit = { }, -) { - var hasSetAtUnread by rememberSaveable(key = "0") { mutableStateOf(false) } - - HandleMessageReads(listState, messages, hasSetAtUnread) { - dispatch(MessageListEvent.AdvancePointer(it)) - } - - HandleStartAtUnread(listState, messages, hasSetAtUnread) { - hasSetAtUnread = true - dispatch(MessageListEvent.UnreadStateHandled) - } - - LazyColumn( - modifier = modifier, - state = listState, - reverseLayout = true, - contentPadding = PaddingValues( - top = CodeTheme.dimens.inset, - bottom = CodeTheme.dimens.grid.x1, - ), - verticalArrangement = Arrangement.Top, - ) { - if (footer != null) { - item { - footer.invoke() - } - } - - items( - count = messages.itemCount, - key = messages.itemKey { item -> item.key }, - contentType = messages.itemContentType { item -> - when (item) { - is ChatItem.Date -> "date" - is ChatItem.Message -> "message" - is ChatItem.UnreadSeparator -> "unread_divider" - is ChatItem.Separators -> "separator" - } - } - ) { index -> - when (val item = messages[index]) { - is ChatItem.Date -> DateBubble( - modifier = Modifier - .padding(vertical = CodeTheme.dimens.grid.x2), - date = item.dateString - ) - - is ChatItem.Message -> { - // reverse layout so +1 to get previous - val prev = messages.safeGet(index + 1) as? ChatItem.Message - val next = messages.safeGet(index - 1) as? ChatItem.Message - - val pointerRef = MessageListPointer(item, prev, next) - - val (isPreviousGrouped, isNextGrouped) = handleMessagePointers(pointerRef) - - val spacingBefore = when { - item.message is MessageContent.Announcement -> CodeTheme.dimens.grid.x1 - else -> 0.dp - } - val spacingAfter = when { - index > messages.itemCount -> 0.dp - item.message is MessageContent.Announcement -> CodeTheme.dimens.grid.x2 - isNextGrouped -> 3.dp - else -> CodeTheme.dimens.grid.x2 - } - - val showTimestamp = - remember(isPreviousGrouped, isNextGrouped, item.date, next?.date) { - !isPreviousGrouped - || !isNextGrouped - || next?.date?.epochSeconds?.div(60) != item.date.epochSeconds.div( - 60 - ) - } - - val updatedSender by rememberUpdatedState(item.sender) - val updatedActions by rememberUpdatedState(item.messageControls) - - val updatedTips by rememberUpdatedState(item.tips) - val updatedReactions by rememberUpdatedState(item.reactions) - - val actionHandler = remember(item) { - object : MessageContentActionHandler { - override fun openMessageControls() { - dispatch( - MessageListEvent.OpenMessageActions(item.chatMessageId, updatedActions.actions) - ) - } - - override fun giveTip() { - dispatch(MessageListEvent.TipMessage(item)) - } - - override fun addReaction(emoji: String) { - dispatch(MessageListEvent.AddReaction(item.chatMessageId, emoji)) - } - - override fun removeReaction(reactionMessageId: ID) { - dispatch(MessageListEvent.RemoveReaction(reactionMessageId)) - } - - override fun viewReactions(selected: SelectedReaction) { - dispatch(MessageListEvent.ShowMessageReactions(updatedTips, updatedReactions, selected)) - } - - override fun startReply() { - dispatch(MessageListEvent.ReplyToMessage(item)) - } - - override fun viewOriginalMessage() { - item.originalMessage?.id?.let { - dispatch(MessageListEvent.ViewOriginalMessage(item.chatMessageId, it)) - } - } - - override fun openUserProfile() { - item.sender.id?.let { - dispatch(MessageListEvent.ViewUserProfile(it)) - } - } - } - } - MessageNode( - modifier = Modifier - .fillMaxWidth() - .padding(top = spacingBefore, bottom = spacingAfter), - contents = item.message, - status = item.status, - isDeleted = item.isDeleted, - deletedBy = item.deletedBy, - sender = updatedSender, - date = item.date, - options = MessageNodeOptions( - showStatus = item.showStatus && showTimestamp, - showTimestamp = showTimestamp, - isPreviousGrouped = isPreviousGrouped, - isNextGrouped = isNextGrouped, - isInteractive = updatedActions.hasAny, - canReplyTo = item.enableReply, - canTip = item.enableTipping, - linkImagePreviewEnabled = item.enableLinkImagePreview, - canViewUserProfiles = item.enableAvatarClicks, - onMarkupClicked = if (item.enableMarkup) { markup: Markup.Interactive -> - dispatch(MessageListEvent.OnMarkupEvent(markup)) - } else null, - contentStyle = contentStyle, - ), - tips = item.tips, - reactions = item.reactions, - originalMessage = item.originalMessage, - actionHandler = actionHandler, - ) - } - - is ChatItem.UnreadSeparator -> { - if (item.count > 0) { - UnreadSeparator( - modifier = Modifier - .fillParentMaxWidth() - .padding(vertical = CodeTheme.dimens.grid.x2), - count = item.count - ) - } - } - - is ChatItem.Separators -> { - item.separators.fastForEach { separator -> - when (separator) { - is ChatItem.Date -> { - DateBubble( - modifier = Modifier - .padding(vertical = CodeTheme.dimens.grid.x2), - date = separator.dateString - ) - } - - is ChatItem.UnreadSeparator -> { - if (separator.count > 0) { - UnreadSeparator( - modifier = Modifier - .fillParentMaxWidth() - .padding(vertical = CodeTheme.dimens.grid.x2), - count = separator.count - ) - } - } - } - } - } - - else -> Unit - } - } - // add last separator - // this isn't handled by paging separators due to no `beforeItem` to reference against - // at end of list due to reverseLayout - if (messages.itemCount > 0) { - (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> - item { - val dateString = remember(date) { - date.formatDateRelatively() - } - DateBubble( - modifier = Modifier - .padding(bottom = CodeTheme.dimens.grid.x2), - date = dateString - ) - } - } - } - - // opts out of the list maintaining - // scroll position when adding elements before the first item - // we are checking first visible item index to ensure - // the list doesn't shift when viewing scroll back - Snapshot.withoutReadObservation { - if (listState.firstVisibleItemIndex == 0) { - listState.requestScrollToItem( - index = listState.firstVisibleItemIndex, - scrollOffset = listState.firstVisibleItemScrollOffset - ) - } - } - } -} - -fun LazyPagingItems.safeGet(index: Int): T? { - return if (index in 0 until itemCount) get(index) else null -} - -@Composable -private fun HandleMessageReads( - listState: LazyListState, - messages: LazyPagingItems, - hasSetAtUnread: Boolean, - markAsRead: (ID) -> Unit, -) { - var lastMarkedMessageId: ID? by remember { mutableStateOf(null) } - - LaunchedEffect(listState, messages, hasSetAtUnread) { - combine( - snapshotFlow { - messages.loadState.prepend is LoadState.NotLoading || - messages.loadState.append is LoadState.NotLoading - }.distinctUntilChanged(), - snapshotFlow { listState.isScrollInProgress }, - snapshotFlow { listState.firstVisibleItemIndex }, - snapshotFlow { messages.itemCount }.distinctUntilChanged() // Avoid rapid duplicate emissions - ) { loadState, isScrolling, firstVisibleIndex, itemCount -> - Triple(loadState, isScrolling, firstVisibleIndex) to itemCount - } - .filter { (state, itemCount) -> - val (loadStateIsNotLoading, isScrolling, firstVisibleIndex) = state - // Ensure we react to new messages only when truly at the bottom - loadStateIsNotLoading && !isScrolling && hasSetAtUnread && - (firstVisibleIndex == itemCount - 1 || firstVisibleIndex == 0) && itemCount > 0 - } - .distinctUntilChanged() // Ensure we don't process the same event multiple times - .onEach { (state, _) -> - val (_, _, firstVisibleIndex) = state - - val closestChatMessage = - messages[firstVisibleIndex]?.let { it as? ChatItem.Message } - - val mostRecentReadMessage = - messages.itemSnapshotList.filterIsInstance() - .filter { it.status == MessageStatus.Read } - .maxByOrNull { it.date } - - val mostRecentReadAt = mostRecentReadMessage?.date - - closestChatMessage?.let { message -> - // Prevent duplicate mark-as-read calls for the same message - if (message.status != MessageStatus.Read && message.chatMessageId != lastMarkedMessageId) { - if (mostRecentReadAt == null || message.date >= mostRecentReadAt) { - lastMarkedMessageId = message.chatMessageId - markAsRead(message.chatMessageId) - } - } - } - }.launchIn(this) - } - -} - -@Composable -private fun HandleStartAtUnread( - listState: LazyListState, - messages: LazyPagingItems, - hasSetAtUnread: Boolean, - onHandled: () -> Unit, -) { - // Flag to ensure scroll logic runs only once - var hasScrolledToUnread by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(listState, messages) { - snapshotFlow { messages.loadState } - .filterNot { hasScrolledToUnread } - .collect { loadState -> - if (loadState.refresh is LoadState.NotLoading && messages.itemCount > 0) { - val separatorIndex = messages.itemSnapshotList - .indexOfFirst { it is ChatItem.UnreadSeparator || (it is ChatItem.Separators && it.separators.any { it is ChatItem.UnreadSeparator }) } - - if (separatorIndex > 0 && !hasSetAtUnread) { - val previousItemIndex = separatorIndex - 1 - - // First scroll to bring the item into view - listState.scrollToItem(previousItemIndex) - - // Dynamically calculate the correct offset - val itemInfo = listState.layoutInfo.visibleItemsInfo - .find { it.index == previousItemIndex } - - itemInfo?.let { - val viewportEnd = listState.layoutInfo.viewportEndOffset - val offsetFromEnd = viewportEnd - (it.offset + it.size) - - // Scroll only if the item isn't sufficiently visible - if (offsetFromEnd > 0) { - listState.scrollToItem(previousItemIndex, scrollOffset = it.offset) - } - } - hasScrolledToUnread = true - onHandled() - } else { - hasScrolledToUnread = true - onHandled() - } - } - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt deleted file mode 100644 index d3f2f20547..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt +++ /dev/null @@ -1,598 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.splineBasedDecay -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxWithConstraintsScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import com.getcode.model.chat.Deleter -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.MessageStatus -import com.getcode.model.chat.Sender -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.messagecontents.ActionableAnnouncementMessage -import com.getcode.ui.components.chat.messagecontents.AnnouncementMessage -import com.getcode.ui.components.chat.messagecontents.DeletedMessage -import com.getcode.ui.components.chat.messagecontents.EncryptedContent -import com.getcode.ui.components.chat.messagecontents.MarkupTouchHandler -import com.getcode.ui.components.chat.messagecontents.MessageContentActionHandler -import com.getcode.ui.components.chat.messagecontents.MessagePayment -import com.getcode.ui.components.chat.messagecontents.MessageReplyContent -import com.getcode.ui.components.chat.messagecontents.MessageText -import com.getcode.ui.components.chat.utils.MessageReaction -import com.getcode.ui.components.chat.utils.MessageTip -import com.getcode.ui.components.chat.utils.ReplyMessageAnchor -import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.ui.components.text.markup.Markup -import com.getcode.ui.components.user.social.SenderNameDisplay -import com.getcode.ui.core.noRippleClickable -import com.getcode.util.vibration.LocalVibrator -import kotlin.math.roundToInt -import kotlin.reflect.KClass -import kotlin.time.Instant - -object MessageNodeDefaults { - - val DefaultShape: CornerBasedShape - @Composable get() = CodeTheme.shapes.small - - private val PreviousSameShapeIncoming: CornerBasedShape - @Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp)) - - private val NextSameShapeIncoming: CornerBasedShape - @Composable get() = DefaultShape.copy( - bottomStart = CornerSize(3.dp), - topStart = CornerSize(3.dp) - ) - - private val MiddleSameShapeIncoming: CornerBasedShape - @Composable get() = DefaultShape.copy( - topStart = CornerSize(3.dp), - bottomStart = CornerSize(3.dp) - ) - - private val PreviousSameShapeOutgoing: CornerBasedShape - @Composable get() = DefaultShape.copy(topEnd = CornerSize(3.dp)) - - private val NextSameShapeOutgoing: CornerBasedShape - @Composable get() = DefaultShape.copy( - bottomEnd = CornerSize(3.dp), - topEnd = CornerSize(3.dp) - ) - - private val MiddleSameShapeOutgoing: CornerBasedShape - @Composable get() = DefaultShape.copy( - bottomEnd = CornerSize(3.dp), - topEnd = CornerSize(3.dp) - ) - - @Composable - fun messageShape( - isIncoming: Boolean, - isPreviousInGroup: Boolean, - isNextInGroup: Boolean, - ): Shape { - return if (isIncoming) { - when { - isPreviousInGroup && isNextInGroup -> MiddleSameShapeIncoming - isPreviousInGroup -> PreviousSameShapeIncoming - isNextInGroup -> NextSameShapeIncoming - else -> DefaultShape.copy(topStart = CornerSize(3.dp)) - } - } else { - when { - isPreviousInGroup && isNextInGroup -> MiddleSameShapeOutgoing - isPreviousInGroup -> PreviousSameShapeOutgoing - isNextInGroup -> NextSameShapeOutgoing - else -> DefaultShape.copy(topEnd = CornerSize(3.dp)) - } - } - } - - val ContentStyle: TextStyle - @Composable get() = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) -} - -class MessageNodeScope( - private val contents: MessageContent, - private val boxScope: BoxWithConstraintsScope -) { - fun Modifier.sizeableWidth() = - this.widthIn(max = boxScope.maxWidth * 0.60f) - - val isAnnouncement: Boolean - @Composable get() = remember { - when (contents) { - is MessageContent.Announcement -> true - is MessageContent.ActionableAnnouncement -> true - else -> false - } - } - - val color: Color - @Composable get() = when { - isAnnouncement -> CodeTheme.colors.secondary - contents.isFromSelf -> CodeTheme.colors.secondary - else -> CodeTheme.colors.brandDark - } -} - -@Composable -private fun rememberMessageNodeScope( - contents: MessageContent, - boxScope: BoxWithConstraintsScope -): MessageNodeScope { - return remember(contents, boxScope) { - MessageNodeScope(contents, boxScope) - } -} - -data class MessageNodeOptions( - val showStatus: Boolean = true, - val showTimestamp: Boolean = true, - val isPreviousGrouped: Boolean = false, - val isNextGrouped: Boolean = false, - val isInteractive: Boolean = false, - val canReplyTo: Boolean = false, - val canTip: Boolean = false, - val linkImagePreviewEnabled: Boolean = false, - val canViewUserProfiles: Boolean = false, - val markupsToResolve: List> = listOf( - Markup.RoomNumber::class, - Markup.Url::class, - Markup.Phone::class, - ), - val onMarkupClicked: ((Markup.Interactive) -> Unit)? = null, - val contentStyle: TextStyle, -) - -private enum class MessageNodeDragAnchors { - DEFAULT, REPLY -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun MessageNode( - contents: MessageContent, - isDeleted: Boolean, - deletedBy: Deleter?, - date: Instant, - sender: Sender, - status: MessageStatus, - originalMessage: ReplyMessageAnchor?, - modifier: Modifier = Modifier, - options: MessageNodeOptions = MessageNodeOptions(contentStyle = MessageNodeDefaults.ContentStyle), - tips: List, - reactions: List, - actionHandler: MessageContentActionHandler, -) { - val vibrator = LocalVibrator.current - - val enableReply = remember(contents, options) { - if (!options.canReplyTo) return@remember false - when (contents) { - is MessageContent.RawText -> true - is MessageContent.Reply -> true - else -> false - } - } - - val density = LocalDensity.current - - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val maxWidth = maxWidth - val swipeThreshold = with(density) { maxWidth.toPx() } * 0.40f - var hasReachedThreshold by remember { mutableStateOf(false) } - - val anchors = remember(maxWidth) { - DraggableAnchors { - MessageNodeDragAnchors.DEFAULT at 0f - MessageNodeDragAnchors.REPLY at swipeThreshold - } - } - - val replyDragState = remember(anchors) { - AnchoredDraggableState( - initialValue = MessageNodeDragAnchors.DEFAULT, - anchors = anchors, - positionalThreshold = { it * 0.9f }, - velocityThreshold = { Float.POSITIVE_INFINITY }, - confirmValueChange = { targetValue -> - if (targetValue == MessageNodeDragAnchors.REPLY && !hasReachedThreshold) { - hasReachedThreshold = true - vibrator.tick() - } - false - }, - snapAnimationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - decayAnimationSpec = splineBasedDecay(density) - ) - } - - LaunchedEffect(hasReachedThreshold, replyDragState.targetValue) { - if (hasReachedThreshold && replyDragState.targetValue == MessageNodeDragAnchors.DEFAULT && replyDragState.isAnimationRunning) { - actionHandler.startReply() - hasReachedThreshold = false - } - } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - // Block vertical scrolling only when horizontal drag is active - return if (replyDragState.offset != 0f) Offset.Zero else super.onPreScroll( - available, - source - ) - } - } - } - - Box( - modifier = Modifier - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - clip = false - } - .nestedScroll(nestedScrollConnection) - .anchoredDraggable( - state = replyDragState, - enabled = enableReply, - orientation = Orientation.Horizontal, - reverseDirection = false - ) - ) { - Box( - modifier = Modifier - .offset { - IntOffset( - x = replyDragState.offset.coerceAtMost(maxWidth.toPx() * 0.3f) - .roundToInt(), - y = 0 - ) - } - ) { - val scope = rememberMessageNodeScope( - contents = contents, - boxScope = this@BoxWithConstraints - ) - - with(scope) { - val shape = when { - isAnnouncement -> MessageNodeDefaults.DefaultShape - else -> MessageNodeDefaults.messageShape( - !sender.isSelf, options.isPreviousGrouped, options.isNextGrouped - ) - } - - if (isDeleted) { - ContentFromSender( - modifier = Modifier.fillMaxWidth(), - sender = sender, - canOpenProfile = options.canViewUserProfiles, - openUserProfile = actionHandler::openUserProfile, - isFirstInSeries = !options.isPreviousGrouped - ) { - DeletedMessage( - modifier = Modifier.fillMaxWidth(), - sender = sender, - deletedBy = deletedBy, - shape = shape, - date = date, - ) - } - } else { - when (contents) { - is MessageContent.Exchange -> { - val alignment = - if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = alignment - ) { - MessagePayment( - modifier = Modifier - .sizeableWidth() - .background(color = color, shape = shape), - contents = contents, - status = status, - date = date, - ) - } - } - - is MessageContent.Localized -> { - ContentFromSender( - modifier = Modifier.fillMaxWidth(), - sender = sender, - canOpenProfile = options.canViewUserProfiles, - openUserProfile = actionHandler::openUserProfile, - isFirstInSeries = !options.isPreviousGrouped - ) { - MarkupTouchHandler(options = options) { onTap -> - MessageText( - content = contents.localizedText, - shape = shape, - date = date, - status = status, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - options = options, - isFullMember = sender.isFullMember, - tips = tips, - reactions = reactions, - onTap = onTap, - actionHandler = actionHandler, - ) - } - } - } - - is MessageContent.SodiumBox -> { - val alignment = - if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = alignment - ) { - EncryptedContent( - modifier = Modifier - .sizeableWidth() - .background( - color = color, - shape = shape, - ) - .padding(CodeTheme.dimens.grid.x2), - date = date - ) - } - } - - is MessageContent.Decrypted -> { - ContentFromSender( - modifier = Modifier.fillMaxWidth(), - sender = sender, - canOpenProfile = options.canViewUserProfiles, - openUserProfile = actionHandler::openUserProfile, - isFirstInSeries = !options.isPreviousGrouped - ) { - MarkupTouchHandler(options = options) { onTap -> - MessageText( - content = contents.data, - shape = shape, - options = options, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - tips = tips, - reactions = reactions, - date = date, - status = status, - isFullMember = sender.isFullMember, - actionHandler = actionHandler, - onTap = onTap, - ) - } - } - } - - is MessageContent.RawText -> { - ContentFromSender( - modifier = Modifier.fillMaxWidth(), - sender = sender, - canOpenProfile = options.canViewUserProfiles, - openUserProfile = actionHandler::openUserProfile, - isFirstInSeries = !options.isPreviousGrouped - ) { - MarkupTouchHandler(options = options) { onTap -> - MessageText( - content = contents.value, - shape = shape, - options = options, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - tips = tips, - reactions = reactions, - date = date, - status = status, - isFullMember = sender.isFullMember, - actionHandler = actionHandler, - onTap = onTap, - ) - } - } - } - - is MessageContent.ActionableAnnouncement -> { - ActionableAnnouncementMessage( - modifier = Modifier.fillMaxWidth(), - text = contents.localizedText, - action = contents.action, - ) - } - is MessageContent.Announcement -> { - AnnouncementMessage( - modifier = Modifier.fillMaxWidth(), - text = contents.localizedText - ) - } - - is MessageContent.Reaction -> Unit - is MessageContent.Reply -> { - ContentFromSender( - modifier = Modifier.fillMaxWidth(), - sender = sender, - canOpenProfile = options.canViewUserProfiles, - openUserProfile = actionHandler::openUserProfile, - isFirstInSeries = !options.isPreviousGrouped - ) { - MarkupTouchHandler(options = options) { onTap -> - if (originalMessage != null) { - MessageReplyContent( - content = contents.text, - shape = shape, - date = date, - status = status, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - wasSentAsFullMember = sender.isFullMember, - options = options, - tips = tips, - reactions = reactions, - originalMessage = originalMessage, - onTap = onTap, - actionHandler = actionHandler - ) - } else { - MessageText( - content = contents.text, - shape = shape, - options = options, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - tips = tips, - reactions = reactions, - date = date, - status = status, - isFullMember = sender.isFullMember, - actionHandler = actionHandler, - onTap = onTap - ) - } - } - } - } - - is MessageContent.DeletedMessage -> Unit - is MessageContent.Unknown -> Unit - is MessageContent.MessageTip -> Unit - is MessageContent.MessageInReview -> { - // TODO: - } - } - } - } - } - - AnimatedVisibility( - visible = replyDragState.offset > with(density) { 5.dp.toPx() }, - enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut(), - modifier = Modifier - .align(Alignment.CenterStart) - .offset { - IntOffset( - x = replyDragState.offset.times(0.2f).coerceAtMost(20.dp.toPx()) - .roundToInt(), y = 0 - ) - } - ) { - Icon( - modifier = Modifier.graphicsLayer { - alpha = (replyDragState.offset.times(0.2f) / 20.dp.toPx()).coerceIn(0f, 1f) - }, - imageVector = Icons.AutoMirrored.Default.Reply, - contentDescription = "Swipe to Reply", - ) - } - } - } -} - -@Composable -private fun ContentFromSender( - modifier: Modifier = Modifier, - sender: Sender, - isFirstInSeries: Boolean, - canOpenProfile: Boolean, - openUserProfile: () -> Unit, - content: @Composable () -> Unit -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) - ) { - if (!sender.isSelf) { - if (isFirstInSeries) { - Column { - Spacer(Modifier.size(CodeTheme.dimens.grid.x4)) - HostableAvatar( - modifier = Modifier.padding( - top = CodeTheme.dimens.grid.x1, - start = CodeTheme.dimens.inset - ).noRippleClickable(enabled = canOpenProfile) { openUserProfile() }, - imageData = sender.profileImage ?: sender.id, - isHost = sender.isHost - ) - } - } else { - Spacer( - modifier = Modifier - .padding(start = CodeTheme.dimens.inset) - .size(CodeTheme.dimens.staticGrid.x8)) - } - } - Box(modifier = Modifier.weight(1f)) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) - ) { - if (!sender.isSelf && isFirstInSeries) { - SenderNameDisplay(sender) - } - content() - } - } - Spacer(Modifier.width(CodeTheme.dimens.inset)) - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNodeDefaults.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNodeDefaults.kt new file mode 100644 index 0000000000..e8afee9bfb --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNodeDefaults.kt @@ -0,0 +1,72 @@ +package com.getcode.ui.components.chat + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +object MessageNodeDefaults { + + val DefaultShape: CornerBasedShape + @Composable get() = CodeTheme.shapes.small + + private val PreviousSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp)) + + private val NextSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomStart = CornerSize(3.dp), + topStart = CornerSize(3.dp) + ) + + private val MiddleSameShapeIncoming: CornerBasedShape + @Composable get() = DefaultShape.copy( + topStart = CornerSize(3.dp), + bottomStart = CornerSize(3.dp) + ) + + private val PreviousSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy(topEnd = CornerSize(3.dp)) + + private val NextSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomEnd = CornerSize(3.dp), + topEnd = CornerSize(3.dp) + ) + + private val MiddleSameShapeOutgoing: CornerBasedShape + @Composable get() = DefaultShape.copy( + bottomEnd = CornerSize(3.dp), + topEnd = CornerSize(3.dp) + ) + + @Composable + fun messageShape( + isIncoming: Boolean, + isPreviousInGroup: Boolean, + isNextInGroup: Boolean, + ): Shape { + return if (isIncoming) { + when { + isPreviousInGroup && isNextInGroup -> MiddleSameShapeIncoming + isPreviousInGroup -> PreviousSameShapeIncoming + isNextInGroup -> NextSameShapeIncoming + else -> DefaultShape.copy(topStart = CornerSize(3.dp)) + } + } else { + when { + isPreviousInGroup && isNextInGroup -> MiddleSameShapeOutgoing + isPreviousInGroup -> PreviousSameShapeOutgoing + isNextInGroup -> NextSameShapeOutgoing + else -> DefaultShape.copy(topEnd = CornerSize(3.dp)) + } + } + } + + val ContentStyle: TextStyle + @Composable get() = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt deleted file mode 100644 index 7260b0327a..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/AnnouncementMessage.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.getcode.model.chat.AnnouncementAction -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.Pill -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton - -@Composable -internal fun AnnouncementMessage( - modifier: Modifier = Modifier, - text: String, -) { - BoxWithConstraints(modifier = modifier) { - Pill( - modifier = Modifier - .align(Alignment.Center) - .widthIn(max = maxWidth * 0.78f), - text = text, - textStyle = CodeTheme.typography.caption.copy(textAlign = TextAlign.Center), - backgroundColor = CodeTheme.colors.surfaceVariant, - contentColor = CodeTheme.colors.textSecondary - ) - } -} - -@Composable -internal fun ActionableAnnouncementMessage( - modifier: Modifier = Modifier, - text: String, - action: AnnouncementAction, -) { - val resolver = LocalAnnouncementActionResolver.current - val resolvedAction = remember(resolver, action) { resolver(action) } - - val inset = CodeTheme.dimens.inset - - BoxWithConstraints(modifier = modifier) { - BoxWithConstraints( - modifier = Modifier - .align(Alignment.Center) - .widthIn(max = maxWidth - inset - inset) // max width sans inset on both sides - .border( - color = CodeTheme.colors.tertiary, - width = 1.dp, - shape = CodeTheme.shapes.medium, - ) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x2 - ) - ) contents@{ - Column( - modifier = Modifier, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - - Text( - text = text, - style = CodeTheme.typography.textSmall.copy(textAlign = TextAlign.Center), - color = CodeTheme.colors.textSecondary, - - ) - - if (resolvedAction != null) { - CodeButton( - modifier = Modifier.fillMaxWidth(), - text = resolvedAction.text, - buttonState = ButtonState.Filled, - ) { resolvedAction.onClick() } - } - } - } - } -} - -data class ResolvedAction( - val text: String, - val onClick: () -> Unit -) - -val LocalAnnouncementActionResolver: ProvidableCompositionLocal<(AnnouncementAction) -> ResolvedAction?> = staticCompositionLocalOf { { null } } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt deleted file mode 100644 index 5ee472396d..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DateWithStatus.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import com.getcode.model.chat.MessageStatus -import com.getcode.theme.CodeTheme -import com.getcode.theme.DesignSystem -import com.getcode.ui.components.R -import com.getcode.ui.components.chat.MessageNodeDefaults -import com.getcode.util.formatTimeRelatively -import kotlin.time.Clock -import kotlin.time.Instant - -object DateWithStatusDefaults { - val DateTextStyle: TextStyle - @Composable get() = CodeTheme.typography.caption - val IconWidth: Dp - @Composable get() = CodeTheme.dimens.staticGrid.x3 - val Spacing: Dp - @Composable get() = CodeTheme.dimens.grid.x1 -} - -@Composable -internal fun DateWithStatus( - modifier: Modifier = Modifier, - date: Instant, - isFromSelf: Boolean, - showStatus: Boolean = true, - showTimestamp: Boolean = true, - status: MessageStatus, -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(DateWithStatusDefaults.Spacing, Alignment.End), - ) { - if (showTimestamp) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = date.formatTimeRelatively(), - style = DateWithStatusDefaults.DateTextStyle, - color = CodeTheme.colors.textSecondary, - maxLines = 1 - ) - } - if (status.isValid() && isFromSelf && showStatus) { - Icon( - modifier = Modifier - .requiredWidth(width = DateWithStatusDefaults.IconWidth) - .padding(top = CodeTheme.dimens.grid.x1 / 2), - painter = painterResource( - id = when (status) { - MessageStatus.Sent -> R.drawable.ic_message_status_sent - MessageStatus.Delivered -> R.drawable.ic_message_status_delivered - MessageStatus.Read -> R.drawable.ic_message_status_read - else -> -1 - } - ), - tint = Color.Unspecified, - contentDescription = "status" - ) - } - } -} - - - - -@Preview -@Composable -private fun Preview_DateWithStatus() { - DesignSystem { - @Composable - fun Bubble( - status: MessageStatus - ) { - Box( - modifier = Modifier - .wrapContentWidth() - .background( - color = CodeTheme.colors.secondary, - shape = MessageNodeDefaults.DefaultShape - ) - .padding(CodeTheme.dimens.grid.x2) - ) { - DateWithStatus(date = Clock.System.now(), status = status, isFromSelf = true) - } - } - - Column( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3) - ) { - Bubble(status = MessageStatus.Sent) - Bubble(status = MessageStatus.Delivered) - Bubble(status = MessageStatus.Read) - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt deleted file mode 100644 index 697a574884..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/DeletedMessage.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.getcode.model.chat.Deleter -import com.getcode.model.chat.MessageStatus -import com.getcode.model.chat.Sender -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.R -import com.getcode.ui.components.chat.MessageNodeDefaults -import com.getcode.ui.components.chat.MessageNodeOptions -import com.getcode.ui.components.chat.MessageNodeScope -import kotlin.time.Instant - -@Composable -internal fun MessageNodeScope.DeletedMessage( - modifier: Modifier = Modifier, - sender: Sender, - shape: Shape, - deletedBy: Deleter?, - date: Instant, -) { - val alignment = if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart - - BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { - BoxWithConstraints( - modifier = Modifier - .sizeableWidth() - .border( - color = CodeTheme.colors.tertiary, - width = 1.dp, - shape = shape, - ) - .padding(CodeTheme.dimens.grid.x2) - ) contents@{ - val maxWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } - Column( - modifier = Modifier - // add top padding to accommodate ascents - .padding(top = CodeTheme.dimens.grid.x1), - ) { - - val message = buildAnnotatedString { - when { - deletedBy?.isSelf == true -> { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(stringResource(R.string.title_messageDeletedByYou)) - pop() - } - deletedBy?.isHost == true -> { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(stringResource(R.string.title_messageDeletedByHost)) - pop() - } - else -> { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(stringResource(R.string.title_messageWasDeleted)) - pop() - } - } - } - - MessageContent( - maxWidth = maxWidthPx, - annotatedMessage = message, - date = date, - status = MessageStatus.Unknown, - isFromSelf = sender.isSelf, - isFromBlockedMember = sender.isBlocked, - options = MessageNodeOptions( - contentStyle = MessageNodeDefaults.ContentStyle.copy( - color = CodeTheme.colors.textSecondary, - fontWeight = FontWeight.W400, - ) - ), - ) - } - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt deleted file mode 100644 index c53dc8b8a0..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/EncryptedContent.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.R -import com.getcode.util.formatTimeRelatively -import kotlin.time.Instant - -@Composable -internal fun EncryptedContent(modifier: Modifier = Modifier, date: Instant) { - Column( - modifier = modifier - // add top padding to accommodate ascents - .padding(top = CodeTheme.dimens.grid.x1), - ) { - Image( - modifier = Modifier - .padding(CodeTheme.dimens.grid.x2) - .size(CodeTheme.dimens.staticGrid.x6) - .align(Alignment.CenterHorizontally), - painter = painterResource(id = R.drawable.lock_app_dashed), - colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground), - contentDescription = null - ) - Text( - modifier = Modifier.align(Alignment.End), - text = date.formatTimeRelatively(), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - ) - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/Feedback.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/Feedback.kt deleted file mode 100644 index b4e0f8c2c1..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/Feedback.kt +++ /dev/null @@ -1,300 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import android.annotation.SuppressLint -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed -import androidx.compose.ui.zIndex -import com.getcode.model.RandomId -import com.getcode.model.chat.Sender -import com.getcode.utils.FormatUtils -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.R -import com.getcode.ui.components.chat.UserAvatar -import com.getcode.ui.components.chat.utils.MessageReaction -import com.getcode.ui.components.chat.utils.MessageTip -import com.getcode.ui.emojis.processEmoji -import kotlin.time.Clock - -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun Feedback( - tips: List, - reactions: List, - isMessageFromSelf: Boolean, - modifier: Modifier = Modifier, - actionHandler: MessageContentActionHandler, -) { - if (tips.isNotEmpty() || reactions.isNotEmpty()) { - FlowRow( - modifier = modifier.padding(top = 5.dp).animateContentSize(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - if (tips.isNotEmpty()) { - TipCounter( - tips = tips, - isMessageFromSelf = isMessageFromSelf, - actionHandler = actionHandler, - ) - } - - val rxns = remember(reactions) { - reactions - .sortedBy { it.sentAt } - .groupBy { it.emoji } - } - - rxns.onEach { (emoji, occurrences) -> - EmojiCounter( - emoji = emoji, - occurrences = occurrences, - isMessageFromSelf = isMessageFromSelf, - actionHandler = actionHandler, - ) - } - } - } -} - -@SuppressLint("DefaultLocale") -@Composable -private fun TipCounter( - tips: List, - isMessageFromSelf: Boolean, - modifier: Modifier = Modifier, - actionHandler: MessageContentActionHandler, -) { - val didUserTip = tips.any { it.tipper.isSelf } - val backgroundColor by animateColorAsState( - when { - isMessageFromSelf -> CodeTheme.colors.surface - didUserTip -> CodeTheme.colors.tertiary.copy(alpha = 0.30f) - else -> Color.Transparent - } - ) - - val borderColor by animateColorAsState( - when { - !didUserTip && !isMessageFromSelf -> CodeTheme.colors.dividerVariant - else -> CodeTheme.colors.tertiary - } - ) - - val contentColor by animateColorAsState( - when { - isMessageFromSelf -> CodeTheme.colors.onBackground - didUserTip -> CodeTheme.colors.onBackground - else -> CodeTheme.colors.onBackground - } - ) - - // TODO(chat-v2): reimplement tip formatting with OCP Fiat - val totalTips = FormatUtils.formatWholeRoundDown(tips.sumOf { it.amountInQuarks }.toDouble()) - - Row( - modifier = modifier - .clip(CircleShape) - .handleTapGestures( - onTap = { - if (!isMessageFromSelf) { actionHandler.giveTip() } - }, - onDesiredLongPress = { - actionHandler.viewReactions(SelectedReaction.Tips) - }, - onMissedLongPress = { - actionHandler.openMessageControls() - } - ) - .background(backgroundColor, CircleShape) - .border(CodeTheme.dimens.border, borderColor, CircleShape) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.wrapContentWidth(), - text = stringResource(R.string.title_kinAmountWithLogo, totalTips), - color = contentColor, - maxLines = 1, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.W700), - ) - } -} - -@Composable -private fun EmojiCounter( - emoji: String, - occurrences: List, - isMessageFromSelf: Boolean, - modifier: Modifier = Modifier, - actionHandler: MessageContentActionHandler, -) { - var localOccurrences by remember(occurrences) { mutableStateOf(occurrences) } - - val selfOccurrence = localOccurrences.find { it.sender.isSelf } - val backgroundColor by animateColorAsState( - when { - isMessageFromSelf -> { - if (selfOccurrence != null) { - CodeTheme.colors.tertiary.copy(alpha = 0.54f).compositeOver(CodeTheme.colors.surface) - } else { - Color.Transparent - } - } - selfOccurrence != null -> CodeTheme.colors.tertiary.copy(alpha = 0.30f) - else -> Color.Transparent - } - ) - - val borderColor by animateColorAsState( - when { - selfOccurrence == null -> CodeTheme.colors.dividerVariant - else -> CodeTheme.colors.tertiary - } - ) - - val contentColor by animateColorAsState( - when { - isMessageFromSelf -> CodeTheme.colors.onBackground - selfOccurrence != null -> CodeTheme.colors.onBackground - else -> CodeTheme.colors.onBackground - } - ) - - val countForEmoji = localOccurrences.count() - - Row( - modifier = modifier - .clip(CircleShape) - .handleTapGestures( - key = (emoji + countForEmoji), - onTap = { - if (selfOccurrence != null) { - localOccurrences -= selfOccurrence - actionHandler.removeReaction(selfOccurrence.messageId) - } else { - localOccurrences += MessageReaction( - messageId = RandomId, - sender = Sender(id = RandomId, isSelf = true), - emoji = emoji, - sentAt = Clock.System.now().toEpochMilliseconds() - ) - actionHandler.addReaction(emoji) - } - }, - onDesiredLongPress = { - actionHandler.viewReactions(SelectedReaction.Emoji(emoji)) - }, - onMissedLongPress = { - actionHandler.openMessageControls() - } - ) - .background(backgroundColor, CircleShape) - .border(CodeTheme.dimens.border, borderColor, CircleShape) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - val emojiText = if (LocalInspectionMode.current) { - emoji - } else { - processEmoji(emoji).toString() - } - - Text( - text = "$emojiText $countForEmoji", - color = contentColor, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.W700), - ) - } -} - -private fun Modifier.handleTapGestures( - key: Any = Unit, - onTap: () -> Unit, - onDesiredLongPress: () -> Unit, - onMissedLongPress: () -> Unit, -): Modifier { - return this.pointerInput(key) { - detectTapGestures( - onTap = { onTap() }, - onLongPress = { offset -> - val width = size.width.toFloat() - val height = size.height.toFloat() - - if (width == 0f || height == 0f) return@detectTapGestures - - val isInLast75Percent = offset.y >= (height * 0.25f) - - if (isInLast75Percent) { - onDesiredLongPress() - } else { - onMissedLongPress() - } - }, - ) - } -} - -@Composable -internal fun TipperDisplay( - tips: List, - contentColor: Color, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((-8).dp) - ) { - val imageModifier = Modifier - .size(CodeTheme.dimens.staticGrid.x4) - .clip(CircleShape) - .border(CodeTheme.dimens.border, contentColor, CircleShape) - - val tippers = remember(tips) { tips.map { it.tipper }.distinct() } - - tippers.take(3).fastForEachIndexed { index, tipper -> - UserAvatar( - modifier = imageModifier - .zIndex((tips.size - index).toFloat()), - data = tipper.profileImage.nullIfEmpty() ?: tipper.id - ) - } - } -} - -private fun String?.nullIfEmpty() = if (this?.isEmpty() == true) null else this - diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MarkupTouchHandler.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MarkupTouchHandler.kt deleted file mode 100644 index c6a09e6229..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MarkupTouchHandler.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.TextLayoutResult -import com.getcode.ui.components.chat.MessageNodeOptions -import com.getcode.ui.components.text.markup.Markup - -@Composable -internal fun MarkupTouchHandler( - options: MessageNodeOptions, - content: @Composable (onTap: (contentPadding: PaddingValues, touchOffset: Offset) -> Unit) -> Unit, -) { - MarkupTouchHandler(onMarkupClicked = options.onMarkupClicked, content = content) -} - -@Composable -fun MarkupTouchHandler( - onMarkupClicked: ((Markup.Interactive) -> Unit)? = null, - content: @Composable (onTap: (contentPadding: PaddingValues, touchOffset: Offset) -> Unit) -> Unit, -) { - var layoutResult by remember { mutableStateOf(null) } - - val handleTouchedContent = { offset: Int -> - layoutResult?.layoutInput?.text?.getStringAnnotations( - tag = Markup.RoomNumber.TAG, - start = offset, - end = offset - )?.firstOrNull()?.let { annotation -> - onMarkupClicked?.invoke(Markup.RoomNumber(annotation.item.toLong())) - } - - layoutResult?.layoutInput?.text?.getStringAnnotations( - tag = Markup.Url.TAG, - start = offset, - end = offset - )?.firstOrNull()?.let { annotation -> - onMarkupClicked?.invoke(Markup.Url(annotation.item)) - } - - layoutResult?.layoutInput?.text?.getStringAnnotations( - tag = Markup.Phone.TAG, - start = offset, - end = offset - )?.firstOrNull()?.let { annotation -> - onMarkupClicked?.invoke(Markup.Phone(annotation.item)) - } - } - - val density = LocalDensity.current - val ldr = LocalLayoutDirection.current - CompositionLocalProvider(LocalTextLayoutResult provides { layoutResult = it }) { - content { padding, touchOffset -> - layoutResult?.let { layoutResult -> - val adjustedOffset = Offset( - x = touchOffset.x - with(density) { padding.calculateStartPadding(ldr).toPx() }, - y = touchOffset.y - with(density) { padding.calculateTopPadding().toPx() }) - val position = layoutResult.getOffsetForPosition(adjustedOffset) - handleTouchedContent(position) - } - } - } -} - -val LocalTextLayoutResult: ProvidableCompositionLocal<(TextLayoutResult) -> Unit> = - staticCompositionLocalOf { { } } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageContextAction.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageContextAction.kt deleted file mode 100644 index 98a864c07d..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageContextAction.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Flag -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.PersonRemove -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.VoiceOverOff -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.getcode.ui.components.R -import com.getcode.ui.core.ContextMenuAction - -data class MessageControls( - val actions: List = emptyList() -) { - val hasAny: Boolean - get() = actions.isNotEmpty() -} - - -sealed interface MessageContextAction : ContextMenuAction { - data class Emojis(val onSelect: (String) -> Unit, override val Content: @Composable () -> Unit = { }): MessageContextAction, ContextMenuAction.Custom - - data class Copy(override val onSelect: () -> Unit) : MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = false - override val delayUponSelection: Boolean = false - - override val title: String - @Composable get() = stringResource(R.string.action_copyMessage) - - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.ContentCopy) - } - - data class Reply(override val onSelect: () -> Unit) : MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = false - override val delayUponSelection: Boolean = false - - override val title: String - @Composable get() = stringResource(R.string.action_reply) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Default.Reply) - } - - data class Tip(override val onSelect: () -> Unit) : MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = false - override val title: String - @Composable get() = stringResource(R.string.action_giveTip) - override val painter: Painter - @Composable get() = painterResource(R.drawable.ic_kin_white_small) - override val delayUponSelection: Boolean = true - } - - data class Delete(override val onSelect: () -> Unit) : MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = true - override val title: String - @Composable get() = stringResource(R.string.action_deleteMessage) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Delete) - override val delayUponSelection: Boolean = false - } - - data class RemoveUser(val name: String, override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = true - override val title: String - @Composable get() = stringResource(R.string.action_removeUser, name) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.PersonRemove) - override val delayUponSelection: Boolean = false - } - - data class PromoteUser(override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val title: String - @Composable get() = stringResource(R.string.action_promote) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.RecordVoiceOver) - override val delayUponSelection: Boolean = false - override val isDestructive: Boolean = false - } - - data class DemoteUser(override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val title: String - @Composable get() = stringResource(R.string.action_demote) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.VoiceOverOff) - override val delayUponSelection: Boolean = false - override val isDestructive: Boolean = false - } - - data class MuteUser(override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = true - - override val title: String - @Composable get() = stringResource(R.string.action_muteUser) - - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.MicOff) - - override val delayUponSelection: Boolean = false - } - - data class ReportUserForMessage(val name: String, override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = true - override val title: String - @Composable get() = stringResource(R.string.action_report) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Flag) - override val delayUponSelection: Boolean = false - } - - data class BlockUser(override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = true - override val title: String - @Composable get() = stringResource(R.string.action_blockUser) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Block) - override val delayUponSelection: Boolean = false - } - - data class UnblockUser(override val onSelect: () -> Unit) : - MessageContextAction, ContextMenuAction.Single { - override val isDestructive: Boolean = false - override val title: String - @Composable get() = stringResource(R.string.action_unblockUser) - override val painter: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Person) - override val delayUponSelection: Boolean = false - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt deleted file mode 100644 index 8ba023d204..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessagePayment.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.MessageStatus -import com.getcode.model.chat.Verb -import com.getcode.theme.CodeTheme -import com.getcode.utils.FormatUtils -import com.getcode.utils.flagResId -import com.getcode.ui.components.PriceWithFlag -import com.getcode.ui.components.chat.utils.localizedText -import kotlin.time.Instant - -@Composable -internal fun MessagePayment( - modifier: Modifier = Modifier, - contents: MessageContent.Exchange, - date: Instant, - status: MessageStatus = MessageStatus.Unknown, - showStatus: Boolean = true, -) { - Column( - modifier = modifier - // payments have an extra 10.dp inner padding - .padding(CodeTheme.dimens.grid.x1) - .background(CodeTheme.colors.background, RoundedCornerShape(3.dp)) // small - padding - .padding(CodeTheme.dimens.grid.x2), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // TODO(chat-v2): reimplement payment formatting with OCP Fiat - val formattedAmount = FormatUtils.formatCurrency(contents.amount, contents.currencyCode) - - Column( - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x3) - .padding(horizontal = CodeTheme.dimens.grid.x6), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (contents.verb == Verb.Returned) { - PriceWithFlag( - currencyCode = contents.currencyCode.name, - amount = formattedAmount, - flag = contents.currencyCode.flagResId, - text = { price -> - Text( - text = price, - color = Color.White, - style = CodeTheme.typography.displaySmall - ) - } - ) - Text( - text = contents.verb.localizedText, - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) - ) - } else { - Text( - text = contents.verb.localizedText, - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W500) - ) - PriceWithFlag( - currencyCode = contents.currencyCode.name, - amount = formattedAmount, - flag = contents.currencyCode.flagResId, - text = { price -> - Text( - text = price, - color = Color.White, - style = CodeTheme.typography.displaySmall - ) - } - ) - } - } - - DateWithStatus( - modifier = Modifier - .align(Alignment.End), - date = date, - status = status, - isFromSelf = contents.isFromSelf, - showStatus = showStatus - ) - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt deleted file mode 100644 index c16378b7c5..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageReplyContent.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.getcode.model.chat.MessageStatus -import com.getcode.theme.CodeTheme -import com.getcode.theme.extraSmall -import com.getcode.ui.components.R -import com.getcode.ui.components.chat.MessageNodeDefaults -import com.getcode.ui.components.chat.MessageNodeOptions -import com.getcode.ui.components.chat.MessageNodeScope -import com.getcode.ui.components.chat.utils.MessageReaction -import com.getcode.ui.components.chat.utils.MessageTip -import com.getcode.ui.components.chat.utils.ReplyMessageAnchor -import com.getcode.ui.components.chat.utils.localizedText -import com.getcode.ui.components.user.social.SenderNameDisplay -import com.getcode.ui.core.addIf -import com.getcode.ui.core.dashedBorder -import com.getcode.ui.utils.generateComplementaryColorPalette -import com.getcode.ui.core.measured -import kotlin.time.Instant - -@Composable -internal fun MessageNodeScope.MessageReplyContent( - modifier: Modifier = Modifier, - content: String, - originalMessage: ReplyMessageAnchor, - shape: Shape = MessageNodeDefaults.DefaultShape, - options: MessageNodeOptions, - isFromSelf: Boolean, - isFromBlockedMember: Boolean, - wasSentAsFullMember: Boolean, - date: Instant, - status: MessageStatus = MessageStatus.Unknown, - tips: List, - reactions: List, - onTap: (contentPadding: PaddingValues, touchOffset: Offset) -> Unit, - actionHandler: MessageContentActionHandler, -) { - val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart - var originalMessagePreviewHeight by remember { - mutableStateOf(0.dp) - } - - Box(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { - Box( - modifier = Modifier - .sizeableWidth() - .addIf(wasSentAsFullMember) { - Modifier.background(color = color, shape = shape) - } - .addIf(!wasSentAsFullMember) { - Modifier.dashedBorder( - strokeWidth = CodeTheme.dimens.border, - dashWidth = 2.dp, - gapWidth = 2.dp, - dashColor = CodeTheme.colors.tertiary, - shape = shape - ) - } - .pointerInput(Unit) { - detectTapGestures( - onTap = { offset -> - onTap( - PaddingValues( - vertical = originalMessagePreviewHeight + 2.5.dp - ), - offset - ) - }, - onLongPress = if (!options.isInteractive) null else { - { actionHandler.openMessageControls() } - }, - onDoubleTap = { if (options.canTip) actionHandler.giveTip() }, - ) - } - .padding(CodeTheme.dimens.grid.x2) - ) { - SubcomposeLayout { constraints -> - val spacing = 2.5.dp.roundToPx() - - val messageContentPlaceable = subcompose("MessageContent") { - MessageContent( - maxWidth = constraints.maxWidth, - message = content, - date = date, - status = status, - isFromSelf = isFromSelf, - isFromBlockedMember = isFromBlockedMember, - options = options, - tips = tips, - reactions = reactions, - actionHandler = actionHandler - ) - }.first().measure(constraints) - - val replyPreviewPlaceable = subcompose("MessageReplyPreview") { - MessageReplyPreview( - modifier = Modifier - .widthIn(min = messageContentPlaceable.width.toDp()) - .pointerInput(Unit) { - detectTapGestures( - onTap = { actionHandler.viewOriginalMessage() } - ) - } - .measured { originalMessagePreviewHeight = it.height }, - originalMessage = originalMessage, - backgroundColor = Color.Black.copy(0.1f), - ) - }.first().measure( - constraints.copy( - minWidth = messageContentPlaceable.width, - maxWidth = constraints.maxWidth - ) - ) - - // Determine the final width based on the longer of the two components - val finalWidth = - maxOf(messageContentPlaceable.width, replyPreviewPlaceable.width) - - // Remeasure MessageContent with the updated width - val remeasuredMessageContentPlaceable = subcompose("MessageContentRemeasured") { - MessageContent( - maxWidth = finalWidth, - minWidth = finalWidth, - message = content, - date = date, - status = status, - isFromSelf = isFromSelf, - isFromBlockedMember = isFromBlockedMember, - options = options, - tips = tips, - reactions = reactions, - actionHandler = actionHandler - ) - }.first().measure( - constraints.copy(minWidth = finalWidth, maxWidth = finalWidth) - ) - - // Calculate the total height - val totalHeight = - replyPreviewPlaceable.height + spacing + remeasuredMessageContentPlaceable.height - - // Layout the components - layout(finalWidth, totalHeight) { - replyPreviewPlaceable.place(0, 0) - remeasuredMessageContentPlaceable.place( - 0, - replyPreviewPlaceable.height + spacing - ) - } - } - } - } -} - -@Composable -fun MessageReplyPreview( - originalMessage: ReplyMessageAnchor, - modifier: Modifier = Modifier, - backgroundColor: Color = Color.Transparent, - - ) { - val colors = generateComplementaryColorPalette(originalMessage.sender.id!!) - Row( - modifier = modifier - .width(IntrinsicSize.Max) - .height(IntrinsicSize.Min) - .background(backgroundColor, CodeTheme.shapes.extraSmall) - .clip(CodeTheme.shapes.extraSmall), - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(3.dp) - .background(colors?.first ?: CodeTheme.colors.tertiary) - ) - Column( - modifier = Modifier - .fillMaxHeight() - .padding(horizontal = CodeTheme.dimens.grid.x1) - .padding(vertical = CodeTheme.dimens.grid.x1) - .weight(1f) - ) { - SenderNameDisplay( - sender = originalMessage.sender, - textColor = colors?.second ?: CodeTheme.colors.tertiary, - textStyle = CodeTheme.typography.textSmall - ) - - val messageBody = when { - originalMessage.isDeleted -> { - val deletionMessage = when { - originalMessage.deletedBy?.isSelf == true -> stringResource(R.string.title_messageDeletedByYou) - originalMessage.deletedBy?.isHost == true -> stringResource(R.string.title_messageDeletedByHost) - else -> stringResource(R.string.title_messageWasDeleted) - } - AnnotatedString.Builder().apply { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(deletionMessage) - pop() - }.toAnnotatedString() - } - - originalMessage.sender.isBlocked -> { - AnnotatedString.Builder().apply { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(stringResource(R.string.title_blockedMessage)) - pop() - }.toAnnotatedString() - } - - else -> AnnotatedString(originalMessage.message.localizedText) - } - - Text( - text = messageBody, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = CodeTheme.colors.textMain, - style = CodeTheme.typography.caption - ) - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt deleted file mode 100644 index 5b03d15de1..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/MessageTextContent.kt +++ /dev/null @@ -1,459 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents - -import android.os.Parcelable -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.Text -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.getcode.libs.opengraph.LocalOpenGraphParser -import com.getcode.libs.opengraph.callback.OpenGraphCallback -import com.getcode.libs.opengraph.model.OpenGraphResult -import com.getcode.model.ID -import com.getcode.model.chat.MessageStatus -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.R -import com.getcode.ui.components.chat.MessageNodeDefaults -import com.getcode.ui.components.chat.MessageNodeOptions -import com.getcode.ui.components.chat.MessageNodeScope -import com.getcode.ui.components.chat.messagecontents.utils.AlignmentRule -import com.getcode.ui.components.chat.messagecontents.utils.rememberAlignmentRule -import com.getcode.ui.components.chat.utils.MessageReaction -import com.getcode.ui.components.chat.utils.MessageTip -import com.getcode.ui.components.text.markup.Markup -import com.getcode.ui.components.text.markup.MarkupTextHelper -import com.getcode.ui.core.addIf -import com.getcode.ui.core.dashedBorder -import kotlinx.parcelize.Parcelize -import kotlin.time.Instant - -@Parcelize -sealed interface SelectedReaction: Parcelable { - @Parcelize - data class Emoji(val unicode: String): SelectedReaction - @Parcelize - data object Tips: SelectedReaction -} - -interface MessageContentActionHandler { - fun openMessageControls() - fun giveTip() - fun addReaction(emoji: String) - fun removeReaction(reactionMessageId: ID) - fun viewReactions(selected: SelectedReaction) - fun startReply() - fun viewOriginalMessage() - fun openUserProfile() -} - -private data object StubActionHandler: MessageContentActionHandler { - override fun openMessageControls() = Unit - override fun giveTip() = Unit - override fun addReaction(emoji: String) = Unit - override fun removeReaction(reactionMessageId: ID) = Unit - override fun viewReactions(selected: SelectedReaction) = Unit - override fun startReply() = Unit - override fun viewOriginalMessage() = Unit - override fun openUserProfile() = Unit -} - -@Composable -internal fun MessageNodeScope.MessageText( - modifier: Modifier = Modifier, - content: String, - shape: Shape = MessageNodeDefaults.DefaultShape, - options: MessageNodeOptions, - isFromSelf: Boolean, - isFromBlockedMember: Boolean, - tips: List, - reactions: List, - date: Instant, - status: MessageStatus = MessageStatus.Unknown, - isFullMember: Boolean, - actionHandler: MessageContentActionHandler, - onTap: (contentPadding: PaddingValues, touchOffset: Offset) -> Unit -) { - val alignment = if (isFromSelf) Alignment.CenterEnd else Alignment.CenterStart - - BoxWithConstraints(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) { - BoxWithConstraints( - modifier = Modifier - .sizeableWidth() - .addIf(isFullMember) { - Modifier.background(color = color, shape = shape) - } - .addIf(!isFullMember) { - Modifier.dashedBorder( - strokeWidth = CodeTheme.dimens.border, - dashWidth = 2.dp, - gapWidth = 2.dp, - dashColor = CodeTheme.colors.tertiary, - shape = shape - ) - } - .pointerInput(Unit) { - detectTapGestures( - // entire bubble of a normal message is the contents of the text - onTap = { offset -> onTap(PaddingValues(), offset) }, - onLongPress = if (!options.isInteractive) null else { - { actionHandler.openMessageControls() } - }, - onDoubleTap = { if (options.canTip) actionHandler.giveTip() } - ) - } - .padding(CodeTheme.dimens.grid.x2) - ) contents@{ - val maxWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } - Column { - MessageContent( - maxWidth = maxWidthPx, - message = content, - date = date, - status = status, - isFromSelf = isFromSelf, - isFromBlockedMember = isFromBlockedMember, - options = options, - tips = tips, - reactions = reactions, - actionHandler = actionHandler, - ) - } - } - } -} - -@Composable -internal fun MessageContent( - modifier: Modifier = Modifier, - minWidth: Int = 0, - maxWidth: Int, - message: String, - date: Instant, - status: MessageStatus, - isFromSelf: Boolean, - isFromBlockedMember: Boolean, - options: MessageNodeOptions, - tips: List, - reactions: List, - actionHandler: MessageContentActionHandler -) { - MessageContent( - modifier = modifier, - minWidth = minWidth, - maxWidth = maxWidth, - annotatedMessage = AnnotatedString(message), - date = date, - status = status, - isFromSelf = isFromSelf, - isFromBlockedMember = isFromBlockedMember, - tips = tips, - options = options, - reactions = reactions, - actionHandler = actionHandler, - ) -} - -@Composable -internal fun MessageContent( - modifier: Modifier = Modifier, - minWidth: Int = 0, - maxWidth: Int, - annotatedMessage: AnnotatedString, - date: Instant, - status: MessageStatus, - isFromSelf: Boolean, - isFromBlockedMember: Boolean, - options: MessageNodeOptions, - tips: List = emptyList(), - reactions: List = emptyList(), - actionHandler: MessageContentActionHandler = StubActionHandler, -) { - val openGraphParser = LocalOpenGraphParser.current - var linkImageUrl: String? by rememberSaveable(annotatedMessage) { mutableStateOf(null) } - - LaunchedEffect(annotatedMessage) { - if (linkImageUrl == null && options.linkImagePreviewEnabled) { - val link = Markup.Url().resolve(annotatedMessage.text).firstOrNull() - if (link != null) { - openGraphParser?.parse(link, object : OpenGraphCallback { - override fun onResponse(result: OpenGraphResult) { - linkImageUrl = result.image - } - - override fun onError(error: String) = Unit - }) - } - } - } - - val alignmentRule by rememberAlignmentRule( - contentTextStyle = options.contentStyle, - minWidth = minWidth, - maxWidth = maxWidth, - message = annotatedMessage, - date = date, - hasFeedback = tips.isNotEmpty() || reactions.isNotEmpty(), - hasLink = linkImageUrl != null && options.linkImagePreviewEnabled - ) - - when (alignmentRule) { - AlignmentRule.Column -> { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) - ) { - val measurer = rememberTextMeasurer() - val textWidth = measurer.measure(annotatedMessage, constraints = Constraints(maxWidth = maxWidth)).size.width - - MarkupTextHandler( - text = annotatedMessage, - options = options, - isFromBlockedMember = isFromBlockedMember, - ) - - if (options.linkImagePreviewEnabled) { - AnimatedVisibility(linkImageUrl != null) { - AsyncImage( - model = linkImageUrl, - contentDescription = null, - ) - } - } - - ColumnBasedFooter( - tips = tips, - reactions = reactions, - isFromSelf = isFromSelf, - textWidth = textWidth, - date = date, - status = status, - options = options, - actionHandler = actionHandler - ) - } - } - - AlignmentRule.ParagraphLastLine -> { - Column( - modifier = modifier.padding(CodeTheme.dimens.grid.x1), - ) { - MarkupTextHandler( - text = annotatedMessage, - options = options, - isFromBlockedMember = isFromBlockedMember, - ) - DateWithStatus( - modifier = Modifier - .padding(top = 2.dp) - .align(Alignment.End), - date = date, - status = status, - isFromSelf = isFromSelf, - showStatus = options.showStatus, - showTimestamp = options.showTimestamp, - ) - - AnimatedVisibility(linkImageUrl != null) { - AsyncImage( - model = linkImageUrl, - contentDescription = null, - ) - } - } - } - - AlignmentRule.SingleLineEnd -> { - Column(modifier = modifier) { - Row( - modifier = Modifier.width(IntrinsicSize.Max), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) - ) { - MarkupTextHandler( - modifier = Modifier.weight(1f), - text = annotatedMessage, - options = options, - isFromBlockedMember = isFromBlockedMember, - ) - DateWithStatus( - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x1 + 2.dp), - date = date, - status = status, - isFromSelf = isFromSelf, - showStatus = options.showStatus, - showTimestamp = options.showTimestamp, - ) - } - - AnimatedVisibility(linkImageUrl != null) { - AsyncImage( - model = linkImageUrl, - contentDescription = null, - ) - } - } - } - - else -> Unit - } -} - -@Composable -private fun MarkupTextHandler( - text: AnnotatedString, - options: MessageNodeOptions, - isFromBlockedMember: Boolean, - modifier: Modifier = Modifier, -) { - when { - isFromBlockedMember -> { - Text( - modifier = modifier, - text = AnnotatedString.Builder().apply { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - append(stringResource(R.string.title_blockedMessage)) - pop() - }.toAnnotatedString(), - style = options.contentStyle - ) - - } - - options.onMarkupClicked != null -> { - val markupTextHelper = remember { MarkupTextHelper() } - val markups = remember(options.markupsToResolve) { - options.markupsToResolve.map { Markup.create(it) } - } - - val annotatedString = markupTextHelper.annotate(text.text, markups) - val markupHoist = LocalTextLayoutResult.current - - Text( - text = annotatedString, - style = options.contentStyle.copy(color = CodeTheme.colors.textMain), - modifier = modifier, - onTextLayout = { markupHoist(it) } - ) - } - - else -> { - Text(modifier = modifier, text = text, style = options.contentStyle) - } - } -} - -@Composable -private fun ColumnBasedFooter( - modifier: Modifier = Modifier, - tips: List, - reactions: List, - isFromSelf: Boolean, - date: Instant, - status: MessageStatus, - options: MessageNodeOptions, - textWidth: Int, - actionHandler: MessageContentActionHandler, -) { - val x1 = CodeTheme.dimens.grid.x1 - val x2 = CodeTheme.dimens.grid.x2 - SubcomposeLayout(modifier = modifier.wrapContentWidth()) { constraints -> - // Measure Feedback first if exists - val feedbackPlaceable = subcompose("Feedback") { - if (tips.isNotEmpty() || reactions.isNotEmpty()) { - Feedback( - tips = tips, - reactions = reactions, - isMessageFromSelf = isFromSelf, - actionHandler = actionHandler, - ) - } - }.firstOrNull()?.measure(constraints) - - val feedbackWidth = feedbackPlaceable?.width - - // Measure DateWithStatus separately - val datePlaceable = subcompose("DateWithStatus") { - DateWithStatus( - date = date, - status = status, - isFromSelf = isFromSelf, - showStatus = options.showStatus, - showTimestamp = options.showTimestamp, - ) - }.first().measure(constraints) - - val dateWidth = datePlaceable.width - val dateHeight = datePlaceable.height - - val maxContentWidth = (feedbackWidth?.let { it + x2.roundToPx() } ?: 0) + dateWidth - val fitsInSameRow = maxContentWidth <= constraints.maxWidth - - val layoutWidth = if (fitsInSameRow) { - if (feedbackPlaceable == null) { - constraints.maxWidth - } else { - minOf(constraints.maxWidth, maxContentWidth + textWidth) - } - } else { - maxOf(feedbackWidth ?: 0, dateWidth) - } - - val layoutHeight = if (fitsInSameRow) { - feedbackPlaceable?.height ?: dateHeight - } else { - (feedbackPlaceable?.height ?: 0) + dateHeight - } - - layout(layoutWidth, layoutHeight) { - var yOffset = 0 - var xOffset = 0 - - feedbackPlaceable?.placeRelative(xOffset, yOffset) - - if (fitsInSameRow) { - if (feedbackPlaceable != null) { - xOffset = layoutWidth - dateWidth - yOffset += feedbackPlaceable.height - dateHeight - } else { - xOffset = layoutWidth - dateWidth - yOffset += x1.roundToPx() - } - datePlaceable.placeRelative(xOffset, yOffset) - } else { - yOffset += (feedbackPlaceable?.height ?: 0) + x1.roundToPx() - datePlaceable.placeRelative(layoutWidth - dateWidth, yOffset) - } - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt deleted file mode 100644 index 1851ce133c..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/messagecontents/utils/ContentAlignment.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.getcode.ui.components.chat.messagecontents.utils - -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.rememberTextMeasurer -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.chat.messagecontents.DateWithStatusDefaults -import com.getcode.util.formatDateRelatively -import kotlin.math.max -import kotlin.time.Instant - -internal sealed interface AlignmentRule { - data object ParagraphLastLine : AlignmentRule - data object Column : AlignmentRule - data object SingleLineEnd : AlignmentRule -} - -@Composable -internal fun rememberAlignmentRule( - contentTextStyle: TextStyle, - minWidth: Int = 0, - maxWidth: Int, - message: AnnotatedString, - date: Instant, - hasFeedback: Boolean = false, - hasLink: Boolean = false, -): State { - val density = LocalDensity.current - val dateTextStyle = DateWithStatusDefaults.DateTextStyle - val iconSizePx = with(density) { DateWithStatusDefaults.IconWidth.roundToPx() } - val spacingPx = with(density) { DateWithStatusDefaults.Spacing.roundToPx() } - val contentPaddingPx = with(density) { CodeTheme.dimens.grid.x2.roundToPx() } - - return remember(minWidth, maxWidth, message, date, hasLink, hasFeedback) { - mutableStateOf(null) - }.apply { - val textMeasurer = rememberTextMeasurer() - val dateStatusWidth = remember(message, date) { - val result = textMeasurer.measure( - text = date.formatDateRelatively(), - style = dateTextStyle, - maxLines = 1 - ) - - max(minWidth, result.size.width + spacingPx + iconSizePx) - } - - val bufferSize by remember(dateStatusWidth) { - derivedStateOf { - dateStatusWidth + spacingPx + contentPaddingPx * 2 - } - } - - if (value == null) { - Text( - modifier = Modifier.drawWithContent { }, - text = message, - style = contentTextStyle, - onTextLayout = { textLayoutResult -> - val lastLineNum = textLayoutResult.lineCount - 1 - val lineStart = textLayoutResult.getLineStart(lastLineNum) - val lineEnd = - textLayoutResult.getLineEnd(lastLineNum, visibleEnd = true) - val lineContent = message.substring(lineStart, lineEnd) - - val lineContentWidth = - textMeasurer.measure(lineContent, contentTextStyle).size.width - - value = when { - lineContentWidth + bufferSize > maxWidth || hasFeedback || hasLink -> AlignmentRule.Column - textLayoutResult.lineCount == 1 -> AlignmentRule.SingleLineEnd - else -> AlignmentRule.ParagraphLastLine - } - } - ) - } - } -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt deleted file mode 100644 index 0236eee9e6..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/ChatItem.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.getcode.ui.components.chat.utils - -import android.os.Parcelable -import androidx.compose.runtime.Stable -import com.getcode.model.ID -import com.getcode.model.chat.ChatMessage -import com.getcode.model.chat.Deleter -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.MessageStatus -import com.getcode.model.chat.Sender -import com.getcode.model.uuid -import com.getcode.ui.components.chat.messagecontents.MessageControls -import com.getcode.util.formatDateRelatively -import com.getcode.utils.base58 -import kotlinx.parcelize.Parcelize -import java.util.UUID -import kotlin.time.Instant - -data class ChatMessageIndice( - val message: ChatMessage, - val messageContent: MessageContent, -) - -data class ReplyMessageAnchor( - val id: ID, - val sender: Sender, - val message: MessageContent, - val isDeleted : Boolean = false, - val deletedBy: Deleter? = null, -) - -// TODO(chat-v2): MessageTip will use OCP Fiat for amount -@Parcelize -data class MessageTip(val amountInQuarks: Long, val tipper: Sender): Parcelable - -@Parcelize -data class MessageReaction(val messageId: ID, val sender: Sender, val emoji: String, val sentAt: Long): Parcelable - -@Stable -sealed class ChatItem(open val key: Any) { - @Stable - data class Message( - val chatMessageId: ID, - val message: MessageContent, - val sender: Sender, - val date: Instant, - val isDeleted: Boolean = false, - val deletedBy: Deleter? = null, - val status: MessageStatus, - val showStatus: Boolean = true, - val showTimestamp: Boolean = true, - val wasSentAsFullMember: Boolean = false, - val messageControls: MessageControls = MessageControls(), - val enableMarkup: Boolean = false, - val enableReply: Boolean = false, - val enableLinkImagePreview: Boolean = false, - val enableAvatarClicks: Boolean = false, - val enableTipping : Boolean = false, - val originalMessage: ReplyMessageAnchor? = null, - val tips: List = emptyList(), - val reactions: List = emptyList(), - override val key: Any = chatMessageId - ) : ChatItem(key) { - val relativeDate: String = date.formatDateRelatively() - } - - sealed interface Separator - - @Stable - data class UnreadSeparator(val count: Int) : ChatItem("unread"), Separator - - @Stable - data class Date(val date: Instant) : ChatItem(date), Separator { - val dateString: String = date.formatDateRelatively() - - override val key: Any = date.toEpochMilliseconds() - } - - @Stable - data class Separators(val separators: List): ChatItem(separators.hashCode()) -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt deleted file mode 100644 index 1108ffbdfa..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.getcode.ui.components.chat.utils - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.paging.LoadState -import androidx.paging.compose.LazyPagingItems -import com.getcode.model.chat.MessageStatus -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.mapNotNull - -@Composable -fun HandleMessageChanges( - listState: LazyListState, - items: LazyPagingItems, - onMessageDelivered: (ChatItem.Message) -> Unit, -) { - var lastMessageSent by rememberSaveable { - mutableLongStateOf(0L) - } - var lastMessageReceived by rememberSaveable { - mutableLongStateOf(0L) - } - - // handle incoming/outgoing messages - scroll to bottom to reset view in the following circumstances: - // 1) New message is from self (e.g outgoing) - // 2) New message is from participant and we are already at the bottom (to prevent rug pull) - LaunchedEffect(listState, items) { - snapshotFlow { items.loadState } - .filter { it.refresh is LoadState.NotLoading } - .mapNotNull { items.itemSnapshotList.firstOrNull() } - .filterIsInstance() - .distinctUntilChangedBy { it.chatMessageId } - .collect { newMessage -> - if (newMessage.status.isOutgoing()) { - if (newMessage.date.toEpochMilliseconds() > lastMessageSent) { - listState.handleAndReplayAfter(300) { - scrollToItem(0) - lastMessageSent = newMessage.date.toEpochMilliseconds() - } - } - } else { - listState.handleAndReplayAfter(300) { - if (newMessage.date.toEpochMilliseconds() > lastMessageReceived) { - if (listState.canScrollBackward) { - if (newMessage.status == MessageStatus.Unknown) { - onMessageDelivered(newMessage) - } - } - } - - lastMessageReceived = newMessage.date.toEpochMilliseconds() - } - } - } - } -} - -private suspend fun LazyListState.handleAndReplayAfter( - delay: Long, - block: suspend LazyListState.() -> Unit -) { - block() - delay(delay) - block() -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt deleted file mode 100644 index 33b87bdba5..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.getcode.ui.components.chat.utils - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.platform.LocalContext -import com.getcode.ui.components.R -import com.getcode.model.chat.MessageContent -import com.getcode.model.chat.Title -import com.getcode.model.chat.Verb -import com.getcode.util.resources.ResourceHelper -import com.getcode.util.resources.ResourceType -import com.getcode.utils.FormatUtils -import java.util.Locale - -val LocalLocalizeCurrencyFormatting = staticCompositionLocalOf { true } - -fun MessageContent.localizedText( - resources: ResourceHelper, - localizeCurrency: Boolean = true, - currencyUtils: com.getcode.utils.CurrencyUtils, -): String { - return when (val content = this) { - is MessageContent.Exchange -> { - // TODO(chat-v2): Exchange message amounts will use com.getcode.opencode.model.financial.Fiat - val amount = FormatUtils.formatCurrency(content.amount, content.currencyCode) - - val localized = content.verb.localizedText(resources) - - when (content.verb) { - Verb.Deposited, - Verb.Gave, - Verb.Paid, - Verb.Purchased, - Verb.Received, - Verb.Sent, - Verb.Spent, - Verb.Unknown, - Verb.Withdrew, - Verb.ReceivedTip, - Verb.SentTip -> { - "$localized $amount" - } - - Verb.Returned -> { - "$amount $localized" - } - } - } - - is MessageContent.Localized -> { - val resId = resources.getIdentifier( - content.value.replace(".", "_"), - ResourceType.String, - ).let { if (it == 0) null else it } - - resId?.let { resources.getString(it) } ?: content.value - } - - is MessageContent.SodiumBox -> { - // TODO: -// val organizer = SessionManager.getOrganizer() ?: return "" -// val domain = com.getcode.crypt.Domain.from(title) ?: return "" -// val relationship = organizer.relationshipFor(domain) ?: return "" -// val decrypted = content.data.decryptMessageUsingNaClBox( -// keyPair = relationship.getCluster().authority.keyPair -// ) ?: return "" -// -// decrypted - return "" - } - - is MessageContent.Decrypted -> content.data - - is MessageContent.RawText -> content.value - is MessageContent.ActionableAnnouncement -> { - val resId = resources.getIdentifier( - content.keyOrText.replace(".", "_"), - ResourceType.String, - ).let { if (it == 0) null else it } - - resId?.let { resources.getString(it) } ?: content.keyOrText - } - is MessageContent.Announcement -> { - val resId = if (content.isFromSelf) R.string.title_chat_announcement_thanksSent - else R.string.title_chat_announcement_thanksReceived - - resources.getString(resId, "some username") - } - - is MessageContent.Reaction -> content.emoji - is MessageContent.Reply -> content.text - is MessageContent.DeletedMessage, - is MessageContent.MessageTip, - is MessageContent.MessageInReview, - is MessageContent.Unknown -> "" - - - } -} - -val MessageContent.localizedText: String - @Composable get() { - val context = LocalContext.current - return when (val content = this) { - is MessageContent.Exchange -> { - // TODO(chat-v2): reimplement Exchange amount formatting with OCP Fiat - val amount = FormatUtils.formatCurrency(content.amount, content.currencyCode) - - val localized = content.verb.localizedText - - when (content.verb) { - Verb.Deposited, - Verb.Gave, - Verb.Paid, - Verb.Purchased, - Verb.Received, - Verb.Sent, - Verb.Spent, - Verb.Unknown, - Verb.Withdrew, - Verb.ReceivedTip, - Verb.SentTip -> { - "$localized $amount" - } - - Verb.Returned -> { - "$amount $localized" - } - - } - } - - is MessageContent.Localized -> { - with(LocalContext.current) { - val resId = resources.getIdentifier( - content.value.replace(".", "_"), - "string", - context.packageName - ).let { if (it == 0) null else it } - - resId?.let { getString(it) } ?: content.value - } - } - - is MessageContent.SodiumBox -> "" - is MessageContent.Decrypted -> content.data - is MessageContent.RawText -> content.value - is MessageContent.Announcement -> content.value - is MessageContent.ActionableAnnouncement -> { - with(LocalContext.current) { - val resId = resources.getIdentifier( - content.keyOrText.replace(".", "_"), - "string", - context.packageName - ).let { if (it == 0) null else it } - - resId?.let { getString(it) } ?: content.keyOrText - } - } - is MessageContent.Reaction -> content.emoji - is MessageContent.Reply -> content.text - is MessageContent.DeletedMessage, - is MessageContent.MessageTip, - is MessageContent.MessageInReview, - is MessageContent.Unknown -> "" - } - } - -fun Verb.localizedText(resources: ResourceHelper): String { - val resId = when (this@localizedText) { - Verb.Deposited, - Verb.Gave, - Verb.Paid, - Verb.Purchased, - Verb.Received, - Verb.Sent, - Verb.Spent, - Verb.Withdrew -> { - resources.getIdentifier( - "subtitle_you${this@localizedText.toString().capitalize(Locale.ENGLISH)}", - ResourceType.String, - ).let { if (it == 0) null else it } - } - - Verb.ReceivedTip -> { - resources.getIdentifier( - "subtitle_someoneTippedYou", - ResourceType.String, - ).let { if (it == 0) null else it } - } - - Verb.SentTip -> { - resources.getIdentifier( - "subtitle_youTipped", - ResourceType.String, - ).let { if (it == 0) null else it } - } - - Verb.Returned -> { - resources.getIdentifier( - "subtitle_was${this@localizedText.toString().capitalize(Locale.ENGLISH)}ToYou", - ResourceType.String, - ).let { if (it == 0) null else it } - } - - Verb.Unknown -> { - R.string.title_unknown - } - } - - return resId?.let { resources.getString(it) } ?: toString() -} - -val Verb.localizedText: String - @SuppressLint("DiscouragedApi") - @Composable get() = with(LocalContext.current) context@{ - val resId = when (this@localizedText) { - Verb.Deposited, - Verb.Gave, - Verb.Paid, - Verb.Purchased, - Verb.Received, - Verb.Sent, - Verb.Spent, - Verb.Withdrew -> { - resources.getIdentifier( - "subtitle_you${this@localizedText.toString().capitalize(Locale.ENGLISH)}", - "string", - packageName - ).let { if (it == 0) null else it } - } - - Verb.ReceivedTip -> { - resources.getIdentifier( - "subtitle_someoneTippedYou", - "string", - packageName - ).let { if (it == 0) null else it } - } - - Verb.SentTip -> { - resources.getIdentifier( - "subtitle_youTipped", - "string", - packageName - ).let { if (it == 0) null else it } - } - - Verb.Returned -> { - resources.getIdentifier( - "subtitle_was${this@localizedText.toString().capitalize(Locale.ENGLISH)}ToYou", - "string", - packageName - ).let { if (it == 0) null else it } - } - - Verb.Unknown -> { - R.string.title_unknown - } - } - - resId?.let { getString(it) } ?: this@localizedText.toString() - } \ No newline at end of file diff --git a/ui/components/src/main/res/drawable/ic_message_status_delivered.xml b/ui/components/src/main/res/drawable/ic_message_status_delivered.xml deleted file mode 100644 index ee4abc3126..0000000000 --- a/ui/components/src/main/res/drawable/ic_message_status_delivered.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/ui/components/src/main/res/drawable/ic_message_status_read.xml b/ui/components/src/main/res/drawable/ic_message_status_read.xml deleted file mode 100644 index fc0dd1a563..0000000000 --- a/ui/components/src/main/res/drawable/ic_message_status_read.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/ui/components/src/main/res/drawable/ic_message_status_sent.xml b/ui/components/src/main/res/drawable/ic_message_status_sent.xml deleted file mode 100644 index 7c8dbd5f61..0000000000 --- a/ui/components/src/main/res/drawable/ic_message_status_sent.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ui/components/src/main/res/drawable/lock_app_dashed.xml b/ui/components/src/main/res/drawable/lock_app_dashed.xml deleted file mode 100644 index 3f12c79c9e..0000000000 --- a/ui/components/src/main/res/drawable/lock_app_dashed.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/ui/components/src/main/res/values/strings.xml b/ui/components/src/main/res/values/strings.xml index 1bf71707de..e029cc525d 100644 --- a/ui/components/src/main/res/values/strings.xml +++ b/ui/components/src/main/res/values/strings.xml @@ -3,8 +3,6 @@ You revealed your identity to %1$s %1$s revealed their identity to you This person tipped you %1$s - 🙏 You thanked them - 🙏 %1$s thanked you for your tip 0 Unread Messages 1 Unread Message @@ -19,9 +17,6 @@ @string/title_conversationUnreadCountMany - Message deleted by you - Message deleted by host - This message was deleted You Listener