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:
committed by
GitHub
parent
0e05a0e4ed
commit
902dd24e72
@@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- takeScreenshot: build/maestro/510-Timeline
|
||||
- tapOn:
|
||||
id: "rich_text_editor"
|
||||
id: "text_editor"
|
||||
- inputText: "Hello world!"
|
||||
- tapOn: "Send"
|
||||
- hideKeyboard
|
||||
|
||||
@@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID}
|
||||
|
||||
- tapOn:
|
||||
text: "Advanced settings"
|
||||
- assertVisible: "Rich text editor"
|
||||
- assertVisible: "View source"
|
||||
- back
|
||||
|
||||
- tapOn:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.appconfig
|
||||
|
||||
object MessageComposerConfig {
|
||||
/**
|
||||
* Enable the rich text editing in the composer.
|
||||
*/
|
||||
const val ENABLE_RICH_TEXT_EDITING = true
|
||||
}
|
||||
1
changelog.d/2840.feature
Normal file
1
changelog.d/2840.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add plain text editor based on Markdown input.
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
|
||||
import io.element.android.compound.theme.Theme
|
||||
|
||||
sealed interface AdvancedSettingsEvents {
|
||||
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data object ChangeTheme : AdvancedSettingsEvents
|
||||
|
||||
@@ -38,9 +38,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||
@Composable
|
||||
override fun present(): AdvancedSettingsState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val isRichTextEditorEnabled by appPreferencesStore
|
||||
.isRichTextEditorEnabledFlow()
|
||||
.collectAsState(initial = false)
|
||||
val isDeveloperModeEnabled by appPreferencesStore
|
||||
.isDeveloperModeEnabledFlow()
|
||||
.collectAsState(initial = false)
|
||||
@@ -54,9 +51,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||
when (event) {
|
||||
is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
|
||||
appPreferencesStore.setRichTextEditorEnabled(event.enabled)
|
||||
}
|
||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
||||
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
|
||||
}
|
||||
@@ -73,7 +67,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||
}
|
||||
|
||||
return AdvancedSettingsState(
|
||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||
theme = theme,
|
||||
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
|
||||
import io.element.android.compound.theme.Theme
|
||||
|
||||
data class AdvancedSettingsState(
|
||||
val isRichTextEditorEnabled: Boolean,
|
||||
val isDeveloperModeEnabled: Boolean,
|
||||
val isSharePresenceEnabled: Boolean,
|
||||
val theme: Theme,
|
||||
|
||||
@@ -23,7 +23,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||
override val values: Sequence<AdvancedSettingsState>
|
||||
get() = sequenceOf(
|
||||
aAdvancedSettingsState(),
|
||||
aAdvancedSettingsState(isRichTextEditorEnabled = true),
|
||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||
aAdvancedSettingsState(showChangeThemeDialog = true),
|
||||
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
|
||||
@@ -31,12 +30,10 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||
}
|
||||
|
||||
fun aAdvancedSettingsState(
|
||||
isRichTextEditorEnabled: Boolean = false,
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
isSendPublicReadReceiptsEnabled: Boolean = false,
|
||||
showChangeThemeDialog: Boolean = false,
|
||||
) = AdvancedSettingsState(
|
||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
|
||||
theme = Theme.System,
|
||||
|
||||
@@ -57,18 +57,6 @@ fun AdvancedSettingsView(
|
||||
state.eventSink(AdvancedSettingsEvents.ChangeTheme)
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = CommonStrings.common_rich_text_editor))
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_rich_text_editor_description))
|
||||
},
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.isRichTextEditorEnabled,
|
||||
),
|
||||
onClick = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(!state.isRichTextEditorEnabled)) }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = CommonStrings.action_view_source))
|
||||
|
||||
@@ -41,7 +41,6 @@ class AdvancedSettingsPresenterTest {
|
||||
}.test {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
assertThat(initialState.isDeveloperModeEnabled).isFalse()
|
||||
assertThat(initialState.isRichTextEditorEnabled).isFalse()
|
||||
assertThat(initialState.showChangeThemeDialog).isFalse()
|
||||
assertThat(initialState.isSharePresenceEnabled).isTrue()
|
||||
assertThat(initialState.theme).isEqualTo(Theme.System)
|
||||
@@ -63,21 +62,6 @@ class AdvancedSettingsPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - rich text editor on off`() = runTest {
|
||||
val presenter = createAdvancedSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
assertThat(initialState.isRichTextEditorEnabled).isFalse()
|
||||
initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(true))
|
||||
assertThat(awaitItem().isRichTextEditorEnabled).isTrue()
|
||||
initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(false))
|
||||
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - share presence off on`() = runTest {
|
||||
val presenter = createAdvancedSettingsPresenter()
|
||||
|
||||
@@ -43,7 +43,7 @@ serialization_json = "1.6.3"
|
||||
showkase = "1.0.2"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.2"
|
||||
wysiwyg = "2.37.2"
|
||||
wysiwyg = "2.37.3"
|
||||
telephoto = "0.11.2"
|
||||
|
||||
# DI
|
||||
|
||||
@@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
|
||||
class FakePermalinkBuilder(
|
||||
private val result: () -> Result<String> = { Result.failure(Exception("Not implemented")) }
|
||||
private val result: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) }
|
||||
) : PermalinkBuilder {
|
||||
override fun permalinkForUser(userId: UserId): Result<String> {
|
||||
return result()
|
||||
return result(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ package io.element.android.features.preferences.api.store
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AppPreferencesStore {
|
||||
suspend fun setRichTextEditorEnabled(enabled: Boolean)
|
||||
fun isRichTextEditorEnabledFlow(): Flow<Boolean>
|
||||
|
||||
suspend fun setDeveloperModeEnabled(enabled: Boolean)
|
||||
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.core.bool.orTrue
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.di.AppScope
|
||||
@@ -36,7 +35,6 @@ import javax.inject.Inject
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_preferences")
|
||||
|
||||
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
|
||||
private val developerModeKey = booleanPreferencesKey("developerMode")
|
||||
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
|
||||
private val themeKey = stringPreferencesKey("theme")
|
||||
@@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor(
|
||||
) : AppPreferencesStore {
|
||||
private val store = context.dataStore
|
||||
|
||||
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
|
||||
store.edit { prefs ->
|
||||
prefs[richTextEditorKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override fun isRichTextEditorEnabledFlow(): Flow<Boolean> {
|
||||
return store.data.map { prefs ->
|
||||
// enabled by default
|
||||
prefs[richTextEditorKey].orTrue()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
||||
store.edit { prefs ->
|
||||
prefs[developerModeKey] = enabled
|
||||
|
||||
@@ -21,24 +21,14 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class InMemoryAppPreferencesStore(
|
||||
isRichTextEditorEnabled: Boolean = false,
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
customElementCallBaseUrl: String? = null,
|
||||
theme: String? = null,
|
||||
) : AppPreferencesStore {
|
||||
private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
|
||||
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||
private val theme = MutableStateFlow(theme)
|
||||
|
||||
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
|
||||
isRichTextEditorEnabled.value = enabled
|
||||
}
|
||||
|
||||
override fun isRichTextEditorEnabledFlow(): Flow<Boolean> {
|
||||
return isRichTextEditorEnabled
|
||||
}
|
||||
|
||||
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
||||
isDeveloperModeEnabled.value = enabled
|
||||
}
|
||||
|
||||
@@ -75,9 +75,14 @@ object TestTags {
|
||||
val welcomeScreenTitle = TestTag("welcome_screen-title")
|
||||
|
||||
/**
|
||||
* RichTextEditor.
|
||||
* TextEditor.
|
||||
*/
|
||||
val richTextEditor = TestTag("rich_text_editor")
|
||||
val textEditor = TestTag("text_editor")
|
||||
|
||||
/**
|
||||
* EditText inside the MarkdownTextInput.
|
||||
*/
|
||||
val plainTextEditor = TestTag("plain_text_editor")
|
||||
|
||||
/**
|
||||
* Message bubble.
|
||||
|
||||
@@ -22,6 +22,9 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.textcomposer"
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -47,9 +50,13 @@ dependencies {
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun ComposerModeView(
|
||||
composerMode: MessageComposerMode,
|
||||
onResetComposerMode: () -> Unit,
|
||||
) {
|
||||
when (composerMode) {
|
||||
is MessageComposerMode.Edit -> {
|
||||
EditingModeView(onResetComposerMode = onResetComposerMode)
|
||||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
ReplyToModeView(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
senderName = composerMode.senderName,
|
||||
text = composerMode.defaultContent,
|
||||
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditingModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = stringResource(CommonStrings.common_editing),
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(CommonStrings.common_editing),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onResetComposerMode,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToModeView(
|
||||
senderName: String,
|
||||
text: String?,
|
||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
if (attachmentThumbnailInfo != null) {
|
||||
AttachmentThumbnail(
|
||||
info = attachmentThumbnailInfo,
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = senderName,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clipToBounds(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.primary,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onResetComposerMode,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ package io.element.android.libraries.textcomposer
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,30 +32,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
@@ -66,7 +56,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
@@ -79,11 +68,13 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
|
||||
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
|
||||
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
|
||||
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.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
@@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun TextComposer(
|
||||
state: RichTextEditorState,
|
||||
state: TextEditorState,
|
||||
voiceMessageState: VoiceMessageState,
|
||||
permalinkParser: PermalinkParser,
|
||||
composerMode: MessageComposerMode,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
currentUserId: UserId,
|
||||
onRequestFocus: () -> Unit,
|
||||
onSendMessage: (Message) -> Unit,
|
||||
onSendMessage: () -> Unit,
|
||||
onResetComposerMode: () -> Unit,
|
||||
onAddAttachment: () -> Unit,
|
||||
onDismissTextFormatting: () -> Unit,
|
||||
@@ -122,9 +112,12 @@ fun TextComposer(
|
||||
showTextFormatting: Boolean = false,
|
||||
subcomposing: Boolean = false,
|
||||
) {
|
||||
val markdown = when (state) {
|
||||
is TextEditorState.Markdown -> state.state.text.value()
|
||||
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
|
||||
}
|
||||
val onSendClicked = {
|
||||
val html = if (enableTextFormatting) state.messageHtml else null
|
||||
onSendMessage(Message(html = html, markdown = state.messageMarkdown))
|
||||
onSendMessage()
|
||||
}
|
||||
|
||||
val onPlayVoiceMessageClicked = {
|
||||
@@ -153,32 +146,57 @@ fun TextComposer(
|
||||
}
|
||||
}
|
||||
|
||||
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(
|
||||
currentUserId = currentUserId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
TextInput(
|
||||
state = state,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
},
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
|
||||
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
|
||||
onError = onError,
|
||||
onTyping = onTyping,
|
||||
onRichContentSelected = onRichContentSelected,
|
||||
)
|
||||
val placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
}
|
||||
val textInput: @Composable () -> Unit = when (state) {
|
||||
is TextEditorState.Rich -> {
|
||||
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(
|
||||
currentUserId = currentUserId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
TextInput(
|
||||
state = state.richTextEditorState,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = placeholder,
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
|
||||
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
|
||||
onError = onError,
|
||||
onTyping = onTyping,
|
||||
onRichContentSelected = onRichContentSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TextEditorState.Markdown -> {
|
||||
@Composable {
|
||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
|
||||
TextInputBox(
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
placeholder = placeholder,
|
||||
showPlaceholder = { state.state.text.value().isEmpty() },
|
||||
subcomposing = subcomposing,
|
||||
) {
|
||||
MarkdownTextInput(
|
||||
state = state.state,
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onSuggestionReceived = onSuggestionReceived,
|
||||
richTextEditorStyle = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } }
|
||||
val canSendMessage = markdown.isNotBlank()
|
||||
val sendButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = canSendMessage,
|
||||
@@ -205,7 +223,9 @@ fun TextComposer(
|
||||
)
|
||||
}
|
||||
|
||||
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
|
||||
@Composable { TextFormatting(state = it.richTextEditorState) }
|
||||
}
|
||||
|
||||
val sendOrRecordButton = when {
|
||||
enableVoiceMessages && !canSendMessage ->
|
||||
@@ -217,8 +237,7 @@ fun TextComposer(
|
||||
false -> sendVoiceButton
|
||||
}
|
||||
}
|
||||
else ->
|
||||
sendButton
|
||||
else -> sendButton
|
||||
}
|
||||
|
||||
val voiceRecording = @Composable {
|
||||
@@ -251,7 +270,7 @@ fun TextComposer(
|
||||
}
|
||||
}
|
||||
|
||||
if (showTextFormatting) {
|
||||
if (showTextFormatting && textFormattingOptions != null) {
|
||||
TextFormattingLayout(
|
||||
modifier = layoutModifier,
|
||||
textInput = textInput,
|
||||
@@ -282,14 +301,16 @@ fun TextComposer(
|
||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||
}
|
||||
|
||||
val menuAction = state.menuAction
|
||||
val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived)
|
||||
LaunchedEffect(menuAction) {
|
||||
if (menuAction is MenuAction.Suggestion) {
|
||||
val suggestion = Suggestion(menuAction.suggestionPattern)
|
||||
latestOnSuggestionReceived(suggestion)
|
||||
} else {
|
||||
latestOnSuggestionReceived(null)
|
||||
if (state is TextEditorState.Rich) {
|
||||
val menuAction = state.richTextEditorState.menuAction
|
||||
LaunchedEffect(menuAction) {
|
||||
if (menuAction is MenuAction.Suggestion) {
|
||||
val suggestion = Suggestion(menuAction.suggestionPattern)
|
||||
latestOnSuggestionReceived(suggestion)
|
||||
} else {
|
||||
latestOnSuggestionReceived(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,17 +421,13 @@ private fun TextFormattingLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextInput(
|
||||
state: RichTextEditorState,
|
||||
subcomposing: Boolean,
|
||||
placeholder: String,
|
||||
private fun TextInputBox(
|
||||
composerMode: MessageComposerMode,
|
||||
onResetComposerMode: () -> Unit,
|
||||
resolveRoomMentionDisplay: () -> TextDisplay,
|
||||
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
||||
onError: (Throwable) -> Unit,
|
||||
onTyping: (Boolean) -> Unit,
|
||||
onRichContentSelected: ((Uri) -> Unit)?,
|
||||
placeholder: String,
|
||||
showPlaceholder: () -> Boolean,
|
||||
subcomposing: Boolean,
|
||||
textInput: @Composable () -> Unit,
|
||||
) {
|
||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val borderColor = ElementTheme.colors.borderDisabled
|
||||
@@ -431,11 +448,12 @@ private fun TextInput(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp)
|
||||
.testTag(TestTags.richTextEditor),
|
||||
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// Placeholder
|
||||
if (state.messageHtml.isEmpty()) {
|
||||
if (showPlaceholder()) {
|
||||
Text(
|
||||
placeholder,
|
||||
style = defaultTypography.copy(
|
||||
@@ -446,155 +464,45 @@ private fun TextInput(
|
||||
)
|
||||
}
|
||||
|
||||
RichTextEditor(
|
||||
state = state,
|
||||
// Disable most of the editor functionality if it's just being measured for a subcomposition.
|
||||
// This prevents it gaining focus and mutating the state.
|
||||
registerStateUpdates = !subcomposing,
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||
onError = onError,
|
||||
onRichContentSelected = onRichContentSelected,
|
||||
onTyping = onTyping,
|
||||
)
|
||||
textInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposerModeView(
|
||||
private fun TextInput(
|
||||
state: RichTextEditorState,
|
||||
subcomposing: Boolean,
|
||||
placeholder: String,
|
||||
composerMode: MessageComposerMode,
|
||||
onResetComposerMode: () -> Unit,
|
||||
resolveRoomMentionDisplay: () -> TextDisplay,
|
||||
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
||||
onError: (Throwable) -> Unit,
|
||||
onTyping: (Boolean) -> Unit,
|
||||
onRichContentSelected: ((Uri) -> Unit)?,
|
||||
) {
|
||||
when (composerMode) {
|
||||
is MessageComposerMode.Edit -> {
|
||||
EditingModeView(onResetComposerMode = onResetComposerMode)
|
||||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
ReplyToModeView(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
senderName = composerMode.senderName,
|
||||
text = composerMode.defaultContent,
|
||||
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditingModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp)
|
||||
TextInputBox(
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
placeholder = placeholder,
|
||||
showPlaceholder = { state.messageHtml.isEmpty() },
|
||||
subcomposing = subcomposing,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = stringResource(CommonStrings.common_editing),
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
RichTextEditor(
|
||||
state = state,
|
||||
// Disable most of the editor functionality if it's just being measured for a subcomposition.
|
||||
// This prevents it gaining focus and mutating the state.
|
||||
registerStateUpdates = !subcomposing,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(CommonStrings.common_editing),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onResetComposerMode,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToModeView(
|
||||
senderName: String,
|
||||
text: String?,
|
||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
if (attachmentThumbnailInfo != null) {
|
||||
AttachmentThumbnail(
|
||||
info = attachmentThumbnailInfo,
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = senderName,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clipToBounds(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.primary,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text.orEmpty(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onResetComposerMode,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||
onError = onError,
|
||||
onRichContentSelected = onRichContentSelected,
|
||||
onTyping = onTyping,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -606,43 +514,41 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||
items = persistentListOf(
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "", initialFocus = true),
|
||||
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost"),
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message", initialFocus = true),
|
||||
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(
|
||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
initialFocus = true
|
||||
TextEditorState.Markdown(
|
||||
aMarkdownTextEditorState(
|
||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
initialFocus = true
|
||||
)
|
||||
),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message without focus"),
|
||||
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
@@ -656,33 +562,32 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||
internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
ATextComposer(
|
||||
aRichTextEditorState(),
|
||||
TextEditorState.Rich(aRichTextEditorState()),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message"),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
ATextComposer(
|
||||
aRichTextEditorState(
|
||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
TextEditorState.Rich(
|
||||
aRichTextEditorState(
|
||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
)
|
||||
),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
@@ -694,10 +599,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||
internal fun TextComposerEditPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message", initialFocus = true),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
ATextComposer(
|
||||
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
@@ -711,7 +629,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
items = persistentListOf(
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(),
|
||||
TextEditorState.Rich(aRichTextEditorState()),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -722,14 +640,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
"With several lines\n" +
|
||||
"To preview larger textfields and long lines with overflow"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(),
|
||||
TextEditorState.Rich(aRichTextEditorState()),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
@@ -740,14 +657,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
"With several lines\n" +
|
||||
"To preview larger textfields and long lines with overflow"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message"),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
@@ -761,14 +677,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
defaultContent = "image.jpg"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message"),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -782,14 +697,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
defaultContent = "video.mp4"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message"),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -803,14 +717,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
defaultContent = "logs.txt"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
ATextComposer(
|
||||
aRichTextEditorState(initialText = "A message", initialFocus = true),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -824,7 +737,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
defaultContent = "Shared location"
|
||||
),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
@@ -840,10 +752,9 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
fun VoicePreview(
|
||||
voiceMessageState: VoiceMessageState
|
||||
) = ATextComposer(
|
||||
aRichTextEditorState(initialFocus = true),
|
||||
TextEditorState.Rich(aRichTextEditorState(initialFocus = true)),
|
||||
voiceMessageState = voiceMessageState,
|
||||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
@@ -902,23 +813,21 @@ private fun PreviewColumn(
|
||||
|
||||
@Composable
|
||||
private fun ATextComposer(
|
||||
richTextEditorState: RichTextEditorState,
|
||||
state: TextEditorState,
|
||||
voiceMessageState: VoiceMessageState,
|
||||
composerMode: MessageComposerMode,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
currentUserId: UserId,
|
||||
showTextFormatting: Boolean = false,
|
||||
) {
|
||||
TextComposer(
|
||||
state = richTextEditorState,
|
||||
state = state,
|
||||
showTextFormatting = showTextFormatting,
|
||||
voiceMessageState = voiceMessageState,
|
||||
permalinkParser = object : PermalinkParser {
|
||||
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
|
||||
},
|
||||
composerMode = composerMode,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
currentUserId = currentUserId,
|
||||
onRequestFocus = {},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.components.markdown
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
|
||||
internal class MarkdownEditText(
|
||||
context: Context,
|
||||
) : AppCompatEditText(context) {
|
||||
var onSelectionChangeListener: ((Int, Int) -> Unit)? = null
|
||||
|
||||
private var isModifyingText = false
|
||||
|
||||
fun updateEditableText(charSequence: CharSequence) {
|
||||
isModifyingText = true
|
||||
editableText.clear()
|
||||
editableText.append(charSequence)
|
||||
isModifyingText = false
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
isModifyingText = true
|
||||
super.setText(text, type)
|
||||
isModifyingText = false
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||
super.onSelectionChanged(selStart, selEnd)
|
||||
if (!isModifyingText) {
|
||||
onSelectionChangeListener?.invoke(selStart, selEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.components.markdown
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.Editable
|
||||
import android.text.Selection
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorStyle
|
||||
import io.element.android.wysiwyg.compose.internal.applyStyleInCompose
|
||||
|
||||
@Suppress("ModifierMissing")
|
||||
@Composable
|
||||
fun MarkdownTextInput(
|
||||
state: MarkdownTextEditorState,
|
||||
subcomposing: Boolean,
|
||||
onTyping: (Boolean) -> Unit,
|
||||
onSuggestionReceived: (Suggestion?) -> Unit,
|
||||
richTextEditorStyle: RichTextEditorStyle,
|
||||
) {
|
||||
val canUpdateState = !subcomposing
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp, bottom = 6.dp)
|
||||
.fillMaxWidth(),
|
||||
factory = { context ->
|
||||
MarkdownEditText(context).apply {
|
||||
tag = TestTags.plainTextEditor.value // Needed for UI tests
|
||||
setPadding(0)
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
setText(state.text.value())
|
||||
if (canUpdateState) {
|
||||
setSelection(state.selection.first, state.selection.last)
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
state.hasFocus = hasFocus
|
||||
}
|
||||
addTextChangedListener { editable ->
|
||||
onTyping(!editable.isNullOrEmpty())
|
||||
state.text.update(editable, false)
|
||||
state.lineCount = lineCount
|
||||
|
||||
state.currentMentionSuggestion = editable?.checkSuggestionNeeded()
|
||||
onSuggestionReceived(state.currentMentionSuggestion)
|
||||
}
|
||||
onSelectionChangeListener = { selStart, selEnd ->
|
||||
state.selection = selStart..selEnd
|
||||
state.currentMentionSuggestion = editableText.checkSuggestionNeeded()
|
||||
onSuggestionReceived(state.currentMentionSuggestion)
|
||||
}
|
||||
state.requestFocusAction = { this.requestFocus() }
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { editText ->
|
||||
editText.applyStyleInCompose(richTextEditorStyle)
|
||||
|
||||
if (state.text.needsDisplaying()) {
|
||||
editText.updateEditableText(state.text.value())
|
||||
if (canUpdateState) {
|
||||
state.text.update(editText.editableText, false)
|
||||
}
|
||||
}
|
||||
if (canUpdateState) {
|
||||
val newSelectionStart = state.selection.first
|
||||
val newSelectionEnd = state.selection.last
|
||||
val currentTextRange = 0..editText.editableText.length
|
||||
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
|
||||
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
|
||||
if (didSelectionChange() && isNewSelectionValid()) {
|
||||
editText.setSelection(state.selection.first, state.selection.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun Editable.checkSuggestionNeeded(): Suggestion? {
|
||||
if (this.isEmpty()) return null
|
||||
val start = Selection.getSelectionStart(this)
|
||||
val end = Selection.getSelectionEnd(this)
|
||||
var startOfWord = start
|
||||
while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) {
|
||||
startOfWord--
|
||||
}
|
||||
if (startOfWord !in indices) return null
|
||||
val firstChar = this[startOfWord]
|
||||
|
||||
// If a mention span already exists we don't need suggestions
|
||||
if (getSpans<MentionSpan>(startOfWord, startOfWord + 1).isNotEmpty()) return null
|
||||
|
||||
return if (firstChar in listOf('@', '#', '/')) {
|
||||
var endOfWord = end
|
||||
while (endOfWord < this.length && !this[endOfWord].isWhitespace()) {
|
||||
endOfWord++
|
||||
}
|
||||
val text = this.subSequence(startOfWord + 1, endOfWord).toString()
|
||||
val suggestionType = when (firstChar) {
|
||||
'@' -> SuggestionType.Mention
|
||||
'#' -> SuggestionType.Room
|
||||
'/' -> SuggestionType.Command
|
||||
else -> error("Unknown suggestion type. This should never happen.")
|
||||
}
|
||||
Suggestion(startOfWord, endOfWord, suggestionType, text)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MarkdownTextInputPreview() {
|
||||
ElementPreview {
|
||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true)
|
||||
MarkdownTextInput(
|
||||
state = aMarkdownTextEditorState(),
|
||||
subcomposing = false,
|
||||
onTyping = {},
|
||||
onSuggestionReceived = {},
|
||||
richTextEditorStyle = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun aMarkdownTextEditorState(
|
||||
initialText: String = "Hello, World!",
|
||||
initialFocus: Boolean = true,
|
||||
) = MarkdownTextEditorState(
|
||||
initialText = initialText,
|
||||
initialFocus = initialFocus,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.components.markdown
|
||||
|
||||
import android.text.SpannableString
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
|
||||
@Stable
|
||||
class StableCharSequence(initialText: CharSequence = "") {
|
||||
private var value by mutableStateOf<SpannableString>(SpannableString(initialText))
|
||||
private var needsDisplaying by mutableStateOf(false)
|
||||
|
||||
fun update(newText: CharSequence?, needsDisplaying: Boolean) {
|
||||
value = SpannableString(newText.orEmpty())
|
||||
this.needsDisplaying = needsDisplaying
|
||||
}
|
||||
|
||||
fun value(): CharSequence = value
|
||||
fun needsDisplaying(): Boolean = needsDisplaying
|
||||
|
||||
override fun toString(): String {
|
||||
return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MentionSpan(
|
||||
val text: String,
|
||||
val rawValue: String,
|
||||
val type: Type,
|
||||
val backgroundColor: Int,
|
||||
val textColor: Int,
|
||||
@@ -39,29 +41,25 @@ class MentionSpan(
|
||||
|
||||
private var actualText: CharSequence? = null
|
||||
private var textWidth = 0
|
||||
private var cachedRect: RectF = RectF()
|
||||
private val backgroundPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = backgroundColor
|
||||
}
|
||||
|
||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
||||
val mentionText = getActualText(text, start, end)
|
||||
val mentionText = getActualText(this.text)
|
||||
paint.typeface = typeface
|
||||
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
|
||||
return textWidth + startPadding + endPadding
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
||||
val mentionText = getActualText(text, start, end)
|
||||
val mentionText = getActualText(this.text)
|
||||
|
||||
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
||||
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
||||
if (cachedRect.isEmpty) {
|
||||
cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
|
||||
}
|
||||
|
||||
val rect = cachedRect
|
||||
val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
|
||||
val radius = rect.height() / 2
|
||||
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
||||
paint.color = textColor
|
||||
@@ -69,24 +67,24 @@ class MentionSpan(
|
||||
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
|
||||
}
|
||||
|
||||
private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence {
|
||||
private fun getActualText(text: String): CharSequence {
|
||||
if (actualText != null) return actualText!!
|
||||
return buildString {
|
||||
val mentionText = text.orEmpty()
|
||||
when (type) {
|
||||
Type.USER -> {
|
||||
if (start in mentionText.indices && mentionText[start] != '@') {
|
||||
if (text.firstOrNull() != '@') {
|
||||
append("@")
|
||||
}
|
||||
}
|
||||
Type.ROOM -> {
|
||||
if (start in mentionText.indices && mentionText[start] != '#') {
|
||||
if (text.firstOrNull() != '#') {
|
||||
append("#")
|
||||
}
|
||||
}
|
||||
}
|
||||
append(mentionText.substring(start, min(end, start + MAX_LENGTH)))
|
||||
if (end - start > MAX_LENGTH) {
|
||||
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
|
||||
if (mentionText.length > MAX_LENGTH) {
|
||||
append("…")
|
||||
}
|
||||
actualText = this
|
||||
|
||||
@@ -84,6 +84,8 @@ class MentionSpanProvider(
|
||||
permalinkData is PermalinkData.UserLink -> {
|
||||
val isCurrentUser = permalinkData.userId == currentSessionId
|
||||
MentionSpan(
|
||||
text = text,
|
||||
rawValue = permalinkData.userId.toString(),
|
||||
type = MentionSpan.Type.USER,
|
||||
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
||||
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
||||
@@ -94,6 +96,8 @@ class MentionSpanProvider(
|
||||
}
|
||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
||||
MentionSpan(
|
||||
text = text,
|
||||
rawValue = "@room",
|
||||
type = MentionSpan.Type.USER,
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
@@ -102,8 +106,22 @@ class MentionSpanProvider(
|
||||
typeface = typeface.value,
|
||||
)
|
||||
}
|
||||
permalinkData is PermalinkData.RoomLink -> {
|
||||
MentionSpan(
|
||||
text = text,
|
||||
rawValue = permalinkData.roomIdOrAlias.toString(),
|
||||
type = MentionSpan.Type.ROOM,
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
startPadding = startPaddingPx,
|
||||
endPadding = endPaddingPx,
|
||||
typeface = typeface.value,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
MentionSpan(
|
||||
text = text,
|
||||
rawValue = text,
|
||||
type = MentionSpan.Type.ROOM,
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
@@ -155,8 +173,8 @@ internal fun MentionSpanPreview() {
|
||||
provider.setup()
|
||||
|
||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
||||
fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
||||
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
|
||||
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
|
||||
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
|
||||
AndroidView(factory = { context ->
|
||||
TextView(context).apply {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 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.
|
||||
@@ -14,13 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.mentions
|
||||
package io.element.android.libraries.textcomposer.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
|
||||
sealed interface ResolvedMentionSuggestion {
|
||||
data object AtRoom : ResolvedMentionSuggestion
|
||||
data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.model
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.text.getSpans
|
||||
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.room.Mention
|
||||
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
|
||||
@Stable
|
||||
class MarkdownTextEditorState(
|
||||
initialText: String?,
|
||||
initialFocus: Boolean,
|
||||
) {
|
||||
var text by mutableStateOf(StableCharSequence(initialText ?: ""))
|
||||
var selection by mutableStateOf(0..0)
|
||||
var hasFocus by mutableStateOf(initialFocus)
|
||||
var requestFocusAction by mutableStateOf({})
|
||||
var lineCount by mutableIntStateOf(1)
|
||||
var currentMentionSuggestion by mutableStateOf<Suggestion?>(null)
|
||||
|
||||
fun insertMention(
|
||||
mention: ResolvedMentionSuggestion,
|
||||
mentionSpanProvider: MentionSpanProvider,
|
||||
permalinkBuilder: PermalinkBuilder,
|
||||
) {
|
||||
val suggestion = currentMentionSuggestion ?: return
|
||||
when (mention) {
|
||||
is ResolvedMentionSuggestion.AtRoom -> {
|
||||
val currentText = SpannableStringBuilder(text.value())
|
||||
val replaceText = "@room"
|
||||
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
|
||||
currentText.replace(suggestion.start, suggestion.end, ". ")
|
||||
val end = suggestion.start + 1
|
||||
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
text.update(currentText, true)
|
||||
selection = IntRange(end + 1, end + 1)
|
||||
}
|
||||
is ResolvedMentionSuggestion.Member -> {
|
||||
val currentText = SpannableStringBuilder(text.value())
|
||||
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
|
||||
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return
|
||||
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
|
||||
currentText.replace(suggestion.start, suggestion.end, ". ")
|
||||
val end = suggestion.start + 1
|
||||
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
this.text.update(currentText, true)
|
||||
this.selection = IntRange(end + 1, end + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String {
|
||||
val charSequence = text.value()
|
||||
return if (charSequence is Spanned) {
|
||||
val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java)
|
||||
buildString {
|
||||
append(charSequence.toString())
|
||||
if (mentions != null && mentions.isNotEmpty()) {
|
||||
for (mention in mentions.reversed()) {
|
||||
val start = charSequence.getSpanStart(mention)
|
||||
val end = charSequence.getSpanEnd(mention)
|
||||
if (mention.type == MentionSpan.Type.USER) {
|
||||
if (mention.rawValue == "@room") {
|
||||
replace(start, end, "@room")
|
||||
} else {
|
||||
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
|
||||
replace(start, end, "[${mention.text}]($link)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
charSequence.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMentions(): List<Mention> {
|
||||
val text = SpannableString(text.value())
|
||||
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
|
||||
return mentionSpans.mapNotNull { mentionSpan ->
|
||||
when (mentionSpan.type) {
|
||||
MentionSpan.Type.USER -> {
|
||||
if (mentionSpan.rawValue == "@room") {
|
||||
Mention.AtRoom
|
||||
} else {
|
||||
Mention.User(UserId(mentionSpan.rawValue))
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
|
||||
@Immutable
|
||||
sealed interface TextEditorState {
|
||||
data class Markdown(
|
||||
val state: MarkdownTextEditorState,
|
||||
) : TextEditorState
|
||||
|
||||
data class Rich(
|
||||
val richTextEditorState: RichTextEditorState
|
||||
) : TextEditorState
|
||||
|
||||
fun messageHtml(): String? = when (this) {
|
||||
is Markdown -> null
|
||||
is Rich -> richTextEditorState.messageHtml
|
||||
}
|
||||
|
||||
fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) {
|
||||
is Markdown -> state.getMessageMarkdown(permalinkBuilder)
|
||||
is Rich -> richTextEditorState.messageMarkdown
|
||||
}
|
||||
|
||||
fun hasFocus(): Boolean = when (this) {
|
||||
is Markdown -> state.hasFocus
|
||||
is Rich -> richTextEditorState.hasFocus
|
||||
}
|
||||
|
||||
suspend fun reset() {
|
||||
when (this) {
|
||||
is Markdown -> {
|
||||
state.selection = IntRange.EMPTY
|
||||
state.text.update("", true)
|
||||
}
|
||||
is Rich -> richTextEditorState.setHtml("")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestFocus() {
|
||||
when (this) {
|
||||
is Markdown -> state.requestFocusAction()
|
||||
is Rich -> richTextEditorState.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val lineCount: Int get() = when (this) {
|
||||
is Markdown -> state.lineCount
|
||||
is Rich -> richTextEditorState.lineCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.impl.components.markdown
|
||||
|
||||
import android.widget.EditText
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_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.aRoomMember
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
|
||||
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MarkdownTextInputTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `when user types onTyping is triggered with value 'true'`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit)
|
||||
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("Test")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
onTyping.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user removes text onTyping is triggered with value 'false'`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onTyping = EventsRecorder<Boolean>()
|
||||
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editText = it.findEditor()
|
||||
editText.setText("Test")
|
||||
editText.setText("")
|
||||
editText.setText(null)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
onTyping.assertList(listOf(true, false, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onSuggestionReceived = EventsRecorder<Suggestion?>()
|
||||
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("Test")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
onSuggestionReceived.assertSingle(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onSuggestionReceived = EventsRecorder<Suggestion?>()
|
||||
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("@")
|
||||
it.findEditor().setText("#")
|
||||
it.findEditor().setText("/")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
onSuggestionReceived.assertList(
|
||||
listOf(
|
||||
// User mention suggestion
|
||||
Suggestion(0, 1, SuggestionType.Mention, ""),
|
||||
// Room suggestion
|
||||
Suggestion(0, 1, SuggestionType.Room, ""),
|
||||
// Slash command suggestion
|
||||
Suggestion(0, 1, SuggestionType.Command, ""),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the selection changes in the UI the state is updated`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editor = it.findEditor()
|
||||
editor.setSelection(2)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
// Selection is updated
|
||||
assertThat(state.selection).isEqualTo(2..2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the selection state changes in the view is updated`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
var editor: EditText? = null
|
||||
rule.activityRule.scenario.onActivity {
|
||||
editor = it.findEditor()
|
||||
state.selection = 2..2
|
||||
}
|
||||
rule.awaitIdle()
|
||||
// Selection state is updated
|
||||
assertThat(editor?.selectionStart).isEqualTo(2)
|
||||
assertThat(editor?.selectionEnd).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the view focus changes the state is updated`() = runTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editor = it.findEditor()
|
||||
editor.requestFocus()
|
||||
}
|
||||
// Focus state is updated
|
||||
assertThat(state.hasFocus).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inserting a mention replaces the existing text with a span`() = runTest {
|
||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
|
||||
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
|
||||
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
|
||||
state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
var editor: EditText? = null
|
||||
rule.activityRule.scenario.onActivity {
|
||||
editor = it.findEditor()
|
||||
state.insertMention(
|
||||
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
|
||||
MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser),
|
||||
permalinkBuilder,
|
||||
)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
|
||||
// Text is replaced with a placeholder
|
||||
assertThat(editor?.editableText.toString()).isEqualTo(". ")
|
||||
// The placeholder contains a MentionSpan
|
||||
val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
|
||||
assertThat(mentionSpans).isNotEmpty()
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
|
||||
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
||||
subcomposing: Boolean = false,
|
||||
onTyping: (Boolean) -> Unit = {},
|
||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||
) {
|
||||
rule.setContent {
|
||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
|
||||
MarkdownTextInput(
|
||||
state = state,
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onSuggestionReceived = onSuggestionReceived,
|
||||
richTextEditorStyle = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ComponentActivity.findEditor(): EditText {
|
||||
return window.decorView.findViewWithTag(TestTags.plainTextEditor.value)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.libraries.textcomposer.impl.mentions
|
||||
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -66,6 +67,14 @@ class MentionSpanProviderTest {
|
||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting mention span for everyone in the room`() {
|
||||
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
|
||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
|
||||
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
|
||||
permalinkParser.givenResult(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.libraries.textcomposer.impl.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_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.aRoomMember
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MarkdownTextEditorStateTest {
|
||||
@Test
|
||||
fun `insertMention - with no currentMentionSuggestion does nothing`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||
val member = aRoomMember()
|
||||
val mention = ResolvedMentionSuggestion.Member(member)
|
||||
val permalinkBuilder = FakePermalinkBuilder()
|
||||
val mentionSpanProvider = aMentionSpanProvider()
|
||||
|
||||
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
|
||||
|
||||
assertThat(state.getMentions()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insertMention - with member but failed PermalinkBuilder result`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
||||
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
||||
}
|
||||
val member = aRoomMember()
|
||||
val mention = ResolvedMentionSuggestion.Member(member)
|
||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
|
||||
val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) })
|
||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||
|
||||
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
|
||||
|
||||
val mentions = state.getMentions()
|
||||
assertThat(mentions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insertMention - with member`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
||||
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
||||
}
|
||||
val member = aRoomMember()
|
||||
val mention = ResolvedMentionSuggestion.Member(member)
|
||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
|
||||
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") })
|
||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||
|
||||
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
|
||||
|
||||
val mentions = state.getMentions()
|
||||
assertThat(mentions).isNotEmpty()
|
||||
assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insertMention - with @room`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
|
||||
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
|
||||
}
|
||||
val mention = ResolvedMentionSuggestion.AtRoom
|
||||
val permalinkBuilder = FakePermalinkBuilder()
|
||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
|
||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||
|
||||
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
|
||||
|
||||
val mentions = state.getMentions()
|
||||
assertThat(mentions).isNotEmpty()
|
||||
assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
|
||||
val text = "No mentions here"
|
||||
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
|
||||
|
||||
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
|
||||
|
||||
assertThat(markdown).isEqualTo(text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() {
|
||||
val text = "No mentions here"
|
||||
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") })
|
||||
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
|
||||
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
||||
|
||||
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
|
||||
|
||||
assertThat(markdown).isEqualTo(
|
||||
"Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||
|
||||
assertThat(state.getMentions()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
|
||||
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
||||
|
||||
val mentions = state.getMentions()
|
||||
|
||||
assertThat(mentions).isNotEmpty()
|
||||
assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
|
||||
assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java)
|
||||
}
|
||||
|
||||
private fun aMentionSpanProvider(
|
||||
currentSessionId: SessionId = A_SESSION_ID,
|
||||
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
|
||||
): MentionSpanProvider {
|
||||
return MentionSpanProvider(currentSessionId, permalinkParser)
|
||||
}
|
||||
|
||||
private fun aMarkdownTextWithMentions(): CharSequence {
|
||||
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0)
|
||||
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.USER, 0, 0, 0, 0)
|
||||
return buildSpannedString {
|
||||
append("Hello ")
|
||||
inSpans(userMentionSpan) {
|
||||
append("@")
|
||||
}
|
||||
append(" and everyone in ")
|
||||
inSpans(atRoomMentionSpan) {
|
||||
append("@")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,15 @@ pluginManagement {
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
// Snapshot versions
|
||||
maven {
|
||||
url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots")
|
||||
content {
|
||||
includeModule("org.matrix.rustcomponents", "sdk-android")
|
||||
includeModule("io.element.android", "wysiwyg")
|
||||
includeModule("io.element.android", "wysiwyg-compose")
|
||||
}
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") }
|
||||
|
||||
@@ -78,6 +78,7 @@ class KonsistPreviewTest {
|
||||
"IconTitleSubtitleMoleculeWithResIconPreview",
|
||||
"IconsCompoundPreview",
|
||||
"IconsOtherPreview",
|
||||
"MarkdownTextComposerEditPreview",
|
||||
"MentionSpanPreview",
|
||||
"MessageComposerViewVoicePreview",
|
||||
"MessagesReactionButtonAddPreview",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user