Plain text editor implementation based on markdown input (#2840)
* Add plain text editor based on markdown input - Fix autofocus of message composer. - Remove `Message` data class, fetch the details in `MessagesPresenter` instead. - Remove `enable rich text` option from advanced settings, set it as a build configuration instead. * Fix MentionSpanProvider * Bump RTE library to released `v2.37.3` --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
3f2413bc95
commit
880ebb4de8
94 changed files with 1554 additions and 524 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4931140dafa7377d1583d4af9603337fbb3a0045ddbb348bf23a220bd77083d1
|
||||
size 56388
|
||||
oid sha256:de588d3ef8770778779d09b2f883e4327c4cc3afce98d33aab32298e32ca070a
|
||||
size 44474
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:13cffd3bf3d2eb4d266b81dd05d40e05cfd4663851ea06e9c6c24374e457fb1d
|
||||
size 55859
|
||||
oid sha256:342ef5ebb8ece155939816d6ba04298b5827dd1c125891986ee3d6389c75fe64
|
||||
size 43970
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00cdf4f635352c11d572faae361c066986a6812f80124fe23ab142f8c87c077f
|
||||
size 55888
|
||||
oid sha256:92ed07e7bea55582c88ca8c8f3918c1058a5f56a1f7a196fd8080c37bff6bc52
|
||||
size 35864
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4a9814198e0a6be655ec1bfb8038071e29a8ee4959b5209936e6b63d92c4e07
|
||||
size 36289
|
||||
oid sha256:4219e66ccebc52570ee2193b9a4b564a8c9c370c051a8c377e4ad12a9f3f8ffb
|
||||
size 44001
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:89f646eede8992256de5df9181eccc28f0ebc3fd2d07d58e102b6cabfbf4e5e2
|
||||
size 55834
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e9fd3a8cee62f283571b37670b4f064dd9f6c5195dec92b20c6ee3d53c9defae
|
||||
size 53103
|
||||
oid sha256:706666dd4351887061879359d6947daef5ce74e552784b18bd0216b233ee6048
|
||||
size 41826
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6ad13ab2b02480a9d436fb62039139284af250aaaf806434f528307e6704162a
|
||||
size 52797
|
||||
oid sha256:731f570f939af1f515f030f03420aeca39880100707e74e4ebe2e3433ac56a95
|
||||
size 41441
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1584885ecab5a8aa3bbf1f0b116d74d398043d0b12c905d69717bec5b1e80ea7
|
||||
size 52819
|
||||
oid sha256:9d7cadd4ed9e918a52a7b7ab0c6416c0cb06b1d77900c68606c0f532a6b0647c
|
||||
size 31551
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:99acb52cafefefbf3a17ce759e85b821d949df9c5d37fce4038b0d99e22993b5
|
||||
size 32144
|
||||
oid sha256:ffa635614cbf5a57c4b481c99eadaf56777342090fbc26441be3e413b16ff95b
|
||||
size 41436
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc26ca6ad489264a7008c528b762a22b6349aa9c16f6d43ed41628b20aa0659c
|
||||
size 52824
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21d806b169f6cdce5f454c43e7877d454c4e6243af3f6de85f5c70637c8f3603
|
||||
size 7277
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a22b040124c765a1ab46b91afa2df411c3da3333ff12977bff2537bd152840d
|
||||
size 7001
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c17bc39b80bbed42378b2df6ad2332b8da78b315a177f56a7eca0a7b595a3036
|
||||
size 43943
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6bef87fa9b2320571b91093cbe3293b67d9e2180edf036b9bc894da6c6ea6a8d
|
||||
size 39499
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0464a6fc68efe26f6233d993a06024c88ec1936a108f33829e7b474042199fb6
|
||||
size 37110
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a03fbda4d8199f29ca78201e6e9ae38d17760332ef7b0ee12585819b3c92dc1
|
||||
size 33348
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1eac95cd279b53be30c45f6ee959d617ca70f1cc1097fafe9cdcb58c76a3d72b
|
||||
size 14190
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d43d29a19b1639f9e569018abd0b3a77d34dac9eb374bff28fa8d673ede272ce
|
||||
size 13302
|
||||
Loading…
Add table
Add a link
Reference in a new issue