From c75f6936d9507cb6b401854ebebe4dc3f919a24a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 27 Feb 2026 10:42:08 +1100 Subject: [PATCH 01/23] Paging and compose message list --- app/build.gradle.kts | 2 + .../conversation/v2/ConversationActivityV2.kt | 6 ++ .../conversation/v3/ConversationActivityV3.kt | 6 ++ .../v3/ConversationPagingSource.kt | 47 ++++++++++ .../conversation/v3/ConversationV3NavHost.kt | 2 + .../v3/ConversationV3ViewModel.kt | 89 +++++++++++++++++-- .../v3/compose/ConversationScreen.kt | 56 ++++++++++-- .../ui/components/ConversationAppBar.kt | 15 ++++ gradle/libs.versions.toml | 3 + 9 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt 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/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/ConversationPagingSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt new file mode 100644 index 0000000000..98603d08cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.conversation.v3 + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord + +class ConversationPagingSource( + private val threadId: Long, + private val mmsSmsDatabase: MmsSmsDatabase, + private val reverse: Boolean, +) : 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 messages = mmsSmsDatabase.getConversation( + threadId, reverse, offset.toLong(), params.loadSize.toLong() + ).use { cursor -> + buildList { + val reader = mmsSmsDatabase.readerFor(cursor) + var record = reader.getNext() + while (record != null) { + add(record) + record = reader.getNext() + } + } + } + LoadResult.Page( + data = messages, + prevKey = if (offset == 0) null else maxOf(0, offset - params.loadSize), + nextKey = if (messages.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..9da2608095 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 @@ -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..6f60c0235b 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,14 +15,26 @@ 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.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 network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode @@ -33,8 +49,15 @@ 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.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.ConversationAppBarData @@ -55,14 +78,24 @@ 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, + + ) : ViewModel() { + + val threadIdFlow: StateFlow = + storage.getThreadId(address) + ?.let { MutableStateFlow(it) } + ?: threadDb.updateNotifications + .map { storage.getThreadId(address) } + .flowOn(Dispatchers.Default) + .filterNotNull() + .take(1) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _uiState: MutableStateFlow = MutableStateFlow( UIState() @@ -111,8 +144,46 @@ class ConversationV3ViewModel @AssistedInject constructor( avatarUIData = AvatarUIData(emptyList()) )) - init { + private var pagingSource: ConversationPagingSource? = null + + @OptIn(ExperimentalCoroutinesApi::class) + val conversationMessages: Flow> = threadIdFlow + .filterNotNull() + .flatMapLatest { id -> + Pager( + config = PagingConfig( + pageSize = 50, + initialLoadSize = 100, + enablePlaceholders = false + ), + pagingSourceFactory = { + ConversationPagingSource(id, mmsSmsDatabase, reverse = true).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/compose/ConversationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt index 93bde690ea..6d94f11c12 100644 --- 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 @@ -1,16 +1,20 @@ package org.thoughtcrime.securesms.conversation.v3.compose import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box 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.fillMaxWidth 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,19 +23,28 @@ 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 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 androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination import org.thoughtcrime.securesms.conversation.v3.ConversationV3ViewModel +import org.thoughtcrime.securesms.database.model.MessageRecord 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.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -46,15 +59,19 @@ import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun ConversationScreen( viewModel: ConversationV3ViewModel, + switchConvoVersion: () -> Unit, onBack: () -> Unit, ) { val conversationState by viewModel.uiState.collectAsStateWithLifecycle() val appBarData by viewModel.appBarData.collectAsStateWithLifecycle() + val messages = viewModel.conversationMessages.collectAsLazyPagingItems() Conversation( conversationState = conversationState, appBarData = appBarData, + messages = messages, sendCommand = viewModel::onCommand, + switchConvoVersion = switchConvoVersion, onBack = onBack, ) } @@ -64,7 +81,9 @@ fun ConversationScreen( fun Conversation( conversationState: ConversationV3ViewModel.UIState, appBarData: ConversationAppBarData, + messages: LazyPagingItems, sendCommand: (ConversationV3ViewModel.Commands) -> Unit, + switchConvoVersion: () -> Unit, onBack: () -> Unit, ) { Scaffold( @@ -77,6 +96,7 @@ fun Conversation( onSearchQueryChanged = {}, //todo ConvoV3 implement onSearchQueryClear = {}, //todo ConvoV3 implement onSearchCanceled = {}, //todo ConvoV3 implement + switchConvoVersion = switchConvoVersion, onAvatarPressed = { sendCommand( ConversationV3ViewModel.Commands.GoTo( @@ -89,20 +109,36 @@ fun Conversation( contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ) { paddings -> - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(paddings) - .consumeWindowInsets(paddings) - .padding( - horizontal = LocalDimensions.current.spacing, - ) - .verticalScroll(rememberScrollState()), - horizontalAlignment = CenterHorizontally + .consumeWindowInsets(paddings), + reverseLayout = true, // newest messages at the bottom + state = rememberLazyListState(), ) { - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + items( + count = messages.itemCount, + key = messages.itemKey { msg -> "${msg.id}_${msg.isMms}" } + ) { index -> + messages[index]?.let { message -> + Text(message.body) + } + } - Text("--- Conversation V3 WIP ---") + // todo Convov3 do we want a loader for pagination? + if (messages.loadState.append is LoadState.Loading) { + item(key = "loading_append") { + Box( + modifier = Modifier.fillMaxWidth().padding( + LocalDimensions.current.spacing + ), + contentAlignment = Alignment.Center + ) { + SmallCircularProgressIndicator() + } + } + } } } } @@ -131,7 +167,9 @@ fun PreviewConversation( ) ) ), + messages = emptyFlow>().collectAsLazyPagingItems(), sendCommand = {}, + switchConvoVersion = {}, onBack = {}, ) } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ef0d89d4f..2f04e05492 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,7 @@ googlePlayReviewVersion = "2.0.2" coilVersion = "3.3.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" } From f5775511a37454a75b1bd0dcc1767d13a820b0b4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 27 Feb 2026 11:43:43 +1100 Subject: [PATCH 02/23] Mapping to MessageViewData --- .../conversation/v3/ConversationDataMapper.kt | 257 ++++++++++++++++++ .../v3/ConversationPagingSource.kt | 30 +- .../v3/ConversationV3ViewModel.kt | 17 +- .../conversation/v3/compose/AudioMessage.kt | 4 + .../v3/compose/ConversationScreen.kt | 55 +++- .../v3/compose/DocumentMessage.kt | 6 + .../v3/compose/MessageComposables.kt | 9 + .../conversation/v3/compose/MessageLink.kt | 5 + .../conversation/v3/compose/MessageMedia.kt | 15 + .../conversation/v3/compose/MessageQuote.kt | 6 + .../securesms/onboarding/landing/Landing.kt | 13 +- 11 files changed, 397 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt 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..7534592ffc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -0,0 +1,257 @@ +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 network.loki.messenger.R +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.displayName +import org.thoughtcrime.securesms.conversation.v3.compose.Audio +import org.thoughtcrime.securesms.conversation.v3.compose.Document +import org.thoughtcrime.securesms.conversation.v3.compose.MessageLinkData +import org.thoughtcrime.securesms.conversation.v3.compose.MessageMediaItem +import org.thoughtcrime.securesms.conversation.v3.compose.MessageQuote +import org.thoughtcrime.securesms.conversation.v3.compose.MessageQuoteIcon +import org.thoughtcrime.securesms.conversation.v3.compose.MessageType +import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewStatus +import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewStatusIcon +import org.thoughtcrime.securesms.conversation.v3.compose.ReactionItem +import org.thoughtcrime.securesms.conversation.v3.compose.ReactionViewState +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +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 javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class ConversationDataMapper @Inject constructor( + @ApplicationContext private val context: Context, + private val avatarUtils: AvatarUtils, +) { + fun map( + record: MessageRecord, + previous: MessageRecord?, + threadRecipient: Recipient, + localUserAddress: String, + highlightKey: Any? = null, + showStatus: Boolean = false, + ): MessageViewData { + val isOutgoing = record.isOutgoing + + // Show sender name only in groups, and only when the author changes between messages. + // Show avatar for all incoming messages when the author changes. + val isGroup = threadRecipient.isGroupOrCommunityRecipient + val previousSameAuthor = previous != null + && previous.isOutgoing == record.isOutgoing + && previous.individualRecipient.address == record.individualRecipient.address + + val showSenderName = !isOutgoing && isGroup && !previousSameAuthor + val showAvatar = !isOutgoing && !previousSameAuthor + + val senderName = record.individualRecipient.displayName() + + val avatarData = if (showAvatar) { + avatarUtils.getUIDataFromRecipient(record.individualRecipient) + } else { + null + } + + return MessageViewData( + id = record.messageId, + type = mapMessageType(record, isOutgoing), + author = senderName, + displayName = showSenderName, + avatar = avatarData, + status = if (showStatus && isOutgoing) mapStatus(record) else null, + quote = mapQuote(record), + link = mapLinkPreview(record), + reactionsState = mapReactions(record, localUserAddress), + highlightKey = highlightKey, + ) + } + + // ---- Message type ---- + + private fun mapMessageType(record: MessageRecord, isOutgoing: Boolean): MessageType { + val mms = record as? MmsMessageRecord + + // Deleted messages — check first; body is not meaningful for these + if (record.isDeleted) { + return MessageType.Text( + outgoing = isOutgoing, + text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), + ) + } + + // Audio + //todo convov3 maybe this should be packed inside an audio composable to live listen to changes locally? + val audioSlide = mms?.slideDeck?.audioSlide + if (audioSlide != null) { + return Audio( + outgoing = isOutgoing, + title = audioSlide.filename, // todo CONVOv3: drive from playback state + speedText = "1x", // todo CONVOv3: drive from playback state + remainingText = "", // todo CONVOv3: drive from playback state + durationMs = 0L, // todo CONVOv3: resolve from audio metadata + positionMs = 0L, // todo CONVOv3: drive from playback state + isPlaying = false, // todo CONVOv3: drive from playback state + showLoader = audioSlide.isInProgress || audioSlide.isPendingDownload, + text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, + ) + } + + // Document + val documentSlide = mms?.slideDeck?.documentSlide + if (documentSlide != null) { + return Document( + outgoing = isOutgoing, + name = documentSlide.filename, + size = Formatter.formatFileSize(context, documentSlide.fileSize), + loading = documentSlide.isInProgress || documentSlide.isPendingDownload, + uri = documentSlide.uri?.toString() ?: "", + text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, + ) + } + + // Images + video — MediaMmsMessageRecord specifically holds downloaded media + if (record is MediaMmsMessageRecord) { + val mediaSlides = record.slideDeck.slides.filter { it.hasImage() || it.hasVideo() } + + //todp convoV3 map this properly + 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) + } + } + return MessageType.Media( + outgoing = isOutgoing, + items = items, + loading = items.any { it.loading }, + text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, + ) + } + } + + // Plain text + // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting + return MessageType.Text( + outgoing = isOutgoing, + text = AnnotatedString(record.body), + ) + } + + // ---- Quote ---- +// todo CONVOv3: sort out properly + private fun mapQuote(record: MessageRecord): MessageQuote? { + val quote = (record as? MmsMessageRecord)?.quote ?: return null + + 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 + }*/ + + return MessageQuote( + title = quote.author.displayName(), + subtitle = quote.text?.ifBlank { null } + ?: context.getString(R.string.document), + icon = icon, + ) + } + + // ---- 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 + onReactionClick = {}, + onReactionLongClick = {}, + onShowMoreClick = {}, + ) + } + + // ---- 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 index 98603d08cc..36a13516ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -2,16 +2,23 @@ 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.MessageViewData import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord class ConversationPagingSource( private val threadId: Long, private val mmsSmsDatabase: MmsSmsDatabase, private val reverse: Boolean, -) : PagingSource() { + private val dataMapper: ConversationDataMapper, + private val threadRecipient: Recipient, + private val localUserAddress: String, + private val lastSentMessageId: MessageId?, +) : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = + 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) @@ -19,11 +26,11 @@ class ConversationPagingSource( ?: page?.nextKey?.minus(state.config.pageSize) } - override suspend fun load(params: LoadParams): LoadResult { + override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { // getConversation already handles LIMIT/OFFSET in SQL - val messages = mmsSmsDatabase.getConversation( + val records = mmsSmsDatabase.getConversation( threadId, reverse, offset.toLong(), params.loadSize.toLong() ).use { cursor -> buildList { @@ -35,10 +42,21 @@ class ConversationPagingSource( } } } + + val mapped = records.mapIndexed { index, record -> + dataMapper.map( + record = record, + previous = records.getOrNull(index + 1), + threadRecipient = threadRecipient, + localUserAddress = localUserAddress, + showStatus = record.messageId == lastSentMessageId, + ) + } + LoadResult.Page( - data = messages, + data = mapped, prevKey = if (offset == 0) null else maxOf(0, offset - params.loadSize), - nextKey = if (messages.size < params.loadSize) null else offset + params.loadSize + nextKey = if (records.size < params.loadSize) null else offset + params.loadSize, ) } catch (e: Exception) { LoadResult.Error(e) 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 6f60c0235b..51c20fcd4f 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 @@ -49,15 +49,14 @@ 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.conversation.v3.compose.MessageViewData 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.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.ConversationAppBarData @@ -84,7 +83,7 @@ class ConversationV3ViewModel @AssistedInject constructor( private val recipientSettingsDatabase: RecipientSettingsDatabase, private val attachmentDatabase: AttachmentDatabase, private val reactionDb: ReactionDatabase, - + private val dataMapper: ConversationDataMapper, ) : ViewModel() { val threadIdFlow: StateFlow = @@ -147,7 +146,7 @@ class ConversationV3ViewModel @AssistedInject constructor( private var pagingSource: ConversationPagingSource? = null @OptIn(ExperimentalCoroutinesApi::class) - val conversationMessages: Flow> = threadIdFlow + val conversationMessages: Flow> = threadIdFlow .filterNotNull() .flatMapLatest { id -> Pager( @@ -157,7 +156,15 @@ class ConversationV3ViewModel @AssistedInject constructor( enablePlaceholders = false ), pagingSourceFactory = { - ConversationPagingSource(id, mmsSmsDatabase, reverse = true).also { + ConversationPagingSource( + id, + mmsSmsDatabase, + reverse = true, + dataMapper = dataMapper, + threadRecipient = recipient, + localUserAddress = storage.getUserPublicKey() ?: "", + lastSentMessageId = mmsSmsDatabase.getLastSentMessageID(id), + ).also { pagingSource = it } } 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/AudioMessage.kt index dce8c5b0eb..356d2767f3 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/AudioMessage.kt @@ -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 @@ -258,6 +259,7 @@ fun AudioMessagePreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.audio() )) @@ -265,6 +267,7 @@ fun AudioMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.audio( @@ -276,6 +279,7 @@ fun AudioMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.audio( playing = false 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 index 6d94f11c12..a6309e8b9f 100644 --- 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 @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.conversation.v3.compose import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -35,9 +36,12 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import network.loki.messenger.R import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination import org.thoughtcrime.securesms.conversation.v3.ConversationV3ViewModel +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.ui.components.ConversationAppBar import org.thoughtcrime.securesms.ui.components.ConversationAppBarData @@ -81,7 +85,7 @@ fun ConversationScreen( fun Conversation( conversationState: ConversationV3ViewModel.UIState, appBarData: ConversationAppBarData, - messages: LazyPagingItems, + messages: LazyPagingItems, sendCommand: (ConversationV3ViewModel.Commands) -> Unit, switchConvoVersion: () -> Unit, onBack: () -> Unit, @@ -106,7 +110,7 @@ fun Conversation( } ) }, - contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), + contentWindowInsets = WindowInsets.safeDrawing, ) { paddings -> LazyColumn( @@ -115,14 +119,19 @@ fun Conversation( .padding(paddings) .consumeWindowInsets(paddings), reverseLayout = true, // newest messages at the bottom + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xsSpacing + ), state = rememberLazyListState(), ) { items( count = messages.itemCount, - key = messages.itemKey { msg -> "${msg.id}_${msg.isMms}" } + key = messages.itemKey { msg -> "${msg.id}_${msg.type}" } ) { index -> messages[index]?.let { message -> - Text(message.body) + Message( + data = message, + ) } } @@ -167,7 +176,43 @@ fun PreviewConversation( ) ) ), - messages = emptyFlow>().collectAsLazyPagingItems(), + messages = flowOf>( + PagingData.from( + data = listOf( + MessageViewData( + id = MessageId(0, false), + author = "Toto", + type = PreviewMessageData.text() + ), + MessageViewData( + id = MessageId(0, false), + author = "Toto", + avatar = PreviewMessageData.sampleAvatar, + type = PreviewMessageData.text( + outgoing = false, + text = "I have lots of reactions - Closed" + ), + reactionsState = 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, + onReactionClick = {}, + onReactionLongClick = {}, + onShowMoreClick = {} + ) + ) + ) + ) + ).collectAsLazyPagingItems(), sendCommand = {}, switchConvoVersion = {}, onBack = {}, 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/DocumentMessage.kt index ce046bba2b..007f184b92 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/DocumentMessage.kt @@ -26,6 +26,7 @@ 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.database.model.MessageId import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -112,6 +113,7 @@ fun DocumentMessagePreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.document() )) @@ -119,6 +121,7 @@ fun DocumentMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.document( @@ -130,6 +133,7 @@ fun DocumentMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.document( loading = true @@ -139,6 +143,7 @@ fun DocumentMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = PreviewMessageData.document( @@ -149,6 +154,7 @@ fun DocumentMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = PreviewMessageData.document( 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/MessageComposables.kt index 1e5471e2f8..754a567114 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/MessageComposables.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.max 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.components.Avatar import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -351,6 +352,7 @@ internal fun defaultMessageBubblePadding() = PaddingValues( ) data class MessageViewData( + val id: MessageId, val type: MessageType, val author: String, val displayName: Boolean = false, @@ -432,6 +434,7 @@ fun MessagePreview( var testData by remember { mutableStateOf( MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text() ) @@ -441,6 +444,7 @@ fun MessagePreview( var testData2 by remember { mutableStateOf( MessageViewData( + id = MessageId(0, false), author = "Toto", displayName = true, avatar = PreviewMessageData.sampleAvatar, @@ -477,6 +481,7 @@ fun MessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text( 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?" @@ -487,6 +492,7 @@ fun MessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( @@ -510,6 +516,7 @@ fun MessageReactionsPreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text( text = "I have 3 emoji reactions" @@ -530,6 +537,7 @@ fun MessageReactionsPreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( @@ -558,6 +566,7 @@ fun MessageReactionsPreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( 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/MessageLink.kt index c0fb73a506..264068ebda 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/MessageLink.kt @@ -27,6 +27,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import network.loki.messenger.R +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 @@ -103,6 +104,7 @@ fun LinkMessagePreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), link = MessageLinkData( @@ -114,6 +116,7 @@ fun LinkMessagePreview( Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(text="Quoting text"), link = MessageLinkData( @@ -124,6 +127,7 @@ fun LinkMessagePreview( )) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), @@ -136,6 +140,7 @@ fun LinkMessagePreview( Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), 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/MessageMedia.kt index dee9e50866..4987858662 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/MessageMedia.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +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 @@ -164,6 +165,7 @@ fun MediaMessagePreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = true, @@ -178,6 +180,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = true, @@ -192,6 +195,7 @@ fun MediaMessagePreview( var testData by remember { mutableStateOf( MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( text = AnnotatedString("This also has text"), @@ -216,6 +220,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = false, @@ -227,6 +232,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = true, @@ -238,6 +244,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = false, @@ -249,6 +256,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = false, @@ -260,6 +268,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = true, @@ -271,6 +280,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( outgoing = false, @@ -282,6 +292,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Media( text = AnnotatedString("This also has text"), @@ -294,6 +305,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( @@ -306,6 +318,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( @@ -319,6 +332,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( @@ -332,6 +346,7 @@ fun MediaMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( 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/MessageQuote.kt index 3d40d81f1b..e2cfcf2d03 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/MessageQuote.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest import network.loki.messenger.R +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 @@ -128,6 +129,7 @@ fun QuoteMessagePreview( ) { Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) @@ -136,6 +138,7 @@ fun QuoteMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text(text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) @@ -144,6 +147,7 @@ fun QuoteMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text(outgoing = false, text="Quoting a document"), @@ -153,6 +157,7 @@ fun QuoteMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Text(outgoing = true, AnnotatedString("Quoting audio")), quote = PreviewMessageData.quote( @@ -165,6 +170,7 @@ fun QuoteMessagePreview( Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( + id = MessageId(0, false), author = "Toto", type = MessageType.Text(outgoing = true, AnnotatedString("Quoting an image")), quote = PreviewMessageData.quote(icon = PreviewMessageData.quoteImage()) 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..945bfa5b6d 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 @@ -39,6 +39,7 @@ 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.database.model.MessageId import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.AccentFillButton @@ -79,18 +80,21 @@ internal fun LandingScreen( .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() ), outgoing = false), - author = "Test" + author = "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" + author = "Test", + id = MessageId(0, false) ), MessageViewData( type = MessageType.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), - author = "Test" + author = "Test", + id = MessageId(0, false) ), MessageViewData( type = MessageType.Text(text = AnnotatedString( @@ -98,7 +102,8 @@ internal fun LandingScreen( .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() ), outgoing = true), - author = "Test" + author = "Test", + id = MessageId(0, false) ), ) } From 785dfbf9b3b25273339dbb7586c2c9613c8ea2b1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 27 Feb 2026 16:38:16 +1100 Subject: [PATCH 03/23] More mapping and UI --- .../conversation/v3/ConversationDataMapper.kt | 160 ++++++++++++++---- .../v3/ConversationPagingSource.kt | 12 +- .../conversation/v3/ConversationV3NavHost.kt | 2 +- .../v3/ConversationV3ViewModel.kt | 3 +- .../conversation/ConversationElements.kt | 92 ++++++++++ .../{ => conversation}/ConversationScreen.kt | 69 ++++---- .../v3/compose/{ => message}/AudioMessage.kt | 2 +- .../BaseMessage.kt} | 46 +++-- .../compose/{ => message}/DocumentMessage.kt | 2 +- .../compose/{ => message}/MessageEffects.kt | 5 +- .../{ => message}/MessageEmojiReactions.kt | 2 +- .../v3/compose/{ => message}/MessageLink.kt | 2 +- .../v3/compose/{ => message}/MessageMedia.kt | 2 +- .../v3/compose/{ => message}/MessageQuote.kt | 2 +- .../securesms/onboarding/landing/Landing.kt | 6 +- 15 files changed, 307 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationElements.kt rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => conversation}/ConversationScreen.kt (77%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/AudioMessage.kt (99%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{MessageComposables.kt => message/BaseMessage.kt} (94%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/DocumentMessage.kt (98%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/MessageEffects.kt (92%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/MessageEmojiReactions.kt (99%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/MessageLink.kt (98%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/MessageMedia.kt (99%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/{ => message}/MessageQuote.kt (99%) 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 index 7534592ffc..70e7629b06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -8,72 +8,158 @@ import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.thoughtcrime.securesms.conversation.v3.compose.Audio -import org.thoughtcrime.securesms.conversation.v3.compose.Document -import org.thoughtcrime.securesms.conversation.v3.compose.MessageLinkData -import org.thoughtcrime.securesms.conversation.v3.compose.MessageMediaItem -import org.thoughtcrime.securesms.conversation.v3.compose.MessageQuote -import org.thoughtcrime.securesms.conversation.v3.compose.MessageQuoteIcon -import org.thoughtcrime.securesms.conversation.v3.compose.MessageType -import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewData -import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewStatus -import org.thoughtcrime.securesms.conversation.v3.compose.MessageViewStatusIcon -import org.thoughtcrime.securesms.conversation.v3.compose.ReactionItem -import org.thoughtcrime.securesms.conversation.v3.compose.ReactionViewState +import org.thoughtcrime.securesms.conversation.v3.compose.message.Audio +import org.thoughtcrime.securesms.conversation.v3.compose.message.ClusterPosition +import org.thoughtcrime.securesms.conversation.v3.compose.message.Document +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageAvatar +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.MessageQuote +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageQuoteIcon +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageType +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.model.MediaMmsMessageRecord 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.math.abs @Singleton class ConversationDataMapper @Inject constructor( @ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, + private val dateUtils: DateUtils ) { + private val timeZoneOffsetSeconds = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000 + + sealed interface ConversationItem { + data class Message(val data: MessageViewData) : ConversationItem + data class DateBreak(val date: String) : ConversationItem + data class UnreadMarker(val count: Int) : ConversationItem + } + fun map( record: MessageRecord, previous: MessageRecord?, + next: MessageRecord?, threadRecipient: Recipient, localUserAddress: String, highlightKey: Any? = null, showStatus: Boolean = false, - ): MessageViewData { + ): List { val isOutgoing = record.isOutgoing - - // Show sender name only in groups, and only when the author changes between messages. - // Show avatar for all incoming messages when the author changes. + val senderName = record.individualRecipient.displayName() val isGroup = threadRecipient.isGroupOrCommunityRecipient - val previousSameAuthor = previous != null - && previous.isOutgoing == record.isOutgoing - && previous.individualRecipient.address == record.individualRecipient.address - val showSenderName = !isOutgoing && isGroup && !previousSameAuthor - val showAvatar = !isOutgoing && !previousSameAuthor + val isStart = isStartOfCluster(record, previous, isGroup) + val isEnd = isEndOfCluster(record, next, isGroup) - val senderName = record.individualRecipient.displayName() + 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 - val avatarData = if (showAvatar) { - avatarUtils.getUIDataFromRecipient(record.individualRecipient) + // 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, + type = mapMessageType(record, isOutgoing), + author = senderName, + displayName = showAuthorName, + avatar = avatar, + status = if (showStatus && isOutgoing) mapStatus(record) else null, + quote = mapQuote(record), + link = mapLinkPreview(record), + reactionsState = mapReactions(record, localUserAddress), + highlightKey = highlightKey, + )) + + return buildList { + add(message) + + // Items added after message appear visually ABOVE it (with reverseLayout = true) + if (showDateBreak) add(ConversationItem.DateBreak( + dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) + )) + } + } + + 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 { - null + current.isOutgoing != previous.isOutgoing } - return MessageViewData( - id = record.messageId, - type = mapMessageType(record, isOutgoing), - author = senderName, - displayName = showSenderName, - avatar = avatarData, - status = if (showStatus && isOutgoing) mapStatus(record) else null, - quote = mapQuote(record), - link = mapLinkPreview(record), - reactionsState = mapReactions(record, localUserAddress), - highlightKey = highlightKey, - ) + private fun isEndOfCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = + next == null || next.isControlMessage || !dateUtils.isSameHour(current.timestamp, next.timestamp) || 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 + + // Never show before control messages + if (current.isControlMessage) return false + + // Always show before a message that follows a control message + if (previous.isControlMessage) 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 + val day1 = ((t1 / 1000) + timeZoneOffsetSeconds) / 86400 + val day2 = ((t2 / 1000) + timeZoneOffsetSeconds) / 86400 + + return day1 != day2 + } + + 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 type ---- 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 index 36a13516ae..7ba7ca3d82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -3,10 +3,9 @@ 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.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.model.MessageId -import org.thoughtcrime.securesms.database.model.MessageRecord class ConversationPagingSource( private val threadId: Long, @@ -16,9 +15,9 @@ class ConversationPagingSource( private val threadRecipient: Recipient, private val localUserAddress: String, private val lastSentMessageId: MessageId?, -) : PagingSource() { +) : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = + 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) @@ -26,7 +25,7 @@ class ConversationPagingSource( ?: page?.nextKey?.minus(state.config.pageSize) } - override suspend fun load(params: LoadParams): LoadResult { + override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { // getConversation already handles LIMIT/OFFSET in SQL @@ -43,10 +42,11 @@ class ConversationPagingSource( } } - val mapped = records.mapIndexed { index, record -> + val mapped = records.flatMapIndexed { index, record -> dataMapper.map( record = record, previous = records.getOrNull(index + 1), + next = records.getOrNull(index - 1), threadRecipient = threadRecipient, localUserAddress = localUserAddress, showStatus = record.messageId == lastSentMessageId, 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 9da2608095..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 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 51c20fcd4f..222e1981ae 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 @@ -49,7 +49,6 @@ 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.conversation.v3.compose.MessageViewData import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase @@ -146,7 +145,7 @@ class ConversationV3ViewModel @AssistedInject constructor( private var pagingSource: ConversationPagingSource? = null @OptIn(ExperimentalCoroutinesApi::class) - val conversationMessages: Flow> = threadIdFlow + val conversationItems: Flow> = threadIdFlow .filterNotNull() .flatMapLatest { id -> Pager( 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..1b7846c6db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationElements.kt @@ -0,0 +1,92 @@ +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(), + 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(horizontal = 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/ConversationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt similarity index 77% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt index a6309e8b9f..9a8bd2125d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/ConversationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/conversation/ConversationScreen.kt @@ -1,31 +1,21 @@ -package org.thoughtcrime.securesms.conversation.v3.compose +package org.thoughtcrime.securesms.conversation.v3.compose.conversation import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -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.fillMaxWidth -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.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -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 -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 @@ -34,27 +24,26 @@ 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.emptyFlow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import network.loki.messenger.R 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.MessageViewData +import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessageData +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.database.model.MessageRecord 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.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.ui.theme.primaryOrange import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement @@ -68,12 +57,12 @@ fun ConversationScreen( ) { val conversationState by viewModel.uiState.collectAsStateWithLifecycle() val appBarData by viewModel.appBarData.collectAsStateWithLifecycle() - val messages = viewModel.conversationMessages.collectAsLazyPagingItems() + val conversationItems = viewModel.conversationItems.collectAsLazyPagingItems() Conversation( conversationState = conversationState, appBarData = appBarData, - messages = messages, + conversationItems = conversationItems, sendCommand = viewModel::onCommand, switchConvoVersion = switchConvoVersion, onBack = onBack, @@ -85,7 +74,7 @@ fun ConversationScreen( fun Conversation( conversationState: ConversationV3ViewModel.UIState, appBarData: ConversationAppBarData, - messages: LazyPagingItems, + conversationItems: LazyPagingItems, sendCommand: (ConversationV3ViewModel.Commands) -> Unit, switchConvoVersion: () -> Unit, onBack: () -> Unit, @@ -125,18 +114,32 @@ fun Conversation( state = rememberLazyListState(), ) { items( - count = messages.itemCount, - key = messages.itemKey { msg -> "${msg.id}_${msg.type}" } + count = conversationItems.itemCount, + key = conversationItems.itemKey { item -> + when (item) { + is ConversationItem.Message -> "msg_${item.data.id}" + is ConversationItem.DateBreak -> "date_${item.date}" + is ConversationItem.UnreadMarker -> "unread" + } + }, + contentType = conversationItems.itemContentType { item -> + when (item) { + is ConversationItem.Message -> 0 + is ConversationItem.DateBreak -> 1 + is ConversationItem.UnreadMarker -> 2 + } + } ) { index -> - messages[index]?.let { message -> - Message( - data = message, - ) + 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 (messages.loadState.append is LoadState.Loading) { + if (conversationItems.loadState.append is LoadState.Loading) { item(key = "loading_append") { Box( modifier = Modifier.fillMaxWidth().padding( @@ -176,14 +179,16 @@ fun PreviewConversation( ) ) ), - messages = flowOf>( + conversationItems = flowOf>( PagingData.from( data = listOf( + ConversationItem.Message( MessageViewData( id = MessageId(0, false), author = "Toto", type = PreviewMessageData.text() - ), + )), + ConversationItem.Message( MessageViewData( id = MessageId(0, false), author = "Toto", @@ -209,7 +214,7 @@ fun PreviewConversation( onReactionLongClick = {}, onShowMoreClick = {} ) - ) + )) ) ) ).collectAsLazyPagingItems(), 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 99% 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 356d2767f3..b0527e4dba 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 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 94% 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 754a567114..784eb0132b 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,6 +35,7 @@ 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 @@ -111,14 +112,21 @@ fun MessageContent( horizontalAlignment = if (data.type.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( @@ -356,14 +364,28 @@ data class MessageViewData( val type: MessageType, val author: String, val displayName: Boolean = false, - val avatar: AvatarUIData? = null, + 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 highlightKey: Any? = null, + val clusterPosition: ClusterPosition = ClusterPosition.ISOLATED ) +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, @@ -638,7 +660,9 @@ 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) 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 98% 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 007f184b92..6c06100daf 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 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 99% 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..67604c8d28 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 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 98% 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 264068ebda..3dec119248 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 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 99% 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 4987858662..f83d8cf8fc 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 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 99% 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 e2cfcf2d03..711a0c7d43 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 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 945bfa5b6d..6980c81b7a 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,9 @@ 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.MessageType +import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewData import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.TCPolicyDialog From 7539f48bf59e3c91fc109b5bd83e5953b281b84a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 27 Feb 2026 17:43:38 +1100 Subject: [PATCH 04/23] MEssage spacing --- .../securesms/conversation/v3/ConversationDataMapper.kt | 1 + .../v3/compose/conversation/ConversationElements.kt | 8 ++++++-- .../conversation/v3/compose/message/BaseMessage.kt | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) 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 index 70e7629b06..cd5be5e12e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -98,6 +98,7 @@ class ConversationDataMapper @Inject constructor( link = mapLinkPreview(record), reactionsState = mapReactions(record, localUserAddress), highlightKey = highlightKey, + clusterPosition = clusterPosition )) return buildList { 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 index 1b7846c6db..3435f37ede 100644 --- 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 @@ -36,7 +36,8 @@ fun ConversationDateBreak( modifier: Modifier = Modifier ){ Text( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth() + .padding(vertical = LocalDimensions.current.xxxsSpacing), text = date, color = LocalColors.current.text, style = LocalType.current.small.bold(), @@ -50,7 +51,10 @@ fun ConversationUnreadBreak( ){ Row( modifier = modifier.fillMaxWidth() - .padding(horizontal = LocalDimensions.current.smallSpacing), + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxxsSpacing + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 784eb0132b..af52a746d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -283,8 +283,14 @@ fun Message( data: MessageViewData, modifier: Modifier = Modifier ) { + val bottomPadding = when (data.clusterPosition) { + ClusterPosition.BOTTOM, ClusterPosition.ISOLATED -> LocalDimensions.current.contentSpacing + ClusterPosition.TOP, ClusterPosition.MIDDLE -> LocalDimensions.current.xxxsSpacing + } + BoxWithConstraints( modifier = modifier.fillMaxWidth() + .padding(bottom = bottomPadding) ) { val maxMessageWidth = max( LocalDimensions.current.minMessageWidth, From 9d2932bc516d8e2947e28d81c1e013d26f6b88e9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Mar 2026 13:57:03 +1100 Subject: [PATCH 05/23] showing unread marker --- .../conversation/v3/ConversationDataMapper.kt | 11 +++++- .../v3/ConversationPagingSource.kt | 2 ++ .../v3/ConversationV3ViewModel.kt | 36 ++++++++++++------- .../conversation/ConversationElements.kt | 4 +-- .../v3/compose/message/BaseMessage.kt | 6 ++-- 5 files changed, 41 insertions(+), 18 deletions(-) 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 index cd5be5e12e..986a6154ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -45,7 +45,7 @@ class ConversationDataMapper @Inject constructor( sealed interface ConversationItem { data class Message(val data: MessageViewData) : ConversationItem data class DateBreak(val date: String) : ConversationItem - data class UnreadMarker(val count: Int) : ConversationItem + data object UnreadMarker : ConversationItem } fun map( @@ -54,6 +54,7 @@ class ConversationDataMapper @Inject constructor( next: MessageRecord?, threadRecipient: Recipient, localUserAddress: String, + lastSeen: Long?, highlightKey: Any? = null, showStatus: Boolean = false, ): List { @@ -101,6 +102,11 @@ class ConversationDataMapper @Inject constructor( clusterPosition = clusterPosition )) + val showUnreadMarker = lastSeen != null + && record.timestamp > lastSeen + && (previous == null || previous.timestamp <= lastSeen) + && !record.isOutgoing + return buildList { add(message) @@ -108,6 +114,9 @@ class ConversationDataMapper @Inject constructor( if (showDateBreak) add(ConversationItem.DateBreak( dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) )) + + // unread marker, if needed + if (showUnreadMarker) add(ConversationItem.UnreadMarker) } } 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 index 7ba7ca3d82..44db440431 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -15,6 +15,7 @@ class ConversationPagingSource( private val threadRecipient: Recipient, private val localUserAddress: String, private val lastSentMessageId: MessageId?, + private val lastSeen: Long? ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? = @@ -50,6 +51,7 @@ class ConversationPagingSource( threadRecipient = threadRecipient, localUserAddress = localUserAddress, showStatus = record.messageId == lastSentMessageId, + lastSeen = lastSeen ) } 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 222e1981ae..366be4a770 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 @@ -29,6 +29,7 @@ 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 @@ -36,6 +37,7 @@ 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 @@ -144,28 +146,38 @@ class ConversationV3ViewModel @AssistedInject constructor( private var pagingSource: ConversationPagingSource? = null - @OptIn(ExperimentalCoroutinesApi::class) - val conversationItems: Flow> = threadIdFlow + // 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 - ), + config = PagingConfig(pageSize = 50, initialLoadSize = 100, enablePlaceholders = false), pagingSourceFactory = { ConversationPagingSource( - id, - mmsSmsDatabase, + threadId = id, + mmsSmsDatabase = mmsSmsDatabase, reverse = true, dataMapper = dataMapper, threadRecipient = recipient, localUserAddress = storage.getUserPublicKey() ?: "", lastSentMessageId = mmsSmsDatabase.getLastSentMessageID(id), - ).also { - pagingSource = it - } + lastSeen = lastSeen, + ).also { pagingSource = it } } ).flow } 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 index 3435f37ede..e85c63c4c0 100644 --- 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 @@ -52,8 +52,8 @@ fun ConversationUnreadBreak( Row( modifier = modifier.fillMaxWidth() .padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxxsSpacing + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.smallSpacing ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index af52a746d0..0937c1dc34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -63,7 +63,6 @@ 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 @@ -284,8 +283,8 @@ fun Message( modifier: Modifier = Modifier ) { val bottomPadding = when (data.clusterPosition) { - ClusterPosition.BOTTOM, ClusterPosition.ISOLATED -> LocalDimensions.current.contentSpacing - ClusterPosition.TOP, ClusterPosition.MIDDLE -> LocalDimensions.current.xxxsSpacing + 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( @@ -446,6 +445,7 @@ sealed class MessageType(){ val loading: Boolean, override val text: AnnotatedString? = null ): MessageType() + } /*@PreviewScreenSizes*/ From 1e55a2bc61039170eac28487a69f3297075188fb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Mar 2026 14:30:01 +1100 Subject: [PATCH 06/23] Adding pro badge and display name extra to messages --- .../conversation/v3/ConversationDataMapper.kt | 25 +++++--- .../conversation/ConversationElements.kt | 5 +- .../conversation/ConversationScreen.kt | 6 +- .../v3/compose/message/AudioMessage.kt | 6 +- .../v3/compose/message/BaseMessage.kt | 60 ++++++++++++------- .../v3/compose/message/DocumentMessage.kt | 10 ++-- .../v3/compose/message/MessageLink.kt | 8 +-- .../v3/compose/message/MessageMedia.kt | 28 ++++----- .../v3/compose/message/MessageQuote.kt | 10 ++-- .../securesms/onboarding/landing/Landing.kt | 8 +-- 10 files changed, 100 insertions(+), 66 deletions(-) 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 index 986a6154ea..4c975669e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -6,8 +6,10 @@ import androidx.compose.ui.text.AnnotatedString import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.R +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.v3.compose.message.Audio import org.thoughtcrime.securesms.conversation.v3.compose.message.ClusterPosition import org.thoughtcrime.securesms.conversation.v3.compose.message.Document @@ -23,6 +25,7 @@ import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewSta import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionItem import org.thoughtcrime.securesms.conversation.v3.compose.message.ReactionViewState 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 @@ -44,7 +47,7 @@ class ConversationDataMapper @Inject constructor( sealed interface ConversationItem { data class Message(val data: MessageViewData) : ConversationItem - data class DateBreak(val date: String) : ConversationItem + data class DateBreak(val messageId: MessageId, val date: String) : ConversationItem data object UnreadMarker : ConversationItem } @@ -59,7 +62,15 @@ class ConversationDataMapper @Inject constructor( showStatus: Boolean = false, ): List { val isOutgoing = record.isOutgoing + 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) @@ -91,8 +102,10 @@ class ConversationDataMapper @Inject constructor( MessageViewData( id = record.messageId, type = mapMessageType(record, isOutgoing), - author = senderName, - displayName = showAuthorName, + displayName = senderName, + displayNameExtra = extraDisplayName, + showDisplayName = showAuthorName, + showProBadge = record.recipient.shouldShowProBadge, avatar = avatar, status = if (showStatus && isOutgoing) mapStatus(record) else null, quote = mapQuote(record), @@ -112,7 +125,8 @@ class ConversationDataMapper @Inject constructor( // Items added after message appear visually ABOVE it (with reverseLayout = true) if (showDateBreak) add(ConversationItem.DateBreak( - dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) + messageId = message.data.id, // useful in case of repeated dates due to logic + date = dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) )) // unread marker, if needed @@ -141,9 +155,6 @@ class ConversationDataMapper @Inject constructor( // Never show before control messages if (current.isControlMessage) return false - // Always show before a message that follows a control message - if (previous.isControlMessage) return true - val t1 = previous.timestamp val t2 = current.timestamp 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 index e85c63c4c0..2fdb7006be 100644 --- 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 @@ -37,7 +37,10 @@ fun ConversationDateBreak( ){ Text( modifier = modifier.fillMaxWidth() - .padding(vertical = LocalDimensions.current.xxxsSpacing), + .padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xxsSpacing + ), text = date, color = LocalColors.current.text, style = LocalType.current.small.bold(), 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 index 9a8bd2125d..f19a678023 100644 --- 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 @@ -118,7 +118,7 @@ fun Conversation( key = conversationItems.itemKey { item -> when (item) { is ConversationItem.Message -> "msg_${item.data.id}" - is ConversationItem.DateBreak -> "date_${item.date}" + is ConversationItem.DateBreak -> "date_${item.date}_${item.messageId}" is ConversationItem.UnreadMarker -> "unread" } }, @@ -185,13 +185,13 @@ fun PreviewConversation( ConversationItem.Message( MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text() )), ConversationItem.Message( MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( outgoing = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt index b0527e4dba..35fd91a374 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt @@ -260,7 +260,7 @@ fun AudioMessagePreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.audio() )) @@ -268,7 +268,7 @@ fun AudioMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.audio( outgoing = false, @@ -280,7 +280,7 @@ fun AudioMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.audio( playing = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 0937c1dc34..d98c8472a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -45,7 +45,10 @@ import androidx.compose.ui.unit.max import androidx.core.net.toUri import kotlinx.coroutines.delay import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.truncatedForDisplay 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 @@ -132,13 +135,26 @@ fun MessageContent( horizontalAlignment = if(data.type.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)) } @@ -367,8 +383,10 @@ internal fun defaultMessageBubblePadding() = PaddingValues( data class MessageViewData( val id: MessageId, val type: MessageType, - val author: String, - val displayName: Boolean = false, + 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, @@ -445,7 +463,7 @@ sealed class MessageType(){ val loading: Boolean, override val text: AnnotatedString? = null ): MessageType() - + } /*@PreviewScreenSizes*/ @@ -463,9 +481,11 @@ fun MessagePreview( mutableStateOf( MessageViewData( id = MessageId(0, false), - author = "Toto", - type = PreviewMessageData.text() - ) + displayName = "Toto", + showProBadge = true, + displayNameExtra = "(some extra text)", + type = PreviewMessageData.text() + ) ) } @@ -473,8 +493,8 @@ fun MessagePreview( mutableStateOf( MessageViewData( id = MessageId(0, false), - author = "Toto", - displayName = true, + displayName = "Toto", + showDisplayName = true, avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( outgoing = false, @@ -510,7 +530,7 @@ fun MessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text( 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?" ), @@ -521,7 +541,7 @@ fun MessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( outgoing = false, @@ -545,7 +565,7 @@ fun MessageReactionsPreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text( text = "I have 3 emoji reactions" ), @@ -566,7 +586,7 @@ fun MessageReactionsPreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( outgoing = false, @@ -595,7 +615,7 @@ fun MessageReactionsPreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text( outgoing = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt index 6c06100daf..9734aae7b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt @@ -114,7 +114,7 @@ fun DocumentMessagePreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.document() )) @@ -122,7 +122,7 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.document( outgoing = false, @@ -134,7 +134,7 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.document( loading = true )) @@ -144,7 +144,7 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = PreviewMessageData.document( loading = true @@ -155,7 +155,7 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = PreviewMessageData.document( outgoing = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt index 3dec119248..3202f04139 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt @@ -105,7 +105,7 @@ fun LinkMessagePreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), link = MessageLinkData( url = "https://getsession.org/", @@ -117,7 +117,7 @@ fun LinkMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(text="Quoting text"), link = MessageLinkData( url = "https://picsum.photos/id/0/367/267", @@ -128,7 +128,7 @@ fun LinkMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), link = MessageLinkData( @@ -141,7 +141,7 @@ fun LinkMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), link = MessageLinkData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt index f83d8cf8fc..a4a05a8d45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt @@ -166,7 +166,7 @@ fun MediaMessagePreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = true, items = listOf(PreviewMessageData.image( @@ -181,7 +181,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = true, items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), @@ -196,7 +196,7 @@ fun MediaMessagePreview( mutableStateOf( MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( text = AnnotatedString("This also has text"), outgoing = true, @@ -221,7 +221,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = false, items = listOf(PreviewMessageData.image(true)), @@ -233,7 +233,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = true, items = listOf(PreviewMessageData.video()), @@ -245,7 +245,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = false, items = listOf(PreviewMessageData.video()), @@ -257,7 +257,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = false, items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), @@ -269,7 +269,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = true, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(true), PreviewMessageData.image()), @@ -281,7 +281,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( outgoing = false, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), @@ -293,7 +293,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Media( text = AnnotatedString("This also has text"), outgoing = false, @@ -306,7 +306,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( outgoing = true, @@ -319,7 +319,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( outgoing = false, @@ -333,7 +333,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( text = AnnotatedString("This also has text"), @@ -347,7 +347,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), type = MessageType.Media( text = AnnotatedString("This also has text"), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 711a0c7d43..5fe4ef5035 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -130,7 +130,7 @@ fun QuoteMessagePreview( ) { Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(outgoing = false, text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) )) @@ -139,7 +139,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = PreviewMessageData.text(text="Quoting text"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) )) @@ -148,7 +148,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, type = PreviewMessageData.text(outgoing = false, text="Quoting a document"), quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Icon(R.drawable.ic_file)) @@ -158,7 +158,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Text(outgoing = true, AnnotatedString("Quoting audio")), quote = PreviewMessageData.quote( title = "You", @@ -171,7 +171,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), - author = "Toto", + displayName = "Toto", type = MessageType.Text(outgoing = true, AnnotatedString("Quoting an image")), quote = PreviewMessageData.quote(icon = PreviewMessageData.quoteImage()) )) 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 6980c81b7a..8c64ef713b 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 @@ -80,7 +80,7 @@ internal fun LandingScreen( .put(EMOJI_KEY, "\uD83D\uDC4B") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() ), outgoing = false), - author = "Test", + displayName = "Test", id = MessageId(0, false) ), MessageViewData( @@ -88,12 +88,12 @@ internal fun LandingScreen( Phrase.from(context.getString(R.string.onboardingBubbleSessionIsEngineered)) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format().toString()), outgoing = true), - author = "Test", + displayName = "Test", id = MessageId(0, false) ), MessageViewData( type = MessageType.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), - author = "Test", + displayName = "Test", id = MessageId(0, false) ), MessageViewData( @@ -102,7 +102,7 @@ internal fun LandingScreen( .put(EMOJI_KEY, "\uD83D\uDC47") // this hardcoded emoji might be moved to NonTranslatableConstants eventually .format().toString() ), outgoing = true), - author = "Test", + displayName = "Test", id = MessageId(0, false) ), ) From 92801e38b207a393c9e2921fc22c3fd672a96b5c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Mar 2026 14:41:57 +1100 Subject: [PATCH 07/23] Updated "end cluster" logic --- .../conversation/v3/ConversationDataMapper.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 4c975669e4..7be4b1d08c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -141,12 +141,18 @@ class ConversationDataMapper @Inject constructor( current.isOutgoing != previous.isOutgoing } - private fun isEndOfCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = - next == null || next.isControlMessage || !dateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { + 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) From 27305421114004b6e5f3846bf40df7cd88f8f5e5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 2 Mar 2026 17:31:03 +1100 Subject: [PATCH 08/23] Community Invite - Restructuring message types --- .../conversation/v3/ConversationDataMapper.kt | 25 +- .../v3/compose/message/AudioMessage.kt | 2 +- .../v3/compose/message/BaseMessage.kt | 262 +++++++++++------- .../compose/message/CommunityInviteMessage.kt | 153 ++++++++++ .../v3/compose/message/DocumentMessage.kt | 2 +- .../v3/compose/message/MessageMedia.kt | 30 +- .../v3/compose/message/MessageQuote.kt | 4 +- .../securesms/onboarding/landing/Landing.kt | 8 +- 8 files changed, 357 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt 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 index 7be4b1d08c..b601ae0b4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -5,7 +5,9 @@ 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 @@ -41,7 +43,8 @@ import kotlin.math.abs class ConversationDataMapper @Inject constructor( @ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, - private val dateUtils: DateUtils + private val dateUtils: DateUtils, + private val json: Json, ) { private val timeZoneOffsetSeconds = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000 @@ -194,9 +197,21 @@ class ConversationDataMapper @Inject constructor( private fun mapMessageType(record: MessageRecord, isOutgoing: Boolean): MessageType { val mms = record as? MmsMessageRecord + // community invites + if(record.isOpenGroupInvitation){ + val jsonData = UpdateMessageData.fromJSON(json, record.body) + if(jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation){ + return MessageType.RecipientMessage.CommunityInvite( + outgoing = isOutgoing, + communityName = jsonData.kind.groupName, + url = jsonData.kind.groupUrl + ) + } + } + // Deleted messages — check first; body is not meaningful for these if (record.isDeleted) { - return MessageType.Text( + return MessageType.RecipientMessage.Text( outgoing = isOutgoing, text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), ) @@ -236,7 +251,7 @@ class ConversationDataMapper @Inject constructor( if (record is MediaMmsMessageRecord) { val mediaSlides = record.slideDeck.slides.filter { it.hasImage() || it.hasVideo() } - //todp convoV3 map this properly + //todo convoV3 map this properly if (mediaSlides.isNotEmpty()) { val items = mediaSlides.map { slide -> val uri = (slide.uri ?: slide.thumbnailUri) ?: "".toUri() @@ -251,7 +266,7 @@ class ConversationDataMapper @Inject constructor( MessageMediaItem.Image(uri, filename, loading, width, height) } } - return MessageType.Media( + return MessageType.RecipientMessage.Media( outgoing = isOutgoing, items = items, loading = items.any { it.loading }, @@ -262,7 +277,7 @@ class ConversationDataMapper @Inject constructor( // Plain text // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting - return MessageType.Text( + return MessageType.RecipientMessage.Text( outgoing = isOutgoing, text = AnnotatedString(record.body), ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt index 35fd91a374..68d7fe6371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt @@ -246,7 +246,7 @@ data class Audio( val bufferedPositionMs: Long = 0L, val isPlaying: Boolean, val showLoader: Boolean, -) : MessageType() +) : MessageType.RecipientMessage @Preview @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index d98c8472a4..03995dc8f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -45,8 +45,6 @@ import androidx.compose.ui.unit.max import androidx.core.net.toUri import kotlinx.coroutines.delay import network.loki.messenger.R -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.truncatedForDisplay import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.components.Avatar @@ -71,7 +69,6 @@ import org.thoughtcrime.securesms.util.AvatarUIElement //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 @@ -80,23 +77,62 @@ import org.thoughtcrime.securesms.util.AvatarUIElement //todo CONVOv3 new "read more" expandable feature /** - * 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) + when(data.type){ + is MessageType.RecipientMessage -> { + RecipientMessage( + data = data, + type = data.type, + modifier = modifier ) - .clip(shape = RoundedCornerShape(LocalDimensions.current.messageCornerRadius)) + } + + is MessageType.ControlMessage -> { + /* ControlMessage( + data = data, + modifier = modifier + )*/ + } + } +} + +@Composable +fun RecipientMessage( + data: MessageViewData, + type: MessageType.RecipientMessage, + modifier: Modifier = Modifier +){ + 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 = 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 + ) + + RecipientMessageContent( + modifier = Modifier + .align(if (type.outgoing) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxMessageWidth) + .wrapContentWidth(), + data = data, + type = type, + maxWidth = maxMessageWidth + ) } } @@ -104,14 +140,16 @@ fun MessageBubble( * All the content of a message: Bubble with its internal content, avatar, status */ @Composable -fun MessageContent( +fun RecipientMessageContent( data: MessageViewData, + type: MessageType.RecipientMessage, modifier: Modifier = Modifier, maxWidth: Dp ) { + Column( modifier = modifier, - horizontalAlignment = if (data.type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if (type.outgoing) Alignment.End else Alignment.Start ) { Row { if (data.avatar !is MessageAvatar.None) { @@ -132,7 +170,7 @@ fun MessageContent( } Column( - horizontalAlignment = if(data.type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if(type.outgoing) Alignment.End else Alignment.Start ) { if (data.showDisplayName) { @@ -161,60 +199,74 @@ fun MessageContent( // 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 + val hasFirstBubble = + data.quote != null || data.link != null || type.text != null + || type is MessageType.RecipientMessage.CommunityInvite + val hasSecondBubble = data.type !is MessageType.RecipientMessage.Text + && type !is MessageType.RecipientMessage.CommunityInvite // First bubble if (hasFirstBubble) { MessageBubble( modifier = Modifier.accentHighlight(data.highlightKey), - color = if (data.type.outgoing) LocalColors.current.accent + color = if (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 - ) - } - - if(data.type.text != null){ - // Text messages - MessageText( - modifier = Modifier.padding(defaultMessageBubblePadding()), - text = data.type.text!!, - outgoing = data.type.outgoing - ) + // community invites + if (data.type is MessageType.RecipientMessage.CommunityInvite) { + //todo convov3 add onclick for community invite + CommunityInviteMessage( + data = data, + type = data.type, + modifier = Modifier.accentHighlight(data.highlightKey), + ) + } else { // regular recipient messages + Column { + // Display quote if there is one + if (data.quote != null) { + MessageQuote( + modifier = Modifier.padding( + bottom = + if (data.link == null && type.text == null) + defaultMessageBubblePadding().calculateBottomPadding() + else 0.dp + ), + outgoing = 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 = type.outgoing + ) + } + + if (type.text != null) { + // Text messages + MessageText( + modifier = Modifier.padding(defaultMessageBubblePadding()), + text = type.text!!, + outgoing = type.outgoing + ) + } } } } } // Second bubble - if(hasSecondBubble){ + if (hasSecondBubble) { // add spacing if there is a first bubble - if(hasFirstBubble){ + 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){ + if (data.type is MessageType.RecipientMessage.Media) { MediaMessage( modifier = Modifier.accentHighlight(data.highlightKey), data = data.type, @@ -223,7 +275,7 @@ fun MessageContent( } else { MessageBubble( modifier = Modifier.accentHighlight(data.highlightKey), - color = if (data.type.outgoing) LocalColors.current.accent + color = if (type.outgoing) LocalColors.current.accent else LocalColors.current.backgroundBubbleReceived ) { // Apply content based on message type @@ -248,8 +300,8 @@ 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(type.outgoing) 0.dp + else if (data.avatar !is MessageAvatar.None) LocalDimensions.current.iconMediumAvatar + LocalDimensions.current.smallSpacing else 0.dp // reactions @@ -259,7 +311,7 @@ fun MessageContent( modifier = Modifier.padding(start = indentation), reactions = data.reactionsState.reactions, isExpanded = data.reactionsState.isExtended, - outgoing = data.type.outgoing, + outgoing = type.outgoing, onReactionClick = { //todo CONVOv3 implement }, @@ -282,7 +334,7 @@ fun MessageContent( modifier = Modifier .padding(horizontal = LocalDimensions.current.tinySpacing) .padding(start = indentation) - .align(if (data.type.outgoing) Alignment.End else Alignment.Start), + .align(if (type.outgoing) Alignment.End else Alignment.Start), data = data.status ) } @@ -290,37 +342,23 @@ fun MessageContent( } /** - * 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 = {} ) { - 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) + 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() } } @@ -448,22 +486,35 @@ 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() +sealed interface MessageType{ + + sealed interface RecipientMessage: MessageType { + val outgoing: Boolean + val text: AnnotatedString? + + data class Text( + override val outgoing: Boolean, + override val text: AnnotatedString + ) : RecipientMessage + + data class Media( + override val outgoing: Boolean, + val items: List, + val loading: Boolean, + override val text: AnnotatedString? = null + ) : RecipientMessage + + data class CommunityInvite( + override val outgoing: Boolean, + override val text: AnnotatedString = AnnotatedString(""), + val communityName: String, + val url: String + ) : RecipientMessage + } - data class Media( - override val outgoing: Boolean, - val items: List, - val loading: Boolean, - override val text: AnnotatedString? = null - ): MessageType() + sealed interface ControlMessage: MessageType { + } } /*@PreviewScreenSizes*/ @@ -694,10 +745,19 @@ object PreviewMessageData { icon = MessageViewStatusIcon.DrawableIcon(icon = R.drawable.ic_circle_check) ) + fun communityInvite( + outgoing: Boolean = true + ) = MessageType.RecipientMessage.CommunityInvite( + outgoing = outgoing, + text = AnnotatedString(""), + communityName = "Test Community", + url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + ) + fun text( text: String = "Hi there", outgoing: Boolean = true - ) = MessageType.Text(outgoing = outgoing, AnnotatedString(text)) + ) = MessageType.RecipientMessage.Text(outgoing = outgoing, AnnotatedString(text)) fun document( name: String = "Document name", 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..bb1395d6ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/CommunityInviteMessage.kt @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.message + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.content.MediaType.Companion.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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 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 +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 +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.bold + +@Composable +fun ColumnScope.CommunityInviteMessage( + data: MessageViewData, + type: MessageType.RecipientMessage.CommunityInvite, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.padding(defaultMessageBubblePadding()), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon background circle + Box( + modifier = Modifier + .size(LocalDimensions.current.iconLarge) + .background( + color = if(type.outgoing) blackAlpha06 else LocalColors.current.accent, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource( + id = if(type.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 = type.communityName, + style = LocalType.current.h6, + color = getTextColor(type.outgoing), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.communityInvitation), + style = LocalType.current.base, + color = getTextColor(type.outgoing), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = type.url, + style = LocalType.current.small, + color = getTextColor(type.outgoing), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview +@Composable +fun CommunityInvitePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + val outgoingInvite = MessageViewData( + id = MessageId(0, false), + displayName = "Toto", + type = PreviewMessageData.communityInvite() + ) + + val incomingInvite = MessageViewData( + id = MessageId(0, false), + displayName = "Toto", + type = PreviewMessageData.communityInvite( + outgoing = false + ) + ) + + + PreviewTheme(colors) { + Column ( + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + MessageBubble( + color = LocalColors.current.accent + ) { + Column() { + CommunityInviteMessage( + data = outgoingInvite, + type = outgoingInvite.type as MessageType.RecipientMessage.CommunityInvite + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + MessageBubble( + color = LocalColors.current.backgroundBubbleReceived + ) { + Column() { + CommunityInviteMessage( + data = incomingInvite, + type = incomingInvite.type as MessageType.RecipientMessage.CommunityInvite + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt index 9734aae7b2..b79ab5a799 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt @@ -100,7 +100,7 @@ data class Document( val uri: String, val loading: Boolean, override val text: AnnotatedString? = null -) : MessageType() +) : MessageType.RecipientMessage @Preview @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt index a4a05a8d45..013979ea3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt @@ -43,7 +43,7 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun MediaMessage( - data: MessageType.Media, + data: MessageType.RecipientMessage.Media, maxWidth: Dp, modifier: Modifier = Modifier, ){ @@ -167,7 +167,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = true, items = listOf(PreviewMessageData.image( width = 50, @@ -182,7 +182,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = true, items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), loading = false @@ -197,7 +197,7 @@ fun MediaMessagePreview( MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( text = AnnotatedString("This also has text"), outgoing = true, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), @@ -222,7 +222,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = false, items = listOf(PreviewMessageData.image(true)), loading = false @@ -234,7 +234,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = true, items = listOf(PreviewMessageData.video()), loading = false @@ -246,7 +246,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = false, items = listOf(PreviewMessageData.video()), loading = false @@ -258,7 +258,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = false, items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), loading = false @@ -270,7 +270,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = true, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(true), PreviewMessageData.image()), loading = false @@ -282,7 +282,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = false, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), loading = false @@ -294,7 +294,7 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( text = AnnotatedString("This also has text"), outgoing = false, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), @@ -308,7 +308,7 @@ fun MediaMessagePreview( id = MessageId(0, false), displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = true, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), loading = false @@ -321,7 +321,7 @@ fun MediaMessagePreview( id = MessageId(0, false), displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( outgoing = false, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), loading = false @@ -335,7 +335,7 @@ fun MediaMessagePreview( id = MessageId(0, false), displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( text = AnnotatedString("This also has text"), outgoing = true, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), @@ -349,7 +349,7 @@ fun MediaMessagePreview( id = MessageId(0, false), displayName = "Toto", quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.Media( + type = MessageType.RecipientMessage.Media( text = AnnotatedString("This also has text"), outgoing = false, items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 5fe4ef5035..4fdc7163d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -159,7 +159,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Text(outgoing = true, AnnotatedString("Quoting audio")), + type = MessageType.RecipientMessage.Text(outgoing = true, AnnotatedString("Quoting audio")), quote = PreviewMessageData.quote( title = "You", subtitle = "Audio message", @@ -172,7 +172,7 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.Text(outgoing = true, AnnotatedString("Quoting an image")), + type = MessageType.RecipientMessage.Text(outgoing = true, AnnotatedString("Quoting an image")), quote = PreviewMessageData.quote(icon = PreviewMessageData.quoteImage()) )) 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 8c64ef713b..8b67202fc6 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 @@ -74,7 +74,7 @@ internal fun LandingScreen( val messages = remember(context) { listOf( MessageViewData( - type = MessageType.Text(text = AnnotatedString( + type = MessageType.RecipientMessage.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 @@ -84,7 +84,7 @@ internal fun LandingScreen( id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString( + type = MessageType.RecipientMessage.Text(text = AnnotatedString( Phrase.from(context.getString(R.string.onboardingBubbleSessionIsEngineered)) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format().toString()), outgoing = true), @@ -92,12 +92,12 @@ internal fun LandingScreen( id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), + type = MessageType.RecipientMessage.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), displayName = "Test", id = MessageId(0, false) ), MessageViewData( - type = MessageType.Text(text = AnnotatedString( + type = MessageType.RecipientMessage.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() From 52316d24b99585779606eb355279ce6b3d9d505e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 09:31:44 +1100 Subject: [PATCH 09/23] highlight hard typed --- .../conversation/v3/ConversationDataMapper.kt | 3 ++- .../conversation/v3/compose/message/BaseMessage.kt | 13 ++++++++----- .../v3/compose/message/CommunityInviteMessage.kt | 3 ++- .../conversation/v3/compose/message/MessageMedia.kt | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) 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 index b601ae0b4a..30633cb6b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -15,6 +15,7 @@ import org.session.libsession.utilities.truncatedForDisplay import org.thoughtcrime.securesms.conversation.v3.compose.message.Audio import org.thoughtcrime.securesms.conversation.v3.compose.message.ClusterPosition import org.thoughtcrime.securesms.conversation.v3.compose.message.Document +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.MessageLinkData import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageMediaItem @@ -61,7 +62,7 @@ class ConversationDataMapper @Inject constructor( threadRecipient: Recipient, localUserAddress: String, lastSeen: Long?, - highlightKey: Any? = null, + highlightKey: HighlightMessage? = null, showStatus: Boolean = false, ): List { val isOutgoing = record.isOutgoing diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 03995dc8f4..8bc2f95091 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -75,6 +75,7 @@ import org.thoughtcrime.securesms.util.AvatarUIElement //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 /** * The overall Message composable @@ -430,10 +431,12 @@ data class MessageViewData( val quote: MessageQuote? = null, val link: MessageLinkData? = null, val reactionsState: ReactionViewState? = null, - val highlightKey: Any? = null, + val highlightKey: HighlightMessage? = null, val clusterPosition: ClusterPosition = ClusterPosition.ISOLATED ) +data class HighlightMessage(val token: Long) + enum class ClusterPosition { TOP, MIDDLE, @@ -450,7 +453,7 @@ sealed interface MessageAvatar { data class ReactionViewState( val reactions: List, val isExtended: Boolean, - val onReactionClick: (String) -> Unit, + val onReactionClick: (String) -> Unit, //todo convov3 lift lambdas out val onReactionLongClick: (String) -> Unit, val onShowMoreClick: () -> Unit ) @@ -559,8 +562,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) @@ -572,7 +575,7 @@ fun MessagePreview( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - testData2 = testData2.copy(highlightKey = System.currentTimeMillis()) + testData2 = testData2.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) }), data = testData2 ) 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 index bb1395d6ab..24a348676c 100644 --- 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 @@ -44,7 +44,8 @@ fun ColumnScope.CommunityInviteMessage( modifier: Modifier = Modifier ) { Row( - modifier = modifier.padding(defaultMessageBubblePadding()), + modifier = modifier.padding(defaultMessageBubblePadding()) + .padding(vertical = LocalDimensions.current.tinySpacing), verticalAlignment = Alignment.CenterVertically ) { // Icon background circle diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt index 013979ea3a..419aa5abe0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt @@ -212,7 +212,7 @@ fun MediaMessagePreview( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - testData = testData.copy(highlightKey = System.currentTimeMillis()) + testData = testData.copy(highlightKey = HighlightMessage(System.currentTimeMillis())) }), data = testData ) From c1dcd9c1a203fafd9075aa0ea52bef3b831656ac Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 12:58:11 +1100 Subject: [PATCH 10/23] Reworked overall UI data structure to allow for flexible building blocks --- .../conversation/v3/ConversationDataMapper.kt | 128 +++--- .../conversation/ConversationScreen.kt | 13 +- .../v3/compose/message/AudioMessage.kt | 56 ++- .../v3/compose/message/BaseMessage.kt | 427 +++++++----------- .../compose/message/CommunityInviteMessage.kt | 51 +-- .../v3/compose/message/ControlMessage.kt | 33 ++ .../v3/compose/message/DocumentMessage.kt | 57 ++- .../compose/message/MessageEmojiReactions.kt | 8 +- .../v3/compose/message/MessageLink.kt | 77 ++-- .../v3/compose/message/MessageMedia.kt | 178 ++------ .../v3/compose/message/MessageQuote.kt | 33 +- .../securesms/onboarding/landing/Landing.kt | 45 +- 12 files changed, 493 insertions(+), 613 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt 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 index 30633cb6b8..cf2666fecc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -12,16 +12,18 @@ 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.v3.compose.message.Audio +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.Document +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.MessageContentGroup +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.MessageQuote +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.MessageType 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 @@ -67,6 +69,12 @@ class ConversationDataMapper @Inject constructor( ): List { 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 -> @@ -105,16 +113,15 @@ class ConversationDataMapper @Inject constructor( val message = ConversationItem.Message( MessageViewData( id = record.messageId, - type = mapMessageType(record, isOutgoing), + 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, - quote = mapQuote(record), - link = mapLinkPreview(record), - reactionsState = mapReactions(record, localUserAddress), + reactions = mapReactions(record, localUserAddress), highlightKey = highlightKey, clusterPosition = clusterPosition )) @@ -193,66 +200,77 @@ class ConversationDataMapper @Inject constructor( || previous.isControlMessage } - // ---- Message type ---- + // ---- Message content ---- - private fun mapMessageType(record: MessageRecord, isOutgoing: Boolean): MessageType { + private fun mapContentGroups(record: MessageRecord): List { + val groups = mutableListOf() val mms = record as? MmsMessageRecord - // community invites - if(record.isOpenGroupInvitation){ - val jsonData = UpdateMessageData.fromJSON(json, record.body) - if(jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation){ - return MessageType.RecipientMessage.CommunityInvite( - outgoing = isOutgoing, - communityName = jsonData.kind.groupName, - url = jsonData.kind.groupUrl + // Deleted messages — check first; body is not meaningful for these + if (record.isDeleted) { + return listOf(MessageContentGroup(listOf( + MessageContent.Text( + text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), ) + ))) + } + + // Group 1: Quotes, Links, and Text + val primaryContent = mutableListOf() + + // quotes + mapQuote(record)?.let { primaryContent.add(MessageContent.Quote(it)) } + + // links + mapLinkPreview(record)?.let { primaryContent.add(MessageContent.Link(it)) } + + // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting + if (record.body.isNotBlank()) { + primaryContent.add(MessageContent.Text(AnnotatedString(record.body))) + } + + if (record.isOpenGroupInvitation) { + val jsonData = UpdateMessageData.fromJSON(json, record.body) + if (jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation) { + primaryContent.add(MessageContent.CommunityInvite(jsonData.kind.groupName, jsonData.kind.groupUrl)) } } - // Deleted messages — check first; body is not meaningful for these - if (record.isDeleted) { - return MessageType.RecipientMessage.Text( - outgoing = isOutgoing, - text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), - ) + if (primaryContent.isNotEmpty()) { + groups.add(MessageContentGroup(primaryContent, showBubble = true)) } - // Audio - //todo convov3 maybe this should be packed inside an audio composable to live listen to changes locally? + // Group 2: Media, Audio, or Documents val audioSlide = mms?.slideDeck?.audioSlide if (audioSlide != null) { - return Audio( - outgoing = isOutgoing, - title = audioSlide.filename, // todo CONVOv3: drive from playback state - speedText = "1x", // todo CONVOv3: drive from playback state - remainingText = "", // todo CONVOv3: drive from playback state - durationMs = 0L, // todo CONVOv3: resolve from audio metadata - positionMs = 0L, // todo CONVOv3: drive from playback state - isPlaying = false, // todo CONVOv3: drive from playback state - showLoader = audioSlide.isInProgress || audioSlide.isPendingDownload, - text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, - ) + // 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 + groups.add( + MessageContentGroup(listOf(MessageContent.Audio( + AudioMessageData( + title = audioSlide.filename, + speedText = "1x", + remainingText = "", + durationMs = 0L, + positionMs = 0L, + isPlaying = false, + showLoader = audioSlide.isInProgress + ))), showBubble = true)) } - // Document val documentSlide = mms?.slideDeck?.documentSlide if (documentSlide != null) { - return Document( - outgoing = isOutgoing, + groups.add(MessageContentGroup(listOf(MessageContent.Document(DocumentMessageData( name = documentSlide.filename, size = Formatter.formatFileSize(context, documentSlide.fileSize), - loading = documentSlide.isInProgress || documentSlide.isPendingDownload, uri = documentSlide.uri?.toString() ?: "", - text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, - ) + loading = documentSlide.isInProgress + ))), showBubble = true)) } - // Images + video — MediaMmsMessageRecord specifically holds downloaded media if (record is MediaMmsMessageRecord) { val mediaSlides = record.slideDeck.slides.filter { it.hasImage() || it.hasVideo() } - - //todo convoV3 map this properly if (mediaSlides.isNotEmpty()) { val items = mediaSlides.map { slide -> val uri = (slide.uri ?: slide.thumbnailUri) ?: "".toUri() @@ -267,26 +285,16 @@ class ConversationDataMapper @Inject constructor( MessageMediaItem.Image(uri, filename, loading, width, height) } } - return MessageType.RecipientMessage.Media( - outgoing = isOutgoing, - items = items, - loading = items.any { it.loading }, - text = record.body.takeIf { it.isNotBlank() }?.let { AnnotatedString(it) }, - ) + groups.add(MessageContentGroup(listOf(MessageContent.Media(items, items.any { it.loading })), showBubble = false)) } } - // Plain text - // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting - return MessageType.RecipientMessage.Text( - outgoing = isOutgoing, - text = AnnotatedString(record.body), - ) + return groups } // ---- Quote ---- // todo CONVOv3: sort out properly - private fun mapQuote(record: MessageRecord): MessageQuote? { + private fun mapQuote(record: MessageRecord): QuoteMessageData? { val quote = (record as? MmsMessageRecord)?.quote ?: return null val icon: MessageQuoteIcon = MessageQuoteIcon.Bar @@ -299,7 +307,7 @@ class ConversationDataMapper @Inject constructor( else -> MessageQuoteIcon.Bar }*/ - return MessageQuote( + return QuoteMessageData( title = quote.author.displayName(), subtitle = quote.text?.ifBlank { null } ?: context.getString(R.string.document), 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 index f19a678023..862849fb35 100644 --- 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 @@ -31,8 +31,10 @@ 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 @@ -186,18 +188,17 @@ fun PreviewConversation( MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text() + layout = MessageLayout.OUTGOING, + contentGroups = textGroup() )), ConversationItem.Message( MessageViewData( id = MessageId(0, false), displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, - type = PreviewMessageData.text( - outgoing = false, - text = "I have lots of reactions - Closed" - ), - reactionsState = ReactionViewState( + layout = MessageLayout.INCOMING, + contentGroups = textGroup("I have lots of reactions - Closed"), + reactions = ReactionViewState( reactions = listOf( ReactionItem("👍", 3, selected = true), ReactionItem("❤️", 12, selected = false), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt index 68d7fe6371..c650f1996d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt @@ -49,7 +49,8 @@ private val playPauseSize = 36.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudioMessage( - data: Audio, + data: AudioMessageData, + outgoing: Boolean, modifier: Modifier = Modifier ) { Column( @@ -59,9 +60,9 @@ fun AudioMessage( ) { - 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 @@ -154,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 } @@ -235,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, @@ -246,45 +245,42 @@ data class Audio( val bufferedPositionMs: Long = 0L, val isPlaying: Boolean, val showLoader: Boolean, -) : MessageType.RecipientMessage +) @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( - id = MessageId(0, false), - displayName = "Toto", - type = PreviewMessageData.audio() - )) + + Message(data = audioMessage()) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "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( - id = MessageId(0, false), - displayName = "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/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 8bc2f95091..b9255e7135 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -86,20 +86,12 @@ fun Message( data: MessageViewData, modifier: Modifier = Modifier ) { - when(data.type){ - is MessageType.RecipientMessage -> { - RecipientMessage( - data = data, - type = data.type, - modifier = modifier - ) + when (data.layout) { + MessageLayout.CONTROL -> { + ControlMessage(data = data, modifier = modifier) } - - is MessageType.ControlMessage -> { - /* ControlMessage( - data = data, - modifier = modifier - )*/ + MessageLayout.INCOMING, MessageLayout.OUTGOING -> { + RecipientMessage(data = data, modifier = modifier) } } } @@ -107,9 +99,10 @@ fun Message( @Composable fun RecipientMessage( data: MessageViewData, - type: MessageType.RecipientMessage, 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 @@ -127,11 +120,10 @@ fun RecipientMessage( RecipientMessageContent( modifier = Modifier - .align(if (type.outgoing) Alignment.CenterEnd else Alignment.CenterStart) + .align(if (outgoing) Alignment.CenterEnd else Alignment.CenterStart) .widthIn(max = maxMessageWidth) .wrapContentWidth(), data = data, - type = type, maxWidth = maxMessageWidth ) } @@ -143,14 +135,14 @@ fun RecipientMessage( @Composable fun RecipientMessageContent( data: MessageViewData, - type: MessageType.RecipientMessage, modifier: Modifier = Modifier, maxWidth: Dp ) { + val outgoing = data.layout == MessageLayout.OUTGOING Column( modifier = modifier, - horizontalAlignment = if (type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start ) { Row { if (data.avatar !is MessageAvatar.None) { @@ -171,7 +163,7 @@ fun RecipientMessageContent( } Column( - horizontalAlignment = if(type.outgoing) Alignment.End else Alignment.Start + horizontalAlignment = if(outgoing) Alignment.End else Alignment.Start ) { if (data.showDisplayName) { @@ -198,102 +190,25 @@ fun RecipientMessageContent( 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 || type.text != null - || type is MessageType.RecipientMessage.CommunityInvite - val hasSecondBubble = data.type !is MessageType.RecipientMessage.Text - && type !is MessageType.RecipientMessage.CommunityInvite - - // First bubble - if (hasFirstBubble) { - MessageBubble( - modifier = Modifier.accentHighlight(data.highlightKey), - color = if (type.outgoing) LocalColors.current.accent - else LocalColors.current.backgroundBubbleReceived - ) { - // community invites - if (data.type is MessageType.RecipientMessage.CommunityInvite) { - //todo convov3 add onclick for community invite - CommunityInviteMessage( - data = data, - type = data.type, - modifier = Modifier.accentHighlight(data.highlightKey), - ) - } else { // regular recipient messages - Column { - // Display quote if there is one - if (data.quote != null) { - MessageQuote( - modifier = Modifier.padding( - bottom = - if (data.link == null && type.text == null) - defaultMessageBubblePadding().calculateBottomPadding() - else 0.dp - ), - outgoing = 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 = type.outgoing - ) - } - - if (type.text != null) { - // Text messages - MessageText( - modifier = Modifier.padding(defaultMessageBubblePadding()), - text = type.text!!, - outgoing = type.outgoing - ) - } + data.contentGroups.forEachIndexed { index, group -> + if (index > 0) Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + 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.RecipientMessage.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 (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) } } } @@ -301,25 +216,25 @@ fun RecipientMessageContent( //////// Below the Avatar + Message bubbles //// - val indentation = if(type.outgoing) 0.dp + 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 = 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 = { @@ -335,13 +250,38 @@ fun RecipientMessageContent( modifier = Modifier .padding(horizontal = LocalDimensions.current.tinySpacing) .padding(start = indentation) - .align(if (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 + when (content) { + is MessageContent.Text -> MessageText( + text = content.text, outgoing = isOutgoing + ) + + is MessageContent.Quote -> MessageQuote( + modifier = Modifier.padding( + bottom = if (content.addBottomBubblePadding) + defaultMessageBubblePadding().calculateBottomPadding() + else 0.dp + ), + quote = content.data, + outgoing = isOutgoing + ) + is MessageContent.Link -> MessageLink(content.data, isOutgoing) + is MessageContent.Document -> DocumentMessage(content.data, isOutgoing) + is MessageContent.Audio -> AudioMessage(content.data, isOutgoing) + is MessageContent.CommunityInvite -> CommunityInviteMessage(content.name, content.url, isOutgoing) + is MessageContent.Media -> MediaMessage(content.items, content.loading, maxWidth) + } +} + /** * Basic message building block: Bubble */ @@ -402,7 +342,7 @@ fun MessageText( modifier: Modifier = Modifier ){ Text( - modifier = modifier, + modifier = modifier.padding(defaultMessageBubblePadding()), text = text, style = LocalType.current.large, color = getTextColor(outgoing), @@ -421,20 +361,40 @@ internal fun defaultMessageBubblePadding() = PaddingValues( data class MessageViewData( val id: MessageId, - val type: MessageType, + 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 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: HighlightMessage? = 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 +) + +sealed interface MessageContent { + data class Text(val text: AnnotatedString) : MessageContent + data class Media(val items: List, val loading: Boolean) : MessageContent + data class Link(val data: MessageLinkData) : MessageContent + data class Quote(val data: QuoteMessageData, val addBottomBubblePadding: Boolean = false) : MessageContent + data class Document(val data: DocumentMessageData) : MessageContent + data class Audio(val data: AudioMessageData) : MessageContent + data class CommunityInvite(val name: String, val url: String) : MessageContent +} + +enum class MessageLayout { + INCOMING, + OUTGOING, + CONTROL +} + data class HighlightMessage(val token: Long) enum class ClusterPosition { @@ -453,9 +413,6 @@ sealed interface MessageAvatar { data class ReactionViewState( val reactions: List, val isExtended: Boolean, - val onReactionClick: (String) -> Unit, //todo convov3 lift lambdas out - val onReactionLongClick: (String) -> Unit, - val onShowMoreClick: () -> Unit ) data class ReactionItem( @@ -464,7 +421,7 @@ data class ReactionItem( val selected: Boolean ) -data class MessageQuote( +data class QuoteMessageData( val title: String, val subtitle: String, val icon: MessageQuoteIcon @@ -489,37 +446,6 @@ sealed interface MessageViewStatusIcon{ data object DisappearingMessageIcon: MessageViewStatusIcon } -sealed interface MessageType{ - - sealed interface RecipientMessage: MessageType { - val outgoing: Boolean - val text: AnnotatedString? - - data class Text( - override val outgoing: Boolean, - override val text: AnnotatedString - ) : RecipientMessage - - data class Media( - override val outgoing: Boolean, - val items: List, - val loading: Boolean, - override val text: AnnotatedString? = null - ) : RecipientMessage - - data class CommunityInvite( - override val outgoing: Boolean, - override val text: AnnotatedString = AnnotatedString(""), - val communityName: String, - val url: String - ) : RecipientMessage - } - - sealed interface ControlMessage: MessageType { - - } -} - /*@PreviewScreenSizes*/ @Preview @Composable @@ -538,7 +464,8 @@ fun MessagePreview( displayName = "Toto", showProBadge = true, displayNameExtra = "(some extra text)", - type = PreviewMessageData.text() + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.textGroup(), ) ) } @@ -550,10 +477,10 @@ fun MessagePreview( 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?" - ) + ), ) ) } @@ -585,7 +512,8 @@ fun MessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text( + 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 @@ -597,10 +525,8 @@ fun MessagePreview( 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 )) } @@ -620,19 +546,17 @@ fun MessageReactionsPreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text( + 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 = {} ) )) @@ -642,11 +566,11 @@ fun MessageReactionsPreview( 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), @@ -658,10 +582,7 @@ fun MessageReactionsPreview( ReactionItem("🐙", 8, selected = false), ReactionItem("✅", 8, selected = false), ), - isExtended = false, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} + isExtended = false ) )) @@ -671,11 +592,11 @@ fun MessageReactionsPreview( 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), @@ -688,9 +609,6 @@ fun MessageReactionsPreview( ReactionItem("✅", 8, selected = false), ), isExtended = true, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} ) )) } @@ -748,96 +666,81 @@ object PreviewMessageData { icon = MessageViewStatusIcon.DrawableIcon(icon = R.drawable.ic_circle_check) ) - fun communityInvite( - outgoing: Boolean = true - ) = MessageType.RecipientMessage.CommunityInvite( - outgoing = outgoing, - text = AnnotatedString(""), - communityName = "Test Community", - url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + fun textGroup(text: String = "Hi there") = listOf( + MessageContentGroup(listOf(text(text)), showBubble = true) ) - fun text( - text: String = "Hi there", - outgoing: Boolean = true - ) = MessageType.RecipientMessage.Text(outgoing = outgoing, AnnotatedString(text)) - - 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(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.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(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.Text(AnnotatedString(text))), showBubble = true)) + add(mediaGroup(items)) + } - fun quote( + fun mediaGroup( + items: List, + ) = MessageContentGroup(listOf(MessageContent.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 + ): List { + val group = mutableListOf() + group.add( + quote(title = title, subtitle = subtitle, icon = icon) + ) - fun quoteImage( - uri: Uri = "".toUri(), - filename: String = "" - ) = MessageQuoteIcon.Image( - uri = uri, - filename = filename - ) + if(text != null) group.add(MessageContent.Text(AnnotatedString(text))) + + return listOf(MessageContentGroup(group, showBubble = true)) + } + + // Individual item helpers + fun text( + text: String = "Hi there", + ) = MessageContent.Text(AnnotatedString(text)) + fun text( + text: AnnotatedString, + ) = MessageContent.Text(text) + + fun document( + name: String = "Document.pdf", + loading: Boolean = false + ) = MessageContent.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) = + MessageContent.Quote(QuoteMessageData(title, subtitle, icon)) + + fun composeContent(vararg content: MessageContent): MessageContentGroup { + return MessageContentGroup(content.toList()) + } } 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 index 24a348676c..b8e168a09c 100644 --- 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 @@ -2,11 +2,9 @@ package org.thoughtcrime.securesms.conversation.v3.compose.message import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.content.MediaType.Companion.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -26,7 +24,7 @@ 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 org.thoughtcrime.securesms.database.model.MessageId +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 @@ -34,13 +32,12 @@ 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 -import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.theme.bold @Composable -fun ColumnScope.CommunityInviteMessage( - data: MessageViewData, - type: MessageType.RecipientMessage.CommunityInvite, +fun CommunityInviteMessage( + name: String, + url: String, + outgoing: Boolean, modifier: Modifier = Modifier ) { Row( @@ -53,14 +50,14 @@ fun ColumnScope.CommunityInviteMessage( modifier = Modifier .size(LocalDimensions.current.iconLarge) .background( - color = if(type.outgoing) blackAlpha06 else LocalColors.current.accent, + color = if(outgoing) blackAlpha06 else LocalColors.current.accent, shape = CircleShape ), contentAlignment = Alignment.Center ) { Image( painter = painterResource( - id = if(type.outgoing) R.drawable.ic_globe else R.drawable.ic_plus + id = if(outgoing) R.drawable.ic_globe else R.drawable.ic_plus ), contentDescription = null, modifier = Modifier.size(LocalDimensions.current.iconSmall), @@ -74,9 +71,9 @@ fun ColumnScope.CommunityInviteMessage( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) ) { Text( - text = type.communityName, + text = name, style = LocalType.current.h6, - color = getTextColor(type.outgoing), + color = getTextColor(outgoing), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -84,15 +81,15 @@ fun ColumnScope.CommunityInviteMessage( Text( text = stringResource(R.string.communityInvitation), style = LocalType.current.base, - color = getTextColor(type.outgoing), + color = getTextColor(outgoing), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( - text = type.url, + text = url, style = LocalType.current.small, - color = getTextColor(type.outgoing), + color = getTextColor(outgoing), maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -105,20 +102,6 @@ fun ColumnScope.CommunityInviteMessage( fun CommunityInvitePreview( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { - val outgoingInvite = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = PreviewMessageData.communityInvite() - ) - - val incomingInvite = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = PreviewMessageData.communityInvite( - outgoing = false - ) - ) - PreviewTheme(colors) { Column ( @@ -131,8 +114,9 @@ fun CommunityInvitePreview( ) { Column() { CommunityInviteMessage( - data = outgoingInvite, - type = outgoingInvite.type as MessageType.RecipientMessage.CommunityInvite + name = "Test Community", + url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + outgoing = true ) } } @@ -144,8 +128,9 @@ fun CommunityInvitePreview( ) { Column() { CommunityInviteMessage( - data = incomingInvite, - type = incomingInvite.type as MessageType.RecipientMessage.CommunityInvite + name = "Test Community", + url = "https://www.test-community-url.com/testing-the-url-look-and-feel", + outgoing = false ) } } 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..5b1bfb547d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt @@ -0,0 +1,33 @@ +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 + ) { + data.contentGroups.forEach { group -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + group.contents.forEach { content -> + // Cast to specific content types or render text + if (content is MessageContent.Text) { + Text(text = content.text, style = LocalType.current.small) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt index b79ab5a799..be38fbdb9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/DocumentMessage.kt @@ -21,11 +21,11 @@ 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 @@ -38,7 +38,8 @@ import org.thoughtcrime.securesms.ui.theme.blackAlpha06 @Composable fun DocumentMessage( - data: Document, + data: DocumentMessageData, + outgoing: Boolean, modifier: Modifier = Modifier ) { Row( @@ -55,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) @@ -81,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.RecipientMessage - +) @Preview @Composable fun DocumentMessagePreview( @@ -115,7 +113,8 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.document() + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.documentGroup() )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -124,8 +123,8 @@ fun DocumentMessagePreview( 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" ) )) @@ -135,33 +134,33 @@ fun DocumentMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.document( - loading = true - )) - ) + layout = MessageLayout.OUTGOING, + contentGroups = PreviewMessageData.documentGroup(loading = true) + )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = PreviewMessageData.document( - loading = true - )) - ) + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + composeContent(PreviewMessageData.document(loading = true)), + ) + )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = PreviewMessageData.document( - outgoing = false, - loading = true - )) - ) + 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/message/MessageEmojiReactions.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt index 67604c8d28..aecadc8311 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageEmojiReactions.kt @@ -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/message/MessageLink.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt index 3202f04139..e16efbc6b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt @@ -27,6 +27,7 @@ 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 @@ -106,49 +107,73 @@ fun LinkMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text(outgoing = false, text="Quoting text"), - link = MessageLinkData( - url = "https://getsession.org/", - title = "Welcome to Session", - imageUri = null - ) + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent( + MessageContent.Link( + MessageLinkData( + url = "https://getsession.org/", + title = "Welcome to Session", + imageUri = null + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) Message(data = MessageViewData( id = MessageId(0, false), displayName = "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" - ) + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent( + MessageContent.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") + )) )) Message(data = MessageViewData( id = MessageId(0, false), displayName = "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 - ) + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent( + PreviewMessageData.quote(), + MessageContent.Link( + MessageLinkData( + url = "https://getsession.org/", + title = "Welcome to Session", + imageUri = null + ) + ), + PreviewMessageData.text(text = "Quoting text") + )) )) Message(data = MessageViewData( id = MessageId(0, false), displayName = "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" - ) + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent( + PreviewMessageData.quote(), + MessageContent.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/message/MessageMedia.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt index 419aa5abe0..c73c4efded 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageMedia.kt @@ -34,6 +34,11 @@ 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 @@ -43,7 +48,8 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun MediaMessage( - data: MessageType.RecipientMessage.Media, + items: List, + loading: Boolean, maxWidth: Dp, modifier: Modifier = Modifier, ){ @@ -52,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, @@ -71,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), ) } @@ -90,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), ) @@ -98,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), ) } @@ -167,14 +173,14 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = true, - items = listOf(PreviewMessageData.image( + 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)) @@ -182,13 +188,11 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = true, - items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), - loading = false + layout = MessageLayout.OUTGOING, + contentGroups = mediaGroup( + listOf(image(), video()), null) ) - )) - + ) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -197,11 +201,10 @@ fun MediaMessagePreview( MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Media( - text = AnnotatedString("This also has text"), - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + layout = MessageLayout.OUTGOING, + contentGroups = mediaGroup( + items = listOf(video(), image(), image()), + text = "This also has text" ) ) ) @@ -222,96 +225,10 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = false, - items = listOf(PreviewMessageData.image(true)), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = true, - items = listOf(PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = false, - items = listOf(PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = false, - items = listOf(PreviewMessageData.image(), PreviewMessageData.video()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(true), PreviewMessageData.image()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.Media( - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false - ) - )) - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - Message(data = MessageViewData( - id = MessageId(0, false), - displayName = "Toto", - type = MessageType.RecipientMessage.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( - id = MessageId(0, false), - displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.RecipientMessage.Media( - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + mediaGroup(listOf(video(), image(), image())) ) )) @@ -320,11 +237,10 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.RecipientMessage.Media( - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + layout = MessageLayout.INCOMING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote()), + mediaGroup(listOf(video(), image(), image())) ) )) @@ -334,12 +250,10 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.RecipientMessage.Media( - text = AnnotatedString("This also has text"), - outgoing = true, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + layout = MessageLayout.OUTGOING, + contentGroups = listOf( + composeContent(PreviewMessageData.quote(), text("This also has text")), + mediaGroup(listOf(video(), image(), image())) ) )) @@ -348,12 +262,10 @@ fun MediaMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar), - type = MessageType.RecipientMessage.Media( - text = AnnotatedString("This also has text"), - outgoing = false, - items = listOf(PreviewMessageData.video(), PreviewMessageData.image(), PreviewMessageData.image()), - loading = false + 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/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 4fdc7163d9..6c8cba4280 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -28,9 +28,16 @@ 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.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.quoteGroup +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 @@ -44,7 +51,7 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun MessageQuote( outgoing: Boolean, - quote: MessageQuote, + quote: QuoteMessageData, modifier: Modifier = Modifier ){ Row( @@ -131,8 +138,8 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text(outgoing = false, text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) + layout = MessageLayout.INCOMING, + contentGroups = quoteGroup(text = "Quoting text") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -140,8 +147,8 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = PreviewMessageData.text(text="Quoting text"), - quote = PreviewMessageData.quote(icon = MessageQuoteIcon.Bar) + layout = MessageLayout.OUTGOING, + contentGroups = quoteGroup(text = "Quoting text") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -150,8 +157,8 @@ fun QuoteMessagePreview( 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), text = "Quoting a document") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -159,11 +166,12 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Text(outgoing = true, AnnotatedString("Quoting audio")), - quote = PreviewMessageData.quote( + 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" ) )) @@ -172,10 +180,9 @@ fun QuoteMessagePreview( Message(data = MessageViewData( id = MessageId(0, false), displayName = "Toto", - type = MessageType.RecipientMessage.Text(outgoing = true, AnnotatedString("Quoting an image")), - quote = PreviewMessageData.quote(icon = PreviewMessageData.quoteImage()) + 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/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index 8b67202fc6..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 @@ -37,8 +37,9 @@ 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.Message -import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageType +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 @@ -74,34 +75,44 @@ internal fun LandingScreen( val messages = remember(context) { listOf( MessageViewData( - type = MessageType.RecipientMessage.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), + 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.RecipientMessage.Text(text = AnnotatedString( - Phrase.from(context.getString(R.string.onboardingBubbleSessionIsEngineered)) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format().toString()), outgoing = true), + 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.RecipientMessage.Text(text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber)), outgoing = false), + layout = MessageLayout.INCOMING, + contentGroups = textGroup( + text = AnnotatedString(context.getString(R.string.onboardingBubbleNoPhoneNumber) + )), displayName = "Test", id = MessageId(0, false) ), MessageViewData( - type = MessageType.RecipientMessage.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), + 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) ), From b018f5bb4f04391f124b0e2cdd59a693ac8b2654 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 13:54:05 +1100 Subject: [PATCH 11/23] Logic and data structure update --- .../conversation/v3/ConversationDataMapper.kt | 98 ++++++++++---- .../conversation/ConversationScreen.kt | 3 - .../v3/compose/message/BaseMessage.kt | 126 ++++++++++++------ .../v3/compose/message/ControlMessage.kt | 2 +- .../v3/compose/message/MessageLink.kt | 8 +- 5 files changed, 162 insertions(+), 75 deletions(-) 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 index cf2666fecc..e3564edcab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.conversation.v3 import android.content.Context import android.text.format.Formatter +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.json.Json @@ -18,7 +20,9 @@ import org.thoughtcrime.securesms.conversation.v3.compose.message.DocumentMessag 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 @@ -34,11 +38,13 @@ 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.ui.theme.LocalDimensions 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 @@ -208,36 +214,60 @@ class ConversationDataMapper @Inject constructor( // Deleted messages — check first; body is not meaningful for these if (record.isDeleted) { - return listOf(MessageContentGroup(listOf( - MessageContent.Text( + addContentToGroup( + groups, + MessageContentData.Text( text = AnnotatedString(context.getString(R.string.deleteMessageDeletedGlobally)), ) - ))) + ) + + return groups } // Group 1: Quotes, Links, and Text - val primaryContent = mutableListOf() - - // quotes - mapQuote(record)?.let { primaryContent.add(MessageContent.Quote(it)) } + // We map the message content data first + val primaryData = mutableListOf() - // links - mapLinkPreview(record)?.let { primaryContent.add(MessageContent.Link(it)) } + mapQuote(record)?.let { primaryData += MessageContentData.Quote(it) } + mapLinkPreview(record)?.let { primaryData += MessageContentData.Link(it) } // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting if (record.body.isNotBlank()) { - primaryContent.add(MessageContent.Text(AnnotatedString(record.body))) + primaryData += MessageContentData.Text(AnnotatedString(record.body)) } if (record.isOpenGroupInvitation) { val jsonData = UpdateMessageData.fromJSON(json, record.body) if (jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation) { - primaryContent.add(MessageContent.CommunityInvite(jsonData.kind.groupName, jsonData.kind.groupUrl)) + primaryData += MessageContentData.CommunityInvite( + jsonData.kind.groupName, + jsonData.kind.groupUrl + ) } } - if (primaryContent.isNotEmpty()) { - groups.add(MessageContentGroup(primaryContent, showBubble = true)) + // 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 @@ -246,8 +276,9 @@ class ConversationDataMapper @Inject constructor( // 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 - groups.add( - MessageContentGroup(listOf(MessageContent.Audio( + addContentToGroup( + groups, + MessageContentData.Audio( AudioMessageData( title = audioSlide.filename, speedText = "1x", @@ -256,17 +287,21 @@ class ConversationDataMapper @Inject constructor( positionMs = 0L, isPlaying = false, showLoader = audioSlide.isInProgress - ))), showBubble = true)) + )) + ) } val documentSlide = mms?.slideDeck?.documentSlide if (documentSlide != null) { - groups.add(MessageContentGroup(listOf(MessageContent.Document(DocumentMessageData( - name = documentSlide.filename, - size = Formatter.formatFileSize(context, documentSlide.fileSize), - uri = documentSlide.uri?.toString() ?: "", - loading = documentSlide.isInProgress - ))), showBubble = true)) + 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) { @@ -285,13 +320,27 @@ class ConversationDataMapper @Inject constructor( MessageMediaItem.Image(uri, filename, loading, width, height) } } - groups.add(MessageContentGroup(listOf(MessageContent.Media(items, items.any { it.loading })), showBubble = false)) + 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? { @@ -360,9 +409,6 @@ class ConversationDataMapper @Inject constructor( return ReactionViewState( reactions = items, isExtended = false, // todo CONVOv3: drive from per-message expanded state in ViewModel - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {}, ) } 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 index 862849fb35..e6ecdb596b 100644 --- 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 @@ -211,9 +211,6 @@ fun PreviewConversation( ReactionItem("✅", 8, selected = false), ), isExtended = false, - onReactionClick = {}, - onReactionLongClick = {}, - onShowMoreClick = {} ) )) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index b9255e7135..e758cdeee4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -260,25 +260,56 @@ fun RecipientMessageContent( @Composable fun MessageContentRenderer(content: MessageContent, layout: MessageLayout, maxWidth: Dp) { val isOutgoing = layout == MessageLayout.OUTGOING - when (content) { - is MessageContent.Text -> MessageText( - text = content.text, outgoing = isOutgoing + 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, outgoing = isOutgoing + ) - is MessageContent.Quote -> MessageQuote( - modifier = Modifier.padding( - bottom = if (content.addBottomBubblePadding) - defaultMessageBubblePadding().calculateBottomPadding() - else 0.dp - ), - quote = content.data, - outgoing = isOutgoing - ) - is MessageContent.Link -> MessageLink(content.data, isOutgoing) - is MessageContent.Document -> DocumentMessage(content.data, isOutgoing) - is MessageContent.Audio -> AudioMessage(content.data, isOutgoing) - is MessageContent.CommunityInvite -> CommunityInviteMessage(content.name, content.url, isOutgoing) - is MessageContent.Media -> MediaMessage(content.items, content.loading, maxWidth) + 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 + ) + } } } @@ -379,14 +410,24 @@ data class MessageContentGroup( val showBubble: Boolean = true //whether the grouped content should be placed in a bubble ) -sealed interface MessageContent { - data class Text(val text: AnnotatedString) : MessageContent - data class Media(val items: List, val loading: Boolean) : MessageContent - data class Link(val data: MessageLinkData) : MessageContent - data class Quote(val data: QuoteMessageData, val addBottomBubblePadding: Boolean = false) : MessageContent - data class Document(val data: DocumentMessageData) : MessageContent - data class Audio(val data: AudioMessageData) : MessageContent - data class CommunityInvite(val name: String, val url: String) : MessageContent +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 { @@ -667,41 +708,42 @@ object PreviewMessageData { ) fun textGroup(text: String = "Hi there") = listOf( - MessageContentGroup(listOf(text(text)), showBubble = true) + MessageContentGroup(listOf(MessageContent(text(text))), showBubble = true) ) fun textGroup(text: AnnotatedString) = listOf( - MessageContentGroup(listOf(text(text)), showBubble = true) + MessageContentGroup(listOf(MessageContent(text(text))), showBubble = true) ) fun audioGroup( title: String = "Voice Message", playing: Boolean = true ) = listOf( - MessageContentGroup(listOf(MessageContent.Audio(AudioMessageData( + 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) + )))), showBubble = true) ) fun documentGroup( name: String = "Document.pdf", loading: Boolean = false ) = listOf( - MessageContentGroup(listOf(document(name, loading)), showBubble = true) + MessageContentGroup(listOf(MessageContent(document(name, loading))), showBubble = true) ) fun mediaGroup( items: List, text: String? = null ) = buildList { - if(text != null) add(MessageContentGroup(listOf(MessageContent.Text(AnnotatedString(text))), showBubble = true)) + if(text != null) add(MessageContentGroup( + listOf(MessageContent(MessageContentData.Text(AnnotatedString(text)))), showBubble = true)) add(mediaGroup(items)) } fun mediaGroup( items: List, - ) = MessageContentGroup(listOf(MessageContent.Media(items, false)), showBubble = false) + ) = MessageContentGroup(listOf(MessageContent(MessageContentData.Media(items, false))), showBubble = false) fun quoteGroup( icon: MessageQuoteIcon = MessageQuoteIcon.Bar, @@ -709,37 +751,39 @@ object PreviewMessageData { subtitle: String = "This is a quote", text: String? = null ): List { - val group = mutableListOf() + val group = mutableListOf() group.add( quote(title = title, subtitle = subtitle, icon = icon) ) - if(text != null) group.add(MessageContent.Text(AnnotatedString(text))) + if(text != null) group.add(MessageContentData.Text(AnnotatedString(text))) - return listOf(MessageContentGroup(group, showBubble = true)) + return listOf(MessageContentGroup(group.map { MessageContent(it) }, showBubble = true)) } // Individual item helpers fun text( text: String = "Hi there", - ) = MessageContent.Text(AnnotatedString(text)) + ) = MessageContentData.Text(AnnotatedString(text)) fun text( text: AnnotatedString, - ) = MessageContent.Text(text) + ) = MessageContentData.Text(text) fun document( name: String = "Document.pdf", loading: Boolean = false - ) = MessageContent.Document(DocumentMessageData( + ) = 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) = - MessageContent.Quote(QuoteMessageData(title, subtitle, icon)) + MessageContentData.Quote(QuoteMessageData(title, subtitle, icon)) - fun composeContent(vararg content: MessageContent): MessageContentGroup { - return MessageContentGroup(content.toList()) + 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/ControlMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/ControlMessage.kt index 5b1bfb547d..288a0f1d06 100644 --- 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 @@ -23,7 +23,7 @@ fun ControlMessage( Column(horizontalAlignment = Alignment.CenterHorizontally) { group.contents.forEach { content -> // Cast to specific content types or render text - if (content is MessageContent.Text) { + if (content is MessageContentData.Text) { Text(text = content.text, style = LocalType.current.small) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt index e16efbc6b9..cd4d39c8c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt @@ -110,7 +110,7 @@ fun LinkMessagePreview( layout = MessageLayout.INCOMING, contentGroups = listOf( composeContent( - MessageContent.Link( + MessageContentData.Link( MessageLinkData( url = "https://getsession.org/", title = "Welcome to Session", @@ -128,7 +128,7 @@ fun LinkMessagePreview( layout = MessageLayout.OUTGOING, contentGroups = listOf( composeContent( - MessageContent.Link( + MessageContentData.Link( MessageLinkData( url = "https://picsum.photos/id/0/367/267", title = "Welcome to Session with a very long name", @@ -146,7 +146,7 @@ fun LinkMessagePreview( contentGroups = listOf( composeContent( PreviewMessageData.quote(), - MessageContent.Link( + MessageContentData.Link( MessageLinkData( url = "https://getsession.org/", title = "Welcome to Session", @@ -165,7 +165,7 @@ fun LinkMessagePreview( contentGroups = listOf( composeContent( PreviewMessageData.quote(), - MessageContent.Link( + MessageContentData.Link( MessageLinkData( url = "https://picsum.photos/id/0/367/267", title = "Welcome to Session with a very long name", From 4cc6a77c8ab43f71bff6d19e4f0297fc323054a7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 14:05:41 +1100 Subject: [PATCH 12/23] Fixes --- .../conversation/v3/ConversationDataMapper.kt | 26 ++++++++++++------- .../v3/compose/message/AudioMessage.kt | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) 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 index e3564edcab..7b886bdc0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -212,6 +212,7 @@ class ConversationDataMapper @Inject constructor( 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( @@ -224,6 +225,21 @@ class ConversationDataMapper @Inject constructor( 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() @@ -236,16 +252,6 @@ class ConversationDataMapper @Inject constructor( primaryData += MessageContentData.Text(AnnotatedString(record.body)) } - if (record.isOpenGroupInvitation) { - val jsonData = UpdateMessageData.fromJSON(json, record.body) - if (jsonData?.kind is UpdateMessageData.Kind.OpenGroupInvitation) { - primaryData += MessageContentData.CommunityInvite( - jsonData.kind.groupName, - jsonData.kind.groupUrl - ) - } - } - // 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt index c650f1996d..2f969586ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/AudioMessage.kt @@ -56,7 +56,7 @@ fun AudioMessage( Column( modifier = modifier .fillMaxWidth() - .padding(vertical = LocalDimensions.current.xxsSpacing), + .padding(vertical = LocalDimensions.current.messageVerticalPadding), ) { From e8c73a6a3f657114dbb43c86246ca4ad8abbeec8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 14:45:28 +1100 Subject: [PATCH 13/23] More UI additions --- .../conversation/v3/ConversationDataMapper.kt | 1 + .../v3/compose/message/BaseMessage.kt | 21 +++++++++------- .../v3/compose/message/MessageQuote.kt | 24 ++++++++++++------- .../securesms/ui/theme/Dimensions.kt | 1 + 4 files changed, 31 insertions(+), 16 deletions(-) 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 index 7b886bdc0b..4fba188121 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -367,6 +367,7 @@ class ConversationDataMapper @Inject constructor( subtitle = quote.text?.ifBlank { null } ?: context.getString(R.string.document), icon = icon, + showProBadge = quote.author.shouldShowProBadge ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index e758cdeee4..996ee44b53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -42,6 +42,7 @@ 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 @@ -58,6 +59,7 @@ import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement +import kotlin.math.min //todo CONVOv3 status animated icon for disappearing messages //todo CONVOv3 text formatting in bubble including mentions and links @@ -112,11 +114,12 @@ fun RecipientMessage( modifier = modifier.fillMaxWidth() .padding(bottom = bottomPadding) ) { - val maxMessageWidth = max( + 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 - //todo ConvoV3 we probably should cap the max so that large screens/tablets don't extend too far - ) + )) RecipientMessageContent( modifier = Modifier @@ -465,7 +468,8 @@ data class ReactionItem( data class QuoteMessageData( val title: String, val subtitle: String, - val icon: MessageQuoteIcon + val icon: MessageQuoteIcon, + val showProBadge: Boolean ) sealed class MessageQuoteIcon { @@ -749,11 +753,12 @@ object PreviewMessageData { icon: MessageQuoteIcon = MessageQuoteIcon.Bar, title: String = "Toto", subtitle: String = "This is a quote", - text: String? = null + text: String? = null, + showProBadge: Boolean = false ): List { val group = mutableListOf() group.add( - quote(title = title, subtitle = subtitle, icon = icon) + quote(title = title, subtitle = subtitle, icon = icon, showProBadge = showProBadge) ) if(text != null) group.add(MessageContentData.Text(AnnotatedString(text))) @@ -777,8 +782,8 @@ object PreviewMessageData { )) 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) = - MessageContentData.Quote(QuoteMessageData(title, subtitle, icon)) + fun quote(title: String = "Toto", subtitle: String = "This is a quote", icon: MessageQuoteIcon = MessageQuoteIcon.Bar, showProBadge: Boolean = false) = + MessageContentData.Quote(QuoteMessageData(title, subtitle, icon, showProBadge)) fun composeContent(vararg content: MessageContentData): MessageContentGroup { return MessageContentGroup( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 6c8cba4280..a930e846f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v3.compose.message +import android.R.attr.textColor import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -39,6 +40,9 @@ import org.thoughtcrime.securesms.conversation.v3.compose.message.PreviewMessage 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.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 @@ -108,18 +112,20 @@ fun MessageQuote( } Column{ - Text( + ProBadgeText( text = quote.title, - style = LocalType.current.base.bold(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = getTextColor(outgoing) + textStyle = LocalType.current.small.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( text = quote.subtitle, style = LocalType.current.base, - color = getTextColor(outgoing) + color = getTextColor(outgoing), + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } } @@ -148,7 +154,9 @@ fun QuoteMessagePreview( id = MessageId(0, false), displayName = "Toto", layout = MessageLayout.OUTGOING, - contentGroups = quoteGroup(text = "Quoting text") + 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)) @@ -158,7 +166,7 @@ fun QuoteMessagePreview( displayName = "Toto", avatar = PreviewMessageData.sampleAvatar, layout = MessageLayout.INCOMING, - contentGroups = quoteGroup(icon = MessageQuoteIcon.Icon(R.drawable.ic_file), text = "Quoting a document") + contentGroups = quoteGroup(icon = MessageQuoteIcon.Icon(R.drawable.ic_file), showProBadge = true, text = "Quoting a document") )) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) 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, From d871fc66943d706f49c2dc7e48b46c8ef9e55c86 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 17:55:33 +1100 Subject: [PATCH 14/23] Rich text --- .../v2/utilities/MentionUtilities.kt | 230 +++++++++++------- .../v2/utilities/TextUtilities.kt | 29 --- .../conversation/v3/ConversationDataMapper.kt | 40 ++- .../conversation/v3/RichTextFormatter.kt | 116 +++++++++ .../v3/compose/message/BaseMessage.kt | 27 +- .../v3/compose/message/MessageQuote.kt | 5 +- .../v3/compose/message/RichText.kt | 149 ++++++++++++ 7 files changed, 451 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt 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/ConversationDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt index 4fba188121..f8abbfefd4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -14,6 +14,7 @@ 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 @@ -33,6 +34,7 @@ import org.thoughtcrime.securesms.conversation.v3.compose.message.MessageViewSta 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 @@ -54,6 +56,7 @@ class ConversationDataMapper @Inject constructor( 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 @@ -247,9 +250,21 @@ class ConversationDataMapper @Inject constructor( mapQuote(record)?.let { primaryData += MessageContentData.Quote(it) } mapLinkPreview(record)?.let { primaryData += MessageContentData.Link(it) } - // todo CONVOv3: replace with spans for mentions, links, and markdown-style formatting if (record.body.isNotBlank()) { - primaryData += MessageContentData.Text(AnnotatedString(record.body)) + + val parsed = MentionUtilities.parseAndSubstituteMentions( + recipientRepository = recipientRepository, + input = record.body, + context = context + ) + val annotatedBody = RichTextFormatter.formatMessage( + parsed = parsed, + isOutgoing = record.isOutgoing, + ) { url -> + + } + + primaryData += MessageContentData.Text(annotatedBody) } // now we can map the message content data to message content, which is a wrapper @@ -352,6 +367,24 @@ class ConversationDataMapper @Inject constructor( 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 + ) + + RichTextFormatter.formatMessage( + parsed = parsed, + isOutgoing = record.isOutgoing, + onUrlClick = {} + ) + } else { + AnnotatedString(context.getString(R.string.document)) + } + val icon: MessageQuoteIcon = MessageQuoteIcon.Bar /*when { quote.attachment.thumbnail != null -> MessageQuoteIcon.Image( @@ -364,8 +397,7 @@ class ConversationDataMapper @Inject constructor( return QuoteMessageData( title = quote.author.displayName(), - subtitle = quote.text?.ifBlank { null } - ?: context.getString(R.string.document), + subtitle = subtitle, icon = icon, showProBadge = quote.author.shouldShowProBadge ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt new file mode 100644 index 0000000000..93a218ee0a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt @@ -0,0 +1,116 @@ +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.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities + +object RichTextFormatter { + + private val URL_REGEX = Regex("""(?i)\bhttps?://[^\s<>()]+\b""") + + /** + * Build an AnnotatedString for message text with: + * - URLs made clickable using LinkAnnotation (no ClickableText) + * - URL underline via TextLinkStyles + * - Mentions bold + mention metadata + mention_bg tag where needed + * + * Notes: + * - We build sequentially because LinkAnnotation can only be applied while appending (withLink). + * - We do NOT apply colors here (leave to Compose theme). + * + * @param onUrlClick called when a URL is clicked. You decide how to open it. + */ + fun formatMessage( + parsed: MentionUtilities.ParsedMentions, + isOutgoing: Boolean, + onUrlClick: (String) -> Unit + ): AnnotatedString { + val text = parsed.text + + val base = buildAnnotatedString { + var cursor = 0 + + URL_REGEX.findAll(text).forEach { match -> + var start = match.range.first + var endExclusive = match.range.last + 1 + var urlText = match.value + + // Trim common trailing punctuation from URL matches so "https://x.y)." doesn't include ")." + val (trimmedUrl, trailing) = trimTrailingUrlPunctuation(urlText) + if (trimmedUrl != urlText) { + endExclusive = start + trimmedUrl.length + urlText = trimmedUrl + } + + // Append non-url chunk + if (cursor < start) append(text.substring(cursor, start)) + + // Append URL chunk with link + val link = LinkAnnotation.Clickable( + tag = "url", + linkInteractionListener = { onUrlClick(urlText) }, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline) + ) + ) + withLink(link) { append(urlText) } + + // Append trailing punctuation that we trimmed off + if (trailing.isNotEmpty()) append(trailing) + + cursor = match.range.last + 1 // move past the ORIGINAL match + } + + // Append remaining text + if (cursor < text.length) append(text.substring(cursor)) + } + + // Now add mention styles/annotations by range. + // Mention ranges are based on parsed.text; since we rebuilt the same text content in order, + // indices still line up. + val b = AnnotatedString.Builder(base) + + parsed.mentions.forEach { m -> + val start = m.start + val end = m.endExclusive + + // Bold mentions (parity) + b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + + // Metadata tags (optional but handy) + b.addStringAnnotation("mention_pk", m.publicKey, start, end) + b.addStringAnnotation("mention_self", m.isSelf.toString(), start, end) + + // Tag when mention needs rounded background: + // parity with XML: incoming mentioning you + val needsBg = m.isSelf && !isOutgoing + if (needsBg) { + b.addStringAnnotation("mention_bg", "true", start, end) + } + } + + return b.toAnnotatedString() + } + + private fun trimTrailingUrlPunctuation(url: String): Pair { + if (url.isEmpty()) return url to "" + + // Common trailing punctuation we don't want inside URL + val trailingChars = ".,;:!?)]}\"'" + var end = url.length + while (end > 0 && trailingChars.indexOf(url[end - 1]) >= 0) end-- + + // If we trimmed everything, keep original + if (end == 0) return url to "" + + val trimmed = url.substring(0, end) + val trailing = url.substring(end) + return trimmed to trailing + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 996ee44b53..7bcc2dd8e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -37,6 +37,9 @@ 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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp @@ -272,8 +275,10 @@ fun MessageContentRenderer(content: MessageContent, layout: MessageLayout, maxWi ) ) { when (content.contentData) { - is MessageContentData.Text -> MessageText( - text = content.contentData.text, outgoing = isOutgoing + is MessageContentData.Text -> RichText( + text = content.contentData.text, + isOutgoing = isOutgoing, + modifier = Modifier.padding(defaultMessageBubblePadding()) ) is MessageContentData.Quote -> MessageQuote( @@ -369,20 +374,6 @@ fun MessageStatus( } } -@Composable -fun MessageText( - text: AnnotatedString, - outgoing: Boolean, - modifier: Modifier = Modifier -){ - Text( - modifier = modifier.padding(defaultMessageBubblePadding()), - text = text, - style = LocalType.current.large, - color = getTextColor(outgoing), - ) -} - @Composable internal fun getTextColor(outgoing: Boolean) = if(outgoing) LocalColors.current.textBubbleSent else LocalColors.current.textBubbleReceived @@ -467,7 +458,7 @@ data class ReactionItem( data class QuoteMessageData( val title: String, - val subtitle: String, + val subtitle: AnnotatedString, val icon: MessageQuoteIcon, val showProBadge: Boolean ) @@ -783,7 +774,7 @@ object PreviewMessageData { 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, subtitle, icon, showProBadge)) + MessageContentData.Quote(QuoteMessageData(title, AnnotatedString(subtitle), icon, showProBadge)) fun composeContent(vararg content: MessageContentData): MessageContentGroup { return MessageContentGroup( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index a930e846f9..abd57c293f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -120,10 +120,9 @@ fun MessageQuote( else proBadgeColorStandard() ) - Text( + RichText( text = quote.subtitle, - style = LocalType.current.base, - color = getTextColor(outgoing), + isOutgoing = outgoing, maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt new file mode 100644 index 0000000000..b703c447e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.conversation.v3.compose.message + +import androidx.compose.foundation.layout.PaddingValues +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.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.text.style.TextOverflow.Companion +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType + +/** + * Renders message text with: + * - clickable URLs (via LinkAnnotation inside AnnotatedString) + * - underlined URLs (done in formatter via TextLinkStyles) + * - mention bold (done in formatter) + * - mention foreground color parity (applied here) + * - mention rounded background parity (drawn here) + */ +@Composable +fun RichText( + text: AnnotatedString, + isOutgoing: Boolean, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + val colors = LocalColors.current + + // Parity with your XML color logic: + // mainTextColor: default message text color for this bubble direction + val mainTextColor = if (isOutgoing) colors.textBubbleSent else colors.textBubbleReceived + + + // Build a styled copy (preserves existing LinkAnnotations) + val styled = remember(text, isOutgoing, mainTextColor) { + 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) + .firstOrNull()?.item == "true" + + // Foreground parity rules : + val fg = when { + // Incoming mentioning you: foreground is mainTextColor (on accent bg) + !isSelf && !isOutgoing -> colors.accent + + else -> colors.textBubbleSent + } + + addStyle( + SpanStyle(color = fg, fontWeight = FontWeight.Bold), + m.start, + m.end + ) + } + } + } + + var layout by remember { mutableStateOf(null) } + + val density = LocalDensity.current + val cornerPx = with(density) { 6.dp.toPx() } + val padHPx = with(density) { 4.dp.toPx() } + val padVPx = with(density) { 2.dp.toPx() } + + // Mentions that need rounded background (parity tag from formatter) + val bgRanges = remember(text, isOutgoing) { + if (isOutgoing) emptyList() + else text.getStringAnnotations("mention_bg", 0, text.length) + } + + 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) + ) + } + } + } + + // You can apply padding outside or pass in as modifier. + Text( + text = styled, + style = LocalType.current.large.copy(color = mainTextColor), + modifier = modifierWithBg, + onTextLayout = { layout = it }, + maxLines = maxLines, + overflow = overflow + ) +} + +private fun computeLineRectsForRange( + layout: TextLayoutResult, + start: Int, + endExclusive: Int +): List { + if (start >= endExclusive) return emptyList() + + val rectsByLine = LinkedHashMap() + val last = (endExclusive - 1).coerceAtLeast(start) + + for (offset in start..last) { + val line = layout.getLineForOffset(offset) + val box = layout.getBoundingBox(offset) + + val existing = rectsByLine[line] + rectsByLine[line] = if (existing == null) { + Rect(box.left, box.top, box.right, box.bottom) + } else { + Rect( + left = minOf(existing.left, box.left), + top = minOf(existing.top, box.top), + right = maxOf(existing.right, box.right), + bottom = maxOf(existing.bottom, box.bottom) + ) + } + } + + return rectsByLine.map { (line, r) -> + val top = layout.getLineTop(line).toFloat() + val bottom = layout.getLineBottom(line).toFloat() + Rect(r.left, top, r.right, bottom) + } +} \ No newline at end of file From 2ce3398eccc24fe1a605f15033bce67ffa9b6cdd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 3 Mar 2026 18:27:59 +1100 Subject: [PATCH 15/23] rich text update --- .../conversation/v3/ConversationDataMapper.kt | 7 +- .../conversation/v3/RichTextFormatter.kt | 208 +++++++++++------- .../v3/compose/message/RichText.kt | 87 ++++---- 3 files changed, 181 insertions(+), 121 deletions(-) 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 index f8abbfefd4..10d755d0ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -260,9 +260,7 @@ class ConversationDataMapper @Inject constructor( val annotatedBody = RichTextFormatter.formatMessage( parsed = parsed, isOutgoing = record.isOutgoing, - ) { url -> - - } + ) primaryData += MessageContentData.Text(annotatedBody) } @@ -378,8 +376,7 @@ class ConversationDataMapper @Inject constructor( RichTextFormatter.formatMessage( parsed = parsed, - isOutgoing = record.isOutgoing, - onUrlClick = {} + isOutgoing = record.isOutgoing ) } else { AnnotatedString(context.getString(R.string.document)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt index 93a218ee0a..16c25a6f19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt @@ -1,116 +1,174 @@ 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.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +/** + * Formats message text for Compose rendering. + * + * Responsibilities: + * - Adds URL string annotations ("url") + underline style (tap handling is done in the Composable). + * - Bolds mentions and adds mention metadata annotations. + * - Adds a "mention_bg" annotation for mentions that require a rounded background. + * - Inserts subtle *layout spacing* immediately OUTSIDE mention_bg mentions so the rounded background + * doesn't visually butt against neighboring words. + * + * Non-responsibilities: + * - No click handling + * - No theme colors + * - No UI behavior + * + * Safe to call in mappers: deterministic, context-free, no closures. + */ object RichTextFormatter { private val URL_REGEX = Regex("""(?i)\bhttps?://[^\s<>()]+\b""") + //todo convov3 look at new way to match urls + + // Subtle spacing that affects layout but is visually minimal. + // If too subtle, try '\u2009' (thin space). + private const val OUTSIDE_SPACE: Char = '\u200A' // hair space /** - * Build an AnnotatedString for message text with: - * - URLs made clickable using LinkAnnotation (no ClickableText) - * - URL underline via TextLinkStyles - * - Mentions bold + mention metadata + mention_bg tag where needed - * - * Notes: - * - We build sequentially because LinkAnnotation can only be applied while appending (withLink). - * - We do NOT apply colors here (leave to Compose theme). + * Formats parsed message text into an AnnotatedString suitable for RichText(). * - * @param onUrlClick called when a URL is clicked. You decide how to open it. + * @param parsed Result of MentionUtilities.parseAndSubstituteMentions + * @param isOutgoing Whether the message is outgoing */ fun formatMessage( parsed: MentionUtilities.ParsedMentions, - isOutgoing: Boolean, - onUrlClick: (String) -> Unit + isOutgoing: Boolean ): AnnotatedString { - val text = parsed.text - - val base = buildAnnotatedString { - var cursor = 0 - - URL_REGEX.findAll(text).forEach { match -> - var start = match.range.first - var endExclusive = match.range.last + 1 - var urlText = match.value - - // Trim common trailing punctuation from URL matches so "https://x.y)." doesn't include ")." - val (trimmedUrl, trailing) = trimTrailingUrlPunctuation(urlText) - if (trimmedUrl != urlText) { - endExclusive = start + trimmedUrl.length - urlText = trimmedUrl - } - - // Append non-url chunk - if (cursor < start) append(text.substring(cursor, start)) - - // Append URL chunk with link - val link = LinkAnnotation.Clickable( - tag = "url", - linkInteractionListener = { onUrlClick(urlText) }, - styles = TextLinkStyles( - style = SpanStyle(textDecoration = TextDecoration.Underline) - ) - ) - withLink(link) { append(urlText) } - - // Append trailing punctuation that we trimmed off - if (trailing.isNotEmpty()) append(trailing) - - cursor = match.range.last + 1 // move past the ORIGINAL match - } + val input = parsed.text + val mentionsIn = parsed.mentions + .sortedBy { it.start } // ensure deterministic left-to-right processing + + // 1) Build output text and remap mention ranges safely. + val remapped = buildTextWithOutsideSpacing( + text = input, + mentions = mentionsIn, + needsBg = { it.isSelf && !isOutgoing } + ) + + val outText = remapped.text + val outMentions = remapped.mentions + + // 2) Build AnnotatedString with styles/annotations. + val b = AnnotatedString.Builder(outText) + + // URLs: underline + "url" annotation (click handled in composable) + URL_REGEX.findAll(outText).forEach { match -> + val start = match.range.first + val rawUrl = match.value + val (url, _) = trimTrailingUrlPunctuation(rawUrl) + val endExclusive = start + url.length + + b.addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, endExclusive) + b.addStringAnnotation(tag = "url", annotation = url, start = start, end = endExclusive) + } - // Append remaining text - if (cursor < text.length) append(text.substring(cursor)) + // Mentions: bold + metadata + bg marker (ranges are correct in outText) + outMentions.forEach { m -> + b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), m.start, m.endExclusive) + b.addStringAnnotation("mention_pk", m.publicKey, m.start, m.endExclusive) + b.addStringAnnotation("mention_self", m.isSelf.toString(), m.start, m.endExclusive) + if (m.needsBg) b.addStringAnnotation("mention_bg", "true", m.start, m.endExclusive) } - // Now add mention styles/annotations by range. - // Mention ranges are based on parsed.text; since we rebuilt the same text content in order, - // indices still line up. - val b = AnnotatedString.Builder(base) + return b.toAnnotatedString() + } + + // --------------------------------------------------------------------- + // Safe range remapping + // --------------------------------------------------------------------- + + 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 + ) + + /** + * Builds the final text left-to-right and produces new mention ranges. + * This is the only robust way to insert extra characters without corrupting ranges. + */ + 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 - parsed.mentions.forEach { m -> + for (m in mentions) { val start = m.start val end = m.endExclusive - // Bold mentions (parity) - b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + // Guard (mentions should be non-overlapping; if they aren't, we skip safely) + if (start < cursor || start > text.length || end > text.length || start >= end) { + continue + } - // Metadata tags (optional but handy) - b.addStringAnnotation("mention_pk", m.publicKey, start, end) - b.addStringAnnotation("mention_self", m.isSelf.toString(), start, end) + // Append text before mention + if (cursor < start) out.append(text, cursor, start) - // Tag when mention needs rounded background: - // parity with XML: incoming mentioning you - val needsBg = m.isSelf && !isOutgoing - if (needsBg) { - b.addStringAnnotation("mention_bg", "true", start, end) - } + val bg = needsBg(m) + + // Insert OUTSIDE_SPACE before mention if bg + if (bg) out.append(OUTSIDE_SPACE) + + val mentionStartOut = out.length + out.append(text, start, end) + val mentionEndOut = out.length + + // Insert OUTSIDE_SPACE after mention if bg + if (bg) out.append(OUTSIDE_SPACE) + + outMentions += MentionOut( + start = mentionStartOut, + endExclusive = mentionEndOut, + publicKey = m.publicKey, + isSelf = m.isSelf, + needsBg = bg + ) + + cursor = end } - return b.toAnnotatedString() + // Append trailing text after last mention + if (cursor < text.length) out.append(text, cursor, text.length) + + return RemappedText(text = out.toString(), mentions = outMentions) } + // --------------------------------------------------------------------- + // URL helpers + // --------------------------------------------------------------------- + + /** + * Removes common trailing punctuation from detected URLs. + * Example: "https://example.com)." => url="https://example.com", trailing=")." + */ private fun trimTrailingUrlPunctuation(url: String): Pair { if (url.isEmpty()) return url to "" - // Common trailing punctuation we don't want inside URL val trailingChars = ".,;:!?)]}\"'" var end = url.length while (end > 0 && trailingChars.indexOf(url[end - 1]) >= 0) end-- - // If we trimmed everything, keep original if (end == 0) return url to "" - - val trimmed = url.substring(0, end) - val trailing = url.substring(end) - return trimmed to trailing + return url.substring(0, end) to url.substring(end) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt index b703c447e2..a896e81268 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.conversation.v3.compose.message -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -9,6 +9,7 @@ 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -16,18 +17,16 @@ 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.text.style.TextOverflow.Companion import androidx.compose.ui.unit.dp import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalType /** * Renders message text with: - * - clickable URLs (via LinkAnnotation inside AnnotatedString) - * - underlined URLs (done in formatter via TextLinkStyles) - * - mention bold (done in formatter) - * - mention foreground color parity (applied here) - * - mention rounded background parity (drawn here) + * - URL tap handling without ClickableText (tap -> offset -> "url" annotation) + * - Underlined URLs (from formatter) + * - Mention coloring + bold + * - Rounded bg for "mention_bg" ranges (with spacing) */ @Composable fun RichText( @@ -36,16 +35,13 @@ fun RichText( modifier: Modifier = Modifier, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, + onUrlClick: ((String) -> Unit)? = null, ) { val colors = LocalColors.current - - // Parity with your XML color logic: - // mainTextColor: default message text color for this bubble direction val mainTextColor = if (isOutgoing) colors.textBubbleSent else colors.textBubbleReceived - - // Build a styled copy (preserves existing LinkAnnotations) - val styled = remember(text, isOutgoing, mainTextColor) { + // Apply mention foreground styling (keep your rules; adjust as needed) + val styled = remember(text, isOutgoing, mainTextColor, colors) { buildAnnotatedString { append(text) @@ -54,13 +50,7 @@ fun RichText( val isSelf = text.getStringAnnotations("mention_self", m.start, m.end) .firstOrNull()?.item == "true" - // Foreground parity rules : - val fg = when { - // Incoming mentioning you: foreground is mainTextColor (on accent bg) - !isSelf && !isOutgoing -> colors.accent - - else -> colors.textBubbleSent - } + val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent addStyle( SpanStyle(color = fg, fontWeight = FontWeight.Bold), @@ -76,38 +66,52 @@ fun RichText( val density = LocalDensity.current val cornerPx = with(density) { 6.dp.toPx() } val padHPx = with(density) { 4.dp.toPx() } - val padVPx = with(density) { 2.dp.toPx() } + val padVPx = with(density) { 3.dp.toPx() } - // Mentions that need rounded background (parity tag from formatter) val bgRanges = remember(text, isOutgoing) { if (isOutgoing) emptyList() else text.getStringAnnotations("mention_bg", 0, text.length) } - 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) - ) + val modifierWithBgAndClicks = + 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) + ) + } + } + } + .pointerInput(text, onUrlClick) { + if (onUrlClick == null) return@pointerInput + detectTapGestures { pos -> + val lr = layout ?: return@detectTapGestures + val offset = lr.getOffsetForPosition(pos) + + val hit = text.getStringAnnotations("url", offset, offset) + .firstOrNull() + ?.item + ?: return@detectTapGestures + + onUrlClick(hit) + } } - } - } - // You can apply padding outside or pass in as modifier. Text( text = styled, style = LocalType.current.large.copy(color = mainTextColor), - modifier = modifierWithBg, + modifier = modifierWithBgAndClicks, onTextLayout = { layout = it }, maxLines = maxLines, overflow = overflow @@ -141,6 +145,7 @@ private fun computeLineRectsForRange( } } + // Normalize to full line height so the bg looks consistent return rectsByLine.map { (line, r) -> val top = layout.getLineTop(line).toFloat() val bottom = layout.getLineBottom(line).toFloat() From ef4c7ca7190a8dd76bdf08771650d8f3c9746742 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 08:54:05 +1100 Subject: [PATCH 16/23] autolink + quote styling --- .../conversation/v2/messages/QuoteView.kt | 2 +- .../conversation/v3/ConversationDataMapper.kt | 6 +- .../conversation/v3/RichTextFormatter.kt | 124 ++++++++++-------- .../v3/compose/message/MessageLink.kt | 3 +- .../v3/compose/message/MessageQuote.kt | 5 +- .../v3/compose/message/RichText.kt | 4 +- 6 files changed, 84 insertions(+), 60 deletions(-) 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/v3/ConversationDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt index 10d755d0ab..58d0cc8af2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -392,8 +392,12 @@ class ConversationDataMapper @Inject constructor( else -> MessageQuoteIcon.Bar }*/ + // title + val title = if(quote.author.isLocalNumber) context.getString(R.string.you) + else quote.author.displayName() + return QuoteMessageData( - title = quote.author.displayName(), + title = title, subtitle = subtitle, icon = icon, showProBadge = quote.author.shouldShowProBadge diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt index 16c25a6f19..d92e0b3fb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt @@ -4,29 +4,35 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle 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 /** * Formats message text for Compose rendering. * * Responsibilities: - * - Adds URL string annotations ("url") + underline style (tap handling is done in the Composable). - * - Bolds mentions and adds mention metadata annotations. - * - Adds a "mention_bg" annotation for mentions that require a rounded background. - * - Inserts subtle *layout spacing* immediately OUTSIDE mention_bg mentions so the rounded background - * doesn't visually butt against neighboring words. + * - Detects links using autolink-java (URL + WWW) with robust boundaries + * - Adds URL string annotations ("url") + underline style (tap handling in Composable) + * - Bolds mentions and adds mention metadata annotations + * - Adds "mention_bg" annotation for mentions that require rounded background + * - Inserts subtle *layout spacing* immediately OUTSIDE mention_bg mentions, without corrupting + * other mention ranges (safe remap via left-to-right rebuild) * * Non-responsibilities: * - No click handling * - No theme colors * - No UI behavior * - * Safe to call in mappers: deterministic, context-free, no closures. + * Safe to call in mappers. */ object RichTextFormatter { - private val URL_REGEX = Regex("""(?i)\bhttps?://[^\s<>()]+\b""") - //todo convov3 look at new way to match urls + // autolink-java: better link boundaries than regex / Linkify + private val linkExtractor: LinkExtractor = LinkExtractor.builder() + .linkTypes(EnumSet.of(LinkType.URL, LinkType.WWW)) + .build() // Subtle spacing that affects layout but is visually minimal. // If too subtle, try '\u2009' (thin space). @@ -43,34 +49,24 @@ object RichTextFormatter { isOutgoing: Boolean ): AnnotatedString { val input = parsed.text - val mentionsIn = parsed.mentions - .sortedBy { it.start } // ensure deterministic left-to-right processing - // 1) Build output text and remap mention ranges safely. + // 1) Rebuild text with outside spacing around bg mentions and remap mention ranges safely. val remapped = buildTextWithOutsideSpacing( text = input, - mentions = mentionsIn, + mentions = parsed.mentions.sortedBy { it.start }, needsBg = { it.isSelf && !isOutgoing } ) val outText = remapped.text val outMentions = remapped.mentions - // 2) Build AnnotatedString with styles/annotations. + // 2) Create AnnotatedString and apply link + mention annotations/styles. val b = AnnotatedString.Builder(outText) - // URLs: underline + "url" annotation (click handled in composable) - URL_REGEX.findAll(outText).forEach { match -> - val start = match.range.first - val rawUrl = match.value - val (url, _) = trimTrailingUrlPunctuation(rawUrl) - val endExclusive = start + url.length + // Links: underline + "url" annotation + addLinkAnnotationsWithAutolink(b, outText) - b.addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, endExclusive) - b.addStringAnnotation(tag = "url", annotation = url, start = start, end = endExclusive) - } - - // Mentions: bold + metadata + bg marker (ranges are correct in outText) + // Mentions: bold + metadata + bg marker outMentions.forEach { m -> b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), m.start, m.endExclusive) b.addStringAnnotation("mention_pk", m.publicKey, m.start, m.endExclusive) @@ -82,7 +78,48 @@ object RichTextFormatter { } // --------------------------------------------------------------------- - // Safe range remapping + // Links (Compose annotations) via autolink-java + // --------------------------------------------------------------------- + + /** + * Uses autolink-java to detect URL/WWW links and applies: + * - underline style + * - "url" string annotations containing the normalized url (WWW -> https://...) + */ + private fun addLinkAnnotationsWithAutolink( + builder: AnnotatedString.Builder, + text: String + ) { + val links = linkExtractor.extractLinks(text) + + 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.addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + builder.addStringAnnotation( + tag = "url", + annotation = url, + start = start, + end = end + ) + } + } + + // --------------------------------------------------------------------- + // Safe mention spacing + range remapping // --------------------------------------------------------------------- private data class RemappedText( @@ -100,7 +137,11 @@ object RichTextFormatter { /** * Builds the final text left-to-right and produces new mention ranges. - * This is the only robust way to insert extra characters without corrupting ranges. + * This is the robust way to insert extra characters without breaking indices. + * + * We insert OUTSIDE_SPACE immediately before and after mention text for mentions that need bg. + * The mention range excludes this spacing so the rounded pill hugs the mention text, while + * the spacing provides "breathing room" to neighbors. */ private fun buildTextWithOutsideSpacing( text: String, @@ -116,24 +157,20 @@ object RichTextFormatter { val start = m.start val end = m.endExclusive - // Guard (mentions should be non-overlapping; if they aren't, we skip safely) - if (start < cursor || start > text.length || end > text.length || start >= end) { - continue - } + // Defensive: skip invalid/overlapping ranges + if (start < cursor || start < 0 || end > text.length || start >= end) continue // Append text before mention if (cursor < start) out.append(text, cursor, start) val bg = needsBg(m) - // Insert OUTSIDE_SPACE before mention if bg if (bg) out.append(OUTSIDE_SPACE) val mentionStartOut = out.length out.append(text, start, end) val mentionEndOut = out.length - // Insert OUTSIDE_SPACE after mention if bg if (bg) out.append(OUTSIDE_SPACE) outMentions += MentionOut( @@ -147,28 +184,9 @@ object RichTextFormatter { cursor = end } - // Append trailing text after last mention + // Append trailing text if (cursor < text.length) out.append(text, cursor, text.length) - return RemappedText(text = out.toString(), mentions = outMentions) - } - - // --------------------------------------------------------------------- - // URL helpers - // --------------------------------------------------------------------- - - /** - * Removes common trailing punctuation from detected URLs. - * Example: "https://example.com)." => url="https://example.com", trailing=")." - */ - private fun trimTrailingUrlPunctuation(url: String): Pair { - if (url.isEmpty()) return url to "" - - val trailingChars = ".,;:!?)]}\"'" - var end = url.length - while (end > 0 && trailingChars.indexOf(url[end - 1]) >= 0) end-- - - if (end == 0) return url to "" - return url.substring(0, end) to url.substring(end) + return RemappedText(out.toString(), outMentions) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt index cd4d39c8c9..57a2467f28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageLink.kt @@ -57,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 { @@ -132,7 +132,6 @@ fun LinkMessagePreview( 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/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index abd57c293f..5501a604e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -114,12 +114,15 @@ fun MessageQuote( Column{ ProBadgeText( text = quote.title, - textStyle = LocalType.current.small.bold().copy(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() ) + Spacer(Modifier.height(LocalDimensions.current.tinySpacing)) + + //todo convov3 we shouldn't render/click links for quotes RichText( text = quote.subtitle, isOutgoing = outgoing, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt index a896e81268..03e40cfac5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt @@ -23,10 +23,10 @@ import org.thoughtcrime.securesms.ui.theme.LocalType /** * Renders message text with: - * - URL tap handling without ClickableText (tap -> offset -> "url" annotation) + * - URL tap handling (tap -> offset -> "url" annotation) * - Underlined URLs (from formatter) * - Mention coloring + bold - * - Rounded bg for "mention_bg" ranges (with spacing) + * - Rounded bg for "mention_bg" ranges */ @Composable fun RichText( From 1a9a7c54aeb927aeac87d4db55494a26ceaf6643 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 10:34:18 +1100 Subject: [PATCH 17/23] Updated rich text logic --- .../conversation/v3/RichTextFormatter.kt | 118 +++++------- .../v3/compose/message/RichText.kt | 182 ++++++++++-------- 2 files changed, 152 insertions(+), 148 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt index d92e0b3fb6..531f9a2d49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt @@ -1,7 +1,9 @@ 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 @@ -10,92 +12,75 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import java.util.EnumSet /** - * Formats message text for Compose rendering. + * Pure formatting only: + * - No UI colors + * - No click behavior (Compose injects) * - * Responsibilities: - * - Detects links using autolink-java (URL + WWW) with robust boundaries - * - Adds URL string annotations ("url") + underline style (tap handling in Composable) - * - Bolds mentions and adds mention metadata annotations - * - Adds "mention_bg" annotation for mentions that require rounded background - * - Inserts subtle *layout spacing* immediately OUTSIDE mention_bg mentions, without corrupting - * other mention ranges (safe remap via left-to-right rebuild) + * Produces: + * - LinkAnnotation.Clickable(tag=url) with underline style (no default open) + * - Mention annotations: + * - "mention_pk" = publicKey + * - "mention_self" presence-only when true + * - "mention_bg" presence-only when mention should have pill background * - * Non-responsibilities: - * - No click handling - * - No theme colors - * - No UI behavior - * - * Safe to call in mappers. + * Also: + * - Inserts subtle OUTSIDE spacing around bg mentions (layout breathing room) + * - Remaps mention ranges safely. */ object RichTextFormatter { - // autolink-java: better link boundaries than regex / Linkify private val linkExtractor: LinkExtractor = LinkExtractor.builder() .linkTypes(EnumSet.of(LinkType.URL, LinkType.WWW)) .build() - // Subtle spacing that affects layout but is visually minimal. - // If too subtle, try '\u2009' (thin space). - private const val OUTSIDE_SPACE: Char = '\u200A' // hair space + // Layout spacing outside pill; hair space is subtle and works well. + private const val OUTSIDE_SPACE: Char = '\u2009' - /** - * Formats parsed message text into an AnnotatedString suitable for RichText(). - * - * @param parsed Result of MentionUtilities.parseAndSubstituteMentions - * @param isOutgoing Whether the message is outgoing - */ fun formatMessage( parsed: MentionUtilities.ParsedMentions, isOutgoing: Boolean ): AnnotatedString { - val input = parsed.text - - // 1) Rebuild text with outside spacing around bg mentions and remap mention ranges safely. + // Insert spacing ONLY for bg mentions (incoming mentions of self) val remapped = buildTextWithOutsideSpacing( - text = input, + text = parsed.text, mentions = parsed.mentions.sortedBy { it.start }, needsBg = { it.isSelf && !isOutgoing } ) - val outText = remapped.text - val outMentions = remapped.mentions + val text = remapped.text + val mentions = remapped.mentions - // 2) Create AnnotatedString and apply link + mention annotations/styles. - val b = AnnotatedString.Builder(outText) + val b = AnnotatedString.Builder(text) - // Links: underline + "url" annotation - addLinkAnnotationsWithAutolink(b, outText) + // Links first (they key off final text indices) + addLinkAnnotationsWithAutolink(b, text) - // Mentions: bold + metadata + bg marker - outMentions.forEach { m -> + // 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) - b.addStringAnnotation("mention_self", m.isSelf.toString(), m.start, m.endExclusive) - if (m.needsBg) b.addStringAnnotation("mention_bg", "true", 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() } - // --------------------------------------------------------------------- - // Links (Compose annotations) via autolink-java - // --------------------------------------------------------------------- + // ---------------- Links (no click behavior here) ---------------- - /** - * Uses autolink-java to detect URL/WWW links and applies: - * - underline style - * - "url" string annotations containing the normalized url (WWW -> https://...) - */ 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) @@ -104,29 +89,22 @@ object RichTextFormatter { else -> raw } - builder.addStyle( - SpanStyle(textDecoration = TextDecoration.Underline), - start, - end - ) - builder.addStringAnnotation( - tag = "url", - annotation = url, + builder.addLink( + clickable = LinkAnnotation.Clickable( + tag = url, + styles = styles, + // required in your BOM; keep it no-op here + linkInteractionListener = { /* no-op */ } + ), start = start, end = end ) } } - // --------------------------------------------------------------------- - // Safe mention spacing + range remapping - // --------------------------------------------------------------------- - - private data class RemappedText( - val text: String, - val mentions: List - ) + // ---------------- Mention spacing + remap ---------------- + private data class RemappedText(val text: String, val mentions: List) private data class MentionOut( val start: Int, val endExclusive: Int, @@ -136,12 +114,8 @@ object RichTextFormatter { ) /** - * Builds the final text left-to-right and produces new mention ranges. - * This is the robust way to insert extra characters without breaking indices. - * - * We insert OUTSIDE_SPACE immediately before and after mention text for mentions that need bg. - * The mention range excludes this spacing so the rounded pill hugs the mention text, while - * the spacing provides "breathing room" to neighbors. + * Rebuilds the final text left-to-right, inserting OUTSIDE_SPACE before/after + * mentions that need a pill background. Mention ranges exclude the spaces. */ private fun buildTextWithOutsideSpacing( text: String, @@ -157,14 +131,13 @@ object RichTextFormatter { val start = m.start val end = m.endExclusive - // Defensive: skip invalid/overlapping ranges + // Defensive: skip invalid/overlapping if (start < cursor || start < 0 || end > text.length || start >= end) continue - // Append text before mention + // before mention if (cursor < start) out.append(text, cursor, start) val bg = needsBg(m) - if (bg) out.append(OUTSIDE_SPACE) val mentionStartOut = out.length @@ -184,7 +157,6 @@ object RichTextFormatter { cursor = end } - // Append trailing text if (cursor < text.length) out.append(text, cursor, text.length) return RemappedText(out.toString(), outMentions) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt index 03e40cfac5..d08dd42361 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v3.compose.message -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -9,9 +8,10 @@ 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString @@ -21,13 +21,6 @@ import androidx.compose.ui.unit.dp import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalType -/** - * Renders message text with: - * - URL tap handling (tap -> offset -> "url" annotation) - * - Underlined URLs (from formatter) - * - Mention coloring + bold - * - Rounded bg for "mention_bg" ranges - */ @Composable fun RichText( text: AnnotatedString, @@ -38,18 +31,23 @@ fun RichText( onUrlClick: ((String) -> Unit)? = null, ) { val colors = LocalColors.current - val mainTextColor = if (isOutgoing) colors.textBubbleSent else colors.textBubbleReceived - // Apply mention foreground styling (keep your rules; adjust as needed) - val styled = remember(text, isOutgoing, mainTextColor, colors) { + // Your rule: + val mainTextColor = getTextColor(isOutgoing) + + // Keep latest callback without rebuilding annotations on lambda identity changes + val onUrlClickState = rememberUpdatedState(onUrlClick) + + // Mention foreground styling (your rule) + val withMentionColors = remember(text, isOutgoing, colors) { 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) - .firstOrNull()?.item == "true" + val isSelf = text.getStringAnnotations("mention_self", m.start, m.end).isNotEmpty() + // Your rule: val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent addStyle( @@ -61,6 +59,48 @@ fun RichText( } } + val (displayText, bgRanges) = remember(text, isOutgoing, colors) { + 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() + val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent + + addStyle( + SpanStyle(color = fg, fontWeight = FontWeight.Bold), + m.start, + m.end + ) + } + } + + val displayText = 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 + } + } + + val bgRanges = if (isOutgoing) emptyList() + else displayText.getStringAnnotations("mention_bg", 0, displayText.length) + + displayText to bgRanges + } + var layout by remember { mutableStateOf(null) } val density = LocalDensity.current @@ -68,56 +108,40 @@ fun RichText( val padHPx = with(density) { 4.dp.toPx() } val padVPx = with(density) { 3.dp.toPx() } - val bgRanges = remember(text, isOutgoing) { - if (isOutgoing) emptyList() - else text.getStringAnnotations("mention_bg", 0, text.length) - } - - val modifierWithBgAndClicks = - 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) - ) - } - } - } - .pointerInput(text, onUrlClick) { - if (onUrlClick == null) return@pointerInput - detectTapGestures { pos -> - val lr = layout ?: return@detectTapGestures - val offset = lr.getOffsetForPosition(pos) - - val hit = text.getStringAnnotations("url", offset, offset) - .firstOrNull() - ?.item - ?: return@detectTapGestures - - onUrlClick(hit) + 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 = styled, + text = displayText, style = LocalType.current.large.copy(color = mainTextColor), - modifier = modifierWithBgAndClicks, + modifier = modifierWithBg, onTextLayout = { layout = it }, maxLines = maxLines, overflow = overflow ) } +/** + * Fast per-line rects for [start, endExclusive) using typographic edges. + * (Spacing around pill is handled by OUTSIDE_SPACE in formatter + pad in drawBehind.) + */ private fun computeLineRectsForRange( layout: TextLayoutResult, start: Int, @@ -125,30 +149,38 @@ private fun computeLineRectsForRange( ): List { if (start >= endExclusive) return emptyList() - val rectsByLine = LinkedHashMap() - val last = (endExclusive - 1).coerceAtLeast(start) - - for (offset in start..last) { - val line = layout.getLineForOffset(offset) - val box = layout.getBoundingBox(offset) - - val existing = rectsByLine[line] - rectsByLine[line] = if (existing == null) { - Rect(box.left, box.top, box.right, box.bottom) - } else { - Rect( - left = minOf(existing.left, box.left), - top = minOf(existing.top, box.top), - right = maxOf(existing.right, box.right), - bottom = maxOf(existing.bottom, box.bottom) - ) - } - } + val textLen = layout.layoutInput.text.length + val s = start.coerceIn(0, textLen) + val e = endExclusive.coerceIn(0, textLen) + if (s >= e) return emptyList() + + val last = (e - 1).coerceAtLeast(s) + val startLine = layout.getLineForOffset(s) + val endLine = layout.getLineForOffset(last) + + val out = ArrayList(endLine - startLine + 1) + + for (line in startLine..endLine) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineEnd(line, visibleEnd = true) + + 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) - // Normalize to full line height so the bg looks consistent - return rectsByLine.map { (line, r) -> val top = layout.getLineTop(line).toFloat() val bottom = layout.getLineBottom(line).toFloat() - Rect(r.left, top, r.right, bottom) + + out += Rect( + left = minOf(left, right), + top = top, + right = maxOf(left, right), + bottom = bottom + ) } + + return out } \ No newline at end of file From 5bb99fc6a3b437f6178aef3c2753219078f7f8f6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 10:45:49 +1100 Subject: [PATCH 18/23] optional link handling --- .../v3/compose/message/BaseMessage.kt | 5 ++- .../v3/compose/message/RichText.kt | 45 ++++++++++++------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 7bcc2dd8e5..5ae65d4c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -278,7 +278,10 @@ fun MessageContentRenderer(content: MessageContent, layout: MessageLayout, maxWi is MessageContentData.Text -> RichText( text = content.contentData.text, isOutgoing = isOutgoing, - modifier = Modifier.padding(defaultMessageBubblePadding()) + modifier = Modifier.padding(defaultMessageBubblePadding()), + onUrlClick = { + //todo convov3 handle + } ) is MessageContentData.Quote -> MessageQuote( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt index d08dd42361..345d38162e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt @@ -59,7 +59,7 @@ fun RichText( } } - val (displayText, bgRanges) = remember(text, isOutgoing, colors) { + val (displayText, bgRanges) = remember(text, isOutgoing, colors, onUrlClick != null) { val withColors = buildAnnotatedString { append(text) @@ -76,22 +76,33 @@ fun RichText( } } - val displayText = 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 + val displayText = if (onUrlClick != null) { + 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 { + 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) + } } } From a37604399d1f73d975f48040521a173d5933174b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 11:08:49 +1100 Subject: [PATCH 19/23] Renamed Message text and formatter --- .../conversation/v3/ConversationDataMapper.kt | 7 +--- ...xtFormatter.kt => MessageTextFormatter.kt} | 37 +++++++++---------- .../v3/compose/message/BaseMessage.kt | 6 +-- .../v3/compose/message/MessageQuote.kt | 10 +---- .../message/{RichText.kt => MessageText.kt} | 36 +++++------------- 5 files changed, 32 insertions(+), 64 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/{RichTextFormatter.kt => MessageTextFormatter.kt} (82%) rename app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/{RichText.kt => MessageText.kt} (86%) 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 index 58d0cc8af2..c52e8390b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -2,9 +2,7 @@ package org.thoughtcrime.securesms.conversation.v3 import android.content.Context import android.text.format.Formatter -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.json.Json @@ -40,7 +38,6 @@ 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.ui.theme.LocalDimensions import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils import java.util.TimeZone @@ -257,7 +254,7 @@ class ConversationDataMapper @Inject constructor( input = record.body, context = context ) - val annotatedBody = RichTextFormatter.formatMessage( + val annotatedBody = MessageTextFormatter.formatMessage( parsed = parsed, isOutgoing = record.isOutgoing, ) @@ -374,7 +371,7 @@ class ConversationDataMapper @Inject constructor( context = context ) - RichTextFormatter.formatMessage( + MessageTextFormatter.formatMessage( parsed = parsed, isOutgoing = record.isOutgoing ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt similarity index 82% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt index 531f9a2d49..21c3a1c58d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/RichTextFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/MessageTextFormatter.kt @@ -12,28 +12,26 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import java.util.EnumSet /** - * Pure formatting only: - * - No UI colors - * - No click behavior (Compose injects) + * Pure text formatting for message content: + * - No UI colors (Compose layer handles those) + * - No click behavior (Compose layer injects handlers) * - * Produces: - * - LinkAnnotation.Clickable(tag=url) with underline style (no default open) - * - Mention annotations: - * - "mention_pk" = publicKey - * - "mention_self" presence-only when true - * - "mention_bg" presence-only when mention should have pill background + * 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 * - * Also: - * - Inserts subtle OUTSIDE spacing around bg mentions (layout breathing room) - * - Remaps mention ranges safely. + * Inserts thin-space padding around pill mentions for visual breathing room. */ -object RichTextFormatter { +object MessageTextFormatter { private val linkExtractor: LinkExtractor = LinkExtractor.builder() .linkTypes(EnumSet.of(LinkType.URL, LinkType.WWW)) .build() - // Layout spacing outside pill; hair space is subtle and works well. + // Spacing used around the mention highlight bg private const val OUTSIDE_SPACE: Char = '\u2009' fun formatMessage( @@ -66,7 +64,7 @@ object RichTextFormatter { return b.toAnnotatedString() } - // ---------------- Links (no click behavior here) ---------------- + // ------------ Link handling -------------------- private fun addLinkAnnotationsWithAutolink( builder: AnnotatedString.Builder, @@ -93,8 +91,7 @@ object RichTextFormatter { clickable = LinkAnnotation.Clickable( tag = url, styles = styles, - // required in your BOM; keep it no-op here - linkInteractionListener = { /* no-op */ } + linkInteractionListener = null ), start = start, end = end @@ -114,8 +111,10 @@ object RichTextFormatter { ) /** - * Rebuilds the final text left-to-right, inserting OUTSIDE_SPACE before/after - * mentions that need a pill background. Mention ranges exclude the spaces. + * 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt index 5ae65d4c83..6da74cdd77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/BaseMessage.kt @@ -37,9 +37,6 @@ 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.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp @@ -62,7 +59,6 @@ import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement -import kotlin.math.min //todo CONVOv3 status animated icon for disappearing messages //todo CONVOv3 text formatting in bubble including mentions and links @@ -275,7 +271,7 @@ fun MessageContentRenderer(content: MessageContent, layout: MessageLayout, maxWi ) ) { when (content.contentData) { - is MessageContentData.Text -> RichText( + is MessageContentData.Text -> MessageText( text = content.contentData.text, isOutgoing = isOutgoing, modifier = Modifier.padding(defaultMessageBubblePadding()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt index 5501a604e4..8c7ddb7542 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageQuote.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v3.compose.message -import android.R.attr.textColor import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -16,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 @@ -24,7 +22,6 @@ 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 @@ -33,12 +30,7 @@ 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.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.quoteGroup -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.ProBadgeText import org.thoughtcrime.securesms.ui.proBadgeColorOutgoing @@ -123,7 +115,7 @@ fun MessageQuote( Spacer(Modifier.height(LocalDimensions.current.tinySpacing)) //todo convov3 we shouldn't render/click links for quotes - RichText( + MessageText( text = quote.subtitle, isOutgoing = outgoing, maxLines = 2, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt similarity index 86% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt index 345d38162e..2b4865d4c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/RichText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/compose/message/MessageText.kt @@ -10,7 +10,6 @@ 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.AnnotatedString.Range import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -21,8 +20,12 @@ 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. + */ @Composable -fun RichText( +fun MessageText( text: AnnotatedString, isOutgoing: Boolean, modifier: Modifier = Modifier, @@ -32,33 +35,11 @@ fun RichText( ) { val colors = LocalColors.current - // Your rule: val mainTextColor = getTextColor(isOutgoing) // Keep latest callback without rebuilding annotations on lambda identity changes val onUrlClickState = rememberUpdatedState(onUrlClick) - // Mention foreground styling (your rule) - val withMentionColors = remember(text, isOutgoing, colors) { - 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() - - // Your rule: - val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent - - addStyle( - SpanStyle(color = fg, fontWeight = FontWeight.Bold), - m.start, - m.end - ) - } - } - } - val (displayText, bgRanges) = remember(text, isOutgoing, colors, onUrlClick != null) { val withColors = buildAnnotatedString { append(text) @@ -66,6 +47,8 @@ fun RichText( val mentions = text.getStringAnnotations("mention_pk", 0, text.length) for (m in mentions) { val isSelf = text.getStringAnnotations("mention_self", m.start, m.end).isNotEmpty() + + // mention text color val fg = if (!isSelf && !isOutgoing) colors.accentText else colors.textBubbleSent addStyle( @@ -115,6 +98,7 @@ fun RichText( var layout by remember { mutableStateOf(null) } val density = LocalDensity.current + // size and spacing for the mention highlight bg val cornerPx = with(density) { 6.dp.toPx() } val padHPx = with(density) { 4.dp.toPx() } val padVPx = with(density) { 3.dp.toPx() } @@ -182,8 +166,8 @@ private fun computeLineRectsForRange( val left = layout.getHorizontalPosition(segStart, usePrimaryDirection = true) val right = layout.getHorizontalPosition(segEnd, usePrimaryDirection = true) - val top = layout.getLineTop(line).toFloat() - val bottom = layout.getLineBottom(line).toFloat() + val top = layout.getLineTop(line) + val bottom = layout.getLineBottom(line) out += Rect( left = minOf(left, right), From 1cc61745413e333b928ead3ed92ffbf3128b19c3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 11:10:58 +1100 Subject: [PATCH 20/23] Comments for readability --- .../v3/compose/message/MessageText.kt | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) 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 index 2b4865d4c1..4215faea67 100644 --- 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 @@ -23,6 +23,15 @@ 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( @@ -34,13 +43,21 @@ fun MessageText( onUrlClick: ((String) -> Unit)? = null, ) { val colors = LocalColors.current - val mainTextColor = getTextColor(isOutgoing) - // Keep latest callback without rebuilding annotations on lambda identity changes + // 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) - val (displayText, bgRanges) = remember(text, isOutgoing, colors, onUrlClick != null) { + // 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) @@ -48,7 +65,8 @@ fun MessageText( for (m in mentions) { val isSelf = text.getStringAnnotations("mention_self", m.start, m.end).isNotEmpty() - // mention text color + // 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( @@ -59,7 +77,9 @@ fun MessageText( } } + // 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) { @@ -79,6 +99,8 @@ fun MessageText( } } } 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) } @@ -89,20 +111,24 @@ fun MessageText( } } + // 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 - // size and spacing for the mention highlight bg val cornerPx = with(density) { 6.dp.toPx() } - val padHPx = with(density) { 4.dp.toPx() } - val padVPx = with(density) { 3.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 @@ -134,8 +160,14 @@ fun MessageText( } /** - * Fast per-line rects for [start, endExclusive) using typographic edges. - * (Spacing around pill is handled by OUTSIDE_SPACE in formatter + pad in drawBehind.) + * 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, @@ -149,9 +181,8 @@ private fun computeLineRectsForRange( val e = endExclusive.coerceIn(0, textLen) if (s >= e) return emptyList() - val last = (e - 1).coerceAtLeast(s) val startLine = layout.getLineForOffset(s) - val endLine = layout.getLineForOffset(last) + val endLine = layout.getLineForOffset((e - 1).coerceAtLeast(s)) val out = ArrayList(endLine - startLine + 1) @@ -159,16 +190,17 @@ private fun computeLineRectsForRange( 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, From ded4a6a8d3e9387d7746afbef0ff703895d23f81 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 11:28:46 +1100 Subject: [PATCH 21/23] control message WIP --- .../conversation/v3/ConversationDataMapper.kt | 3 --- .../conversation/v3/compose/message/ControlMessage.kt | 11 +---------- 2 files changed, 1 insertion(+), 13 deletions(-) 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 index c52e8390b3..35ac0666c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -175,9 +175,6 @@ class ConversationDataMapper @Inject constructor( // Always show before the first visible message (no previous) if (previous == null) return true - // Never show before control messages - if (current.isControlMessage) return false - val t1 = previous.timestamp val t2 = current.timestamp 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 index 288a0f1d06..efd9df73b0 100644 --- 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 @@ -19,15 +19,6 @@ fun ControlMessage( modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - data.contentGroups.forEach { group -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - group.contents.forEach { content -> - // Cast to specific content types or render text - if (content is MessageContentData.Text) { - Text(text = content.text, style = LocalType.current.small) - } - } - } - } + Text(text = "Control Message WIP", style = LocalType.current.small) } } \ No newline at end of file From b05925e0139eea498e52158a43694a2c8fa65558 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 11:55:21 +1100 Subject: [PATCH 22/23] PR feedback --- .../conversation/v3/ConversationDataMapper.kt | 26 ++++++++----------- .../v3/ConversationPagingSource.kt | 16 +++++++----- 2 files changed, 20 insertions(+), 22 deletions(-) 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 index 35ac0666c2..9d2ed7e3a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationDataMapper.kt @@ -72,7 +72,8 @@ class ConversationDataMapper @Inject constructor( lastSeen: Long?, highlightKey: HighlightMessage? = null, showStatus: Boolean = false, - ): List { + out: MutableList, + ) { val isOutgoing = record.isOutgoing val layout = when { @@ -137,18 +138,16 @@ class ConversationDataMapper @Inject constructor( && (previous == null || previous.timestamp <= lastSeen) && !record.isOutgoing - return buildList { - add(message) + out += message - // Items added after message appear visually ABOVE it (with reverseLayout = true) - if (showDateBreak) add(ConversationItem.DateBreak( - messageId = message.data.id, // useful in case of repeated dates due to logic - date = dateUtils.getDisplayFormattedTimeSpanString(record.timestamp) - )) + // 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) add(ConversationItem.UnreadMarker) - } + // unread marker, if needed + if (showUnreadMarker) out += ConversationItem.UnreadMarker } private fun isStartOfCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = @@ -182,10 +181,7 @@ class ConversationDataMapper @Inject constructor( if (abs(t2 - t1) > 5 * 60 * 1000) return true // Rule 2: crossed midnight in local timezone - val day1 = ((t1 / 1000) + timeZoneOffsetSeconds) / 86400 - val day2 = ((t2 / 1000) + timeZoneOffsetSeconds) / 86400 - - return day1 != day2 + return !dateUtils.isSameDay(t1, t2) } private fun shouldShowAuthorName( 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 index 44db440431..05703732ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationPagingSource.kt @@ -33,7 +33,7 @@ class ConversationPagingSource( val records = mmsSmsDatabase.getConversation( threadId, reverse, offset.toLong(), params.loadSize.toLong() ).use { cursor -> - buildList { + buildList(cursor.count) { val reader = mmsSmsDatabase.readerFor(cursor) var record = reader.getNext() while (record != null) { @@ -43,15 +43,17 @@ class ConversationPagingSource( } } - val mapped = records.flatMapIndexed { index, record -> + val mapped = mutableListOf() + for (i in records.indices) { dataMapper.map( - record = record, - previous = records.getOrNull(index + 1), - next = records.getOrNull(index - 1), + record = records[i], + previous = records.getOrNull(i - 1), + next = records.getOrNull(i + 1), threadRecipient = threadRecipient, localUserAddress = localUserAddress, - showStatus = record.messageId == lastSentMessageId, - lastSeen = lastSeen + showStatus = records[i].messageId == lastSentMessageId, + lastSeen = lastSeen, + out = mapped, ) } From 7364e5e8f1a791bb6340740d483e0c348c63484f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 4 Mar 2026 12:05:18 +1100 Subject: [PATCH 23/23] PR feedback --- .../v3/ConversationV3ViewModel.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 366be4a770..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 @@ -86,16 +86,17 @@ class ConversationV3ViewModel @AssistedInject constructor( private val reactionDb: ReactionDatabase, private val dataMapper: ConversationDataMapper, ) : ViewModel() { - - val threadIdFlow: StateFlow = - storage.getThreadId(address) - ?.let { MutableStateFlow(it) } - ?: threadDb.updateNotifications - .map { storage.getThreadId(address) } - .flowOn(Dispatchers.Default) - .filterNotNull() - .take(1) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + //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()