Merge branch 'develop' into feature-oled-black

This commit is contained in:
Timur Gilfanov 2026-04-05 12:06:20 +04:00 committed by GitHub
commit f19295d63d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
291 changed files with 4973 additions and 1595 deletions

View file

@ -293,6 +293,10 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -502,6 +506,10 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
@ -567,7 +575,7 @@ class MessagesFlowNode(
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
mode = mode
).takeIf { locationService.isServiceAvailable() }
}
else -> null

View file

@ -23,6 +23,8 @@ 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()
fun close()
}

View file

@ -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,7 @@ class MessagesNode(
fun navigateToRoomDetails()
fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList()
fun navigateToDeveloperSettings()
}
override fun onBuilt() {
@ -222,10 +223,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()
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}

View file

@ -39,6 +39,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
@ -464,6 +465,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,
@ -479,11 +483,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(),
) {

View file

@ -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
}

View file

@ -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,12 +34,14 @@ 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.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
@ -68,6 +71,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
@ -104,6 +110,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,
@ -125,10 +132,15 @@ class MessageComposerPresenter(
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
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())
@ -218,6 +230,8 @@ class MessageComposerPresenter(
}
)
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(Unit) {
val draft = draftService.loadDraft(
roomId = room.roomId,
@ -246,12 +260,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,
@ -340,6 +355,9 @@ class MessageComposerPresenter(
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
is ResolvedSuggestion.Command -> {
richTextEditorState.replaceSuggestion(suggestion.command.command)
}
}
} else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertSuggestion(
@ -354,6 +372,9 @@ class MessageComposerPresenter(
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
}
MessageComposerEvent.ClearSlashError -> {
slashCommandAction.value = AsyncAction.Uninitialized
}
}
}
@ -385,6 +406,7 @@ class MessageComposerPresenter(
suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
slashCommandAction = slashCommandAction.value,
eventSink = ::handleEvent,
)
}
@ -422,6 +444,7 @@ class MessageComposerPresenter(
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
isInThread = isInThread,
)
suggestions.clear()
suggestions.addAll(result)
@ -433,9 +456,69 @@ 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
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()
}
}
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
}
}
// Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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

View file

@ -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,6 +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.command
}
}
) {
@ -91,54 +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 -> null
}
val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> 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.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.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,
)
}
@ -174,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 = {}
)

View file

@ -15,6 +15,7 @@ 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.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -23,7 +24,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 +34,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 +43,7 @@ class SuggestionsProcessor {
roomAliasSuggestions: List<RoomAliasSuggestion>,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
isInThread: Boolean,
): List<ResolvedSuggestion> {
suggestion ?: return emptyList()
return when (suggestion.type) {
@ -69,7 +74,16 @@ class SuggestionsProcessor {
)
}
}
SuggestionType.Command,
SuggestionType.Command -> {
// Command suggestions are valid only if this is the beginning of the message
if (suggestion.start == 0) {
slashCommandService.getSuggestions(suggestion.text, isInThread).map {
ResolvedSuggestion.Command(it)
}
} else {
emptyList()
}
}
SuggestionType.Emoji,
is SuggestionType.Custom -> {
// Clear suggestions

View file

@ -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()
}
override fun onBuilt() {
@ -233,10 +234,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 close() = navigateUp()
@Composable

View file

@ -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,
)
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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
@ -105,6 +107,7 @@ fun TimelineView(
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState)
@ -210,6 +213,15 @@ fun TimelineView(
onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender,
)
if (state.displayFloatingDateBadge && useReverseLayout) {
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
}
}

View file

@ -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")
}
}

View file

@ -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),

View file

@ -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,

View file

@ -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)

View file

@ -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()
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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(

View file

@ -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 {