Merge branch 'main' into wallet
# Conflicts: # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt # libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt
This commit is contained in:
commit
0ef6b69a79
912 changed files with 17051 additions and 4425 deletions
|
|
@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel
|
|||
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
|
||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
|
||||
import io.element.android.features.messages.impl.threads.list.ThreadsListNode
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
@ -197,6 +198,9 @@ class MessagesFlowNode(
|
|||
val recipientAddress: String?,
|
||||
val amountLovelace: Long?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ThreadsList : NavTarget
|
||||
}
|
||||
|
||||
private val callback: MessagesEntryPoint.Callback = callback()
|
||||
|
|
@ -324,6 +328,14 @@ class MessagesFlowNode(
|
|||
) {
|
||||
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
|
||||
}
|
||||
|
||||
override fun navigateToThreadsList() {
|
||||
backstack.push(NavTarget.ThreadsList)
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
|
|
@ -459,6 +471,10 @@ class MessagesFlowNode(
|
|||
override fun handleForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
backstack.push(NavTarget.Thread(threadRootId, null))
|
||||
}
|
||||
}
|
||||
createNode<PinnedMessagesListNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
|
|
@ -542,6 +558,10 @@ class MessagesFlowNode(
|
|||
) {
|
||||
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
|
|
@ -601,6 +621,14 @@ class MessagesFlowNode(
|
|||
.setAmount(navTarget.amountLovelace?.toString())
|
||||
.build()
|
||||
}
|
||||
NavTarget.ThreadsList -> {
|
||||
val callback = object : ThreadsListNode.Callback {
|
||||
override fun openThread(threadId: ThreadId) {
|
||||
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
|
||||
}
|
||||
}
|
||||
createNode<ThreadsListNode>(buildContext, listOf(callback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -663,7 +691,7 @@ class MessagesFlowNode(
|
|||
assetType = event.content.assetType,
|
||||
)
|
||||
NavTarget.LocationViewer(
|
||||
mode = mode
|
||||
mode = mode
|
||||
).takeIf { locationService.isServiceAvailable() }
|
||||
}
|
||||
else -> null
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ interface MessagesNavigator {
|
|||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
||||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
|
||||
/**
|
||||
* Navigate to the payment flow for /pay slash command.
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class MessagesNode(
|
|||
private val timelineController = TimelineController(room, room.liveTimeline)
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||
|
|
@ -130,6 +130,8 @@ class MessagesNode(
|
|||
fun navigateToRoomDetails()
|
||||
fun navigateToPinnedMessagesList()
|
||||
fun navigateToKnockRequestsList()
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToThreadsList()
|
||||
fun navigateToWallet()
|
||||
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
|
||||
}
|
||||
|
|
@ -224,10 +226,18 @@ class MessagesNode(
|
|||
}
|
||||
}
|
||||
|
||||
override fun navigateToMember(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
callback.navigateToThread(threadRootId, focusedEventId)
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
|
|
@ -302,6 +312,7 @@ class MessagesNode(
|
|||
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
||||
)
|
||||
},
|
||||
onThreadsListClick = callback::navigateToThreadsList,
|
||||
)
|
||||
roomMemberModerationRenderer.Render(
|
||||
state = state.roomMemberModerationState,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
|
@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject
|
|||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||
import io.element.android.appconfig.MessageComposerConfig
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.MessagesState.Threads
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
|
|
@ -85,8 +87,11 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
|||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -160,6 +165,13 @@ class MessagesPresenter(
|
|||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||
val roomCallState = roomCallStatePresenter.present()
|
||||
val roomMemberModerationState = roomMemberModerationPresenter.present()
|
||||
val threadsList by produceState(persistentListOf()) {
|
||||
room.threadsListService.subscribeToItemUpdates()
|
||||
.onStart { room.threadsListService.paginate() }
|
||||
.collectLatest { value = it.toImmutableList() }
|
||||
}
|
||||
|
||||
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
|
||||
|
||||
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||
perms.userEventPermissions()
|
||||
|
|
@ -250,12 +262,11 @@ class MessagesPresenter(
|
|||
is MessagesEvent.OnUserClicked -> {
|
||||
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
|
||||
}
|
||||
is MessagesEvent.MarkAsFullyReadAndExit -> coroutineScope.launch {
|
||||
if (!markingAsReadAndExiting.getAndSet(true)) {
|
||||
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
|
||||
coroutineScope.launch {
|
||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||
Timber.w(it, "Failed to get latest event id to mark as fully read")
|
||||
navigator.close()
|
||||
return@launch
|
||||
null
|
||||
}
|
||||
latestEventId?.let { eventId ->
|
||||
sessionCoroutineScope.launch {
|
||||
|
|
@ -263,7 +274,6 @@ class MessagesPresenter(
|
|||
}
|
||||
}
|
||||
navigator.close()
|
||||
markingAsReadAndExiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -297,6 +307,11 @@ class MessagesPresenter(
|
|||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
isDmRoom = roomInfo.isDm,
|
||||
successorRoom = roomInfo.successorRoom,
|
||||
threads = Threads(
|
||||
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
|
||||
// TODO calculate this properly based on the thread list and the read state of each thread
|
||||
hasUnreadThreads = false,
|
||||
),
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,9 +58,15 @@ data class MessagesState(
|
|||
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
||||
val isDmRoom: Boolean,
|
||||
val successorRoom: SuccessorRoom?,
|
||||
val threads: Threads,
|
||||
val eventSink: (MessagesEvent) -> Unit
|
||||
) {
|
||||
val isTombstoned = successorRoom != null
|
||||
|
||||
data class Threads(
|
||||
val hasThreads: Boolean,
|
||||
val hasUnreadThreads: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
/** Type of "shared history" icon to show in the top bar. */
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ fun aMessagesState(
|
|||
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||
isDmRoom: Boolean = false,
|
||||
successorRoom: SuccessorRoom? = null,
|
||||
threads: MessagesState.Threads = MessagesState.Threads(
|
||||
hasThreads = false,
|
||||
hasUnreadThreads = false,
|
||||
),
|
||||
eventSink: (MessagesEvent) -> Unit = {},
|
||||
) = MessagesState(
|
||||
roomId = RoomId("!id:domain"),
|
||||
|
|
@ -152,6 +156,7 @@ fun aMessagesState(
|
|||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
isDmRoom = isDmRoom,
|
||||
successorRoom = successorRoom,
|
||||
threads = threads,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -39,6 +42,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
|
@ -51,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
|
|
@ -73,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
|||
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||
|
|
@ -87,6 +93,7 @@ import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
|||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
|
||||
|
|
@ -98,6 +105,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
|
||||
|
|
@ -133,6 +142,7 @@ fun MessagesView(
|
|||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onWalletClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
onThreadsListClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
|
|
@ -224,14 +234,20 @@ fun MessagesView(
|
|||
roomAvatar = state.roomAvatar,
|
||||
isTombstoned = state.isTombstoned,
|
||||
heroes = state.heroes,
|
||||
roomCallState = state.roomCallState,
|
||||
dmUserIdentityState = state.dmUserVerificationState,
|
||||
sharedHistoryIcon = state.topBarSharedHistoryIcon,
|
||||
isDmRoom = state.isDmRoom,
|
||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onWalletClick = onWalletClick,
|
||||
menuActions = {
|
||||
MessagesMenuActions(
|
||||
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
|
||||
roomCallState = state.roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onThreadsListClick = onThreadsListClick,
|
||||
isDmRoom = state.isDmRoom,
|
||||
onWalletClick = onWalletClick,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -400,6 +416,40 @@ fun MessagesView(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MessagesMenuActions(
|
||||
displayThreads: Boolean,
|
||||
roomCallState: RoomCallState,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onThreadsListClick: () -> Unit,
|
||||
isDmRoom: Boolean = false,
|
||||
onWalletClick: (() -> Unit)? = null,
|
||||
) {
|
||||
if (displayThreads) {
|
||||
Icon(
|
||||
modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick),
|
||||
imageVector = CompoundIcons.ThreadsSolid(),
|
||||
contentDescription = stringResource(CommonStrings.common_threads),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
CallMenuItem(
|
||||
roomCallState = roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
// Wallet button - only show in DM rooms
|
||||
if (isDmRoom && onWalletClick != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(onClick = onWalletClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Chart(),
|
||||
contentDescription = "Cardano Wallet",
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReinviteDialog(state: MessagesState) {
|
||||
if (state.showReinvitePrompt) {
|
||||
|
|
@ -469,6 +519,9 @@ private fun MessagesViewContent(
|
|||
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
|
||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
|
||||
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
|
|
@ -484,11 +537,13 @@ private fun MessagesViewContent(
|
|||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
|
|
@ -601,6 +656,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
|||
onViewAllPinnedMessagesClick = { },
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -653,7 +709,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
|
|||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = { },
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
onThreadsListClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
|
|||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
|
||||
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
|
||||
data object SaveDraft : MessageComposerEvent
|
||||
data object ClearSlashError : MessageComposerEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
|
@ -33,14 +34,16 @@ import im.vector.app.features.analytics.plan.Interaction
|
|||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.Attachment.Media
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
|
||||
import io.element.android.features.wallet.impl.slash.SlashCommandParser
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
|
@ -70,6 +73,9 @@ import io.element.android.libraries.permissions.api.PermissionsEvent
|
|||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.api.message
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
|
|
@ -107,6 +113,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
|||
class MessageComposerPresenter(
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
@Assisted private val timelineController: TimelineController,
|
||||
@Assisted private val isInThread: Boolean,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val room: JoinedRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
|
|
@ -129,10 +136,15 @@ class MessageComposerPresenter(
|
|||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
private val slashCommandParser: SlashCommandParser,
|
||||
private val slashCommandService: SlashCommandService,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
|
||||
fun create(
|
||||
timelineController: TimelineController,
|
||||
navigator: MessagesNavigator,
|
||||
isInThread: Boolean,
|
||||
): MessageComposerPresenter
|
||||
}
|
||||
|
||||
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
|
||||
|
|
@ -222,6 +234,8 @@ class MessageComposerPresenter(
|
|||
}
|
||||
)
|
||||
|
||||
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val draft = draftService.loadDraft(
|
||||
roomId = room.roomId,
|
||||
|
|
@ -250,12 +264,13 @@ class MessageComposerPresenter(
|
|||
sessionCoroutineScope.sendMessage(
|
||||
markdownTextEditorState = markdownTextEditorState,
|
||||
richTextEditorState = richTextEditorState,
|
||||
slashCommandAction = slashCommandAction,
|
||||
)
|
||||
}
|
||||
is MessageComposerEvent.SendUri -> {
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
sessionCoroutineScope.sendAttachment(
|
||||
attachment = Attachment.Media(
|
||||
attachment = Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
|
|
@ -345,9 +360,7 @@ class MessageComposerPresenter(
|
|||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
}
|
||||
is ResolvedSuggestion.Command -> {
|
||||
// Insert the command text with a trailing space
|
||||
richTextEditorState.setMarkdown("${suggestion.command} ")
|
||||
suggestionSearchTrigger.value = null
|
||||
richTextEditorState.replaceSuggestion(suggestion.command.command)
|
||||
}
|
||||
}
|
||||
} else if (markdownTextEditorState.currentSuggestion != null) {
|
||||
|
|
@ -363,6 +376,9 @@ class MessageComposerPresenter(
|
|||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
|
||||
}
|
||||
MessageComposerEvent.ClearSlashError -> {
|
||||
slashCommandAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +410,7 @@ class MessageComposerPresenter(
|
|||
suggestions = suggestions.toImmutableList(),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||
slashCommandAction = slashCommandAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
@ -431,6 +448,7 @@ class MessageComposerPresenter(
|
|||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
isInThread = isInThread,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
|
|
@ -442,53 +460,115 @@ class MessageComposerPresenter(
|
|||
private fun CoroutineScope.sendMessage(
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
slashCommandAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
|
||||
// Check for /pay slash command
|
||||
val payCommand = parsePayCommand(message.markdown)
|
||||
if (payCommand != null) {
|
||||
when (payCommand) {
|
||||
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.ParseError -> {
|
||||
// Show error, keep text in composer
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
return@launch
|
||||
// Check for /pay slash command FIRST (our Cardano wallet integration).
|
||||
// If matched, short-circuit before upstream's slash command service runs.
|
||||
if (capturedMode is MessageComposerMode.Normal) {
|
||||
val payCommand = parsePayCommand(message.markdown)
|
||||
if (payCommand != null) {
|
||||
when (payCommand) {
|
||||
is ParsedPayCommand.ParseError -> {
|
||||
// Show error, keep text in composer
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.WithAddressRecipient -> {
|
||||
// Reset composer and navigate to payment flow
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientAddress = payCommand.address,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.WithMatrixRecipient -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientUserId = payCommand.matrixUserId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.AmountOnly -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.Empty -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.WithAddressRecipient -> {
|
||||
// Reset composer and navigate to payment flow
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientAddress = payCommand.address,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val slashCommand = if (capturedMode is MessageComposerMode.Normal) {
|
||||
slashCommandService.parse(
|
||||
textMessage = message.markdown,
|
||||
formattedMessage = message.html,
|
||||
isInThreadTimeline = isInThread,
|
||||
)
|
||||
} else {
|
||||
SlashCommand.NotACommand
|
||||
}
|
||||
|
||||
when (slashCommand) {
|
||||
is SlashCommand.NotACommand -> Unit
|
||||
is SlashCommand.Error -> {
|
||||
slashCommandAction.value = AsyncAction.Failure(Exception(slashCommand.message()))
|
||||
return@launch
|
||||
}
|
||||
is SlashCommand.SlashCommandNavigation -> {
|
||||
when (slashCommand) {
|
||||
is SlashCommand.ShowUser -> {
|
||||
navigator.navigateToMember(slashCommand.userId)
|
||||
}
|
||||
SlashCommand.DevTools -> {
|
||||
navigator.navigateToDeveloperSettings()
|
||||
}
|
||||
}
|
||||
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.WithMatrixRecipient -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientUserId = payCommand.matrixUserId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.AmountOnly -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.Empty -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
)
|
||||
return@launch
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
return@launch
|
||||
}
|
||||
is SlashCommand.SlashCommandSendMessage -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
slashCommandService.proceedSendMessage(slashCommand, this)
|
||||
.onFailure { cause ->
|
||||
Timber.e(cause, "Failed to proceed with admin slash command")
|
||||
slashCommandAction.value = AsyncAction.Failure(cause)
|
||||
}
|
||||
.onSuccess {
|
||||
// Reset composer
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
is SlashCommand.SlashCommandAdmin -> {
|
||||
slashCommandAction.value = AsyncAction.Loading
|
||||
slashCommandService.proceedAdmin(slashCommand)
|
||||
.onFailure { cause ->
|
||||
Timber.e(cause, "Failed to proceed with admin slash command")
|
||||
slashCommandAction.value = AsyncAction.Failure(cause)
|
||||
}
|
||||
.onSuccess {
|
||||
// Reset composer
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
slashCommandAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
|
@ -26,5 +27,6 @@ data class MessageComposerState(
|
|||
val suggestions: ImmutableList<ResolvedSuggestion>,
|
||||
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
||||
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||
val slashCommandAction: AsyncAction<Unit>,
|
||||
val eventSink: (MessageComposerEvent) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
|
@ -32,6 +33,7 @@ fun aMessageComposerState(
|
|||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
|
||||
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (MessageComposerEvent) -> Unit = {},
|
||||
) = MessageComposerState(
|
||||
textEditorState = textEditorState,
|
||||
|
|
@ -43,5 +45,6 @@ fun aMessageComposerState(
|
|||
suggestions = suggestions,
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
slashCommandAction = slashCommandAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
|
|||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
|
|
@ -115,6 +116,12 @@ internal fun MessageComposerView(
|
|||
onTyping = ::onTyping,
|
||||
onSelectRichContent = ::sendUri,
|
||||
)
|
||||
|
||||
AsyncActionView(
|
||||
async = state.slashCommandAction,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -63,7 +65,7 @@ fun SuggestionsPickerView(
|
|||
is ResolvedSuggestion.AtRoom -> "@room"
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomId.value
|
||||
is ResolvedSuggestion.Command -> suggestion.command
|
||||
is ResolvedSuggestion.Command -> suggestion.command.command
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -92,58 +94,81 @@ private fun SuggestionItemView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable { onSelectSuggestion(suggestion) },
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier
|
||||
.clickable { onSelectSuggestion(suggestion) }
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
val avatarSize = AvatarSize.Suggestion
|
||||
val avatarData = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
|
||||
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
|
||||
is ResolvedSuggestion.Command -> AvatarData(suggestion.command, suggestion.command, null, avatarSize)
|
||||
is ResolvedSuggestion.Command -> null
|
||||
}
|
||||
val avatarType = when (suggestion) {
|
||||
is ResolvedSuggestion.Alias,
|
||||
is ResolvedSuggestion.Command -> AvatarType.Room()
|
||||
is ResolvedSuggestion.Alias -> Room()
|
||||
ResolvedSuggestion.AtRoom,
|
||||
is ResolvedSuggestion.Member -> AvatarType.User
|
||||
is ResolvedSuggestion.Command -> null
|
||||
}
|
||||
val title = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomName
|
||||
is ResolvedSuggestion.Command -> suggestion.command
|
||||
is ResolvedSuggestion.Command -> suggestion.command.command
|
||||
}
|
||||
val details = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom,
|
||||
is ResolvedSuggestion.Member,
|
||||
is ResolvedSuggestion.Alias -> null
|
||||
is ResolvedSuggestion.Command -> suggestion.command.parameters
|
||||
}
|
||||
val subtitle = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> "@room"
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
|
||||
is ResolvedSuggestion.Command -> suggestion.description
|
||||
is ResolvedSuggestion.Command -> suggestion.command.description
|
||||
}
|
||||
if (avatarData != null && avatarType != null) {
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = avatarType,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp),
|
||||
)
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = avatarType,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.padding(top = 8.dp, bottom = 8.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
details?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
maxLines = 1,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
|
@ -179,7 +204,21 @@ internal fun SuggestionsPickerViewPreview() {
|
|||
roomId = RoomId("!room:matrix.org"),
|
||||
roomName = "My room",
|
||||
roomAvatarUrl = null,
|
||||
)
|
||||
),
|
||||
ResolvedSuggestion.Command(
|
||||
command = SlashCommandSuggestion(
|
||||
command = "/noparam",
|
||||
parameters = null,
|
||||
description = "A slash command without parameters",
|
||||
)
|
||||
),
|
||||
ResolvedSuggestion.Command(
|
||||
command = SlashCommandSuggestion(
|
||||
command = "/withparam",
|
||||
parameters = "<user-id> [reason]",
|
||||
description = "A slash command with parameters",
|
||||
)
|
||||
),
|
||||
),
|
||||
onSelectSuggestion = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
|
@ -23,7 +25,9 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
|
|||
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
|
||||
*/
|
||||
@Inject
|
||||
class SuggestionsProcessor {
|
||||
class SuggestionsProcessor(
|
||||
private val slashCommandService: SlashCommandService,
|
||||
) {
|
||||
/**
|
||||
* Process the suggestion.
|
||||
* @param suggestion The current suggestion input
|
||||
|
|
@ -31,6 +35,7 @@ class SuggestionsProcessor {
|
|||
* @param roomAliasSuggestions The available room alias suggestions
|
||||
* @param currentUserId The current user id
|
||||
* @param canSendRoomMention Should return true if the current user can send room mentions
|
||||
* @param isInThread Whether the composer is in a thread or not, used to filter slash commands suggestions
|
||||
* @return The list of suggestions to display
|
||||
*/
|
||||
suspend fun process(
|
||||
|
|
@ -39,6 +44,7 @@ class SuggestionsProcessor {
|
|||
roomAliasSuggestions: List<RoomAliasSuggestion>,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: suspend () -> Boolean,
|
||||
isInThread: Boolean,
|
||||
): List<ResolvedSuggestion> {
|
||||
suggestion ?: return emptyList()
|
||||
return when (suggestion.type) {
|
||||
|
|
@ -70,14 +76,27 @@ class SuggestionsProcessor {
|
|||
}
|
||||
}
|
||||
SuggestionType.Command -> {
|
||||
// Return available slash commands filtered by user input
|
||||
val commands = listOf(
|
||||
ResolvedSuggestion.Command("/pay", "Send ADA to someone"),
|
||||
)
|
||||
commands.filter { command ->
|
||||
// Filter by what user has typed after /
|
||||
command.command.contains(suggestion.text, ignoreCase = true) ||
|
||||
suggestion.text.isEmpty()
|
||||
// Command suggestions are valid only if this is the beginning of the message
|
||||
if (suggestion.start == 0) {
|
||||
val upstream = slashCommandService.getSuggestions(suggestion.text, isInThread).map {
|
||||
ResolvedSuggestion.Command(it)
|
||||
}
|
||||
val wallet = if ("pay".startsWith(suggestion.text, ignoreCase = true)) {
|
||||
listOf(
|
||||
ResolvedSuggestion.Command(
|
||||
SlashCommandSuggestion(
|
||||
command = "pay",
|
||||
parameters = "[recipient] [amount]",
|
||||
description = "Send ADA to someone",
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
upstream + wallet
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
SuggestionType.Emoji,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.pinned.list
|
|||
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
sealed interface PinnedMessagesListEvent {
|
||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent
|
||||
data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@
|
|||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
interface PinnedMessagesListNavigator {
|
||||
fun viewInTimeline(eventId: EventId)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId)
|
||||
fun navigateToThread(threadRootId: ThreadId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
|||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
|
|
@ -55,6 +56,7 @@ class PinnedMessagesListNode(
|
|||
fun handlePermalinkClick(data: PermalinkData.RoomLink)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun handleForwardEventClick(eventId: EventId)
|
||||
fun navigateToThread(threadRootId: ThreadId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
|
|
@ -95,6 +97,10 @@ class PinnedMessagesListNode(
|
|||
callback.handleForwardEventClick(eventId)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
callback.navigateToThread(threadRootId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CompositionLocalProvider(
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class PinnedMessagesListPresenter(
|
|||
fun handleEvent(event: PinnedMessagesListEvent) {
|
||||
when (event) {
|
||||
is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||
is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
|
|||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.link.LinkEvent
|
||||
import io.element.android.features.messages.impl.link.LinkView
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
|
|
@ -235,7 +236,12 @@ private fun PinnedMessagesListLoaded(
|
|||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onJoinCallClick = {},
|
||||
eventSink = {},
|
||||
eventSink = { timelineItemEvent ->
|
||||
when (timelineItemEvent) {
|
||||
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
eventContentView = { event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentViewWrapper(
|
||||
event = event,
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class ThreadedMessagesNode(
|
|||
this.timelineController = timelineController
|
||||
return presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
// TODO add special processor for threaded timeline
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
|
|
@ -136,6 +136,7 @@ class ThreadedMessagesNode(
|
|||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
|
||||
}
|
||||
|
||||
|
|
@ -234,10 +235,18 @@ class ThreadedMessagesNode(
|
|||
callback.handlePermalinkClick(permalinkData)
|
||||
}
|
||||
|
||||
override fun navigateToMember(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
callback.navigateToThread(threadRootId, focusedEventId)
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
|
|
@ -302,6 +311,7 @@ class ThreadedMessagesNode(
|
|||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
roomMemberModerationRenderer.Render(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||
|
||||
data class ThreadListRowItem(
|
||||
val item: ThreadListItem,
|
||||
val rootEventText: String?,
|
||||
val latestEventText: String?,
|
||||
val formattedTimestamp: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class ThreadsListNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: ThreadsListPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun openThread(threadId: ThreadId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
ThreadsListView(
|
||||
state = presenter.present(),
|
||||
modifier = modifier,
|
||||
onThreadClick = callback::openThread,
|
||||
onBackClick = this::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class ThreadsListPresenter(
|
||||
private val room: JoinedRoom,
|
||||
private val timelineItemContentFactory: TimelineItemContentFactory,
|
||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : Presenter<ThreadsListState> {
|
||||
@Composable
|
||||
override fun present(): ThreadsListState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val threadsListService = room.threadsListService
|
||||
|
||||
val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
|
||||
threadsListService.subscribeToItemUpdates()
|
||||
.onStart { threadsListService.paginate() }
|
||||
.collect { items ->
|
||||
Timber.d("Received thread list update with ${items.size} items")
|
||||
value = items.map { item ->
|
||||
val rootTimelineEvent = item.rootEvent.content?.let {
|
||||
timelineItemContentFactory.create(
|
||||
itemContent = it,
|
||||
eventId = item.rootEvent.eventId,
|
||||
isEditable = false,
|
||||
sender = item.rootEvent.senderId,
|
||||
senderProfile = item.rootEvent.senderProfile,
|
||||
)
|
||||
}
|
||||
val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) }
|
||||
|
||||
val latestTimelineEvent = item.latestEvent?.content?.let {
|
||||
timelineItemContentFactory.create(
|
||||
itemContent = it,
|
||||
eventId = item.latestEvent!!.eventId,
|
||||
isEditable = false,
|
||||
sender = item.latestEvent!!.senderId,
|
||||
senderProfile = item.latestEvent!!.senderProfile,
|
||||
)
|
||||
}
|
||||
val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) }
|
||||
|
||||
val formattedTimestamp = dateFormatter.format(
|
||||
timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
useRelative = true,
|
||||
)
|
||||
|
||||
ThreadListRowItem(
|
||||
item = item,
|
||||
rootEventText = rootEventText,
|
||||
latestEventText = latestEventText,
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
val paginationStatus by produceState<ThreadListPaginationStatus>(
|
||||
initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true),
|
||||
key1 = threadsListService
|
||||
) {
|
||||
threadsListService
|
||||
.subscribeToPaginationUpdates()
|
||||
.collect { value = it }
|
||||
}
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
threadsListService.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: ThreadsListEvents) {
|
||||
when (event) {
|
||||
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
|
||||
coroutineScope.launch {
|
||||
Timber.d("Paginating thread list: $paginationStatus")
|
||||
threadsListService.paginate()
|
||||
}
|
||||
} else {
|
||||
Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ThreadsListState(
|
||||
threads = threads,
|
||||
roomId = room.roomId,
|
||||
roomName = roomInfo.name ?: room.roomId.value,
|
||||
roomAvatarUrl = roomInfo.avatarUrl,
|
||||
isRoomTombstoned = roomInfo.successorRoom != null,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ThreadsListState(
|
||||
val roomId: RoomId,
|
||||
val roomName: String,
|
||||
val roomAvatarUrl: String?,
|
||||
val isRoomTombstoned: Boolean,
|
||||
val threads: ImmutableList<ThreadListRowItem>,
|
||||
val eventSink: (ThreadsListEvents) -> Unit,
|
||||
)
|
||||
|
||||
sealed interface ThreadsListEvents {
|
||||
data object Paginate : ThreadsListEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads.list
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.asEventId
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThreadsListView(
|
||||
state: ThreadsListState,
|
||||
onThreadClick: (ThreadId) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = state.roomId.value,
|
||||
name = state.roomName,
|
||||
url = state.roomAvatarUrl,
|
||||
size = AvatarSize.CurrentUserTopBar,
|
||||
),
|
||||
avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned),
|
||||
contentDescription = null,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_threads),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = state.roomName,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onBackClick)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val lazyListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = padding,
|
||||
state = lazyListState,
|
||||
) {
|
||||
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
|
||||
ThreadListItemRow(
|
||||
threadItem = row,
|
||||
onClick = onThreadClick,
|
||||
)
|
||||
|
||||
if (index < state.threads.size - 1) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollHelper(lazyListState) {
|
||||
state.eventSink(ThreadsListEvents.Paginate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollHelper(
|
||||
listState: LazyListState,
|
||||
onPaginate: () -> Unit,
|
||||
) {
|
||||
val lastVisibleItemIndex by remember {
|
||||
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
|
||||
}
|
||||
val needsPagination by remember {
|
||||
derivedStateOf {
|
||||
val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0
|
||||
canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1
|
||||
}
|
||||
}
|
||||
LaunchedEffect(needsPagination, lastVisibleItemIndex) {
|
||||
if (needsPagination) {
|
||||
onPaginate()
|
||||
delay(400L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreadListItemRow(
|
||||
threadItem: ThreadListRowItem,
|
||||
onClick: (ThreadId) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onClick(threadItem.item.threadId) }
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
val rootEvent = threadItem.item.rootEvent
|
||||
val senderProfile = rootEvent.senderProfile
|
||||
Avatar(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
avatarData = AvatarData(
|
||||
id = rootEvent.senderId.value,
|
||||
name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
|
||||
url = senderProfile.getAvatarUrl(),
|
||||
size = AvatarSize.ThreadsListItem,
|
||||
),
|
||||
avatarType = AvatarType.User,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// TODO actually compute these values based on the thread state (not available yet)
|
||||
val hasMentions = false
|
||||
val hasUnreadNotifications = false
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = threadItem.formattedTimestamp,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = threadItem.rootEventText.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp)
|
||||
) {
|
||||
if (hasMentions) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
imageVector = CompoundIcons.Mention(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
}
|
||||
|
||||
UnreadIndicatorAtom(
|
||||
size = 14.dp,
|
||||
isVisible = hasUnreadNotifications,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "${threadItem.item.numberOfReplies}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.ThreadsSolid(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
threadItem.item.latestEvent?.let { latestEvent ->
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = latestEvent.senderId.value,
|
||||
name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
|
||||
url = latestEvent.senderProfile.getAvatarUrl(),
|
||||
size = AvatarSize.TimelineThreadLatestEventSender,
|
||||
),
|
||||
avatarType = AvatarType.User,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = threadItem.latestEventText.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ThreadsListViewPreview() {
|
||||
ElementPreview {
|
||||
ThreadsListView(
|
||||
state = ThreadsListState(
|
||||
roomId = RoomId("!room-id:server"),
|
||||
roomName = "Room name",
|
||||
roomAvatarUrl = null,
|
||||
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
|
||||
isRoomTombstoned = false,
|
||||
eventSink = {},
|
||||
),
|
||||
onThreadClick = {},
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ThreadListItemRowPreview() {
|
||||
ElementPreview {
|
||||
ThreadListItemRow(
|
||||
threadItem = aThreadListRowItem(),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun aThreadListRowItem(
|
||||
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
|
||||
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
|
||||
numberOfReplies: Long = 42,
|
||||
rootEventText: String? = "Hello world!",
|
||||
latestEventText: String? = "Hello again!",
|
||||
formattedTimestamp: String = "12:34",
|
||||
) = ThreadListRowItem(
|
||||
item = aThreadListItem(
|
||||
threadId = threadId,
|
||||
rootEvent = rootEvent,
|
||||
latestEvent = latestEvent,
|
||||
numberOfReplies = numberOfReplies,
|
||||
),
|
||||
rootEventText = rootEventText,
|
||||
latestEventText = latestEventText,
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
)
|
||||
|
||||
fun aThreadListItem(
|
||||
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
|
||||
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
|
||||
numberOfReplies: Long = 42,
|
||||
) = ThreadListItem(
|
||||
rootEvent = rootEvent,
|
||||
latestEvent = latestEvent,
|
||||
numberOfReplies = numberOfReplies,
|
||||
)
|
||||
|
||||
fun aThreadListItemEvent(
|
||||
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||
senderId: UserId = UserId("@a-user-id:server"),
|
||||
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null),
|
||||
isOwn: Boolean = false,
|
||||
content: EventContent = MessageContent(
|
||||
body = "Hello world!",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("Hello world!", null),
|
||||
),
|
||||
timestamp: Long = 0L,
|
||||
) = ThreadListItemEvent(
|
||||
eventId = threadId.asEventId(),
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
isOwn = isOwn,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
|
|
@ -149,6 +149,9 @@ class TimelinePresenter(
|
|||
val displayThreadSummaries by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||
}
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
|
|
@ -315,6 +318,7 @@ class TimelinePresenter(
|
|||
messageShieldDialogData = messageShieldDialogData.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ data class TimelineState(
|
|||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ fun aTimelineState(
|
|||
messageShield: MessageShield? = null,
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayFloatingDateBadge: Boolean = false,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
|
|
@ -75,6 +76,7 @@ fun aTimelineState(
|
|||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
|
|||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
|
||||
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.toText
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
|
|
@ -82,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
|
@ -105,6 +108,7 @@ fun TimelineView(
|
|||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
floatingDateTopOffset: Dp = 0.dp,
|
||||
) {
|
||||
fun clearFocusRequestState() {
|
||||
state.eventSink(TimelineEvent.ClearFocusRequestState)
|
||||
|
|
@ -210,6 +214,15 @@ fun TimelineView(
|
|||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
)
|
||||
|
||||
if (state.displayFloatingDateBadge && useReverseLayout) {
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,11 +263,16 @@ private fun TimelinePrefetchingHelper(
|
|||
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
|
||||
}
|
||||
|
||||
// If we have no timeline items, we need to back paginate to load some messages. This usually happens on all timelines except for live ones.
|
||||
// This automatic pagination was previously done by the SDK, and we received a `Reset` update, but now we need to do it ourselves.
|
||||
val isEmptyTimelineFlow = layoutInfoFlow.map { it.totalItemsCount == 0 }
|
||||
|
||||
combine(
|
||||
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
|
||||
isScrollingFlow.distinctUntilChanged(),
|
||||
) { needsPrefetch, isScrolling ->
|
||||
needsPrefetch && isScrolling
|
||||
isEmptyTimelineFlow,
|
||||
) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination ->
|
||||
isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { needsPrefetch ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.FloatingDateBadgeOverlay(
|
||||
lazyListState: LazyListState,
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
isLive: Boolean,
|
||||
topOffset: Dp = 0.dp,
|
||||
) {
|
||||
// This needs to be a state to trigger a `derivedState` recalculation
|
||||
val updatedTimelineItems by rememberUpdatedState(timelineItems)
|
||||
|
||||
// Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list
|
||||
val lastVisibleItemWithTimestamp by remember {
|
||||
derivedStateOf {
|
||||
var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null
|
||||
while (index >= 0) {
|
||||
when (val item = updatedTimelineItems.getOrNull(index)) {
|
||||
is TimelineItem.Event -> return@derivedStateOf item
|
||||
is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item
|
||||
is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull()
|
||||
null -> Unit
|
||||
}
|
||||
index--
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed
|
||||
var formattedDate: String? by remember { mutableStateOf(null) }
|
||||
// Update the formatted date when we have a new non-null timestamp
|
||||
LaunchedEffect(lastVisibleItemWithTimestamp) {
|
||||
lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it }
|
||||
}
|
||||
|
||||
val isAtBottom by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||
}
|
||||
}
|
||||
|
||||
var isBadgeVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { lazyListState.isScrollInProgress }
|
||||
.collectLatest { isScrolling ->
|
||||
if (isScrolling) {
|
||||
isBadgeVisible = true
|
||||
} else {
|
||||
delay(2000.milliseconds)
|
||||
isBadgeVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBadge,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 8.dp + topOffset),
|
||||
enter = fadeIn(animationSpec = tween(150)),
|
||||
exit = fadeOut(animationSpec = tween(300)),
|
||||
) {
|
||||
formattedDate?.let { dateText ->
|
||||
FloatingDateBadge(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
dateText = dateText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FloatingDateBadge(
|
||||
dateText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = ElementTheme.colors.floatingDateBadgeBackground,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
text = dateText,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FloatingDateBadgePreview() = ElementPreview {
|
||||
Box(modifier = Modifier.padding(16.dp)) {
|
||||
FloatingDateBadge(dateText = "March 9, 2026")
|
||||
}
|
||||
}
|
||||
|
|
@ -747,7 +747,7 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
Box(
|
||||
modifier = talkbackCompatModifier
|
||||
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(6.dp))
|
||||
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ class TimelineItemEventFactory(
|
|||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.TimeOnly,
|
||||
)
|
||||
val sentDate = dateFormatter.format(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Day,
|
||||
useRelative = true,
|
||||
)
|
||||
val senderAvatarData = AvatarData(
|
||||
id = currentSender.value,
|
||||
name = senderProfile.getDisambiguatedDisplayName(currentSender),
|
||||
|
|
@ -108,6 +113,7 @@ class TimelineItemEventFactory(
|
|||
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
|
||||
sentTimeMillis = currentTimelineItem.event.timestamp,
|
||||
sentTime = sentTime,
|
||||
sentDate = sentDate,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -59,6 +60,12 @@ sealed interface TimelineItem {
|
|||
is GroupedEvents -> "groupedEvent"
|
||||
}
|
||||
|
||||
fun formattedDate(): String? = when (this) {
|
||||
is Event -> sentDate.takeIf { it.isNotEmpty() }
|
||||
is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() }
|
||||
is GroupedEvents -> null
|
||||
}
|
||||
|
||||
data class Virtual(
|
||||
val id: UniqueId,
|
||||
val model: TimelineItemVirtualModel
|
||||
|
|
@ -75,6 +82,7 @@ sealed interface TimelineItem {
|
|||
val content: TimelineItemEventContent,
|
||||
val sentTimeMillis: Long = 0L,
|
||||
val sentTime: String = "",
|
||||
val sentDate: String = "",
|
||||
val isMine: Boolean = false,
|
||||
val isEditable: Boolean,
|
||||
val canBeRepliedTo: Boolean,
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -30,8 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.MessagesMenuActions
|
||||
import io.element.android.features.messages.impl.SharedHistoryIcon
|
||||
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.features.roomcall.api.anOngoingCallState
|
||||
|
|
@ -45,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
|
|
@ -63,15 +61,12 @@ internal fun MessagesViewTopBar(
|
|||
roomAvatar: AvatarData,
|
||||
isTombstoned: Boolean,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
roomCallState: RoomCallState,
|
||||
dmUserIdentityState: IdentityState?,
|
||||
sharedHistoryIcon: SharedHistoryIcon,
|
||||
isDmRoom: Boolean,
|
||||
onRoomDetailsClick: () -> Unit,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onWalletClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
menuActions: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
|
|
@ -129,22 +124,7 @@ internal fun MessagesViewTopBar(
|
|||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Wallet button - only show in DM rooms
|
||||
if (isDmRoom) {
|
||||
IconButton(onClick = onWalletClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Chart(),
|
||||
contentDescription = "Cardano Wallet",
|
||||
)
|
||||
}
|
||||
}
|
||||
CallMenuItem(
|
||||
roomCallState = roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
},
|
||||
actions = menuActions,
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -199,19 +179,26 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
|||
dmUserIdentityState: IdentityState? = null,
|
||||
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||
isDmRoom: Boolean = false,
|
||||
displayThreads: Boolean = false,
|
||||
) = MessagesViewTopBar(
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
isTombstoned = isTombstoned,
|
||||
heroes = heroes,
|
||||
roomCallState = roomCallState,
|
||||
dmUserIdentityState = dmUserIdentityState,
|
||||
sharedHistoryIcon = sharedHistoryIcon,
|
||||
isDmRoom = isDmRoom,
|
||||
onRoomDetailsClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onBackClick = {},
|
||||
menuActions = {
|
||||
MessagesMenuActions(
|
||||
roomCallState = roomCallState,
|
||||
displayThreads = displayThreads,
|
||||
onJoinCallClick = {},
|
||||
onThreadsListClick = {},
|
||||
isDmRoom = isDmRoom,
|
||||
onWalletClick = {},
|
||||
)
|
||||
}
|
||||
)
|
||||
Column {
|
||||
AMessagesViewTopBar()
|
||||
|
|
@ -253,5 +240,9 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
|||
roomName = "A room with world_readable history",
|
||||
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
|
||||
)
|
||||
HorizontalDivider()
|
||||
AMessagesViewTopBar(
|
||||
displayThreads = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
<string name="screen_room_attachment_source_camera_video">"Registra video"</string>
|
||||
<string name="screen_room_attachment_source_files">"Allegato"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Libreria di foto e video"</string>
|
||||
<string name="screen_room_attachment_source_location">"Posizione"</string>
|
||||
<string name="screen_room_attachment_source_location">"Condividi posizione"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Sondaggio"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Formattazione del testo"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"La cronologia dei messaggi non è attualmente disponibile."</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="crypto_event_authenticity_mismatched_sender">"イベントの送信者と、使用された端末の所有者が一致しません。"</string>
|
||||
<string name="crypto_event_authenticity_not_guaranteed">"この暗号化されたメッセージの真正性を、この端末では保証できません。"</string>
|
||||
<string name="crypto_event_authenticity_previously_verified">"以前に検証されたユーザーにより暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_sent_in_clear">"暗号化されていません。"</string>
|
||||
<string name="crypto_event_authenticity_unknown_device">"削除されたまたは不明な端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"所有者に検証されていない端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"未検証のユーザーにより暗号化されています。"</string>
|
||||
<string name="emoji_picker_category_activity">"アクティビティ"</string>
|
||||
<string name="emoji_picker_category_flags">"旗"</string>
|
||||
<string name="emoji_picker_category_foods">"食べ物"</string>
|
||||
<string name="emoji_picker_category_nature">"動物・自然"</string>
|
||||
<string name="emoji_picker_category_objects">"物"</string>
|
||||
<string name="emoji_picker_category_people">"顔・人"</string>
|
||||
<string name="emoji_picker_category_places">"旅・場所"</string>
|
||||
<string name="emoji_picker_category_recent">"最近使用"</string>
|
||||
<string name="emoji_picker_category_symbols">"記号"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"古いアプリケーションを使用しているユーザーはキャプションを見られない可能性があります。"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"動画のアップロード画質を変更するにはタップしてください"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"ファイルをアップロードに失敗しました。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"ファイルの処理に失敗しました。再試行してください。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"ファイルのアップロードに失敗しました。再試行してください。"</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"許容されている最大サイズは %1$s です。"</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"ファイルが大きすぎるためアップロードできません"</string>
|
||||
<string name="screen_media_upload_preview_item_count">"個数 %1$d / %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"画像の品質を最適化"</string>
|
||||
<string name="screen_media_upload_preview_processing">"処理中…"</string>
|
||||
<string name="screen_report_content_block_user">"ユーザーをブロック"</string>
|
||||
<string name="screen_report_content_block_user_hint">"このユーザーからのメッセージをすべて非表示にする場合はチェックしてください。"</string>
|
||||
<string name="screen_report_content_explanation">"このメッセージはホームサーバーの管理者に報告されます。暗号化されたメッセージを確認することはできません。"</string>
|
||||
<string name="screen_report_content_hint">"このコンテンツを通報する理由"</string>
|
||||
<string name="screen_room_attachment_source_camera">"カメラ"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"写真を撮影"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"動画を撮影"</string>
|
||||
<string name="screen_room_attachment_source_files">"添付ファイル"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"アルバムの写真・動画"</string>
|
||||
<string name="screen_room_attachment_source_location">"場所を共有"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"書式設定"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"過去のメッセージを現在表示できません。"</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"このルームの過去のメッセージを表示できません。確認するには、この端末を検証してください。"</string>
|
||||
<string name="screen_room_invite_again_alert_message">"招待し直しますか?"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"このチャットにはあなた一人だけです"</string>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"ルーム全体に通知"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"全員"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"再送信する"</string>
|
||||
<string name="screen_room_retry_send_menu_title">"メッセージの送信に失敗しました"</string>
|
||||
<string name="screen_room_timeline_add_reaction">"リアクションを追加"</string>
|
||||
<string name="screen_room_timeline_beginning_of_room">"%1$s の始まりです。"</string>
|
||||
<string name="screen_room_timeline_beginning_of_room_no_name">"ここが会話の開始点です。"</string>
|
||||
<string name="screen_room_timeline_legacy_call">"非対応の着信です。新しい Element X を使用できないか確認してください。"</string>
|
||||
<string name="screen_room_timeline_less_reactions">"一部を表示"</string>
|
||||
<string name="screen_room_timeline_message_copied">"メッセージをコピーしました"</string>
|
||||
<string name="screen_room_timeline_no_permission_to_post">"このルームに発言する権限がありません"</string>
|
||||
<plurals name="screen_room_timeline_reaction_a11y">
|
||||
<item quantity="other">"%1$d 人の反応 %2$s"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_reaction_including_you_a11y">
|
||||
<item quantity="other">"あなたと %1$d 人の反応 %2$s"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_reaction_you_a11y">"%1$s と反応"</string>
|
||||
<string name="screen_room_timeline_reactions_show_less">"一部を表示"</string>
|
||||
<string name="screen_room_timeline_reactions_show_more">"さらに表示"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"リアクションのまとめを表示"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"新着"</string>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="other">"%1$d 個のルーム更新点"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_timeline_tombstoned_room_action">"新しいルームに移動"</string>
|
||||
<string name="screen_room_timeline_tombstoned_room_message">"このルームは移行して非アクティブ状態です"</string>
|
||||
<string name="screen_room_timeline_upgraded_room_action">"古いメッセージを表示"</string>
|
||||
<string name="screen_room_timeline_upgraded_room_message">"このルームは他のルームからの移行先です"</string>
|
||||
<plurals name="screen_room_typing_many_members">
|
||||
<item quantity="other">"%1$s, %2$s 他 %3$d 人"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_typing_notification">
|
||||
<item quantity="other">"%1$s が入力中"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_typing_two_members">"%1$s と %2$s"</string>
|
||||
</resources>
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
<string name="screen_room_attachment_source_camera_video">"Spela in video"</string>
|
||||
<string name="screen_room_attachment_source_files">"Bilaga"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Foto- och videobibliotek"</string>
|
||||
<string name="screen_room_attachment_source_location">"Plats"</string>
|
||||
<string name="screen_room_attachment_source_location">"Dela plats"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Omröstning"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Textformatering"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Meddelandehistoriken är för närvarande otillgänglig."</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="crypto_event_authenticity_mismatched_sender">"Người gửi sự kiện không khớp với chủ sở hữu của thiết bị đã gửi nó."</string>
|
||||
<string name="crypto_event_authenticity_not_guaranteed">"Tin nhắn mã hóa này không thể được xác thực trên thiết bị này."</string>
|
||||
<string name="crypto_event_authenticity_previously_verified">"Được mã hóa bởi một người dùng đã từng được xác minh."</string>
|
||||
<string name="crypto_event_authenticity_sent_in_clear">"Không được mã hóa"</string>
|
||||
<string name="crypto_event_authenticity_unknown_device">"Được mã hóa bởi một thiết bị không xác định hoặc đã bị xóa."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Được mã hóa bởi một thiết bị chưa được chủ sở hữu xác minh."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Được mã hóa bởi một người dùng chưa được xác minh."</string>
|
||||
<string name="emoji_picker_category_activity">"Hoạt động"</string>
|
||||
<string name="emoji_picker_category_flags">"Cờ"</string>
|
||||
<string name="emoji_picker_category_foods">"Thực phẩm và đồ uống"</string>
|
||||
<string name="emoji_picker_category_nature">"Động vật và thiên nhiên"</string>
|
||||
<string name="emoji_picker_category_objects">"Đồ vật"</string>
|
||||
<string name="emoji_picker_category_people">"Mặt cười & mọi người"</string>
|
||||
<string name="emoji_picker_category_places">"Du lịch và địa danh"</string>
|
||||
<string name="emoji_picker_category_symbols">"Biểu tượng"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Xử lý phương tiện tải lên không thành công, vui lòng thử lại."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Không thể tải lên tệp phương tiện. Vui lòng thử lại."</string>
|
||||
<string name="screen_report_content_block_user">"Chặn người dùng"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Chọn tùy chọn này nếu bạn muốn ẩn tất cả tin nhắn hiện tại và tương lai từ người dùng này."</string>
|
||||
<string name="screen_report_content_explanation">"Tin nhắn này sẽ được báo cáo cho quản trị viên máy chủ của bạn. Họ sẽ không thể đọc bất kỳ tin nhắn được mã hóa nào."</string>
|
||||
<string name="screen_report_content_hint">"Lý do báo cáo nội dung này"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Máy ảnh"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Chụp ảnh"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"Quay video"</string>
|
||||
<string name="screen_room_attachment_source_files">"Tệp đính kèm"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Thư viện ảnh và video"</string>
|
||||
<string name="screen_room_attachment_source_location">"Chia sẻ vị trí"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Bỏ phiếu"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Định dạng văn bản"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Lịch sử tin nhắn hiện không khả dụng."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Lịch sử tin nhắn không khả dụng trong phòng này. Vui lòng xác minh thiết bị này để xem lịch sử tin nhắn của bạn."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Bạn có muốn mời họ quay lại không?"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"Bạn đang một mình trong cuộc trò chuyện này"</string>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"Thông báo cho cả phòng"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"Mọi người"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"Gửi lại"</string>
|
||||
<string name="screen_room_retry_send_menu_title">"Gửi tin nhắn không thành công"</string>
|
||||
<string name="screen_room_timeline_add_reaction">"Thêm biểu cảm"</string>
|
||||
<string name="screen_room_timeline_beginning_of_room">"Đây là sự kiện khởi đầu của phòng %1$s ."</string>
|
||||
<string name="screen_room_timeline_beginning_of_room_no_name">"Đây là khởi đầu của cuộc trò chuyện này."</string>
|
||||
<string name="screen_room_timeline_legacy_call">"Cuộc gọi không được hỗ trợ. Hãy hỏi xem người gọi có thể sử dụng ứng dụng Element X mới hay không."</string>
|
||||
<string name="screen_room_timeline_less_reactions">"Thu gọn"</string>
|
||||
<string name="screen_room_timeline_message_copied">"Đã sao chép tin nhắn"</string>
|
||||
<string name="screen_room_timeline_no_permission_to_post">"Bạn không có quyền gửi tin nhắn trong phòng này"</string>
|
||||
<string name="screen_room_timeline_reactions_show_less">"Thu gọn"</string>
|
||||
<string name="screen_room_timeline_reactions_show_more">"Xem thêm"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"Mới"</string>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="other">"%1$d số lượng phòng thay đổi"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_typing_many_members">
|
||||
<item quantity="other">"%1$s,%2$s và %3$d người khác"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_typing_notification">
|
||||
<item quantity="other">"%1$s đang gõ"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_typing_two_members">"%1$s và %2$s"</string>
|
||||
</resources>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<string name="screen_media_upload_preview_item_count">"第%1$d/%2$d项"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"优化图像质量"</string>
|
||||
<string name="screen_media_upload_preview_processing">"处理中…"</string>
|
||||
<string name="screen_report_content_block_user">"封禁用户"</string>
|
||||
<string name="screen_report_content_block_user">"屏蔽用户"</string>
|
||||
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
|
||||
<string name="screen_report_content_explanation">"此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"</string>
|
||||
<string name="screen_report_content_hint">"举报此内容的原因"</string>
|
||||
|
|
@ -35,13 +35,13 @@
|
|||
<string name="screen_room_attachment_source_camera_video">"录制视频"</string>
|
||||
<string name="screen_room_attachment_source_files">"附件"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"照片和视频库"</string>
|
||||
<string name="screen_room_attachment_source_location">"位置"</string>
|
||||
<string name="screen_room_attachment_source_location">"共享位置"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"文本格式化"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"消息历史记录当前不可用。"</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"此聊天室无法查看消息历史记录。请验证此设备以查看之。"</string>
|
||||
<string name="screen_room_invite_again_alert_message">"您想邀请他们回来吗?"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"聊天中只有你一个人"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"此聊天室中只有您一个人"</string>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"通知整个聊天室"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"所有人"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"再次发送"</string>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest {
|
|||
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
||||
override fun navigateToRoom(roomId: RoomId) = lambdaError()
|
||||
override fun navigateToDeveloperSettings() = lambdaError()
|
||||
}
|
||||
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
|
||||
val params = MessagesEntryPoint.Params(initialTarget)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class FakeMessagesNavigator(
|
|||
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
|
||||
private val navigateToMemberLambda: (userId: UserId) -> Unit = { lambdaError() },
|
||||
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
|
||||
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
private val closeLambda: () -> Unit = { lambdaError() },
|
||||
) : MessagesNavigator {
|
||||
|
|
@ -51,10 +53,18 @@ class FakeMessagesNavigator(
|
|||
onNavigateToRoomLambda(roomId, eventId, serverNames)
|
||||
}
|
||||
|
||||
override fun navigateToMember(userId: UserId) {
|
||||
navigateToMemberLambda(userId)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
onOpenThreadLambda(threadRootId, focusedEventId)
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
navigateToDeveloperSettingsLambda()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closeLambda()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
|
|
@ -88,6 +89,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
|||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
|
|
@ -110,6 +112,7 @@ import io.element.android.tests.testutils.testWithLifecycleOwner
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
|
|
@ -1258,6 +1261,35 @@ class MessagesPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - only has threads enabled if the feature flag is on`() = runTest {
|
||||
val itemsFlow = MutableStateFlow(listOf(aThreadListItem()))
|
||||
val room = FakeJoinedRoom(
|
||||
threadsListService = FakeThreadsListService(items = itemsFlow)
|
||||
)
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.Threads.key to false)
|
||||
)
|
||||
val presenter = createMessagesPresenter(
|
||||
joinedRoom = room,
|
||||
featureFlagService = featureFlagService
|
||||
)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
// The feature flag is disabled, so even if the thread list has items, it will return it doesn't have any
|
||||
assertThat(initialState.threads.hasThreads).isFalse()
|
||||
|
||||
// Enable the feature flag, now it should reflect the thread list state
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true)
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().threads.hasThreads).isTrue()
|
||||
|
||||
// And if we remove the items, it should update accordingly
|
||||
itemsFlow.value = emptyList()
|
||||
assertThat(awaitItem().threads.hasThreads).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun roomPermissions(
|
||||
canStartCall: Boolean = true,
|
||||
canRedactOther: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import io.element.android.tests.testutils.assertNoNodeWithText
|
|||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -521,6 +522,9 @@ class MessagesViewTest {
|
|||
rule.setMessagesView(
|
||||
state = stateWithActionListState,
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
|
||||
rule.onNodeWithText(verifiedUserSendFailure).performClick()
|
||||
// Give time for the close animation to complete
|
||||
|
|
@ -584,6 +588,9 @@ class MessagesViewTest {
|
|||
),
|
||||
)
|
||||
rule.setMessagesView(state = state)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
rule.onNodeWithText("This is a pinned message").performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
|
||||
}
|
||||
|
|
@ -600,12 +607,32 @@ class MessagesViewTest {
|
|||
timelineState = aTimelineState(eventSink = eventsRecorder)
|
||||
)
|
||||
rule.setMessagesView(state = state)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action)
|
||||
// The bottomsheet subcompose seems to make the node to appear twice
|
||||
rule.onAllNodesWithText(text).onFirst().performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on threads list button calls the expected function`() {
|
||||
val state = aMessagesState(
|
||||
threads = MessagesState.Threads(
|
||||
hasThreads = true,
|
||||
hasUnreadThreads = false,
|
||||
)
|
||||
)
|
||||
val onThreadsListClicked = lambdaRecorder<Unit> {}
|
||||
rule.setMessagesView(
|
||||
state = state,
|
||||
onThreadsListClicked = onThreadsListClicked,
|
||||
)
|
||||
rule.onNodeWithContentDescription("Threads").performClick()
|
||||
onThreadsListClicked.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no banner shown when there is no successor room`() {
|
||||
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||
|
|
@ -630,6 +657,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
|
||||
onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||
onThreadsListClicked: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setSafeContent {
|
||||
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
||||
|
|
@ -646,6 +674,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onJoinCallClick = onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = onThreadsListClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,38 +48,42 @@ internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Crea
|
|||
}
|
||||
}
|
||||
|
||||
internal fun aTimelineItemContentFactory(
|
||||
timelineEventFormatter: TimelineEventFormatter = aTimelineEventFormatter(),
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
): TimelineItemContentFactory = TimelineItemContentFactory(
|
||||
messageFactory = TimelineItemContentMessageFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
textPillificationHelper = FakeTextPillificationHelper(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
|
||||
),
|
||||
pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()),
|
||||
utdFactory = TimelineItemContentUTDFactory(),
|
||||
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
|
||||
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
|
||||
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
|
||||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||
sessionId = matrixClient.sessionId,
|
||||
)
|
||||
|
||||
internal fun TestScope.aTimelineItemsFactory(
|
||||
config: TimelineItemsFactoryConfig,
|
||||
): TimelineItemsFactory {
|
||||
val timelineEventFormatter = aTimelineEventFormatter()
|
||||
val matrixClient = FakeMatrixClient()
|
||||
return TimelineItemsFactory(
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
eventItemFactoryCreator = object : TimelineItemEventFactory.Creator {
|
||||
override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory {
|
||||
return TimelineItemEventFactory(
|
||||
contentFactory = TimelineItemContentFactory(
|
||||
messageFactory = TimelineItemContentMessageFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
textPillificationHelper = FakeTextPillificationHelper(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
|
||||
),
|
||||
pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()),
|
||||
utdFactory = TimelineItemContentUTDFactory(),
|
||||
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
|
||||
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
|
||||
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
|
||||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||
sessionId = matrixClient.sessionId,
|
||||
),
|
||||
contentFactory = aTimelineItemContentFactory(matrixClient = matrixClient),
|
||||
matrixClient = matrixClient,
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.test.FakeLocationService
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class MessageComposerPresenterSlashCommandTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val pickerProvider = FakePickerProvider().apply {
|
||||
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||
}
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val notificationConversationService = FakeNotificationConversationService()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command error sets failure`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.ErrorUnknownSlashCommand(A_FAILURE_REASON) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(errorState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Composer should not be reset when command is an error
|
||||
assertThat(errorState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
// Close the error
|
||||
errorState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command navigation ShowUser navigates to member and resets composer`() = runTest {
|
||||
val navigateToMember = lambdaRecorder<UserId, Unit> {}
|
||||
val navigator = FakeMessagesNavigator(navigateToMemberLambda = navigateToMember)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.ShowUser(A_USER_ID) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
// navigation should be invoked and composer reset
|
||||
navigateToMember.assertions().isCalledOnce().with(value(A_USER_ID))
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command navigation DevTools navigates to developer settings and resets composer`() = runTest {
|
||||
val navigateToDev = lambdaRecorder<Unit> { }
|
||||
val navigator = FakeMessagesNavigator(navigateToDeveloperSettingsLambda = navigateToDev)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.DevTools }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
navigateToDev.assertions().isCalledOnce()
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command send message proceeds and resets composer`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.SendPlainText(A_MESSAGE) },
|
||||
proceedSendMessageResult = { _, _ -> Result.success(Unit) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
// Composer reset after successful slash send
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
// Ensure no failure
|
||||
assertThat(initialState.slashCommandAction.isFailure()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command send message failure sets failure state`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.SendPlainText("A_MESSAGE") },
|
||||
proceedSendMessageResult = { _, _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Clear the error
|
||||
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command admin proceeds and resets state on success`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||
proceedAdminResult = { _ -> Result.success(Unit) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||
val successState = awaitItem()
|
||||
// After success, state should be Uninitialized
|
||||
assertThat(successState.slashCommandAction.isUninitialized()).isTrue()
|
||||
assertThat(successState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command admin proceeds and emit failure on error`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||
proceedAdminResult = { _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Clear error
|
||||
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createPresenter(
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
timeline: Timeline = room.liveTimeline,
|
||||
navigator: MessagesNavigator = FakeMessagesNavigator(),
|
||||
pickerProvider: PickerProvider = this@MessageComposerPresenterSlashCommandTest.pickerProvider,
|
||||
locationService: LocationService = FakeLocationService(true),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterSlashCommandTest.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterSlashCommandTest.snackbarDispatcher,
|
||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = permalinkParser,
|
||||
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||
),
|
||||
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
localMediaFactory = localMediaFactory,
|
||||
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||
DefaultMediaSender(
|
||||
preProcessor = mediaPreProcessor,
|
||||
room = room,
|
||||
timelineMode = timelineMode,
|
||||
mediaOptimizationConfigProvider = {
|
||||
MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
analyticsService = analyticsService,
|
||||
locationService = locationService,
|
||||
messageComposerContext = DefaultMessageComposerContext(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = permalinkParser,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
timelineController = TimelineController(room, timeline),
|
||||
draftService = draftService,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
pillificationHelper = textPillificationHelper,
|
||||
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
|
|||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
|
@ -89,6 +91,9 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
|
|||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
|
|
@ -144,6 +149,7 @@ class MessageComposerPresenterTest {
|
|||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,10 +380,13 @@ class MessageComposerPresenterTest {
|
|||
val presenter = createPresenter(
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -409,10 +418,13 @@ class MessageComposerPresenterTest {
|
|||
isRichTextEditorEnabled = false,
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -602,7 +614,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -633,7 +645,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false))
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
|
|
@ -967,7 +979,12 @@ class MessageComposerPresenterTest {
|
|||
)
|
||||
givenRoomInfo(aRoomInfo(isDirect = false))
|
||||
}
|
||||
val presenter = createPresenter(room)
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
|
|
@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
|
||||
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest {
|
|||
liveTimeline = timeline,
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
)
|
||||
val presenter = createPresenter(room = room)
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
|
||||
|
|
@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
sendMessageResult.assertions().isCalledOnce()
|
||||
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
|
||||
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))), value(MsgType.MSG_TYPE_TEXT), value(false))
|
||||
|
||||
// Check intentional mentions on reply sent
|
||||
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
||||
|
|
@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
|
||||
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||
|
||||
// Check intentional mentions on edit message
|
||||
skipItems(1)
|
||||
|
|
@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest {
|
|||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
|
|
@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest {
|
|||
draftService = draftService,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
pillificationHelper = textPillificationHelper,
|
||||
suggestionsProcessor = SuggestionsProcessor(),
|
||||
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
|
@ -27,10 +29,13 @@ import org.junit.Test
|
|||
class SuggestionsProcessorTest {
|
||||
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
|
||||
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
|
||||
private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "")
|
||||
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
|
||||
|
||||
private val suggestionsProcessor = SuggestionsProcessor()
|
||||
private val suggestionsProcessor = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `processing null suggestion will return empty suggestion`() = runTest {
|
||||
|
|
@ -40,18 +45,59 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing Command will return empty suggestion`() = runTest {
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = aCommandSuggestion,
|
||||
fun `processing Command will return suggestions from the slash service`() = runTest {
|
||||
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ ->
|
||||
listOf(
|
||||
SlashCommandSuggestion(
|
||||
command = "aCommand",
|
||||
parameters = null,
|
||||
description = "A description",
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
val result = suggestionsProcessorWithCommand.process(
|
||||
suggestion = Suggestion(0, 1, SuggestionType.Command, ""),
|
||||
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing Command will return empty list if start of suggestion is not 0`() = runTest {
|
||||
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ ->
|
||||
listOf(
|
||||
SlashCommandSuggestion(
|
||||
command = "aCommand",
|
||||
parameters = null,
|
||||
description = "A description",
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
val result = suggestionsProcessorWithCommand.process(
|
||||
suggestion = Suggestion(1, 2, SuggestionType.Command, ""),
|
||||
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -64,6 +110,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -76,6 +123,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -88,6 +136,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -100,6 +149,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -120,6 +170,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -149,6 +200,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -178,6 +230,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -198,6 +251,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -227,6 +281,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -240,6 +295,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -257,6 +313,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = UserId("@alice:server.org"),
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -270,6 +327,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -283,6 +341,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -296,6 +355,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -313,6 +373,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -331,6 +392,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { false },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
||||
|
|
@ -26,4 +27,9 @@ class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
|||
override fun forwardEvent(eventId: EventId) {
|
||||
onForwardEventClickLambda?.invoke(eventId)
|
||||
}
|
||||
|
||||
var onOpenThreadLambda: ((ThreadId) -> Unit)? = null
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
onOpenThreadLambda?.invoke(threadRootId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.threads
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemContentFactory
|
||||
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.threads.list.ThreadsListEvents
|
||||
import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter
|
||||
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ThreadsListPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
createThreadsListPresenter().test {
|
||||
awaitItem().run {
|
||||
assertThat(threads).isEmpty()
|
||||
assertThat(roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(roomName).isEqualTo(A_ROOM_NAME)
|
||||
assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - paginate`() = runTest {
|
||||
val paginateRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val threadsListService = FakeThreadsListService(paginate = paginateRecorder)
|
||||
val room = FakeJoinedRoom(threadsListService = threadsListService)
|
||||
createThreadsListPresenter(room).test {
|
||||
val initialItem = awaitItem()
|
||||
|
||||
// Pagination is automatically triggered on start, so we should have one call to paginate already
|
||||
paginateRecorder.assertions().isCalledOnce()
|
||||
|
||||
initialItem.eventSink(ThreadsListEvents.Paginate)
|
||||
|
||||
// Simulate a pagination result
|
||||
threadsListService.emit(listOf(aThreadListItem()))
|
||||
|
||||
// We should have a second call to paginate after the event is sent
|
||||
paginateRecorder.assertions().isCalledExactly(2)
|
||||
|
||||
// And we receive the new items
|
||||
assertThat(awaitItem().threads).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createThreadsListPresenter(
|
||||
room: FakeJoinedRoom = FakeJoinedRoom(),
|
||||
): ThreadsListPresenter {
|
||||
return ThreadsListPresenter(
|
||||
room = room,
|
||||
timelineItemContentFactory = aTimelineItemContentFactory(),
|
||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||
|
|
@ -154,10 +155,10 @@ class TimelineControllerTest {
|
|||
|
||||
@Test
|
||||
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
|
||||
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
|
||||
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention> ->
|
||||
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val liveTimeline = FakeTimeline(name = "live").apply {
|
||||
|
|
|
|||
|
|
@ -67,24 +67,31 @@ class TimelineViewTest {
|
|||
|
||||
@Test
|
||||
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on live timeline does not emit the Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
}
|
||||
|
|
@ -94,15 +101,33 @@ class TimelineViewTest {
|
|||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.JumpToLive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty timeline triggers a prefetch`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show shield dialog`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
|
|
@ -133,11 +158,15 @@ class TimelineViewTest {
|
|||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
messageShield = aCriticalShield(),
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue