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

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string>
<string name="screen_identity_confirmation_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</string>
<string name="screen_identity_confirmation_subtitle">"Vælg, hvordan du vil verificere dig for at konfigurere sikre beskeder."</string>
<string name="screen_identity_confirmation_title">"Bekræft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>

View file

@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string>
<string name="banner_new_sound_message">"Dit notifikationsping er blevet opdateret tydeligere, hurtigere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har opdateret dine lyde"</string>
<string name="banner_set_up_recovery_content">"Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string>
<string name="banner_set_up_recovery_submit">"Opsæt gendannelse"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</string>
<string name="banner_set_up_recovery_content">"Dine chats sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="banner_set_up_recovery_submit">"Hent gendannelsesnøgle"</string>
<string name="banner_set_up_recovery_title">"Sikkerhedskopier dine samtaler"</string>
<string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Vælg, hvor længe du vil dele din aktuelle position."</string>
</resources>

View file

@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.location.impl.share
import app.cash.molecule.RecompositionMode
@ -37,6 +39,7 @@ 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule

View file

@ -23,7 +23,7 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Indtast venligst den samme PIN-kode to gange"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koderne stemmer ikke overens"</string>
<string name="screen_app_lock_signout_alert_message">"Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte."</string>
<string name="screen_app_lock_signout_alert_title">"Du bliver logget ud"</string>
<string name="screen_app_lock_signout_alert_title">"Denne enhed bliver fjernet"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d forsøg på at låse op"</item>
<item quantity="other">"Du har %1$d forsøg på at låse op"</item>
@ -34,5 +34,5 @@ Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af
</plurals>
<string name="screen_app_lock_use_biometric_android">"Brug biometri"</string>
<string name="screen_app_lock_use_pin_android">"Brug PIN-kode"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources>

View file

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at du vil logge ud?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Log ud"</string>
<string name="screen_signout_confirmation_dialog_title">"Log ud"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger ud nu, mister du adgangen til dine krypterede meddelelser."</string>
<string name="screen_signout_key_backup_disabled_title">"Du har slået sikkerhedskopiering fra"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du logger ud."</string>
<string name="screen_signout_confirmation_dialog_content">"Er du sikker på, at ønsker at fjerne denne enhed?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Fjern denne enhed"</string>
<string name="screen_signout_confirmation_dialog_title">"Fjern denne enhed"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_key_backup_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_offline_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Vent på, at dette er fuldført, før du logger ud."</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Vent venligst, indtil dette er færdigt, før du fjerner denne enhed."</string>
<string name="screen_signout_key_backup_ongoing_title">"Dine nøgler bliver stadig sikkerhedskopieret"</string>
<string name="screen_signout_preference_item">"Log ud"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, mister du adgangen til dine krypterede meddelelser."</string>
<string name="screen_signout_recovery_disabled_title">"Gendannelse er ikke konfigureret"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Du er ved at logge ud af din sidste session. Hvis du logger af nu, kan du miste adgangen til dine krypterede meddelelser."</string>
<string name="screen_signout_preference_item">"Fjern denne enhed"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_recovery_disabled_title">"Du er ved at miste adgangen til dine krypterede chats"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Dette er din eneste enhed. Hvis du fjerner den, skal du bruge en gendannelsesnøgle for at bekræfte din digitale identitet og gendanne dine krypterede chats, næste gang du logger ind."</string>
<string name="screen_signout_save_recovery_key_title">"Sørg for, at du har adgang til din gendannelsesnøgle, før du fjerner denne enhed."</string>
</resources>

View file

@ -38,6 +38,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
fun navigateToRoom(roomId: RoomId)
fun navigateToDeveloperSettings()
}
data class Params(val initialTarget: InitialTarget) : NodeInputs

View file

@ -53,6 +53,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
@ -104,4 +105,5 @@ dependencies {
testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.recentemojis.test)
testImplementation(projects.libraries.slashcommands.test)
}

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 {

View file

@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
@Parcelize
data object NotificationTroubleshoot : InitialTarget
@Parcelize
data object DeveloperSettings : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs

View file

@ -34,4 +34,5 @@ internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings
}

View file

@ -192,7 +192,11 @@ class PreferencesFlowNode(
}
override fun onDone() {
backstack.pop()
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
}
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))

View file

@ -39,6 +39,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)

View file

