Plain text editor implementation based on markdown input (#2840)
* Add plain text editor based on markdown input - Fix autofocus of message composer. - Remove `Message` data class, fetch the details in `MessagesPresenter` instead. - Remove `enable rich text` option from advanced settings, set it as a build configuration instead. * Fix MentionSpanProvider * Bump RTE library to released `v2.37.3` --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
0e05a0e4ed
commit
902dd24e72
94 changed files with 1554 additions and 524 deletions
|
|
@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.appconfig.MessageComposerConfig
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
|
|
@ -66,7 +67,6 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
|
|||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
|
@ -113,7 +113,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
|
|
@ -171,17 +170,15 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
|
||||
LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) {
|
||||
withContext(dispatchers.io) {
|
||||
showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L
|
||||
showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDirect && room.activeMemberCount == 1L
|
||||
}
|
||||
}
|
||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(featureFlagsService) {
|
||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||
|
|
@ -194,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
action = event.action,
|
||||
targetEvent = event.event,
|
||||
composerState = composerState,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableTextFormatting = composerState.showTextFormatting,
|
||||
timelineState = timelineState,
|
||||
)
|
||||
}
|
||||
|
|
@ -239,7 +236,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
snackbarMessage = snackbarMessage,
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
inviteProgress = inviteProgress.value,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
appName = buildMeta.applicationName,
|
||||
callState = callState,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
|
|
@ -99,9 +100,9 @@ fun aMessagesState(
|
|||
userHasPermissionToRedactOther: Boolean = false,
|
||||
userHasPermissionToSendReaction: Boolean = true,
|
||||
composerState: MessageComposerState = aMessageComposerState(
|
||||
richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ private fun MessagesViewContent(
|
|||
// Any state change that should trigger a height size should be added to the list of remembered values here.
|
||||
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(
|
||||
state.composerState.richTextEditorState.lineCount,
|
||||
state.composerState.textEditorState.lineCount,
|
||||
state.composerState.showTextFormatting,
|
||||
) {
|
||||
sheetResizeContentKey.intValue = Random.nextInt()
|
||||
|
|
@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.mentions
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
@Immutable
|
||||
sealed interface MentionSuggestion {
|
||||
data object Room : MentionSuggestion
|
||||
data class Member(val roomMember: RoomMember) : MentionSuggestion
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
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.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -51,8 +52,8 @@ fun MentionSuggestionsPickerView(
|
|||
roomId: RoomId,
|
||||
roomName: String?,
|
||||
roomAvatarData: AvatarData?,
|
||||
memberSuggestions: ImmutableList<MentionSuggestion>,
|
||||
onSuggestionSelected: (MentionSuggestion) -> Unit,
|
||||
memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
|
||||
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
|
|
@ -62,8 +63,8 @@ fun MentionSuggestionsPickerView(
|
|||
memberSuggestions,
|
||||
key = { suggestion ->
|
||||
when (suggestion) {
|
||||
is MentionSuggestion.Room -> "@room"
|
||||
is MentionSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
is ResolvedMentionSuggestion.AtRoom -> "@room"
|
||||
is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView(
|
|||
|
||||
@Composable
|
||||
private fun RoomMemberSuggestionItemView(
|
||||
memberSuggestion: MentionSuggestion,
|
||||
memberSuggestion: ResolvedMentionSuggestion,
|
||||
roomId: String,
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
onSuggestionSelected: (MentionSuggestion) -> Unit,
|
||||
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val avatarSize = AvatarSize.TimelineRoom
|
||||
val avatarData = when (memberSuggestion) {
|
||||
is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||
is MentionSuggestion.Member -> AvatarData(
|
||||
is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||
is ResolvedMentionSuggestion.Member -> AvatarData(
|
||||
memberSuggestion.roomMember.userId.value,
|
||||
memberSuggestion.roomMember.displayName,
|
||||
memberSuggestion.roomMember.avatarUrl,
|
||||
|
|
@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView(
|
|||
)
|
||||
}
|
||||
val title = when (memberSuggestion) {
|
||||
is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||
is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
|
||||
is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName
|
||||
}
|
||||
|
||||
val subtitle = when (memberSuggestion) {
|
||||
is MentionSuggestion.Room -> "@room"
|
||||
is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
|
||||
is ResolvedMentionSuggestion.AtRoom -> "@room"
|
||||
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
|
||||
}
|
||||
|
||||
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
|
||||
|
|
@ -159,9 +160,9 @@ internal fun MentionSuggestionsPickerViewPreview() {
|
|||
roomName = "Room",
|
||||
roomAvatarData = null,
|
||||
memberSuggestions = persistentListOf(
|
||||
MentionSuggestion.Room,
|
||||
MentionSuggestion.Member(roomMember),
|
||||
MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
||||
ResolvedMentionSuggestion.AtRoom,
|
||||
ResolvedMentionSuggestion.Member(roomMember),
|
||||
ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
||||
),
|
||||
onSuggestionSelected = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ object MentionSuggestionsProcessor {
|
|||
roomMembersState: MatrixRoomMembersState,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: suspend () -> Boolean,
|
||||
): List<MentionSuggestion> {
|
||||
): List<ResolvedMentionSuggestion> {
|
||||
val members = roomMembersState.roomMembers()
|
||||
return when {
|
||||
members.isNullOrEmpty() || suggestion == null -> {
|
||||
|
|
@ -78,7 +79,7 @@ object MentionSuggestionsProcessor {
|
|||
roomMembers: List<RoomMember>?,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: Boolean,
|
||||
): List<MentionSuggestion> {
|
||||
): List<ResolvedMentionSuggestion> {
|
||||
return if (roomMembers.isNullOrEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
|
|
@ -96,10 +97,10 @@ object MentionSuggestionsProcessor {
|
|||
.filterUpTo(MAX_BATCH_ITEMS) { member ->
|
||||
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
|
||||
}
|
||||
.map(MentionSuggestion::Member)
|
||||
.map(ResolvedMentionSuggestion::Member)
|
||||
|
||||
if ("room".contains(query) && canSendRoomMention) {
|
||||
listOf(MentionSuggestion.Room) + matchingMembers
|
||||
listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers
|
||||
} else {
|
||||
matchingMembers
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
data object ToggleFullScreenState : MessageComposerEvents
|
||||
data class SendMessage(val message: Message) : MessageComposerEvents
|
||||
data object SendMessage : MessageComposerEvents
|
||||
data class SendUri(val uri: Uri) : MessageComposerEvents
|
||||
data object CloseSpecialMode : MessageComposerEvents
|
||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||
|
|
@ -45,5 +44,5 @@ sealed interface MessageComposerEvents {
|
|||
data class Error(val error: Throwable) : MessageComposerEvents
|
||||
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
|
||||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
|
||||
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
|
||||
data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.media3.common.MimeTypes
|
||||
|
|
@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi
|
|||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
|
|
@ -59,17 +60,21 @@ import io.element.android.libraries.mediaupload.api.MediaSender
|
|||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
|
@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor(
|
|||
|
||||
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
|
||||
|
||||
// Used to disable some UI related elements in tests
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal var isTesting: Boolean = false
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal var showTextFormatting: Boolean by mutableStateOf(false)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
// Initially disabled so we don't set focus and text twice
|
||||
var applyFormattingModeChanges by remember { mutableStateOf(false) }
|
||||
val richTextEditorState = richTextEditorStateFactory.remember()
|
||||
if (isTesting) {
|
||||
richTextEditorState.isReadyToProcessActions = true
|
||||
}
|
||||
val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) }
|
||||
|
||||
var isMentionsEnabled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
|
||||
|
|
@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor(
|
|||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val richTextEditorState = richTextEditorStateFactory.create()
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
var showTextFormatting: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
|
||||
LaunchedEffect(messageComposerContext.composerMode) {
|
||||
when (val modeValue = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Edit ->
|
||||
richTextEditorState.setHtml(modeValue.defaultContent)
|
||||
if (showTextFormatting) {
|
||||
richTextEditorState.setHtml(modeValue.defaultContent)
|
||||
} else {
|
||||
markdownTextEditorState.text.update(modeValue.defaultContent, true)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
|
||||
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
|
||||
LaunchedEffect(isMentionsEnabled) {
|
||||
if (!isMentionsEnabled) return@LaunchedEffect
|
||||
val currentUserId = currentSessionIdHolder.current
|
||||
|
|
@ -229,22 +251,69 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val textEditorState by rememberUpdatedState(
|
||||
if (showTextFormatting) {
|
||||
TextEditorState.Rich(richTextEditorState)
|
||||
} else {
|
||||
TextEditorState.Markdown(markdownTextEditorState)
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(showTextFormatting) {
|
||||
if (!applyFormattingModeChanges) {
|
||||
applyFormattingModeChanges = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (showTextFormatting) {
|
||||
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
richTextEditorState.setMarkdown(markdown)
|
||||
richTextEditorState.requestFocus()
|
||||
} else {
|
||||
val markdown = richTextEditorState.messageMarkdown
|
||||
markdownTextEditorState.text.update(markdown, true)
|
||||
// Give some time for the focus of the previous editor to be cleared
|
||||
delay(100)
|
||||
markdownTextEditorState.requestFocusAction()
|
||||
}
|
||||
}
|
||||
|
||||
val mentionSpanProvider = if (isTesting) {
|
||||
null
|
||||
} else {
|
||||
rememberMentionSpanProvider(
|
||||
currentUserId = room.sessionId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
}
|
||||
|
||||
fun handleEvents(event: MessageComposerEvents) {
|
||||
when (event) {
|
||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
MessageComposerEvents.CloseSpecialMode -> {
|
||||
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
|
||||
localCoroutineScope.launch {
|
||||
richTextEditorState.setHtml("")
|
||||
textEditorState.reset()
|
||||
}
|
||||
}
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||
message = event.message,
|
||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
||||
richTextEditorState = richTextEditorState,
|
||||
)
|
||||
is MessageComposerEvents.SendMessage -> {
|
||||
val html = if (showTextFormatting) {
|
||||
richTextEditorState.messageHtml
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val markdown = if (showTextFormatting) {
|
||||
richTextEditorState.messageMarkdown
|
||||
} else {
|
||||
markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
}
|
||||
appCoroutineScope.sendMessage(
|
||||
message = Message(html = html, markdown = markdown),
|
||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
||||
textEditorState = textEditorState,
|
||||
)
|
||||
}
|
||||
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
|
|
@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
is MessageComposerEvents.InsertMention -> {
|
||||
localCoroutineScope.launch {
|
||||
when (val mention = event.mention) {
|
||||
is MentionSuggestion.Room -> {
|
||||
richTextEditorState.insertAtRoomMentionAtSuggestion()
|
||||
if (showTextFormatting) {
|
||||
when (val mention = event.mention) {
|
||||
is ResolvedMentionSuggestion.AtRoom -> {
|
||||
richTextEditorState.insertAtRoomMentionAtSuggestion()
|
||||
}
|
||||
is ResolvedMentionSuggestion.Member -> {
|
||||
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
|
||||
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
|
||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
}
|
||||
}
|
||||
is MentionSuggestion.Member -> {
|
||||
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
|
||||
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
|
||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
} else if (markdownTextEditorState.currentMentionSuggestion != null) {
|
||||
mentionSpanProvider?.let {
|
||||
markdownTextEditorState.insertMention(
|
||||
mention = event.mention,
|
||||
mentionSpanProvider = it,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
)
|
||||
}
|
||||
suggestionSearchTrigger.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
return MessageComposerState(
|
||||
richTextEditorState = richTextEditorState,
|
||||
textEditorState = textEditorState,
|
||||
permalinkParser = permalinkParser,
|
||||
isFullScreen = isFullScreen.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
|
|
@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private fun CoroutineScope.sendMessage(
|
||||
message: Message,
|
||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
textEditorState: TextEditorState,
|
||||
) = launch {
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
val mentions = richTextEditorState.mentionsState?.let { state ->
|
||||
buildList {
|
||||
if (state.hasAtRoomMention) {
|
||||
add(Mention.AtRoom)
|
||||
}
|
||||
for (userId in state.userIds) {
|
||||
add(Mention.User(UserId(userId)))
|
||||
}
|
||||
val mentions = when (textEditorState) {
|
||||
is TextEditorState.Rich -> {
|
||||
textEditorState.richTextEditorState.mentionsState?.let { state ->
|
||||
buildList {
|
||||
if (state.hasAtRoomMention) {
|
||||
add(Mention.AtRoom)
|
||||
}
|
||||
for (userId in state.userIds) {
|
||||
add(Mention.User(UserId(userId)))
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
}
|
||||
}.orEmpty()
|
||||
is TextEditorState.Markdown -> textEditorState.state.getMentions()
|
||||
}
|
||||
// Reset composer right away
|
||||
richTextEditorState.setHtml("")
|
||||
textEditorState.reset()
|
||||
updateComposerMode(MessageComposerMode.Normal)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions)
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Stable
|
||||
data class MessageComposerState(
|
||||
val richTextEditorState: RichTextEditorState,
|
||||
val textEditorState: TextEditorState,
|
||||
val permalinkParser: PermalinkParser,
|
||||
val isFullScreen: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
|
|
@ -37,12 +37,10 @@ data class MessageComposerState(
|
|||
val canShareLocation: Boolean,
|
||||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val memberSuggestions: ImmutableList<MentionSuggestion>,
|
||||
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
|
||||
val currentUserId: UserId,
|
||||
val eventSink: (MessageComposerEvents) -> Unit,
|
||||
) {
|
||||
val hasFocus: Boolean = richTextEditorState.hasFocus
|
||||
}
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface AttachmentsState {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
|||
}
|
||||
|
||||
fun aMessageComposerState(
|
||||
richTextEditorState: RichTextEditorState = aRichTextEditorState(),
|
||||
textEditorState: TextEditorState = TextEditorState.Rich(aRichTextEditorState()),
|
||||
isFullScreen: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal,
|
||||
showTextFormatting: Boolean = false,
|
||||
|
|
@ -43,9 +43,9 @@ fun aMessageComposerState(
|
|||
canShareLocation: Boolean = true,
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
|
||||
memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(),
|
||||
) = MessageComposerState(
|
||||
richTextEditorState = richTextEditorState,
|
||||
textEditorState = textEditorState,
|
||||
permalinkParser = object : PermalinkParser {
|
||||
override fun parse(uriString: String): PermalinkData = TODO()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
|
|
@ -44,13 +43,12 @@ internal fun MessageComposerView(
|
|||
state: MessageComposerState,
|
||||
voiceMessageState: VoiceMessageComposerState,
|
||||
subcomposing: Boolean,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
fun sendMessage(message: Message) {
|
||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
||||
fun sendMessage() {
|
||||
state.eventSink(MessageComposerEvents.SendMessage)
|
||||
}
|
||||
|
||||
fun sendUri(uri: Uri) {
|
||||
|
|
@ -85,7 +83,7 @@ internal fun MessageComposerView(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
fun onRequestFocus() {
|
||||
coroutineScope.launch {
|
||||
state.richTextEditorState.requestFocus()
|
||||
state.textEditorState.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +105,7 @@ internal fun MessageComposerView(
|
|||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
state = state.textEditorState,
|
||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||
permalinkParser = state.permalinkParser,
|
||||
subcomposing = subcomposing,
|
||||
|
|
@ -118,7 +116,6 @@ internal fun MessageComposerView(
|
|||
onResetComposerMode = ::onCloseSpecialMode,
|
||||
onAddAttachment = ::onAddAttachment,
|
||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecorderEvent = onVoiceRecorderEvent,
|
||||
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||
|
|
@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview(
|
|||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
|
|
@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview(
|
|||
modifier = Modifier.height(200.dp),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
|
|
@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview(
|
|||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = aMessageComposerState(),
|
||||
voiceMessageState = state,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ import javax.inject.Inject
|
|||
|
||||
interface RichTextEditorStateFactory {
|
||||
@Composable
|
||||
fun create(): RichTextEditorState
|
||||
fun remember(): RichTextEditorState
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
||||
@Composable
|
||||
override fun create(): RichTextEditorState {
|
||||
override fun remember(): RichTextEditorState {
|
||||
return rememberRichTextEditorState()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,7 +538,7 @@ class MessagesPresenterTest {
|
|||
// Initially the composer doesn't have focus, so we don't show the alert
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
// When the input field is focused we show the alert
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
initialState.composerState.textEditorState.requestFocus()
|
||||
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
|
||||
state.showReinvitePrompt
|
||||
}.last()
|
||||
|
|
@ -561,7 +561,7 @@ class MessagesPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
initialState.composerState.textEditorState.requestFocus()
|
||||
val focusedState = awaitItem()
|
||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||
}
|
||||
|
|
@ -576,7 +576,7 @@ class MessagesPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
initialState.composerState.textEditorState.requestFocus()
|
||||
val focusedState = awaitItem()
|
||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||
}
|
||||
|
|
@ -781,7 +781,7 @@ class MessagesPresenterTest {
|
|||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true)
|
||||
val appPreferencesStore = InMemoryAppPreferencesStore()
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore()
|
||||
val messageComposerPresenter = MessageComposerPresenter(
|
||||
appCoroutineScope = this,
|
||||
|
|
@ -800,7 +800,10 @@ class MessagesPresenterTest {
|
|||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
)
|
||||
).apply {
|
||||
showTextFormatting = true
|
||||
isTesting = true
|
||||
}
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
FakeVoiceRecorder(),
|
||||
|
|
@ -853,7 +856,6 @@ class MessagesPresenterTest {
|
|||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||
navigator = navigator,
|
||||
clipboardHelper = clipboardHelper,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
|
|
@ -77,10 +76,11 @@ 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.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
|
|
@ -127,7 +127,7 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
|
|
@ -158,10 +158,10 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
initialState.richTextEditorState.setHtml("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
initialState.textEditorState.setHtml("")
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ class MessageComposerPresenterTest {
|
|||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
remember(state, state.textEditorState.messageHtml()) { state }
|
||||
}.test {
|
||||
var state = awaitFirstItem()
|
||||
val mode = anEditMode()
|
||||
|
|
@ -178,11 +178,11 @@ class MessageComposerPresenterTest {
|
|||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state = awaitItem()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
state = backToNormalMode(state, skipCount = 1)
|
||||
|
||||
// The message that was being edited is cleared
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -213,11 +213,11 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state.richTextEditorState.setHtml(A_REPLY)
|
||||
state.textEditorState.setHtml(A_REPLY)
|
||||
state = backToNormalMode(state)
|
||||
|
||||
// The message typed while replying is not cleared
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,25 +232,54 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send message`() = runTest {
|
||||
fun `present - send message with rich text enabled`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
remember(state, state.textEditorState.messageHtml()) { state }
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||
waitForPredicate { analyticsService.capturedEvents.size == 1 }
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
inThread = false,
|
||||
isEditing = false,
|
||||
isReply = false,
|
||||
messageType = Composer.MessageType.Text,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send message with plain text enabled`() = runTest {
|
||||
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") })
|
||||
val presenter = createPresenter(this, isRichTextEditorEnabled = false)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder)
|
||||
remember(state, messageMarkdown) { state }
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.textEditorState.setMarkdown(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.textEditorState.messageHtml()).isNull()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("")
|
||||
waitForPredicate { analyticsService.capturedEvents.size == 1 }
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
|
|
@ -278,23 +307,23 @@ class MessageComposerPresenterTest {
|
|||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
remember(state, state.textEditorState.messageHtml()) { state }
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||
val mode = anEditMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -328,23 +357,23 @@ class MessageComposerPresenterTest {
|
|||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
remember(state, state.textEditorState.messageHtml()) { state }
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -380,17 +409,17 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||
val mode = aReplyMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
val state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
state.richTextEditorState.setHtml(A_REPLY)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||
state.textEditorState.setHtml(A_REPLY)
|
||||
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY)
|
||||
state.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -725,7 +754,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
val presenter = createPresenter(this, isRichTextEditorEnabled = false)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -735,11 +764,12 @@ class MessageComposerPresenterTest {
|
|||
val composerOptions = awaitItem()
|
||||
assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
|
||||
composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
|
||||
awaitItem() // composer options closed
|
||||
skipItems(2) // composer options closed
|
||||
val showTextFormatting = awaitItem()
|
||||
assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(showTextFormatting.showTextFormatting).isTrue()
|
||||
showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
|
||||
skipItems(1)
|
||||
val finished = awaitItem()
|
||||
assertThat(finished.showTextFormatting).isFalse()
|
||||
}
|
||||
|
|
@ -781,19 +811,19 @@ class MessageComposerPresenterTest {
|
|||
// An empty suggestion returns the room and joined members that are not the current user
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
|
||||
.containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
|
||||
|
||||
// A suggestion containing a part of "room" will also return the room mention
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room)
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom)
|
||||
|
||||
// A non-empty suggestion will return those joined members whose user id matches it
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob))
|
||||
|
||||
// A non-empty suggestion will return those joined members whose display name matches it
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david))
|
||||
|
||||
// If the suggestion isn't a mention, no suggestions are returned
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
|
||||
|
|
@ -803,7 +833,7 @@ class MessageComposerPresenterTest {
|
|||
room.givenCanTriggerRoomNotification(Result.success(false))
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
|
||||
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
|
||||
|
||||
// If room is a DM, `RoomMemberSuggestion.Room` is not returned
|
||||
room.givenCanTriggerRoomNotification(Result.success(true))
|
||||
|
|
@ -844,7 +874,7 @@ class MessageComposerPresenterTest {
|
|||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
|
||||
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -862,10 +892,10 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.richTextEditorState.setHtml("Hey @bo")
|
||||
initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
|
||||
initialState.textEditorState.setHtml("Hey @bo")
|
||||
initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
|
||||
|
||||
assertThat(initialState.richTextEditorState.messageHtml)
|
||||
assertThat(initialState.textEditorState.messageHtml())
|
||||
.isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>")
|
||||
}
|
||||
}
|
||||
|
|
@ -892,14 +922,14 @@ class MessageComposerPresenterTest {
|
|||
|
||||
// Check intentional mentions on message sent
|
||||
val mentionUser1 = listOf(A_USER_ID.value)
|
||||
initialState.richTextEditorState.mentionsState = MentionsState(
|
||||
(initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||
userIds = mentionUser1,
|
||||
roomIds = emptyList(),
|
||||
roomAliases = emptyList(),
|
||||
hasAtRoomMention = false
|
||||
)
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -908,14 +938,14 @@ class MessageComposerPresenterTest {
|
|||
// Check intentional mentions on reply sent
|
||||
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
|
||||
val mentionUser2 = listOf(A_USER_ID_2.value)
|
||||
awaitItem().richTextEditorState.mentionsState = MentionsState(
|
||||
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||
userIds = mentionUser2,
|
||||
roomIds = emptyList(),
|
||||
roomAliases = emptyList(),
|
||||
hasAtRoomMention = false
|
||||
)
|
||||
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(replyMessageLambda)
|
||||
|
|
@ -926,14 +956,14 @@ class MessageComposerPresenterTest {
|
|||
skipItems(1)
|
||||
initialState.eventSink(MessageComposerEvents.SetMode(anEditMode()))
|
||||
val mentionUser3 = listOf(A_USER_ID_3.value)
|
||||
awaitItem().richTextEditorState.mentionsState = MentionsState(
|
||||
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||
userIds = mentionUser3,
|
||||
roomIds = emptyList(),
|
||||
roomAliases = emptyList(),
|
||||
hasAtRoomMention = false
|
||||
)
|
||||
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||
advanceUntilIdle()
|
||||
|
||||
assert(editMessageLambda)
|
||||
|
|
@ -949,7 +979,7 @@ class MessageComposerPresenterTest {
|
|||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
remember(state, state.textEditorState.messageHtml()) { state }
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri")))
|
||||
|
|
@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest {
|
|||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
) = MessageComposerPresenter(
|
||||
coroutineScope,
|
||||
room,
|
||||
|
|
@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest {
|
|||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
timelineController = TimelineController(room),
|
||||
)
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
// Skip 2 item if Mentions feature is enabled, else 1
|
||||
|
|
@ -1043,7 +1077,10 @@ fun anEditMode(
|
|||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
|
||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||
|
||||
private fun String.toMessage() = Message(
|
||||
html = this,
|
||||
markdown = this,
|
||||
)
|
||||
private suspend fun TextEditorState.setHtml(html: String) {
|
||||
(this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich")
|
||||
}
|
||||
|
||||
private fun TextEditorState.setMarkdown(markdown: String) {
|
||||
(this as? TextEditorState.Markdown)?.state?.text?.update(markdown, needsDisplaying = false) ?: error("TextEditorState is not Markdown")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
|||
|
||||
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
|
||||
@Composable
|
||||
override fun create(): RichTextEditorState {
|
||||
override fun remember(): RichTextEditorState {
|
||||
return rememberRichTextEditorState("", fake = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue