diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a97bd14cdc..a40dddfd66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -365,6 +365,8 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.core.ktx) implementation(libs.androidx.interpolator) + implementation(libs.androidx.paging.common) + implementation(libs.androidx.paging.compose) // Add firebase dependencies to specific variants for (variant in firebaseEnabledVariants) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 8fd65ef538..3e970a6203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -168,6 +168,7 @@ import org.thoughtcrime.securesms.conversation.v3.settings.notification.Notifica import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities +import org.thoughtcrime.securesms.conversation.v3.ConversationActivityV3 import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.database.GroupDatabase @@ -1059,6 +1060,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, onSearchQueryChanged = ::onSearchQueryUpdated, onSearchQueryClear = { onSearchQueryUpdated("") }, onSearchCanceled = ::onSearchClosed, + switchConvoVersion = { + startActivity(ConversationActivityV3.createIntent(this, address = IntentCompat.getParcelableExtra(intent, + ADDRESS, Address.Conversable::class.java)!!)) + finish() + }, onAvatarPressed = { val intent = ConversationSettingsActivity.createIntent(this, address) settingsLauncher.launch(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 0780534765..3bd2955989 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -100,7 +100,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? ProBadgeText( modifier = modifier, text = authorDisplayName, - textStyle = LocalType.current.small.bold().copy(color = Color(textColor)), + textStyle = LocalType.current.base.bold().copy(color = Color(textColor)), showBadge = authorRecipient.shouldShowProBadge, badgeColors = if(isOutgoingMessage && mode == Mode.Regular) proBadgeColorOutgoing() else proBadgeColorStandard() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 5a037e060f..67b87b0623 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -6,8 +6,6 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan -import android.util.Range -import network.loki.messenger.R import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr @@ -17,6 +15,7 @@ import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor +import network.loki.messenger.R import java.util.regex.Pattern object MentionUtilities { @@ -27,152 +26,201 @@ object MentionUtilities { /** * In-place replacement on the *live* MentionEditable that the * input-bar is already using. - * - * It swaps every "@<64-hex>" token for "@DisplayName" **and** - * attaches a MentionSpan so later normalisation still works. */ fun substituteIdsInPlace( editable: MentionEditable, membersById: Map ) { ACCOUNT_ID.findAll(editable) - .toList() // avoid index shifts - .asReversed() // back-to-front replacement + .toList() // avoid index shifts + .asReversed() // back-to-front replacement .forEach { m -> - val id = m.groupValues[1] - val member = membersById[id] ?: return@forEach + val id = m.groupValues[1] + val member = membersById[id] ?: return@forEach val start = m.range.first - val end = m.range.last + 1 // inclusive ➜ exclusive + val end = m.range.last + 1 // inclusive ➜ exclusive editable.replace(start, end, "@${member.name}") - editable.addMention(member, start .. start + member.name.length + 1) + editable.addMention(member, start..start + member.name.length + 1) } } + // ---------------------------- + // Shared parsing/substitution core + // ---------------------------- + + data class MentionToken( + val start: Int, // start in FINAL substituted text + val endExclusive: Int, // end-exclusive in FINAL substituted text + val publicKey: String, + val isSelf: Boolean + ) + + data class ParsedMentions( + val text: String, + val mentions: List + ) /** - * Highlights mentions in a given text. + * Shared core: + * - replaces "@<66-hex>" with "@DisplayName" + * - returns the final text + mention ranges (in that final text) + metadata * - * @param text The text to highlight mentions in. - * @param isOutgoingMessage Whether the message is outgoing. - * @param isQuote Whether the message is a quote. - * @param formatOnly Whether to only format the mentions. If true we only format the text itself, - * for example resolving an accountID to a username. If false we also apply styling, like colors and background. - * @param context The context to use. - * @return A SpannableString with highlighted mentions. + * This is UI-agnostic and is used by BOTH: + * - legacy XML span formatting + * - Compose rich text formatting */ @JvmStatic - fun highlightMentions( + fun parseAndSubstituteMentions( recipientRepository: RecipientRepository, - text: CharSequence, - isOutgoingMessage: Boolean = false, - isQuote: Boolean = false, - formatOnly: Boolean = false, + input: CharSequence, context: Context - ): SpannableString { - @Suppress("NAME_SHADOWING") var text = text + ): ParsedMentions { + @Suppress("NAME_SHADOWING") + var text: CharSequence = input var matcher = pattern.matcher(text) - val mentions = mutableListOf, String>>() + val mentions = mutableListOf() var startIndex = 0 - // Format the mention text if (matcher.find(startIndex)) { while (true) { - val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val user = recipientRepository.getRecipientSync(publicKey.toAddress()) + val publicKey = + text.subSequence(matcher.start() + 1, matcher.end()).toString() // drop '@' - val userDisplayName: String = if (user.isSelf) { + val user = recipientRepository.getRecipientSync(publicKey.toAddress()) + val displayName = if (user.isSelf) { context.getString(R.string.you) } else { user.displayName(attachesBlindedId = true) } - val mention = "@$userDisplayName" - text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) - val endIndex = matcher.start() + 1 + userDisplayName.length - startIndex = endIndex - mentions.add(Pair(Range.create(matcher.start(), endIndex), publicKey)) + val replacement = "@$displayName" + + val newText = buildString( + text.length - (matcher.end() - matcher.start()) + replacement.length + ) { + append(text.subSequence(0, matcher.start())) + append(replacement) + append(text.subSequence(matcher.end(), text.length)) + } + + val start = matcher.start() + val endExclusive = start + replacement.length + + mentions += MentionToken( + start = start, + endExclusive = endExclusive, + publicKey = publicKey, + isSelf = user.isSelf + ) + + text = newText + startIndex = endExclusive matcher = pattern.matcher(text) - if (!matcher.find(startIndex)) { break } + if (!matcher.find(startIndex)) break } } - val result = SpannableString(text) - // apply styling if required + return ParsedMentions( + text = text.toString(), + mentions = mentions + ) + } + + // ---------------------------- + // Legacy (XML/TextView) formatter + // ---------------------------- + + /** + * Legacy (XML/TextView) formatter. + * + * Highlights mentions in a given text. + * + * @param formatOnly If true we only format the text itself, + * for example resolving an accountID to a username. If false we also apply styling. + */ + @JvmStatic + fun highlightMentions( + recipientRepository: RecipientRepository, + text: CharSequence, + isOutgoingMessage: Boolean = false, + isQuote: Boolean = false, + formatOnly: Boolean = false, + context: Context + ): SpannableString { + val parsed = parseAndSubstituteMentions(recipientRepository, text, context) + val result = SpannableString(parsed.text) + + if (formatOnly) return result + // Normal text color: black in dark mode and primary text color for light mode val mainTextColor by lazy { if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black) else context.getColorFromAttr(android.R.attr.textColorPrimary) } - // Highlighted text color: primary/accent in dark mode and primary text color for light mode + // Highlighted text color: accent in dark theme and primary text for light val highlightedTextColor by lazy { if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else context.getColorFromAttr(android.R.attr.textColorPrimary) } - if(!formatOnly) { - for (mention in mentions) { - val backgroundColor: Int? - val foregroundColor: Int? + parsed.mentions.forEach { mention -> + val backgroundColor: Int? + val foregroundColor: Int? - // quotes - if(isQuote) { - backgroundColor = null - // the text color has different rule depending if the message is incoming or outgoing - foregroundColor = if(isOutgoingMessage) null else highlightedTextColor - } - // incoming message mentioning you - else if (recipientRepository.getRecipientSync(mention.second.toAddress()).isSelf) { - backgroundColor = context.getAccentColor() - foregroundColor = mainTextColor - } - // outgoing message - else if (isOutgoingMessage) { - backgroundColor = null - foregroundColor = mainTextColor - } - // incoming messages mentioning someone else - else { - backgroundColor = null - // accent color for dark themes and primary text for light - foregroundColor = highlightedTextColor - } + // quotes + if (isQuote) { + backgroundColor = null + // incoming quote gets accent-ish foreground, outgoing quote keeps default + foregroundColor = if (isOutgoingMessage) null else highlightedTextColor + } + // incoming message mentioning you + else if (mention.isSelf && !isOutgoingMessage) { + backgroundColor = context.getAccentColor() + foregroundColor = mainTextColor + } + // outgoing message + else if (isOutgoingMessage) { + backgroundColor = null + foregroundColor = mainTextColor + } + // incoming messages mentioning someone else + else { + backgroundColor = null + foregroundColor = highlightedTextColor + } - // apply the background, if any - backgroundColor?.let { background -> - result.setSpan( - RoundedBackgroundSpan( - context = context, - textColor = mainTextColor, - backgroundColor = background - ), - mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } + val start = mention.start + val end = mention.endExclusive - // apply the foreground, if any - foregroundColor?.let { - result.setSpan( - ForegroundColorSpan(it), - mention.first.lower, - mention.first.upper, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } + backgroundColor?.let { background -> + result.setSpan( + RoundedBackgroundSpan( + context = context, + textColor = mainTextColor, + backgroundColor = background + ), + start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } - // apply bold on the mention + foregroundColor?.let { fg -> result.setSpan( - StyleSpan(Typeface.BOLD), - mention.first.lower, - mention.first.upper, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ForegroundColorSpan(fg), + start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } + + result.setSpan( + StyleSpan(Typeface.BOLD), + start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } + return result } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt index 37a7f14f30..3bd8130797 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt @@ -15,23 +15,6 @@ import androidx.core.text.toSpannable object TextUtilities { - fun getIntrinsicHeight(text: CharSequence, paint: TextPaint, width: Int): Int { - val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setLineSpacing(0.0f, 1.0f) - .setIncludePad(false) - val layout = builder.build() - return layout.height - } - - fun getIntrinsicLayout(text: CharSequence, paint: TextPaint, width: Int): StaticLayout { - val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setLineSpacing(0.0f, 1.0f) - .setIncludePad(false) - return builder.build() - } - fun TextView.getIntersectedModalSpans(event: MotionEvent): List { val xInt = event.rawX.toInt() val yInt = event.rawY.toInt() @@ -59,17 +42,5 @@ object TextUtilities { fun String.textSizeInBytes(): Int = this.toByteArray(Charsets.UTF_8).size - fun String.breakAt(vararg lengths: Int): String { - var cursor = 0 - val out = StringBuilder() - for (len in lengths) { - val end = (cursor + len).coerceAtMost(length) - out.append(substring(cursor, end)) - if (end < length) out.append('\n') - cursor = end - } - if (cursor < length) out.append('\n').append(substring(cursor)) - return out.toString() - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationActivityV3.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationActivityV3.kt index 7b74a60521..5ec3001df0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationActivityV3.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationActivityV3.kt @@ -8,7 +8,9 @@ import androidx.core.content.IntentCompat import org.session.libsession.utilities.Address import org.session.libsession.utilities.isBlinded import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.util.push class ConversationActivityV3 : FullComposeScreenLockActivity() { @@ -48,6 +50,10 @@ class ConversationActivityV3 : FullComposeScreenLockActivity() { "ConversationV3Activity requires an Address to be passed in the intent." }, startDestination = startDestination, + switchConvoVersion = { + startActivity(ConversationActivityV2.createIntent(this, address = IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java)!!)) + finish() + }, onBack = this::finish ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt new file mode 100644 index 0000000000..9d2ed7e3a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -0,0 +1,470 @@ +package org.thoughtcrime.securesms.conversation.v3 + +import android.content.Context +import android.text.format.Formatter +import androidx.compose.ui.text.AnnotatedString +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import network.loki.messenger.R +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.displayName +import org.session.libsession.utilities.truncatedForDisplay +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +import org.thoughtcrime.securesms.conversation.v3.compose.message.AudioMessageData +import org.thoughtcrime.securesms.conversation.v3.compose.message.ClusterPosition +import org.thoughtcrime.securesms.conversation.v3.compose.message.DocumentMessageData +import org.thoughtcrime.securesms.conversation.v3.compose.message.HighlightMessage +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageAvatar +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageContent +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageContentData +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageContentGroup +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageContentPadding +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageLayout +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageLinkData +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageMediaItem +import org.thoughtcrime.securesms.conversation.v3.compose.message.QuoteMessageData +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageQuoteIcon +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewStatus +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewStatusIcon +import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionItem +import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionViewState +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.DateUtils +import java.util.TimeZone +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.collections.mutableListOf +import kotlin.math.abs + + +@Singleton +class ConversationDataMapper @Inject constructor( + @ApplicationContext private val context: Context, + private val avatarUtils: AvatarUtils, + private val dateUtils: DateUtils, + private val json: Json, + private val recipientRepository: RecipientRepository +) { + private val timeZoneOffsetSeconds = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000 + + sealed interface ConversationItem { + data class Message(val data: MessageViewData) : ConversationItem + data class DateBreak(val messageId: MessageId, val date: String) : ConversationItem + data object UnreadMarker : ConversationItem + } + + fun map( + record: MessageRecord, + previous: MessageRecord?, + next: MessageRecord?, + threadRecipient: Recipient, + localUserAddress: String, + lastSeen: Long?, + highlightKey: HighlightMessage? = null, + showStatus: Boolean = false, + out: MutableList, + ) { + val isOutgoing = record.isOutgoing + + val layout = when { + record.isControlMessage -> MessageLayout.CONTROL + isOutgoing -> MessageLayout.OUTGOING + else -> MessageLayout.INCOMING + } + + val senderName = record.individualRecipient.displayName() + val extraDisplayName = when { + record.recipient.address is Address.Blinded -> + (record.recipient.address as Address.Blinded).blindedId.truncatedForDisplay() + + else -> null + } + + val isGroup = threadRecipient.isGroupOrCommunityRecipient + + val isStart = isStartOfCluster(record, previous, isGroup) + val isEnd = isEndOfCluster(record, next, isGroup) + + val clusterPosition = when { + isStart && isEnd -> ClusterPosition.ISOLATED + isStart -> ClusterPosition.TOP + isEnd -> ClusterPosition.BOTTOM + else -> ClusterPosition.MIDDLE + } + + val avatar = when{ + // outgoing and non group conversations: No avatar + isOutgoing || !isGroup -> MessageAvatar.None + + // if at the right cluster position, show avatar + clusterPosition == ClusterPosition.BOTTOM + || clusterPosition == ClusterPosition.ISOLATED -> MessageAvatar.Visible(avatarUtils.getUIDataFromRecipient(record.individualRecipient)) + + // otherwise leave an empty space the size of the avatar + else -> MessageAvatar.Invisible + } + + val showDateBreak = shouldShowDateBreak(record, previous) + val showAuthorName = shouldShowAuthorName(record, previous, isGroup, showDateBreak) + + val message = ConversationItem.Message( + MessageViewData( + id = record.messageId, + layout = layout, + displayName = senderName, + displayNameExtra = extraDisplayName, + showDisplayName = showAuthorName, + showProBadge = record.recipient.shouldShowProBadge, + avatar = avatar, + contentGroups = mapContentGroups(record), + status = if (showStatus && isOutgoing) mapStatus(record) else null, + reactions = mapReactions(record, localUserAddress), + highlightKey = highlightKey, + clusterPosition = clusterPosition + )) + + val showUnreadMarker = lastSeen != null + && record.timestamp > lastSeen + && (previous == null || previous.timestamp <= lastSeen) + && !record.isOutgoing + + out += message + + // Items added after message appear visually ABOVE it (with reverseLayout = true) + if (showDateBreak) out += ConversationItem.DateBreak( + messageId = message.data.id, + date = dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) + ) + + // unread marker, if needed + if (showUnreadMarker) out += ConversationItem.UnreadMarker + } + + private fun isStartOfCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = + previous == null || previous.isControlMessage || !dateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { + current.recipient.address != previous.recipient.address + } else { + current.isOutgoing != previous.isOutgoing + } + + private fun isEndOfCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { + if (next == null || next.isControlMessage) return true + + // If there's a date break before the next message, this is the end of a cluster + if (shouldShowDateBreak(next, current)) return true + + return if (isGroupThread) { + current.recipient.address != next.recipient.address + } else { + current.isOutgoing != next.isOutgoing + } + } + + private fun shouldShowDateBreak(current: MessageRecord, previous: MessageRecord?): Boolean { + // Always show before the first visible message (no previous) + if (previous == null) return true + + val t1 = previous.timestamp + val t2 = current.timestamp + + // Rule 1: 5+ minute gap + if (abs(t2 - t1) > 5 * 60 * 1000) return true + + // Rule 2: crossed midnight in local timezone + return !dateUtils.isSameDay(t1, t2) + } + + private fun shouldShowAuthorName( + current: MessageRecord, + previous: MessageRecord?, + isGroupThread: Boolean, + showDateBreak: Boolean, + ): Boolean { + if (!isGroupThread) return false + if (current.isOutgoing) return false + + // Show if there's a date break, the author changed, or previous was a control message + return (showDateBreak + || current.individualRecipient.address != previous?.individualRecipient?.address) + || previous.isControlMessage + } + + // ---- Message content ---- + + private fun mapContentGroups(record: MessageRecord): List { + val groups = mutableListOf() + val mms = record as? MmsMessageRecord + + // Special cases - which preclude other content + // Deleted messages — check first; body is not meaningful for these + if (record.isDeleted) { + addContentToGroup( + groups, + MessageContentData.Text( + text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), + ) + ) + + return groups + } + + // community invites + if (record.isOpenGroupInvitation) { + val jsonData = UpdateMessageData.fromJSON(json, record.body) + if (jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation) { + addContentToGroup( + groups, + MessageContentData.CommunityInvite( + jsonData.kind.groupName, + jsonData.kind.groupUrl + )) + + return groups + } + } + + // Group 1: Quotes, Links, and Text + // We map the message content data first + val primaryData = mutableListOf() + + mapQuote(record)?.let { primaryData += MessageContentData.Quote(it) } + mapLinkPreview(record)?.let { primaryData += MessageContentData.Link(it) } + + if (record.body.isNotBlank()) { + + val parsed = MentionUtilities.parseAndSubstituteMentions( + recipientRepository = recipientRepository, + input = record.body, + context = context + ) + val annotatedBody = MessageTextFormatter.formatMessage( + parsed = parsed, + isOutgoing = record.isOutgoing, + ) + + primaryData += MessageContentData.Text(annotatedBody) + } + + // now we can map the message content data to message content, which is a wrapper + // that allows custom padding based on certain rules + // for example used by quotes to change their paddings depending on neighboring content + if (primaryData.isNotEmpty()) { + val primaryContents: List = + primaryData.mapIndexed { index, data -> + val extraPadding = + if (data is MessageContentData.Quote) { + // custom rules for quotes + // add bottom padding if quote is alone or if there is a link below + val isAlone = primaryData.size == 1 + val nextIsLink = primaryData.getOrNull(index + 1) is MessageContentData.Link + + if (isAlone || nextIsLink) MessageContentPadding.Bottom else MessageContentPadding.None + } else { + MessageContentPadding.None + } + + MessageContent(contentData = data, extraPadding = extraPadding) + } + + groups.add(MessageContentGroup(primaryContents, showBubble = true)) + } + + // Group 2: Media, Audio, or Documents + val audioSlide = mms?.slideDeck?.audioSlide + if (audioSlide != null) { + // Audio + //todo convov3 maybe this should be packed inside an audio composable to live listen to changes locally? + // todo CONVOv3: drive values from playback state + addContentToGroup( + groups, + MessageContentData.Audio( + AudioMessageData( + title = audioSlide.filename, + speedText = "1x", + remainingText = "", + durationMs = 0L, + positionMs = 0L, + isPlaying = false, + showLoader = audioSlide.isInProgress + )) + ) + } + + val documentSlide = mms?.slideDeck?.documentSlide + if (documentSlide != null) { + addContentToGroup( + groups, + MessageContentData.Document(DocumentMessageData( + name = documentSlide.filename, + size = Formatter.formatFileSize(context, documentSlide.fileSize), + uri = documentSlide.uri?.toString() ?: "", + loading = documentSlide.isInProgress + )) + ) + } + + if (record is MediaMmsMessageRecord) { + val mediaSlides = record.slideDeck.slides.filter { it.hasImage() || it.hasVideo() } + if (mediaSlides.isNotEmpty()) { + val items = mediaSlides.map { slide -> + val uri = (slide.uri ?: slide.thumbnailUri) ?: "".toUri() + val filename = slide.filename + val loading = slide.isInProgress || slide.isPendingDownload + val width = 100 + val height = 100 + + if (slide.hasVideo()) { + MessageMediaItem.Video(uri, filename, loading, width, height) + } else { + MessageMediaItem.Image(uri, filename, loading, width, height) + } + } + groups.add(MessageContentGroup(listOf(MessageContent( + MessageContentData.Media(items, items.any { it.loading }))) + , showBubble = false) + ) + } + } + + return groups + } + + private fun addContentToGroup( + groups: MutableList, + contentData: MessageContentData, + showBubble: Boolean = true, + paddingValues: MessageContentPadding = MessageContentPadding.None + ){ + groups.add( + MessageContentGroup(listOf(MessageContent(contentData, paddingValues)), showBubble) + ) + } + + // ---- Quote ---- +// todo CONVOv3: sort out properly + private fun mapQuote(record: MessageRecord): QuoteMessageData? { + val quote = (record as? MmsMessageRecord)?.quote ?: return null + + val raw = quote.text?.ifBlank { null } + + val subtitle: AnnotatedString = if (raw != null) { + val parsed = MentionUtilities.parseAndSubstituteMentions( + recipientRepository = recipientRepository, + input = raw, + context = context + ) + + MessageTextFormatter.formatMessage( + parsed = parsed, + isOutgoing = record.isOutgoing + ) + } else { + AnnotatedString(context.getString(R.string.document)) + } + + val icon: MessageQuoteIcon = MessageQuoteIcon.Bar + /*when { + quote.attachment.thumbnail != null -> MessageQuoteIcon.Image( + uri = quote.attachment.thumbnail!!.uri?.toUri() ?: "".toUri(), + filename = quote.attachment.fileName ?: "", + ) + + else -> MessageQuoteIcon.Bar + }*/ + + // title + val title = if(quote.author.isLocalNumber) context.getString(R.string.you) + else quote.author.displayName() + + return QuoteMessageData( + title = title, + subtitle = subtitle, + icon = icon, + showProBadge = quote.author.shouldShowProBadge + ) + } + + // ---- Link preview ---- + + private fun mapLinkPreview(record: MessageRecord): MessageLinkData? { + val preview = (record as? MmsMessageRecord) + ?.linkPreviews + ?.firstOrNull() + ?: return null + + return MessageLinkData( + url = preview.url, + title = preview.title, + imageUri = preview.thumbnail?.thumbnailUri?.toString(), + ) + } + + // ---- Reactions ---- + + private fun mapReactions(record: MessageRecord, localUserAddress: String): ReactionViewState? { + val reactions = record.reactions.ifEmpty { return null } + + // Per ReactionRecord docs: + // - Community: count is the TOTAL for that emoji, same value on every record — use max + // - Private/group: count must be SUMMED across records for the same emoji + val isCommunity = record.recipient.isCommunityRecipient + + val items = reactions + .groupBy { it.emoji } + .entries + .sortedBy { (_, group) -> group.minOf(ReactionRecord::sortId) } + .map { (emoji, group) -> + val count = if (isCommunity) { + group.maxOf { it.count } + } else { + group.sumOf { it.count } + } + ReactionItem( + emoji = emoji, + count = count.toInt(), + selected = group.any { it.author == localUserAddress }, + ) + } + + return ReactionViewState( + reactions = items, + isExtended = false, // todo CONVOv3: drive from per-message expanded state in ViewModel + ) + } + + // ---- Status ---- + + // todo convov3 map properly + private fun mapStatus(record: MessageRecord): MessageViewStatus? { + return when { + record.isFailed -> MessageViewStatus( + name = context.getString(R.string.messageStatusFailedToSend), + icon = MessageViewStatusIcon.DrawableIcon(R.drawable.ic_triangle_alert), + ) + // isSending() = isOutgoing() && !isSent() — BASE_SENDING_TYPE / BASE_OUTBOX_TYPE + record.isSending -> MessageViewStatus( + name = context.getString(R.string.sending), + icon = MessageViewStatusIcon.DrawableIcon(R.drawable.ic_circle_dots_custom), + ) + record.isRead -> MessageViewStatus( + name = context.getString(R.string.read), + icon = MessageViewStatusIcon.DrawableIcon(R.drawable.ic_circle_check), + ) + record.isSent -> MessageViewStatus( + name = context.getString(R.string.sent), + icon = MessageViewStatusIcon.DrawableIcon(R.drawable.ic_circle_check), + ) + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt new file mode 100644 index 0000000000..05703732ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.conversation.v3 + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.model.MessageId + +class ConversationPagingSource( + private val threadId: Long, + private val mmsSmsDatabase: MmsSmsDatabase, + private val reverse: Boolean, + private val dataMapper: ConversationDataMapper, + private val threadRecipient: Recipient, + private val localUserAddress: String, + private val lastSentMessageId: MessageId?, + private val lastSeen: Long? +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { anchor -> + // Snap refresh back to the anchor page so scroll position is preserved + val page = state.closestPageToPosition(anchor) + page?.prevKey?.plus(state.config.pageSize) + ?: page?.nextKey?.minus(state.config.pageSize) + } + + override suspend fun load(params: LoadParams): LoadResult { + val offset = params.key ?: 0 + return try { + // getConversation already handles LIMIT/OFFSET in SQL + val records = mmsSmsDatabase.getConversation( + threadId, reverse, offset.toLong(), params.loadSize.toLong() + ).use { cursor -> + buildList(cursor.count) { + val reader = mmsSmsDatabase.readerFor(cursor) + var record = reader.getNext() + while (record != null) { + add(record) + record = reader.getNext() + } + } + } + + val mapped = mutableListOf() + for (i in records.indices) { + dataMapper.map( + record = records[i], + previous = records.getOrNull(i - 1), + next = records.getOrNull(i + 1), + threadRecipient = threadRecipient, + localUserAddress = localUserAddress, + showStatus = records[i].messageId == lastSentMessageId, + lastSeen = lastSeen, + out = mapped, + ) + } + + LoadResult.Page( + data = mapped, + prevKey = if (offset == 0) null else maxOf(0, offset - params.loadSize), + nextKey = if (records.size < params.loadSize) null else offset + params.loadSize, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt index cea24dbcdf..38f5a234ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt @@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.Rout import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteManageMembers import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteNotifications import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RoutePromoteMembers -import org.thoughtcrime.securesms.conversation.v3.compose.ConversationScreen +import org.thoughtcrime.securesms.conversation.v3.compose.conversation.ConversationScreen import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel @@ -168,6 +168,7 @@ sealed interface ConversationV3Destination: Parcelable { fun ConversationV3NavHost( address: Address.Conversable, startDestination: ConversationV3Destination = RouteConversation, + switchConvoVersion: () -> Unit, onBack: () -> Unit ){ SharedTransitionLayout { @@ -210,6 +211,7 @@ fun ConversationV3NavHost( ConversationScreen( viewModel = viewModel, + switchConvoVersion = switchConvoVersion, onBack = onBack, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt index 517264b33c..81812ff021 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3ViewModel.kt @@ -3,6 +3,10 @@ package org.thoughtcrime.securesms.conversation.v3 import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest @@ -11,15 +15,29 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol @@ -33,8 +51,13 @@ import org.session.libsession.utilities.recipients.displayName import org.session.libsession.utilities.recipients.effectiveNotifyType import org.session.libsession.utilities.recipients.repeatedWithEffectiveNotifyTypeChange import org.session.libsession.utilities.toGroupString +import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.ConversationAppBarData @@ -55,14 +78,25 @@ class ConversationV3ViewModel @AssistedInject constructor( private val storage: StorageProtocol, private val recipientRepository: RecipientRepository, private val groupDb: GroupDatabase, - val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, -) : ViewModel() { - - private val threadId by lazy { - requireNotNull(storage.getThreadId(address)) { - "Thread doesn't exist for this conversation" - } - } + private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + private val threadDb: ThreadDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val recipientSettingsDatabase: RecipientSettingsDatabase, + private val attachmentDatabase: AttachmentDatabase, + private val reactionDb: ReactionDatabase, + private val dataMapper: ConversationDataMapper, + ) : ViewModel() { + //todo convov3 remove references to threadId once we have the notification refactor + val threadIdFlow: StateFlow = merge( + // Initial lookup off main thread + flow { emit(withContext(Dispatchers.IO) { storage.getThreadId(address) }) }, + // Also listen for thread creation in case it doesn't exist yet + threadDb.updateNotifications + .map { withContext(Dispatchers.IO) { storage.getThreadId(address) } } + ) + .filterNotNull() + .take(1) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _uiState: MutableStateFlow = MutableStateFlow( UIState() @@ -111,8 +145,64 @@ class ConversationV3ViewModel @AssistedInject constructor( avatarUIData = AvatarUIData(emptyList()) )) - init { + private var pagingSource: ConversationPagingSource? = null + + // obtain the last seen message id + private val lastSeen: StateFlow = threadIdFlow + .filterNotNull() + .flatMapLatest { id -> + flow { + emit(withContext(Dispatchers.IO) { + threadDb.getLastSeenAndHasSent(id).first().takeIf { it > 0 } + }) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + val conversationItems: Flow> = combine( + threadIdFlow.filterNotNull(), + lastSeen, + ) { id, lastSeen -> + Pair(id, lastSeen) + } + .flatMapLatest { (id, lastSeen) -> + Pager( + config = PagingConfig(pageSize = 50, initialLoadSize = 100, enablePlaceholders = false), + pagingSourceFactory = { + ConversationPagingSource( + threadId = id, + mmsSmsDatabase = mmsSmsDatabase, + reverse = true, + dataMapper = dataMapper, + threadRecipient = recipient, + localUserAddress = storage.getUserPublicKey() ?: "", + lastSentMessageId = mmsSmsDatabase.getLastSentMessageID(id), + lastSeen = lastSeen, + ).also { pagingSource = it } + } + ).flow + } + .cachedIn(viewModelScope) + + @Suppress("OPT_IN_USAGE") + val databaseChanges: SharedFlow<*> = merge( + threadIdFlow + .filterNotNull() + .flatMapLatest { id -> threadDb.updateNotifications.filter { it == id } }, + recipientSettingsDatabase.changeNotification.filter { it == address }, + attachmentDatabase.changesNotification, + reactionDb.changeNotification, + ).debounce(200L) // debounce to avoid too many reloads + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 0) + + + init { + viewModelScope.launch { + databaseChanges.collectLatest { + // Forces the Pager to re-query the PagingSource + pagingSource?.invalidate() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt new file mode 100644 index 0000000000..21c3a1c58d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.conversation.v3 + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import org.nibor.autolink.LinkExtractor +import org.nibor.autolink.LinkType +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +import java.util.EnumSet + +/** + * Pure text formatting for message content: + * - No UI colors (Compose layer handles those) + * - No click behavior (Compose layer injects handlers) + * + * Produces an AnnotatedString with: + * - LinkAnnotation.Clickable(tag = url) with underline style + * - String annotations for mentions: + * "mention_pk" = publicKey + * "mention_self" = presence-only, when mentioning local user + * "mention_bg" = presence-only, when pill background is needed + * + * Inserts thin-space padding around pill mentions for visual breathing room. + */ +object MessageTextFormatter { + + private val linkExtractor: LinkExtractor = LinkExtractor.builder() + .linkTypes(EnumSet.of(LinkType.URL, LinkType.WWW)) + .build() + + // Spacing used around the mention highlight bg + private const val OUTSIDE_SPACE: Char = '\u2009' + + fun formatMessage( + parsed: MentionUtilities.ParsedMentions, + isOutgoing: Boolean + ): AnnotatedString { + // Insert spacing ONLY for bg mentions (incoming mentions of self) + val remapped = buildTextWithOutsideSpacing( + text = parsed.text, + mentions = parsed.mentions.sortedBy { it.start }, + needsBg = { it.isSelf && !isOutgoing } + ) + + val text = remapped.text + val mentions = remapped.mentions + + val b = AnnotatedString.Builder(text) + + // Links first (they key off final text indices) + addLinkAnnotationsWithAutolink(b, text) + + // Mentions: bold + metadata annotations + for (m in mentions) { + b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), m.start, m.endExclusive) + b.addStringAnnotation("mention_pk", m.publicKey, m.start, m.endExclusive) + if (m.isSelf) b.addStringAnnotation("mention_self", "", m.start, m.endExclusive) + if (m.needsBg) b.addStringAnnotation("mention_bg", "", m.start, m.endExclusive) + } + + return b.toAnnotatedString() + } + + // ------------ Link handling -------------------- + + private fun addLinkAnnotationsWithAutolink( + builder: AnnotatedString.Builder, + text: String + ) { + val links = linkExtractor.extractLinks(text) + + val styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline) + ) + + for (link in links) { + val start = link.beginIndex + val end = link.endIndex + if (start < 0 || end > text.length || start >= end) continue + + val raw = text.substring(start, end) + val url = when (link.type) { + LinkType.WWW -> "https://$raw" + else -> raw + } + + builder.addLink( + clickable = LinkAnnotation.Clickable( + tag = url, + styles = styles, + linkInteractionListener = null + ), + start = start, + end = end + ) + } + } + + // ---------------- Mention spacing + remap ---------------- + + private data class RemappedText(val text: String, val mentions: List) + private data class MentionOut( + val start: Int, + val endExclusive: Int, + val publicKey: String, + val isSelf: Boolean, + val needsBg: Boolean + ) + + /** + * Mention ranges in the output exclude the inserted spaces. + * + * Rebuilds text left-to-right, inserting [OUTSIDE_SPACE] before/after + * mentions that need a pill background. + */ + private fun buildTextWithOutsideSpacing( + text: String, + mentions: List, + needsBg: (MentionUtilities.MentionToken) -> Boolean + ): RemappedText { + val out = StringBuilder(text.length + mentions.size * 2) + val outMentions = ArrayList(mentions.size) + + var cursor = 0 + + for (m in mentions) { + val start = m.start + val end = m.endExclusive + + // Defensive: skip invalid/overlapping + if (start < cursor || start < 0 || end > text.length || start >= end) continue + + // before mention + if (cursor < start) out.append(text, cursor, start) + + val bg = needsBg(m) + if (bg) out.append(OUTSIDE_SPACE) + + val mentionStartOut = out.length + out.append(text, start, end) + val mentionEndOut = out.length + + if (bg) out.append(OUTSIDE_SPACE) + + outMentions += MentionOut( + start = mentionStartOut, + endExclusive = mentionEndOut, + publicKey = m.publicKey, + isSelf = m.isSelf, + needsBg = bg + ) + + cursor = end + } + + if (cursor < text.length) out.append(text, cursor, text.length) + + return RemappedText(out.toString(), outMentions) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt deleted file mode 100644 index 93bde690ea..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v3.compose - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination -import org.thoughtcrime.securesms.conversation.v3.ConversationV3ViewModel -import org.thoughtcrime.securesms.ui.components.ConversationAppBar -import org.thoughtcrime.securesms.ui.components.ConversationAppBarData -import org.thoughtcrime.securesms.ui.components.ConversationAppBarPagerData -import org.thoughtcrime.securesms.ui.components.ConversationTopBarParamsProvider -import org.thoughtcrime.securesms.ui.components.ConversationTopBarPreviewParams -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.primaryBlue -import org.thoughtcrime.securesms.ui.theme.primaryOrange -import org.thoughtcrime.securesms.util.AvatarUIData -import org.thoughtcrime.securesms.util.AvatarUIElement - - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun ConversationScreen( - viewModel: ConversationV3ViewModel, - onBack: () -> Unit, -) { - val conversationState by viewModel.uiState.collectAsStateWithLifecycle() - val appBarData by viewModel.appBarData.collectAsStateWithLifecycle() - - Conversation( - conversationState = conversationState, - appBarData = appBarData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) -@Composable -fun Conversation( - conversationState: ConversationV3ViewModel.UIState, - appBarData: ConversationAppBarData, - sendCommand: (ConversationV3ViewModel.Commands) -> Unit, - onBack: () -> Unit, -) { - Scaffold( - topBar = { - ConversationAppBar( - data = appBarData, - onBackPressed = onBack, - onCallPressed = {}, //todo ConvoV3 implement - searchQuery = "", //todo ConvoV3 implement - onSearchQueryChanged = {}, //todo ConvoV3 implement - onSearchQueryClear = {}, //todo ConvoV3 implement - onSearchCanceled = {}, //todo ConvoV3 implement - onAvatarPressed = { - sendCommand( - ConversationV3ViewModel.Commands.GoTo( - ConversationV3Destination.RouteConversationSettings - ) - ) - } - ) - }, - contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), - ) { paddings -> - - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddings) - .consumeWindowInsets(paddings) - .padding( - horizontal = LocalDimensions.current.spacing, - ) - .verticalScroll(rememberScrollState()), - horizontalAlignment = CenterHorizontally - ) { - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - Text("--- Conversation V3 WIP ---") - } - } -} - -@Preview -@Composable -fun PreviewConversation( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors, -) { - PreviewTheme(colors) { - Conversation( - conversationState = ConversationV3ViewModel.UIState(), - appBarData = ConversationAppBarData( - title ="Friendo", - pagerData = emptyList(), - showAvatar = true, - showCall = true, - showSearch = false, - showProBadge = false, - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TO", - color = primaryBlue - ), - ) - ) - ), - sendCommand = {}, - onBack = {}, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationElements.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationElements.kt new file mode 100644 index 0000000000..2fdb7006be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationElements.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.conversation + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold + + +@Composable +fun ConversationDateBreak( + date: String, + modifier: Modifier = Modifier +){ + Text( + modifier = modifier.fillMaxWidth() + .padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xxsSpacing + ), + text = date, + color = LocalColors.current.text, + style = LocalType.current.small.bold(), + textAlign = TextAlign.Center + ) +} + +@Composable +fun ConversationUnreadBreak( + modifier: Modifier = Modifier +){ + Row( + modifier = modifier.fillMaxWidth() + .padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + ) { + Box( + modifier = Modifier.height(1.dp) + .background(LocalColors.current.accent) + .weight(1f), + ) + + Text( + text = stringResource(R.string.messageUnread), + style = LocalType.current.base.bold(), + color = LocalColors.current.accent, + ) + + Box( + modifier = Modifier.height(1.dp) + .background(LocalColors.current.accent) + .weight(1f), + ) + } +} + + +@Preview +@Composable +fun PreviewConversationElements( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + ConversationDateBreak(date = "10:24") + ConversationUnreadBreak() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt new file mode 100644 index 0000000000..e6ecdb596b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt @@ -0,0 +1,224 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.conversation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import kotlinx.coroutines.flow.flowOf +import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination +import org.thoughtcrime.securesms.conversation.v3.ConversationV3ViewModel +import org.thoughtcrime.securesms.conversation.v3.ConversationDataMapper.ConversationItem +import org.thoughtcrime.securesms.conversation.v3.compose.message.Message +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageLayout +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.textGroup +import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionItem +import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionViewState +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.ui.components.ConversationAppBar +import org.thoughtcrime.securesms.ui.components.ConversationAppBarData +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ConversationScreen( + viewModel: ConversationV3ViewModel, + switchConvoVersion: () -> Unit, + onBack: () -> Unit, +) { + val conversationState by viewModel.uiState.collectAsStateWithLifecycle() + val appBarData by viewModel.appBarData.collectAsStateWithLifecycle() + val conversationItems = viewModel.conversationItems.collectAsLazyPagingItems() + + Conversation( + conversationState = conversationState, + appBarData = appBarData, + conversationItems = conversationItems, + sendCommand = viewModel::onCommand, + switchConvoVersion = switchConvoVersion, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun Conversation( + conversationState: ConversationV3ViewModel.UIState, + appBarData: ConversationAppBarData, + conversationItems: LazyPagingItems, + sendCommand: (ConversationV3ViewModel.Commands) -> Unit, + switchConvoVersion: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + ConversationAppBar( + data = appBarData, + onBackPressed = onBack, + onCallPressed = {}, //todo ConvoV3 implement + searchQuery = "", //todo ConvoV3 implement + onSearchQueryChanged = {}, //todo ConvoV3 implement + onSearchQueryClear = {}, //todo ConvoV3 implement + onSearchCanceled = {}, //todo ConvoV3 implement + switchConvoVersion = switchConvoVersion, + onAvatarPressed = { + sendCommand( + ConversationV3ViewModel.Commands.GoTo( + ConversationV3Destination.RouteConversationSettings + ) + ) + } + ) + }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { paddings -> + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddings) + .consumeWindowInsets(paddings), + reverseLayout = true, // newest messages at the bottom + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xsSpacing + ), + state = rememberLazyListState(), + ) { + items( + count = conversationItems.itemCount, + key = conversationItems.itemKey { item -> + when (item) { + is ConversationItem.Message -> "msg_${item.data.id}" + is ConversationItem.DateBreak -> "date_${item.date}_${item.messageId}" + is ConversationItem.UnreadMarker -> "unread" + } + }, + contentType = conversationItems.itemContentType { item -> + when (item) { + is ConversationItem.Message -> 0 + is ConversationItem.DateBreak -> 1 + is ConversationItem.UnreadMarker -> 2 + } + } + ) { index -> + when (val item = conversationItems[index]) { + is ConversationItem.Message -> Message(data = item.data) + is ConversationItem.DateBreak -> ConversationDateBreak(date = item.date) + is ConversationItem.UnreadMarker -> ConversationUnreadBreak() + null -> Unit + } + } + + // todo Convov3 do we want a loader for pagination? + if (conversationItems.loadState.append is LoadState.Loading) { + item(key = "loading_append") { + Box( + modifier = Modifier.fillMaxWidth().padding( + LocalDimensions.current.spacing + ), + contentAlignment = Alignment.Center + ) { + SmallCircularProgressIndicator() + } + } + } + } + } +} + +@Preview +@Composable +fun PreviewConversation( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors, +) { + PreviewTheme(colors) { + Conversation( + conversationState = ConversationV3ViewModel.UIState(), + appBarData = ConversationAppBarData( + title ="Friendo", + pagerData = emptyList(), + showAvatar = true, + showCall = true, + showSearch = false, + showProBadge = false, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryBlue + ), + ) + ) + ), + conversationItems = flowOf>( + PagingData.from( + data = listOf( + ConversationItem.Message( + MessageViewData( + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = textGroup() + )), + ConversationItem.Message( + MessageViewData( + id = MessageId(0, false), + displayName = "Toto", + avatar = PreviewMessageData.sampleAvatar, + layout = MessageLayout.INCOMING, + contentGroups = textGroup("I have lots of reactions - Closed"), + reactions = ReactionViewState( + reactions = listOf( + ReactionItem("👍", 3, selected = true), + ReactionItem("❤️", 12, selected = false), + ReactionItem("😂", 1, selected = false), + ReactionItem("😮", 5, selected = false), + ReactionItem("😢", 2, selected = false), + ReactionItem("🔥", 8, selected = false), + ReactionItem("💕", 8, selected = false), + ReactionItem("🐙", 8, selected = false), + ReactionItem("✅", 8, selected = false), + ), + isExtended = false, + ) + )) + ) + ) + ).collectAsLazyPagingItems(), + sendCommand = {}, + switchConvoVersion = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/AudioMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt similarity index 87% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/AudioMessage.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt index dce8c5b0eb..2f969586ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/AudioMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import network.loki.messenger.R +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -48,19 +49,20 @@ private val playPauseSize = 36.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudioMessage( - data: Audio, + data: AudioMessageData, + outgoing: Boolean, modifier: Modifier = Modifier ) { Column( modifier = modifier .fillMaxWidth() - .padding(vertical = LocalDimensions.current.xxsSpacing), + .padding(vertical = LocalDimensions.current.messageVerticalPadding), ) { - val textColor = getTextColor(data.outgoing) + val textColor = getTextColor(outgoing) - val (color1, color2, trackEmptyColor) = if (data.outgoing) { + val (color1, color2, trackEmptyColor) = if (outgoing) { arrayOf( LocalColors.current.backgroundSecondary, // bg secondary LocalColors.current.text, // text primary @@ -153,8 +155,8 @@ fun AudioMessage( ) { PlaybackSpeedButton( text = data.speedText, - bgColor = if (data.outgoing) color1 else color2, - textColor = if(data.outgoing) color2 else textColor, + bgColor = if (outgoing) color1 else color2, + textColor = if(outgoing) color2 else textColor, onClick = { //todo CONVOV3 implement } @@ -234,9 +236,7 @@ private fun PlaybackSpeedButton( } } -data class Audio( - override val outgoing: Boolean, - override val text: AnnotatedString? = null, +data class AudioMessageData( val title: String, val speedText: String, val remainingText: String, @@ -245,42 +245,42 @@ data class Audio( val bufferedPositionMs: Long = 0L, val isPlaying: Boolean, val showLoader: Boolean, -) : MessageType() +) @Preview @Composable fun AudioMessagePreview( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { + fun audioMessage( + outgoing: Boolean = true, + title: String = "Voice Message", + playing: Boolean = true + ) = MessageViewData( + id = MessageId(0, false), + layout = if (outgoing) MessageLayout.OUTGOING else MessageLayout.INCOMING, + contentGroups = PreviewMessageData.audioGroup(title, playing), + displayName = "Toto" + ) + PreviewTheme(colors) { Column( modifier = Modifier.fillMaxWidth().padding(LocalDimensions.current.spacing) ) { - Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.audio() - )) + + Message(data = audioMessage()) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Message(data = MessageViewData( - author = "Toto", - avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.audio( - outgoing = false, - title = "Audio with a really long name that should ellipsize once it reaches the max width", - ) - )) + Message(data = audioMessage( + outgoing = false, + title = "Audio with a really long name that should ellipsize once it reaches the max width" + ).copy(avatar = PreviewMessageData.sampleAvatar)) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.audio( - playing = false - ) - )) + Message(data = audioMessage(playing = false)) } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageComposables.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt similarity index 52% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageComposables.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 1e5471e2f8..6da74cdd77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageComposables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import android.net.Uri import androidx.annotation.DrawableRes @@ -35,15 +35,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.min import androidx.core.net.toUri import kotlinx.coroutines.delay import network.loki.messenger.R +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -61,38 +65,69 @@ import org.thoughtcrime.securesms.util.AvatarUIElement //todo CONVOv3 typing indicator //todo CONVOv3 long press views (overlay+message+recent reactions+menu) //todo CONVOv3 control messages -//todo CONVOv3 time/date "separator" //todo CONVOv3 bottom search //todo CONVOv3 text input //todo CONVOv3 voice recording //todo CONVOv3 collapsible + menu for attachments //todo CONVOv3 jump down to last message button -//todo CONVOv3 community invites //todo CONVOv3 attachment controls //todo CONVOv3 deleted messages //todo CONVOv3 swipe to reply //todo CONVOv3 inputbar quote/reply //todo CONVOv3 proper accessibility on overall message control //todo CONVOv3 new "read more" expandable feature +//todo CONVOv3 verify immutability/stability of data classes /** - * Basic message building block: Bubble + * The overall Message composable + * This controls the width and position of the message as a whole */ @Composable -fun MessageBubble( - color: Color, - modifier: Modifier = Modifier, - content: @Composable () -> Unit = {} +fun Message( + data: MessageViewData, + modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .background( - color = color, - shape = RoundedCornerShape(LocalDimensions.current.messageCornerRadius) - ) - .clip(shape = RoundedCornerShape(LocalDimensions.current.messageCornerRadius)) + when (data.layout) { + MessageLayout.CONTROL -> { + ControlMessage(data = data, modifier = modifier) + } + MessageLayout.INCOMING, MessageLayout.OUTGOING -> { + RecipientMessage(data = data, modifier = modifier) + } + } +} + +@Composable +fun RecipientMessage( + data: MessageViewData, + modifier: Modifier = Modifier +){ + val outgoing = data.layout == MessageLayout.OUTGOING + + val bottomPadding = when (data.clusterPosition) { + ClusterPosition.BOTTOM, ClusterPosition.ISOLATED -> LocalDimensions.current.smallSpacing // vertical space between mesasges of different authors + ClusterPosition.TOP, ClusterPosition.MIDDLE -> LocalDimensions.current.xxxsSpacing // vertical space between cluster of messages from same author + } + + BoxWithConstraints( + modifier = modifier.fillMaxWidth() + .padding(bottom = bottomPadding) ) { - content() + val maxMessageWidth = min( + LocalDimensions.current.maxMessageWidth, // cap a max width for large content like tablets and large landscape devices + max( + LocalDimensions.current.minMessageWidth, + this.maxWidth * 0.8f // 80% of available width + )) + + RecipientMessageContent( + modifier = Modifier + .align(if (outgoing) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxMessageWidth) + .wrapContentWidth(), + data = data, + maxWidth = maxMessageWidth + ) } } @@ -100,123 +135,82 @@ fun MessageBubble( * All the content of a message: Bubble with its internal content, avatar, status */ @Composable -fun MessageContent( +fun RecipientMessageContent( data: MessageViewData, modifier: Modifier = Modifier, maxWidth: Dp ) { + val outgoing = data.layout == MessageLayout.OUTGOING + Column( modifier = modifier, - horizontalAlignment = if (data.type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start ) { Row { - if (data.avatar != null) { - Avatar( - modifier = Modifier.align(Alignment.Bottom), - size = LocalDimensions.current.iconMediumAvatar, - data = data.avatar - ) + if (data.avatar !is MessageAvatar.None) { + if(data.avatar is MessageAvatar.Visible) { + Avatar( + modifier = Modifier.align(Alignment.Bottom), + size = LocalDimensions.current.iconMediumAvatar, + data = data.avatar.data + ) + } else { + Box( + modifier = Modifier.size(LocalDimensions.current.iconMediumAvatar) + .clearAndSetSemantics {} // no ax for this empty box + ) + } - Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) } Column( - horizontalAlignment = if(data.type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if(outgoing) Alignment.End else Alignment.Start ) { - if (data.displayName) { - Text( - modifier = Modifier.padding(start = LocalDimensions.current.xsSpacing), - text = data.author, - style = LocalType.current.base.bold(), - color = LocalColors.current.text - ) + if (data.showDisplayName) { + Row { + ProBadgeText( + modifier = Modifier.weight(1f, fill = false), + text = data.displayName, + textStyle = LocalType.current.base.bold() + .copy(color = LocalColors.current.text), + showBadge = data.showProBadge, + ) + + if (!data.displayNameExtra.isNullOrEmpty()) { + Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) + + Text( + text = "(${data.displayNameExtra})", + maxLines = 1, + style = LocalType.current.base + ) + } + } Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) } - // There can be two bubbles in a message: First one contains quotes, links and message text - // The second one contains audio, document, images and video - val hasFirstBubble = data.quote != null || data.link != null || data.type.text != null - val hasSecondBubble = data.type !is MessageType.Text - - // First bubble - if (hasFirstBubble) { - MessageBubble( - modifier = Modifier.accentHighlight(data.highlightKey), - color = if (data.type.outgoing) LocalColors.current.accent - else LocalColors.current.backgroundBubbleReceived - ) { - Column { - // Display quote if there is one - if (data.quote != null) { - MessageQuote( - modifier = Modifier.padding(bottom = - if (data.link == null && data.type.text == null) - defaultMessageBubblePadding().calculateBottomPadding() - else 0.dp - ), - outgoing = data.type.outgoing, - quote = data.quote - ) - } - - // display link data if any - if (data.link != null) { - MessageLink( - modifier = Modifier.padding(top = if (data.quote != null) LocalDimensions.current.xxsSpacing else 0.dp), - data = data.link, - outgoing = data.type.outgoing - ) - } + data.contentGroups.forEachIndexed { index, group -> + if (index > 0) Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) - if(data.type.text != null){ - // Text messages - MessageText( - modifier = Modifier.padding(defaultMessageBubblePadding()), - text = data.type.text!!, - outgoing = data.type.outgoing - ) + val contentColumn = @Composable { + Column { + group.contents.forEach { content -> + MessageContentRenderer(content, data.layout, maxWidth) } } } - } - - // Second bubble - if(hasSecondBubble){ - // add spacing if there is a first bubble - if(hasFirstBubble){ - Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) - } - // images and videos are a special case and aren't actually surrounded in a visible bubble - if(data.type is MessageType.Media){ - MediaMessage( + if (group.showBubble) { + MessageBubble( modifier = Modifier.accentHighlight(data.highlightKey), - data = data.type, - maxWidth = maxWidth + color = if (outgoing) LocalColors.current.accent else LocalColors.current.backgroundBubbleReceived, + content = contentColumn ) } else { - MessageBubble( - modifier = Modifier.accentHighlight(data.highlightKey), - color = if (data.type.outgoing) LocalColors.current.accent - else LocalColors.current.backgroundBubbleReceived - ) { - // Apply content based on message type - when (data.type) { - // Document messages - is Document -> DocumentMessage( - data = data.type - ) - - // Audio messages - is Audio -> AudioMessage( - data = data.type - ) - - else -> {} - } - } + contentColumn() // Render naked content (e.g., for media) } } } @@ -224,25 +218,25 @@ fun MessageContent( //////// Below the Avatar + Message bubbles //// - val indentation = if(data.type.outgoing) 0.dp - else if (data.avatar != null) LocalDimensions.current.iconMediumAvatar + LocalDimensions.current.smallSpacing + val indentation = if(outgoing) 0.dp + else if (data.avatar !is MessageAvatar.None) LocalDimensions.current.iconMediumAvatar + LocalDimensions.current.smallSpacing else 0.dp // reactions - if (data.reactionsState != null) { + if (data.reactions != null) { Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) EmojiReactions( modifier = Modifier.padding(start = indentation), - reactions = data.reactionsState.reactions, - isExpanded = data.reactionsState.isExtended, - outgoing = data.type.outgoing, + reactions = data.reactions.reactions, + isExpanded = data.reactions.isExtended, + outgoing = outgoing, onReactionClick = { //todo CONVOv3 implement }, - onExpandClick = { + onReactionExpandClick = { //todo CONVOv3 implement }, - onShowLessClick = { + onReactionShowLessClick = { //todo CONVOv3 implement }, onReactionLongClick = { @@ -258,39 +252,92 @@ fun MessageContent( modifier = Modifier .padding(horizontal = LocalDimensions.current.tinySpacing) .padding(start = indentation) - .align(if (data.type.outgoing) Alignment.End else Alignment.Start), + .align(if (outgoing) Alignment.End else Alignment.Start), data = data.status ) } } } +@Composable +fun MessageContentRenderer(content: MessageContent, layout: MessageLayout, maxWidth: Dp) { + val isOutgoing = layout == MessageLayout.OUTGOING + Box( + modifier = Modifier.padding( + when(content.extraPadding){ + MessageContentPadding.Bottom -> PaddingValues(bottom = defaultMessageBubblePadding().calculateBottomPadding()) + else -> PaddingValues() + } + ) + ) { + when (content.contentData) { + is MessageContentData.Text -> MessageText( + text = content.contentData.text, + isOutgoing = isOutgoing, + modifier = Modifier.padding(defaultMessageBubblePadding()), + onUrlClick = { + //todo convov3 handle + } + ) + + is MessageContentData.Quote -> MessageQuote( + quote = content.contentData.data, + outgoing = isOutgoing + ) + + is MessageContentData.Link -> + MessageLink( + data = content.contentData.data, + outgoing = isOutgoing + ) + + is MessageContentData.Document -> + DocumentMessage( + data = content.contentData.data, + outgoing = isOutgoing + ) + + is MessageContentData.Audio -> + AudioMessage( + data = content.contentData.data, + outgoing = isOutgoing + ) + + is MessageContentData.CommunityInvite -> + CommunityInviteMessage( + name = content.contentData.name, + url = content.contentData.url, + outgoing = isOutgoing + ) + + is MessageContentData.Media -> + MediaMessage( + items = content.contentData.items, + loading = content.contentData.loading, + maxWidth = maxWidth + ) + } + } +} + /** - * The overall Message composable - * This controls the width and position of the message as a whole + * Basic message building block: Bubble */ @Composable -fun Message( - data: MessageViewData, - modifier: Modifier = Modifier +fun MessageBubble( + color: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} ) { - BoxWithConstraints( - modifier = modifier.fillMaxWidth() + Box( + modifier = modifier + .background( + color = color, + shape = RoundedCornerShape(LocalDimensions.current.messageCornerRadius) + ) + .clip(shape = RoundedCornerShape(LocalDimensions.current.messageCornerRadius)) ) { - val maxMessageWidth = max( - LocalDimensions.current.minMessageWidth, - this.maxWidth * 0.8f // 80% of available width - //todo ConvoV3 we probably should cap the max so that large screens/tablets don't extend too far - ) - - MessageContent( - modifier = Modifier - .align(if (data.type.outgoing) Alignment.CenterEnd else Alignment.CenterStart) - .widthIn(max = maxMessageWidth) - .wrapContentWidth(), - data = data, - maxWidth = maxMessageWidth - ) + content() } } @@ -326,20 +373,6 @@ fun MessageStatus( } } -@Composable -fun MessageText( - text: AnnotatedString, - outgoing: Boolean, - modifier: Modifier = Modifier -){ - Text( - modifier = modifier, - text = text, - style = LocalType.current.large, - color = getTextColor(outgoing), - ) -} - @Composable internal fun getTextColor(outgoing: Boolean) = if(outgoing) LocalColors.current.textBubbleSent else LocalColors.current.textBubbleReceived @@ -351,23 +384,69 @@ internal fun defaultMessageBubblePadding() = PaddingValues( ) data class MessageViewData( - val type: MessageType, - val author: String, - val displayName: Boolean = false, - val avatar: AvatarUIData? = null, + val id: MessageId, + val layout: MessageLayout, + val contentGroups: List, + val displayName: String, + val displayNameExtra: String? = null, // when you want to add extra text to the display name, like the blinded id - after the pro badge) + val showDisplayName: Boolean = false, + val showProBadge: Boolean = false, + val avatar: MessageAvatar = MessageAvatar.None, val status: MessageViewStatus? = null, - val quote: MessageQuote? = null, - val link: MessageLinkData? = null, - val reactionsState: ReactionViewState? = null, - val highlightKey: Any? = null + val reactions: ReactionViewState? = null, + val highlightKey: Any? = null, + val clusterPosition: ClusterPosition = ClusterPosition.ISOLATED +) + +data class MessageContentGroup( + val contents: List, + val showBubble: Boolean = true //whether the grouped content should be placed in a bubble +) + +data class MessageContent( + val contentData: MessageContentData, + val extraPadding: MessageContentPadding = MessageContentPadding.None ) +sealed interface MessageContentPadding{ + data object None: MessageContentPadding + data object Bottom: MessageContentPadding +} + +sealed interface MessageContentData { + data class Text(val text: AnnotatedString) : MessageContentData + data class Media(val items: List, val loading: Boolean) : MessageContentData + data class Link(val data: MessageLinkData) : MessageContentData + data class Quote(val data: QuoteMessageData) : MessageContentData + data class Document(val data: DocumentMessageData) : MessageContentData + data class Audio(val data: AudioMessageData) : MessageContentData + data class CommunityInvite(val name: String, val url: String) : MessageContentData +} + +enum class MessageLayout { + INCOMING, + OUTGOING, + CONTROL +} + +data class HighlightMessage(val token: Long) + +enum class ClusterPosition { + TOP, + MIDDLE, + BOTTOM, + ISOLATED +} + +sealed interface MessageAvatar { + data object None: MessageAvatar + data object Invisible: MessageAvatar// the avatar is not visible but still takes up the space + data class Visible(val data: AvatarUIData): MessageAvatar +} + data class ReactionViewState( val reactions: List, val isExtended: Boolean, - val onReactionClick: (String) -> Unit, - val onReactionLongClick: (String) -> Unit, - val onShowMoreClick: () -> Unit ) data class ReactionItem( @@ -376,10 +455,11 @@ data class ReactionItem( val selected: Boolean ) -data class MessageQuote( +data class QuoteMessageData( val title: String, - val subtitle: String, - val icon: MessageQuoteIcon + val subtitle: AnnotatedString, + val icon: MessageQuoteIcon, + val showProBadge: Boolean ) sealed class MessageQuoteIcon { @@ -401,23 +481,6 @@ sealed interface MessageViewStatusIcon{ data object DisappearingMessageIcon: MessageViewStatusIcon } -sealed class MessageType(){ - abstract val outgoing: Boolean - abstract val text: AnnotatedString? - - data class Text( - override val outgoing: Boolean, - override val text: AnnotatedString - ): MessageType() - - data class Media( - override val outgoing: Boolean, - val items: List, - val loading: Boolean, - override val text: AnnotatedString? = null - ): MessageType() -} - /*@PreviewScreenSizes*/ @Preview @Composable @@ -432,22 +495,27 @@ fun MessagePreview( var testData by remember { mutableStateOf( MessageViewData( - author = "Toto", - type = PreviewMessageData.text() - ) + id = MessageId(0, false), + displayName = "Toto", + showProBadge = true, + displayNameExtra = "(some extra text)", + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.textGroup(), + ) ) } var testData2 by remember { mutableStateOf( MessageViewData( - author = "Toto", - displayName = true, + id = MessageId(0, false), + displayName = "Toto", + showDisplayName = true, avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text( - outgoing = false, + layout = MessageLayout.INCOMING, + contentGroups = PreviewMessageData.textGroup( text = "Hello, this is a message with multiple lines To test out styling and making sure it looks good but also continues for even longer as we are testing various screen width and I need to see how far it will go before reaching the max available width so there is a lot to say but also none of this needs to mean anything and yet here we are, are you still reading this by the way?" - ) + ), ) ) } @@ -456,8 +524,8 @@ fun MessagePreview( delay(3000) // to test out the selection - testData = testData.copy(highlightKey = System.currentTimeMillis()) - testData2 = testData2.copy(highlightKey = System.currentTimeMillis()) + testData = testData.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) + testData2 = testData2.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) } Message(data = testData) @@ -469,7 +537,7 @@ fun MessagePreview( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - testData2 = testData2.copy(highlightKey = System.currentTimeMillis()) + testData2 = testData2.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) }), data = testData2 ) @@ -477,8 +545,10 @@ fun MessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text( + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.textGroup( text = "Hello, this is a message with multiple lines To test out styling and making sure it looks good but also continues for even longer as we are testing various screen width and I need to see how far it will go before reaching the max available width so there is a lot to say but also none of this needs to mean anything and yet here we are, are you still reading this by the way?" ), status = PreviewMessageData.sentStatus @@ -487,12 +557,11 @@ fun MessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", + id = MessageId(0, false), + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text( - outgoing = false, - text = "Hello" - ), + layout = MessageLayout.INCOMING, + contentGroups = PreviewMessageData.textGroup(), status = PreviewMessageData.sentStatus )) } @@ -510,33 +579,33 @@ fun MessageReactionsPreview( ) { Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text( + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.textGroup( text = "I have 3 emoji reactions" ), - reactionsState = ReactionViewState( + reactions = ReactionViewState( reactions = listOf( ReactionItem("👍", 3, selected = true), ReactionItem("❤️", 12, selected = false), ReactionItem("😂", 1, selected = false), ), isExtended = false, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} ) )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", + id = MessageId(0, false), + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text( - outgoing = false, + layout = MessageLayout.INCOMING, + contentGroups = PreviewMessageData.textGroup( text = "I have lots of reactions - Closed" ), - reactionsState = ReactionViewState( + reactions = ReactionViewState( reactions = listOf( ReactionItem("👍", 3, selected = true), ReactionItem("❤️", 12, selected = false), @@ -548,23 +617,21 @@ fun MessageReactionsPreview( ReactionItem("🐙", 8, selected = false), ReactionItem("✅", 8, selected = false), ), - isExtended = false, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} + isExtended = false ) )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", + id = MessageId(0, false), + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text( - outgoing = false, + layout = MessageLayout.INCOMING, + contentGroups = PreviewMessageData.textGroup( text = "I have lots of reactions - Open" ), - reactionsState = ReactionViewState( + reactions = ReactionViewState( reactions = listOf( ReactionItem("👍", 3, selected = true), ReactionItem("❤️", 12, selected = false), @@ -577,9 +644,6 @@ fun MessageReactionsPreview( ReactionItem("✅", 8, selected = false), ), isExtended = true, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} ) )) } @@ -629,93 +693,93 @@ fun MediaMessagePreviewReuse( object PreviewMessageData { // Common data - val sampleAvatar = AvatarUIData(listOf(AvatarUIElement(name = "TO", color = primaryBlue))) + val sampleAvatar = MessageAvatar.Visible( + AvatarUIData(listOf(AvatarUIElement(name = "TO", color = primaryBlue))) + ) val sentStatus = MessageViewStatus( name = "Sent", icon = MessageViewStatusIcon.DrawableIcon(icon = R.drawable.ic_circle_check) ) - fun text( - text: String = "Hi there", - outgoing: Boolean = true - ) = MessageType.Text(outgoing = outgoing, AnnotatedString(text)) + fun textGroup(text: String = "Hi there") = listOf( + MessageContentGroup(listOf(MessageContent(text(text))), showBubble = true) + ) - fun document( - name: String = "Document name", - size: String = "5.4MB", - outgoing: Boolean = true, - loading: Boolean = false - ) = Document( - outgoing = outgoing, - name = name, - size = size, - loading = loading, - uri = "" + fun textGroup(text: AnnotatedString) = listOf( + MessageContentGroup(listOf(MessageContent(text(text))), showBubble = true) ) - fun audio( - outgoing: Boolean = true, + fun audioGroup( title: String = "Voice Message", - speedText: String = "1x", - remainingText: String = "0:20", - durationMs: Long = 83_000L, - positionMs: Long = 23_000L, - bufferedPositionMs: Long = 35_000L, - playing: Boolean = true, - showLoader: Boolean = false - ) = Audio( - outgoing = outgoing, - title = title, - speedText = speedText, - remainingText = remainingText, - durationMs = durationMs, - positionMs = positionMs, - bufferedPositionMs = bufferedPositionMs, - isPlaying = playing, - showLoader = showLoader, + playing: Boolean = true + ) = listOf( + MessageContentGroup(listOf(MessageContent(MessageContentData.Audio(AudioMessageData( + title = title, speedText = "1x", remainingText = "0:20", + durationMs = 83_000L, positionMs = 23_000L, isPlaying = playing, showLoader = false + )))), showBubble = true) ) - fun image( - loading: Boolean = false, - width: Int = 100, - height: Int = 100, - ) = MessageMediaItem.Image( - "".toUri(), - "", - loading = loading, - width = width, - height = height + fun documentGroup( + name: String = "Document.pdf", + loading: Boolean = false + ) = listOf( + MessageContentGroup(listOf(MessageContent(document(name, loading))), showBubble = true) ) - fun video( - loading: Boolean = false, - width: Int = 100, - height: Int = 100, - ) = MessageMediaItem.Video( - "".toUri(), - "", - loading = loading, - width = width, - height = height - ) + fun mediaGroup( + items: List, + text: String? = null + ) = buildList { + if(text != null) add(MessageContentGroup( + listOf(MessageContent(MessageContentData.Text(AnnotatedString(text)))), showBubble = true)) + add(mediaGroup(items)) + } - fun quote( + fun mediaGroup( + items: List, + ) = MessageContentGroup(listOf(MessageContent(MessageContentData.Media(items, false))), showBubble = false) + + fun quoteGroup( + icon: MessageQuoteIcon = MessageQuoteIcon.Bar, title: String = "Toto", subtitle: String = "This is a quote", - icon: MessageQuoteIcon = MessageQuoteIcon.Bar - ) = MessageQuote( - title = title, - subtitle = subtitle, - icon = icon - ) + text: String? = null, + showProBadge: Boolean = false + ): List { + val group = mutableListOf() + group.add( + quote(title = title, subtitle = subtitle, icon = icon, showProBadge = showProBadge) + ) - fun quoteImage( - uri: Uri = "".toUri(), - filename: String = "" - ) = MessageQuoteIcon.Image( - uri = uri, - filename = filename - ) + if(text != null) group.add(MessageContentData.Text(AnnotatedString(text))) + + return listOf(MessageContentGroup(group.map { MessageContent(it) }, showBubble = true)) + } + + // Individual item helpers + fun text( + text: String = "Hi there", + ) = MessageContentData.Text(AnnotatedString(text)) + fun text( + text: AnnotatedString, + ) = MessageContentData.Text(text) + + fun document( + name: String = "Document.pdf", + loading: Boolean = false + ) = MessageContentData.Document(DocumentMessageData( + name = name, size = "5.4MB", uri = "", loading = loading + )) + fun image(width: Int = 100, height: Int = 100, loading: Boolean = false) = MessageMediaItem.Image("".toUri(), "img.jpg", loading, width, height) + fun video(width: Int = 100, height: Int = 100, loading: Boolean = false) = MessageMediaItem.Video("".toUri(), "vid.mp4", loading, width, height) + fun quote(title: String = "Toto", subtitle: String = "This is a quote", icon: MessageQuoteIcon = MessageQuoteIcon.Bar, showProBadge: Boolean = false) = + MessageContentData.Quote(QuoteMessageData(title, AnnotatedString(subtitle), icon, showProBadge)) + + fun composeContent(vararg content: MessageContentData): MessageContentGroup { + return MessageContentGroup( + contents = content.map { MessageContent(it) }, + ) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt new file mode 100644 index 0000000000..b8e168a09c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.message + +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.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 androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.blackAlpha06 + +@Composable +fun CommunityInviteMessage( + name: String, + url: String, + outgoing: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.padding(defaultMessageBubblePadding()) + .padding(vertical = LocalDimensions.current.tinySpacing), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon background circle + Box( + modifier = Modifier + .size(LocalDimensions.current.iconLarge) + .background( + color = if(outgoing) blackAlpha06 else LocalColors.current.accent, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource( + id = if(outgoing) R.drawable.ic_globe else R.drawable.ic_plus + ), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconSmall), + colorFilter = ColorFilter.tint(LocalColors.current.textBubbleSent) + ) + } + + Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) + + Column( + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) + ) { + Text( + text = name, + style = LocalType.current.h6, + color = getTextColor(outgoing), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.communityInvitation), + style = LocalType.current.base, + color = getTextColor(outgoing), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = url, + style = LocalType.current.small, + color = getTextColor(outgoing), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview +@Composable +fun CommunityInvitePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + + PreviewTheme(colors) { + Column ( + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + MessageBubble( + color = LocalColors.current.accent + ) { + Column() { + CommunityInviteMessage( + name = "Test Community", + url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + outgoing = true + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + MessageBubble( + color = LocalColors.current.backgroundBubbleReceived + ) { + Column() { + CommunityInviteMessage( + name = "Test Community", + url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + outgoing = false + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt new file mode 100644 index 0000000000..efd9df73b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.message + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +fun ControlMessage( + data: MessageViewData, + modifier: Modifier = Modifier +) { + // Control messages are usually simple text or system info + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text(text = "Control Message WIP", style = LocalType.current.small) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/DocumentMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt similarity index 72% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/DocumentMessage.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt index ce046bba2b..be38fbdb9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/DocumentMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,11 +21,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.composeContent +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -37,7 +38,8 @@ import org.thoughtcrime.securesms.ui.theme.blackAlpha06 @Composable fun DocumentMessage( - data: Document, + data: DocumentMessageData, + outgoing: Boolean, modifier: Modifier = Modifier ) { Row( @@ -54,12 +56,12 @@ fun DocumentMessage( contentAlignment = Alignment.Center ) { if (data.loading) { - SmallCircularProgressIndicator(color = getTextColor(data.outgoing)) + SmallCircularProgressIndicator(color = getTextColor(outgoing)) } else { Image( painter = painterResource(id = R.drawable.ic_file), contentDescription = null, - colorFilter = ColorFilter.tint(getTextColor(data.outgoing)), + colorFilter = ColorFilter.tint(getTextColor(outgoing)), modifier = Modifier .align(Alignment.Center) .size(LocalDimensions.current.iconMedium) @@ -80,27 +82,24 @@ fun DocumentMessage( style = LocalType.current.large, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = getTextColor(data.outgoing) + color = getTextColor(outgoing) ) Text( text = data.size, style = LocalType.current.small, - color = getTextColor(data.outgoing) + color = getTextColor(outgoing) ) } } } -data class Document( - override val outgoing: Boolean, +data class DocumentMessageData( val name: String, val size: String, val uri: String, val loading: Boolean, - override val text: AnnotatedString? = null -) : MessageType() - +) @Preview @Composable fun DocumentMessagePreview( @@ -112,17 +111,20 @@ fun DocumentMessagePreview( ) { Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.document() + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.documentGroup() )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", + id = MessageId(0, false), + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.document( - outgoing = false, + layout = MessageLayout.INCOMING, + contentGroups = PreviewMessageData.documentGroup( name = "Document with a really long name that should ellipsize once it reaches the max width" ) )) @@ -130,32 +132,35 @@ fun DocumentMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.document( - loading = true - )) - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.documentGroup(loading = true) + )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = PreviewMessageData.document( - loading = true - )) - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + composeContent(PreviewMessageData.document(loading = true)), + ) + )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = PreviewMessageData.document( - outgoing = false, - loading = true - )) - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + composeContent(PreviewMessageData.document(loading = true)), + ) + )) } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEffects.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEffects.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEffects.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEffects.kt index 0d896b90ea..63c19280fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEffects.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEffects.kt @@ -1,6 +1,7 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import android.graphics.BlurMaskFilter +import android.graphics.Paint import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable @@ -38,7 +39,7 @@ fun Modifier.accentHighlight( return this.drawBehind { if (alphaAnim.value > 0f) { drawIntoCanvas { canvas -> - val paint = android.graphics.Paint().apply { + val paint = Paint().apply { isAntiAlias = true color = accentColor.copy(alpha = alphaAnim.value).toArgb() maskFilter = BlurMaskFilter(glowRadius.toPx(), BlurMaskFilter.Blur.OUTER) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEmojiReactions.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEmojiReactions.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt index 1efd219f3c..aecadc8311 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageEmojiReactions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -47,8 +47,8 @@ fun EmojiReactions( modifier: Modifier = Modifier, onReactionClick: (emoji: String) -> Unit = {}, onReactionLongClick: (emoji: String) -> Unit = {}, - onExpandClick: () -> Unit = {}, - onShowLessClick: () -> Unit = {}, + onReactionExpandClick: () -> Unit = {}, + onReactionShowLessClick: () -> Unit = {}, ) { val hasOverflow = !isExpanded && reactions.size > REACTIONS_THRESHOLD // When collapsed: show the first (THRESHOLD - 1) pills then the overflow slot, @@ -77,7 +77,7 @@ fun EmojiReactions( if (overflowReactions.isNotEmpty()) { EmojiReactionOverflow( reactions = overflowReactions.take(3), // only use first 3 - onClick = onExpandClick, + onClick = onReactionExpandClick, ) } } @@ -89,7 +89,7 @@ fun EmojiReactions( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() - .clickable(onClick = onShowLessClick) + .clickable(onClick = onReactionShowLessClick) .padding( vertical = LocalDimensions.current.xsSpacing, ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageLink.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt similarity index 56% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageLink.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt index c0fb73a506..57a2467f28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageLink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -27,6 +27,8 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.composeContent +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -55,7 +57,7 @@ fun MessageLink( Image( painter = painterResource(id = R.drawable.ic_link), contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.text), + colorFilter = ColorFilter.tint(getTextColor(outgoing)), modifier = Modifier.align(Alignment.Center) ) } else { @@ -103,47 +105,74 @@ fun LinkMessagePreview( ) { Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(outgoing = false, text="Quoting text"), - link = MessageLinkData( - url = "https://getsession.org/", - title = "Welcome to Session", - imageUri = null - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent( + MessageContentData.Link( + MessageLinkData( + url = "https://getsession.org/", + title = "Welcome to Session", + imageUri = null + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(text="Quoting text"), - link = MessageLinkData( - url = "https://picsum.photos/id/0/367/267", - title = "Welcome to Session with a very long name", - imageUri = "https://picsum.photos/id/1/200/300" - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent( + MessageContentData.Link( + MessageLinkData( + url = "https://picsum.photos/id/0/367/267", + title = "Welcome to Session with a very long name", + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(outgoing = false, text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - link = MessageLinkData( - url = "https://getsession.org/", - title = "Welcome to Session", - imageUri = null - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent( + PreviewMessageData.quote(), + MessageContentData.Link( + MessageLinkData( + url = "https://getsession.org/", + title = "Welcome to Session", + imageUri = null + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - link = MessageLinkData( - url = "https://picsum.photos/id/0/367/267", - title = "Welcome to Session with a very long name", - imageUri = "https://picsum.photos/id/1/200/300" - ) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent( + PreviewMessageData.quote(), + MessageContentData.Link( + MessageLinkData( + url = "https://picsum.photos/id/0/367/267", + title = "Welcome to Session with a very long name", + imageUri = "https://picsum.photos/id/1/200/300" + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageMedia.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt similarity index 56% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageMedia.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt index dee9e50866..c73c4efded 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageMedia.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import android.net.Uri import androidx.compose.foundation.background @@ -34,6 +34,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.composeContent +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.image +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.mediaGroup +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.text +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.video +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme @@ -42,7 +48,8 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun MediaMessage( - data: MessageType.Media, + items: List, + loading: Boolean, maxWidth: Dp, modifier: Modifier = Modifier, ){ @@ -51,10 +58,10 @@ fun MediaMessage( ) { val itemSpacing: Dp = 2.dp - when (data.items.size) { + when (items.size) { 1 -> { MediaItem( - data = data.items[0], + data = items[0], itemSize = MediaItemSize.AspectRatio( minSize = LocalDimensions.current.minMessageWidth, maxSize = maxWidth, @@ -70,12 +77,12 @@ fun MediaMessage( val cellSize = maxWidth * 0.5f - itemSpacing * 0.5f MediaItem( - data = data.items[0], + data = items[0], itemSize = MediaItemSize.SquareSize(size = cellSize), ) MediaItem( - data = data.items[1], + data = items[1], itemSize = MediaItemSize.SquareSize(size = cellSize), ) } @@ -89,7 +96,7 @@ fun MediaMessage( val smallCellSize = largeCellSize * 0.5f - itemSpacing * 0.5f MediaItem( - data = data.items[0], + data = items[0], itemSize = MediaItemSize.SquareSize(size = largeCellSize), ) @@ -97,12 +104,12 @@ fun MediaMessage( verticalArrangement = Arrangement.spacedBy(itemSpacing), ) { MediaItem( - data = data.items[1], + data = items[1], itemSize = MediaItemSize.SquareSize(size = smallCellSize), ) MediaItem( - data = data.items[2], + data = items[2], itemSize = MediaItemSize.SquareSize(size = smallCellSize), ) } @@ -164,40 +171,40 @@ fun MediaMessagePreview( ) { Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = true, - items = listOf(PreviewMessageData.image( + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = mediaGroup( + listOf(image( width = 50, - height = 100 - )), - loading = false - ) + height = 100, + loading = false + )), null + ), )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = true, - items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = mediaGroup( + listOf(image(), video()), null) ) - )) - + ) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) var testData by remember { mutableStateOf( MessageViewData( - author = "Toto", - type = MessageType.Media( - text = AnnotatedString("This also has text"), - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = mediaGroup( + items = listOf(video(), image(), image()), + text = "This also has text" ) ) ) @@ -208,7 +215,7 @@ fun MediaMessagePreview( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - testData = testData.copy(highlightKey = System.currentTimeMillis()) + testData = testData.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) }), data = testData ) @@ -216,102 +223,24 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = false, - items = listOf(PreviewMessageData.image(true)), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = true, - items = listOf(PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = false, - items = listOf(PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = false, - items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(true), PreviewMessageData.image()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - type = MessageType.Media( - text = AnnotatedString("This also has text"), - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + mediaGroup(listOf(video(), image(), image())) ) )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + mediaGroup(listOf(video(), image(), image())) ) )) @@ -319,26 +248,24 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( - text = AnnotatedString("This also has text"), - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote(), text("This also has text")), + mediaGroup(listOf(video(), image(), image())) ) )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( - text = AnnotatedString("This also has text"), - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote(), text("This also has text")), + mediaGroup(listOf(video(), image(), image())) ) )) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt similarity index 68% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageQuote.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 3d40d81f1b..8c7ddb7542 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,14 +22,19 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.request.ImageRequest import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.quoteGroup +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.proBadgeColorOutgoing +import org.thoughtcrime.securesms.ui.proBadgeColorStandard import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -43,7 +47,7 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun MessageQuote( outgoing: Boolean, - quote: MessageQuote, + quote: QuoteMessageData, modifier: Modifier = Modifier ){ Row( @@ -100,18 +104,22 @@ fun MessageQuote( } Column{ - Text( + ProBadgeText( text = quote.title, - style = LocalType.current.base.bold(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = getTextColor(outgoing) + textStyle = LocalType.current.base.bold().copy(color = getTextColor(outgoing)), + showBadge = quote.showProBadge, + badgeColors = if(outgoing) proBadgeColorOutgoing() //todo convov3 xml quotes also checked for mode - regular here to distinguish form the quote used in the input + else proBadgeColorStandard() ) - Text( + Spacer(Modifier.height(LocalDimensions.current.tinySpacing)) + + //todo convov3 we shouldn't render/click links for quotes + MessageText( text = quote.subtitle, - style = LocalType.current.base, - color = getTextColor(outgoing) + isOutgoing = outgoing, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } } @@ -128,48 +136,55 @@ fun QuoteMessagePreview( ) { Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(outgoing = false, text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.INCOMING, + contentGroups = quoteGroup(text = "Quoting text") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = PreviewMessageData.text(text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = quoteGroup( + showProBadge = true, + subtitle = "This is a long text efcwec wf fv d df klsdknvdslkvfds lk djvl jldfs vjldf jlkdfsv jldf jlkd jlkdf jlkdf jl kdvmkl dsfmkldmkldfmldflkdfmklfd lk mdfs fdmlkdfmklfd ml mlk mlkdf", text = "Quoting text") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", + id = MessageId(0, false), + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text(outgoing = false, text="Quoting a document"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Icon(R.drawable.ic_file)) + layout = MessageLayout.INCOMING, + contentGroups = quoteGroup(icon = MessageQuoteIcon.Icon(R.drawable.ic_file), showProBadge = true, text = "Quoting a document") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = MessageType.Text(outgoing = true, AnnotatedString("Quoting audio")), - quote = PreviewMessageData.quote( + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = quoteGroup( title = "You", subtitle = "Audio message", - icon = MessageQuoteIcon.Icon(R.drawable.ic_mic) + icon = MessageQuoteIcon.Icon(R.drawable.ic_mic), + text = "Quoting audio" ) )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( - author = "Toto", - type = MessageType.Text(outgoing = true, AnnotatedString("Quoting an image")), - quote = PreviewMessageData.quote(icon = PreviewMessageData.quoteImage()) + id = MessageId(0, false), + displayName = "Toto", + layout = MessageLayout.OUTGOING, + contentGroups = quoteGroup(icon = MessageQuoteIcon.Image("".toUri(), ""), text = "Quoting an image") )) - } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt new file mode 100644 index 0000000000..4215faea67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt @@ -0,0 +1,213 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.message + +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType + +/** + * Renders formatted message text with mention highlighting and optional link handling. + * Pass [onUrlClick] to enable clickable, underlined links; pass null to render plain text. + * + * Expects an [AnnotatedString] pre-processed by [MessageTextFormatter], which provides: + * - Link annotations (clickable URLs with underline style) + * - Mention metadata via string annotations ("mention_pk", "mention_self", "mention_bg") + * + * This composable then layers on: + * - Mention foreground colors based on theme and message direction + * - URL click handling (when [onUrlClick] is provided) + * - Pill background drawing for self-mentions in incoming messages + */ +@Composable +fun MessageText( + text: AnnotatedString, + isOutgoing: Boolean, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onUrlClick: ((String) -> Unit)? = null, +) { + val colors = LocalColors.current + val mainTextColor = getTextColor(isOutgoing) + + // Capture the latest callback in a ref so that the remember block below + // doesn't need to recompute when only the lambda identity changes. + val onUrlClickState = rememberUpdatedState(onUrlClick) + + // Single processing pass that: + // 1. Applies mention foreground colors + // 2. Wires up URL click handlers (if enabled) or strips link annotations (if disabled) + // 3. Extracts pill background ranges for self-mentions + // + // Keyed on onUrlClick nullity (not identity) to avoid recomposition from lambda captures. + val (displayText, bgRanges) = remember(text, isOutgoing, colors, onUrlClick != null) { + + // Step 1: Apply mention colors on top of the formatter's bold + metadata annotations + val withColors = buildAnnotatedString { + append(text) + + val mentions = text.getStringAnnotations("mention_pk", 0, text.length) + for (m in mentions) { + val isSelf = text.getStringAnnotations("mention_self", m.start, m.end).isNotEmpty() + + // Self-mentions and outgoing messages use the sent bubble text color; + // other-mentions in incoming messages use the accent color for contrast. + val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent + + addStyle( + SpanStyle(color = fg, fontWeight = FontWeight.Bold), + m.start, + m.end + ) + } + } + + // Step 2: Handle links based on whether click handling is enabled + val displayText = if (onUrlClick != null) { + // Replace the formatter's no-op link listeners with our actual click handler + withColors.mapAnnotations { range -> + val item = range.item + if (item is LinkAnnotation.Clickable) { + val url = item.tag + AnnotatedString.Range( + item = LinkAnnotation.Clickable( + tag = url, + styles = item.styles, + linkInteractionListener = { onUrlClickState.value?.invoke(url) } + ), + start = range.start, + end = range.end, + tag = range.tag + ) + } else { + range + } + } + } else { + // Strip all link annotations (removing underlines and click behavior) + // while preserving span styles, paragraph styles, and string annotations + buildAnnotatedString { + append(withColors.text) + withColors.spanStyles.forEach { addStyle(it.item, it.start, it.end) } + withColors.paragraphStyles.forEach { addStyle(it.item, it.start, it.end) } + for (ann in withColors.getStringAnnotations(0, withColors.length)) { + addStringAnnotation(ann.tag, ann.item, ann.start, ann.end) + } + } + } + + // Step 3: Collect pill background ranges (only for incoming messages with self-mentions) + val bgRanges = if (isOutgoing) emptyList() + else displayText.getStringAnnotations("mention_bg", 0, displayText.length) + + displayText to bgRanges + } + + // -- Pill background drawing -- + + var layout by remember { mutableStateOf(null) } + + val density = LocalDensity.current + val cornerPx = with(density) { 6.dp.toPx() } + val padHPx = with(density) { 4.dp.toPx() } // horizontal padding around pill + val padVPx = with(density) { 3.dp.toPx() } // vertical padding around pill + + // Draw rounded-rect pill backgrounds behind self-mention text ranges. + // Uses the text layout result to compute per-line rects (handles line wrapping). + val modifierWithBg = + modifier.drawBehind { + val lr = layout ?: return@drawBehind + if (bgRanges.isEmpty()) return@drawBehind + + bgRanges.forEach { ann -> + computeLineRectsForRange(lr, ann.start, ann.end).forEach { r -> + drawRoundRect( + color = colors.accent, + topLeft = Offset(r.left - padHPx, r.top - padVPx), + size = Size( + width = (r.right - r.left) + padHPx * 2, + height = (r.bottom - r.top) + padVPx * 2 + ), + cornerRadius = CornerRadius(cornerPx, cornerPx) + ) + } + } + } + + Text( + text = displayText, + style = LocalType.current.large.copy(color = mainTextColor), + modifier = modifierWithBg, + onTextLayout = { layout = it }, + maxLines = maxLines, + overflow = overflow + ) +} + +/** + * Computes per-line bounding rectangles for a text range within a [TextLayoutResult]. + * + * When a mention spans multiple lines (e.g. due to wrapping), this returns one [Rect] per line + * so that each segment gets its own pill background. + * + * Spacing around the pill is handled externally: + * - Horizontal text spacing: OUTSIDE_SPACE characters inserted by [MessageTextFormatter] + * - Visual padding: padH/padV applied in the drawBehind block above + */ +private fun computeLineRectsForRange( + layout: TextLayoutResult, + start: Int, + endExclusive: Int +): List { + if (start >= endExclusive) return emptyList() + + val textLen = layout.layoutInput.text.length + val s = start.coerceIn(0, textLen) + val e = endExclusive.coerceIn(0, textLen) + if (s >= e) return emptyList() + + val startLine = layout.getLineForOffset(s) + val endLine = layout.getLineForOffset((e - 1).coerceAtLeast(s)) + + val out = ArrayList(endLine - startLine + 1) + + for (line in startLine..endLine) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineEnd(line, visibleEnd = true) + + // Clamp to the intersection of the mention range and this line + val segStart = maxOf(s, lineStart) + val segEnd = minOf(e, lineEnd) + if (segStart >= segEnd) continue + + val left = layout.getHorizontalPosition(segStart, usePrimaryDirection = true) + val right = layout.getHorizontalPosition(segEnd, usePrimaryDirection = true) + val top = layout.getLineTop(line) + val bottom = layout.getLineBottom(line) + + // min/max handles RTL where left > right + out += Rect( + left = minOf(left, right), + top = top, + right = maxOf(left, right), + bottom = bottom + ) + } + + return out +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index b0795d6455..8817cf7741 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -36,9 +36,11 @@ import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.thoughtcrime.securesms.conversation.v3.compose.Message -import org.thoughtcrime.securesms.conversation.v3.compose.MessageType -import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.Message +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageLayout +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData.textGroup +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.AccentFillButton @@ -73,32 +75,46 @@ internal fun LandingScreen( val messages = remember(context) { listOf( MessageViewData( - type = MessageType.Text(text = AnnotatedString( - Phrase.from(context.getString(R.string.onboardingBubbleWelcomeToSession)) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually - .format().toString() - ), outgoing = false), - author = "Test" + layout = MessageLayout.INCOMING, + contentGroups = textGroup( + text = AnnotatedString( + Phrase.from(context.getString(R.string.onboardingBubbleWelcomeToSession)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually + .format().toString() + )), + displayName = "Test", + id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString( - Phrase.from(context.getString(R.string.onboardingBubbleSessionIsEngineered)) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format().toString()), outgoing = true), - author = "Test" + layout = MessageLayout.OUTGOING, + contentGroups = textGroup( + text = AnnotatedString( + Phrase.from(context.getString(R.string.onboardingBubbleSessionIsEngineered)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString() + )), + displayName = "Test", + id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), - author = "Test" + layout = MessageLayout.INCOMING, + contentGroups = textGroup( + text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber) + )), + displayName = "Test", + id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString( - Phrase.from(context.getString(R.string.onboardingBubbleCreatingAnAccountIsEasy)) - .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually - .format().toString() - ), outgoing = true), - author = "Test" + layout = MessageLayout.OUTGOING, + contentGroups = textGroup( + text = AnnotatedString( + Phrase.from(context.getString(R.string.onboardingBubbleCreatingAnAccountIsEasy)) + .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually + .format().toString() + )), + displayName = "Test", + id = MessageId(0, false) ), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index cad643778f..32020dba2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.SearchBar @@ -80,6 +81,7 @@ fun ConversationAppBar( onBackPressed: () -> Unit, onCallPressed: () -> Unit, onAvatarPressed: () -> Unit, + switchConvoVersion: () -> Unit, modifier: Modifier = Modifier ) { Box(modifier = modifier) { @@ -131,6 +133,18 @@ fun ConversationAppBar( AppBarBackIcon(onBack = onBackPressed) }, actions = { + if (BuildConfig.DEBUG) { + IconButton( + onClick = switchConvoVersion + ) { + Icon( + painter = painterResource(id = R.drawable.ic_pro_sparkle_custom), + contentDescription = null, + modifier = Modifier.size(LocalDimensions.current.iconMedium) + ) + } + } + if (data.showCall) { IconButton( onClick = onCallPressed @@ -492,6 +506,7 @@ fun ConversationTopBarPreview( onBackPressed = { /* no-op for preview */ }, onCallPressed = { /* no-op for preview */ }, onAvatarPressed = { /* no-op for preview */ }, + switchConvoVersion = { /* no-op for preview */ }, searchQuery = "", onSearchQueryChanged = {}, onSearchQueryClear = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index 546b5422b9..627f20358b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -24,6 +24,7 @@ data class Dimensions( val minButtonWidth: Dp = 160.dp, val minSmallButtonWidth: Dp = 50.dp, val minMessageWidth: Dp = 200.dp, + val maxMessageWidth: Dp = 500.dp, val indicatorHeight: Dp = 4.dp, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82a2dd171e..3752e7a383 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,7 @@ googlePlayReviewVersion = "2.0.2" coilVersion = "3.4.0" billingVersion = "8.3.0" autolinkVersion = "0.12.0" +pagingCommonVersion = "3.4.1" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" } @@ -175,6 +176,8 @@ android-billing-ktx = { module = "com.android.billingclient:billing-ktx", versio mockk = { module = "io.mockk:mockk", version = "1.14.9" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlinVersion" } autolink = { module = "org.nibor.autolink:autolink", version.ref = "autolinkVersion" } +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "pagingCommonVersion" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCommonVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "gradlePluginVersion" }