@ -388,6 +388,10 @@ class RoomDetailsFlowNode(
override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList())
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
}
return messagesEntryPoint.createNode(
parentNode = this,

View file

@ -69,6 +69,7 @@ class DefaultRoomDetailsEntryPointTest {
}
val callback = object : RoomDetailsEntryPoint.Callback {
override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()

View file

@ -2,16 +2,17 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Slet nøglelager"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Aktivér sikkerhedskopiering"</string>
<string name="screen_chat_backup_key_backup_description">"Gem din kryptografiske identitet og meddelelsesnøgler sikkert på serveren. Dette giver dig mulighed for at se din meddelelseshistorik på alle nye enheder. %1$s."</string>
<string name="screen_chat_backup_key_backup_description">"Dette giver dig mulighed for at se din chathistorik på alle nye enheder og er påkrævet til sikkerhedskopiering af chats og digital identitet.%1$s ."</string>
<string name="screen_chat_backup_key_backup_title">"Nøgleopbevaring"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Nøglelagring skal være slået til for at konfigurere gendannelse."</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Nøglelagring skal være slået til for at konfigurere gendannelse af dine samtaler."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Upload nøgler fra denne enhed"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Tillad lagring af nøgler"</string>
<string name="screen_chat_backup_recovery_action_change">"Skift gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Gendan din kryptografiske identitet og beskedhistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string>
<string name="screen_chat_backup_recovery_action_change_description">"Dine samtaler sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Indtast gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Din nøglelagring er i øjeblikket ikke synkroniseret."</string>
<string name="screen_chat_backup_recovery_action_setup">"Opsæt gendannelse"</string>
<string name="screen_chat_backup_recovery_action_setup">"Hent gendannelsesnøgle"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Dine chats sikkerhedskopieres automatisk med end-to-end-kryptering. For at kunne gendanne denne sikkerhedskopi og bevare din digitale identitet, hvis du mister adgang til alle dine enheder, får du brug for din gendannelsesnøgle."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Åbn %1$s på en stationær enhed"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Log ind på din konto igen"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Når du bliver bedt om at verificere din enhed, skal du vælge %1$s"</string>
@ -23,12 +24,12 @@
<string name="screen_encryption_reset_bullet_1">"Dine kontodetaljer, kontakter, personlige indstilliger og samtaler vil blive gemt"</string>
<string name="screen_encryption_reset_bullet_2">"Du mister al beskedhistorik, der kun er gemt på serveren."</string>
<string name="screen_encryption_reset_bullet_3">"Du bliver nødt til at verificere alle dine eksisterende enheder og kontakter påny"</string>
<string name="screen_encryption_reset_footer">"Nulstil kun din identitet, hvis du ikke har adgang til en anden enhed, der er logget ind, og du har mistet din gendannelsesnøgle."</string>
<string name="screen_encryption_reset_title">"Kan du ikke bekræfte? Du skal nulstille din identitet."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slå fra"</string>
<string name="screen_key_backup_disable_confirmation_description">"Du mister dine krypterede meddelelser, hvis du er logget ud af alle enheder."</string>
<string name="screen_key_backup_disable_confirmation_title">"Er du sikker på, at du vil slå sikkerhedskopiering fra?"</string>
<string name="screen_key_backup_disable_description">"Hvis du sletter nøglelageret, fjernes din kryptografiske identitet og meddelelsesnøgler fra serveren og følgende sikkerhedsfunktioner deaktiveres:"</string>
<string name="screen_encryption_reset_footer">"Nulstil kun din digitale identitet, hvis du ikke har adgang til en anden enhed, der er logget ind, og du har mistet din gendannelsesnøgle."</string>
<string name="screen_encryption_reset_title">"Kan du ikke bekræfte? Du er nødt til at nulstille din digitale identitet."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Slet"</string>
<string name="screen_key_backup_disable_confirmation_description">"Du mister din krypterede chathistorik og skal nulstille din digitale identitet, hvis du fjerner alle dine enheder."</string>
<string name="screen_key_backup_disable_confirmation_title">"Er du sikker på, at du vil slette nøglelageret?"</string>
<string name="screen_key_backup_disable_description">"Hvis du sletter nøglelageret, fjernes din kryptografiske identitet og beskednøgler fra serveren og følgende sikkerhedsfunktioner deaktiveres:"</string>
<string name="screen_key_backup_disable_description_point_1">"Du vil ikke kunne se historikken for krypterede beskeder på nye enheder"</string>
<string name="screen_key_backup_disable_description_point_2">"Du mister adgangen til dine krypterede meddelelser, hvis du er logget ud %1$s overalt"</string>
<string name="screen_key_backup_disable_title">"Er du sikker på, at du vil deaktivere nøglelagring og slette lageret?"</string>
@ -58,12 +59,12 @@
<string name="screen_recovery_key_setup_generate_key">"Generer din gendannelsesnøgle"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Del ikke dette med nogen!"</string>
<string name="screen_recovery_key_setup_success">"Opsætning af gendannelse lykkedes"</string>
<string name="screen_recovery_key_setup_title">"Opsæt gendannelse"</string>
<string name="screen_recovery_key_setup_title">"Hent gendannelsesnøgle"</string>
<string name="screen_reset_encryption_confirmation_alert_action">"Ja, nulstil nu"</string>
<string name="screen_reset_encryption_confirmation_alert_subtitle">"Denne proces er irreversibel."</string>
<string name="screen_reset_encryption_confirmation_alert_title">"Er du sikker på, at du ønsker at nulstille din identitet?"</string>
<string name="screen_reset_encryption_confirmation_alert_title">"Er du sikker på, at du vil nulstille din digitale identitet?"</string>
<string name="screen_reset_encryption_password_error">"Der opstod en ukendt fejl. Kontroller, at adgangskoden til din konto er korrekt, og prøv igen."</string>
<string name="screen_reset_encryption_password_placeholder">"Indtast…"</string>
<string name="screen_reset_encryption_password_subtitle">"Bekræft, at du ønsker at nulstille din identitet."</string>
<string name="screen_reset_encryption_password_subtitle">"Bekræft, at du ønsker at nulstille din digitale identitet."</string>
<string name="screen_reset_encryption_password_title">"Indtast adgangskoden til din konto for at fortsætte"</string>
</resources>

View file

@ -12,6 +12,7 @@
<string name="screen_chat_backup_recovery_action_confirm">"Введите ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"В настоящее время резервная копия ваших чатов не синхронизирована."</string>
<string name="screen_chat_backup_recovery_action_setup">"Получить ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Ваши чаты автоматически резервируются с использованием сквозного шифрования. Для восстановления этой резервной копии и сохранения вашей цифровой личности в случае потери доступа ко всем вашим устройствам вам потребуется ключ восстановления."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Откройте %1$s на компьютере"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Войдите в свой аккаунт еще раз"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Когда потребуется подтвердить устройство, выберите %1$s"</string>
@ -25,7 +26,7 @@
<string name="screen_encryption_reset_bullet_3">"Вам нужно будет заново подтвердить все существующие устройства и контакты."</string>
<string name="screen_encryption_reset_footer">"Сбрасывайте личность только в том случае, если у вас нет доступа к другим устройству, на которых выполнен вход, и вы потеряли ключ восстановления."</string>
<string name="screen_encryption_reset_title">"Не можете подтвердить? Вам потребуется сбросить личность вашей учетной записи."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Выключить"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Удалить"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</string>
<string name="screen_key_backup_disable_description">"Удаление хранилища ключей приведёт к удалению вашей криптографической личности и ключей сообщений с сервера, а также отключению следующих функций безопасности:"</string>

View file

@ -76,7 +76,7 @@ class SharePresenterTest {
fun `present - on room selected ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
@ -103,7 +103,7 @@ class SharePresenterTest {
fun `present - send text ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan ikke bekræfte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opret en ny gendannelsesnøgle"</string>
<string name="screen_identity_confirmation_subtitle">"Verificér denne enhed for at konfigurere sikre meddelelser."</string>
<string name="screen_identity_confirmation_title">"Bekræft din identitet"</string>
<string name="screen_identity_confirmation_subtitle">"Vælg, hvordan du vil verificere dig for at konfigurere sikre beskeder."</string>
<string name="screen_identity_confirmation_title">"Bekræft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Brug en anden enhed"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Brug gendannelsesnøgle"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed."</string>
@ -17,7 +17,7 @@
<string name="screen_session_verification_compare_numbers_subtitle">"Bekræft, at numrene nedenfor stemmer overens med dem, der vises på din anden session."</string>
<string name="screen_session_verification_compare_numbers_title">"Sammenlign tal"</string>
<string name="screen_session_verification_complete_subtitle">"Nu kan du læse eller sende beskeder sikkert med din anden enhed."</string>
<string name="screen_session_verification_complete_user_subtitle">"Nu kan du stole på identiteten af denne bruger, når I sender og modtager beskeder fra hinanden."</string>
<string name="screen_session_verification_complete_user_subtitle">"Nu kan du stole på denne brugers digitale identitet, når I sender eller modtager beskeder."</string>
<string name="screen_session_verification_device_verified">"Enhed verificeret"</string>
<string name="screen_session_verification_enter_recovery_key">"Indtast gendannelsesnøgle"</string>
<string name="screen_session_verification_failed_subtitle">"Enten udløb anmodningen, den blev afvist, eller der var en fejl i verifikationen."</string>
@ -42,7 +42,7 @@
<string name="screen_session_verification_use_another_device_title">"Åbn appen på en anden bekræftet enhed"</string>
<string name="screen_session_verification_user_initiator_subtitle">"For ekstra sikkerhed, verificér denne bruger ved at sammenligne et sæt emojier på jeres enheder. Gør dette ved at bruge en kommunikationsmetode i stoler på."</string>
<string name="screen_session_verification_user_initiator_title">"Verificér denne bruger?"</string>
<string name="screen_session_verification_user_responder_subtitle">"For ekstra sikkerhed ønsker en anden bruger at bekræfte din identitet. Du får vist et sæt emojier til sammenligning."</string>
<string name="screen_session_verification_user_responder_subtitle">"For ekstra sikkerhed ønsker en anden bruger at bekræfte din digitale identitet. I vil blive vist et sæt emojis, der skal sammenlignes."</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"Du burde se en popup på den anden enhed. Start verifikationen derfra nu."</string>
<string name="screen_session_verification_waiting_another_device_title">"Start verifikation på den anden enhed"</string>
<string name="screen_session_verification_waiting_other_device_title">"Start verifikation på den anden enhed"</string>
@ -50,5 +50,5 @@
<string name="screen_session_verification_waiting_subtitle">"Når du er blevet accepteret, kan du fortsætte med verifikationen."</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Accepter anmodningen om at starte bekræftelsesprocessen i din anden session for at fortsætte."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Venter på at acceptere anmodningen"</string>
<string name="screen_signout_in_progress_dialog_content">"Logger ud…"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enhed…"</string>
</resources>