Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
f19295d63d
291 changed files with 4973 additions and 1595 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
|
|||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
|
||||
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
|
||||
data object SaveDraft : MessageComposerEvent
|
||||
data object ClearSlashError : MessageComposerEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
|
@ -33,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) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
|
@ -26,5 +27,6 @@ data class MessageComposerState(
|
|||
val suggestions: ImmutableList<ResolvedSuggestion>,
|
||||
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
||||
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||
val slashCommandAction: AsyncAction<Unit>,
|
||||
val eventSink: (MessageComposerEvent) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
|
@ -32,6 +33,7 @@ fun aMessageComposerState(
|
|||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
|
||||
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (MessageComposerEvent) -> Unit = {},
|
||||
) = MessageComposerState(
|
||||
textEditorState = textEditorState,
|
||||
|
|
@ -43,5 +45,6 @@ fun aMessageComposerState(
|
|||
suggestions = suggestions,
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
slashCommandAction = slashCommandAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
|
|||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
|
|
@ -115,6 +116,12 @@ internal fun MessageComposerView(
|
|||
onTyping = ::onTyping,
|
||||
onSelectRichContent = ::sendUri,
|
||||
)
|
||||
|
||||
AsyncActionView(
|
||||
async = state.slashCommandAction,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -63,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 = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ class TimelinePresenter(
|
|||
val displayThreadSummaries by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||
}
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
|
|
@ -315,6 +318,7 @@ class TimelinePresenter(
|
|||
messageShieldDialogData = messageShieldDialogData.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ data class TimelineState(
|
|||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ fun aTimelineState(
|
|||
messageShield: MessageShield? = null,
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayFloatingDateBadge: Boolean = false,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
|
|
@ -75,6 +76,7 @@ fun aTimelineState(
|
|||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ import androidx.compose.ui.platform.LocalView
|
|||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
|
||||
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.toText
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,11 @@ class TimelineItemEventFactory(
|
|||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.TimeOnly,
|
||||
)
|
||||
val sentDate = dateFormatter.format(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Day,
|
||||
useRelative = true,
|
||||
)
|
||||
val senderAvatarData = AvatarData(
|
||||
id = currentSender.value,
|
||||
name = senderProfile.getDisambiguatedDisplayName(currentSender),
|
||||
|
|
@ -108,6 +113,7 @@ class TimelineItemEventFactory(
|
|||
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
|
||||
sentTimeMillis = currentTimelineItem.event.timestamp,
|
||||
sentTime = sentTime,
|
||||
sentDate = sentDate,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -59,6 +60,12 @@ sealed interface TimelineItem {
|
|||
is GroupedEvents -> "groupedEvent"
|
||||
}
|
||||
|
||||
fun formattedDate(): String? = when (this) {
|
||||
is Event -> sentDate.takeIf { it.isNotEmpty() }
|
||||
is Virtual -> (model as? TimelineItemDaySeparatorModel)?.formattedDate?.takeIf { it.isNotEmpty() }
|
||||
is GroupedEvents -> null
|
||||
}
|
||||
|
||||
data class Virtual(
|
||||
val id: UniqueId,
|
||||
val model: TimelineItemVirtualModel
|
||||
|
|
@ -75,6 +82,7 @@ sealed interface TimelineItem {
|
|||
val content: TimelineItemEventContent,
|
||||
val sentTimeMillis: Long = 0L,
|
||||
val sentTime: String = "",
|
||||
val sentDate: String = "",
|
||||
val isMine: Boolean = false,
|
||||
val isEditable: Boolean,
|
||||
val canBeRepliedTo: Boolean,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest {
|
|||
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
||||
override fun navigateToRoom(roomId: RoomId) = lambdaError()
|
||||
override fun navigateToDeveloperSettings() = lambdaError()
|
||||
}
|
||||
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
|
||||
val params = MessagesEntryPoint.Params(initialTarget)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class FakeMessagesNavigator(
|
|||
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
|
||||
private val navigateToMemberLambda: (userId: UserId) -> Unit = { lambdaError() },
|
||||
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
|
||||
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
private val closeLambda: () -> Unit = { lambdaError() },
|
||||
) : MessagesNavigator {
|
||||
|
|
@ -51,10 +53,18 @@ class FakeMessagesNavigator(
|
|||
onNavigateToRoomLambda(roomId, eventId, serverNames)
|
||||
}
|
||||
|
||||
override fun navigateToMember(userId: UserId) {
|
||||
navigateToMemberLambda(userId)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
onOpenThreadLambda(threadRootId, focusedEventId)
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
navigateToDeveloperSettingsLambda()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closeLambda()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.test.FakeLocationService
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class MessageComposerPresenterSlashCommandTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val pickerProvider = FakePickerProvider().apply {
|
||||
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||
}
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val notificationConversationService = FakeNotificationConversationService()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command error sets failure`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.ErrorUnknownSlashCommand(A_FAILURE_REASON) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(errorState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Composer should not be reset when command is an error
|
||||
assertThat(errorState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
// Close the error
|
||||
errorState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command navigation ShowUser navigates to member and resets composer`() = runTest {
|
||||
val navigateToMember = lambdaRecorder<UserId, Unit> {}
|
||||
val navigator = FakeMessagesNavigator(navigateToMemberLambda = navigateToMember)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.ShowUser(A_USER_ID) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
// navigation should be invoked and composer reset
|
||||
navigateToMember.assertions().isCalledOnce().with(value(A_USER_ID))
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command navigation DevTools navigates to developer settings and resets composer`() = runTest {
|
||||
val navigateToDev = lambdaRecorder<Unit> { }
|
||||
val navigator = FakeMessagesNavigator(navigateToDeveloperSettingsLambda = navigateToDev)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.DevTools }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
navigateToDev.assertions().isCalledOnce()
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command send message proceeds and resets composer`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.SendPlainText(A_MESSAGE) },
|
||||
proceedSendMessageResult = { _, _ -> Result.success(Unit) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
advanceUntilIdle()
|
||||
// Composer reset after successful slash send
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||
// Ensure no failure
|
||||
assertThat(initialState.slashCommandAction.isFailure()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command send message failure sets failure state`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.SendPlainText("A_MESSAGE") },
|
||||
proceedSendMessageResult = { _, _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Clear the error
|
||||
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command admin proceeds and resets state on success`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||
proceedAdminResult = { _ -> Result.success(Unit) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||
val successState = awaitItem()
|
||||
// After success, state should be Uninitialized
|
||||
assertThat(successState.slashCommandAction.isUninitialized()).isTrue()
|
||||
assertThat(successState.textEditorState.messageHtml()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - slash command admin proceeds and emit failure on error`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||
proceedAdminResult = { _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||
// Clear error
|
||||
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createPresenter(
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
timeline: Timeline = room.liveTimeline,
|
||||
navigator: MessagesNavigator = FakeMessagesNavigator(),
|
||||
pickerProvider: PickerProvider = this@MessageComposerPresenterSlashCommandTest.pickerProvider,
|
||||
locationService: LocationService = FakeLocationService(true),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterSlashCommandTest.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterSlashCommandTest.snackbarDispatcher,
|
||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = permalinkParser,
|
||||
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||
),
|
||||
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
localMediaFactory = localMediaFactory,
|
||||
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||
DefaultMediaSender(
|
||||
preProcessor = mediaPreProcessor,
|
||||
room = room,
|
||||
timelineMode = timelineMode,
|
||||
mediaOptimizationConfigProvider = {
|
||||
MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
analyticsService = analyticsService,
|
||||
locationService = locationService,
|
||||
messageComposerContext = DefaultMessageComposerContext(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = permalinkParser,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
timelineController = TimelineController(room, timeline),
|
||||
draftService = draftService,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
pillificationHelper = textPillificationHelper,
|
||||
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
|
|||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
|
@ -89,6 +91,9 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
|
|||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
|
|
@ -144,6 +149,7 @@ class MessageComposerPresenterTest {
|
|||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,10 +380,13 @@ class MessageComposerPresenterTest {
|
|||
val presenter = createPresenter(
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -409,10 +418,13 @@ class MessageComposerPresenterTest {
|
|||
isRichTextEditorEnabled = false,
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
||||
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||
},
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
),
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -602,7 +614,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -633,7 +645,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false))
|
||||
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
|
|
@ -967,7 +979,12 @@ class MessageComposerPresenterTest {
|
|||
)
|
||||
givenRoomInfo(aRoomInfo(isDirect = false))
|
||||
}
|
||||
val presenter = createPresenter(room)
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
|
|
@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
|
||||
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest {
|
|||
liveTimeline = timeline,
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
)
|
||||
val presenter = createPresenter(room = room)
|
||||
val presenter = createPresenter(
|
||||
room = room,
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
|
||||
|
|
@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
sendMessageResult.assertions().isCalledOnce()
|
||||
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
|
||||
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))), value(MsgType.MSG_TYPE_TEXT), value(false))
|
||||
|
||||
// Check intentional mentions on reply sent
|
||||
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
||||
|
|
@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
assert(replyMessageLambda)
|
||||
.isCalledOnce()
|
||||
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
|
||||
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||
|
||||
// Check intentional mentions on edit message
|
||||
skipItems(1)
|
||||
|
|
@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest {
|
|||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
|
|
@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest {
|
|||
draftService = draftService,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
pillificationHelper = textPillificationHelper,
|
||||
suggestionsProcessor = SuggestionsProcessor(),
|
||||
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
|
@ -27,10 +29,13 @@ import org.junit.Test
|
|||
class SuggestionsProcessorTest {
|
||||
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
|
||||
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
|
||||
private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "")
|
||||
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
|
||||
|
||||
private val suggestionsProcessor = SuggestionsProcessor()
|
||||
private val suggestionsProcessor = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ -> emptyList() },
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `processing null suggestion will return empty suggestion`() = runTest {
|
||||
|
|
@ -40,18 +45,59 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing Command will return empty suggestion`() = runTest {
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = aCommandSuggestion,
|
||||
fun `processing Command will return suggestions from the slash service`() = runTest {
|
||||
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ ->
|
||||
listOf(
|
||||
SlashCommandSuggestion(
|
||||
command = "aCommand",
|
||||
parameters = null,
|
||||
description = "A description",
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
val result = suggestionsProcessorWithCommand.process(
|
||||
suggestion = Suggestion(0, 1, SuggestionType.Command, ""),
|
||||
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing Command will return empty list if start of suggestion is not 0`() = runTest {
|
||||
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||
slashCommandService = FakeSlashCommandService(
|
||||
getSuggestionsResult = { _, _ ->
|
||||
listOf(
|
||||
SlashCommandSuggestion(
|
||||
command = "aCommand",
|
||||
parameters = null,
|
||||
description = "A description",
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
val result = suggestionsProcessorWithCommand.process(
|
||||
suggestion = Suggestion(1, 2, SuggestionType.Command, ""),
|
||||
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -64,6 +110,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -76,6 +123,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -88,6 +136,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -100,6 +149,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -120,6 +170,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -149,6 +200,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -178,6 +230,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -198,6 +251,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -227,6 +281,7 @@ class SuggestionsProcessorTest {
|
|||
),
|
||||
currentUserId = A_USER_ID,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -240,6 +295,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -257,6 +313,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = UserId("@alice:server.org"),
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -270,6 +327,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -283,6 +341,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
|
@ -296,6 +355,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -313,6 +373,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { true },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
@ -331,6 +392,7 @@ class SuggestionsProcessorTest {
|
|||
roomAliasSuggestions = emptyList(),
|
||||
currentUserId = A_USER_ID_2,
|
||||
canSendRoomMention = { false },
|
||||
isInThread = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
|||
|
||||
@Parcelize
|
||||
data object NotificationTroubleshoot : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object DeveloperSettings : InitialTarget
|
||||
}
|
||||
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,11 @@ class PreferencesFlowNode(
|
|||
}
|
||||
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
if (backstack.canPop()) {
|
||||
backstack.pop()
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue