Plain text editor implementation based on markdown input (#2840)
* Add plain text editor based on markdown input - Fix autofocus of message composer. - Remove `Message` data class, fetch the details in `MessagesPresenter` instead. - Remove `enable rich text` option from advanced settings, set it as a build configuration instead. * Fix MentionSpanProvider * Bump RTE library to released `v2.37.3` --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
0e05a0e4ed
commit
902dd24e72
94 changed files with 1554 additions and 524 deletions
|
|
@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID}
|
||||||
---
|
---
|
||||||
- takeScreenshot: build/maestro/510-Timeline
|
- takeScreenshot: build/maestro/510-Timeline
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "rich_text_editor"
|
id: "text_editor"
|
||||||
- inputText: "Hello world!"
|
- inputText: "Hello world!"
|
||||||
- tapOn: "Send"
|
- tapOn: "Send"
|
||||||
- hideKeyboard
|
- hideKeyboard
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID}
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Advanced settings"
|
text: "Advanced settings"
|
||||||
- assertVisible: "Rich text editor"
|
- assertVisible: "View source"
|
||||||
- back
|
- back
|
||||||
|
|
||||||
- tapOn:
|
- 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.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
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.api.timeline.HtmlConverterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
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.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
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.androidutils.clipboard.ClipboardHelper
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -113,7 +113,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val clipboardHelper: ClipboardHelper,
|
private val clipboardHelper: ClipboardHelper,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
|
||||||
private val featureFlagsService: FeatureFlagService,
|
private val featureFlagsService: FeatureFlagService,
|
||||||
private val htmlConverterProvider: HtmlConverterProvider,
|
private val htmlConverterProvider: HtmlConverterProvider,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
|
|
@ -171,17 +170,15 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
||||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
|
LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) {
|
||||||
withContext(dispatchers.io) {
|
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 networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||||
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
||||||
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
|
||||||
|
|
||||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(featureFlagsService) {
|
LaunchedEffect(featureFlagsService) {
|
||||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||||
|
|
@ -194,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
action = event.action,
|
action = event.action,
|
||||||
targetEvent = event.event,
|
targetEvent = event.event,
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = composerState.showTextFormatting,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +236,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
showReinvitePrompt = showReinvitePrompt,
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
inviteProgress = inviteProgress.value,
|
inviteProgress = inviteProgress.value,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
appName = buildMeta.applicationName,
|
appName = buildMeta.applicationName,
|
||||||
callState = callState,
|
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.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
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.persistentListOf
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
|
@ -99,9 +100,9 @@ fun aMessagesState(
|
||||||
userHasPermissionToRedactOther: Boolean = false,
|
userHasPermissionToRedactOther: Boolean = false,
|
||||||
userHasPermissionToSendReaction: Boolean = true,
|
userHasPermissionToSendReaction: Boolean = true,
|
||||||
composerState: MessageComposerState = aMessageComposerState(
|
composerState: MessageComposerState = aMessageComposerState(
|
||||||
richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true),
|
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal,
|
mode = MessageComposerMode.Normal,
|
||||||
),
|
),
|
||||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||||
timelineState: TimelineState = aTimelineState(
|
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.
|
// Any state change that should trigger a height size should be added to the list of remembered values here.
|
||||||
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
|
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
state.composerState.richTextEditorState.lineCount,
|
state.composerState.textEditorState.lineCount,
|
||||||
state.composerState.showTextFormatting,
|
state.composerState.showTextFormatting,
|
||||||
) {
|
) {
|
||||||
sheetResizeContentKey.intValue = Random.nextInt()
|
sheetResizeContentKey.intValue = Random.nextInt()
|
||||||
|
|
@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
voiceMessageState = state.voiceMessageComposerState,
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
enableTextFormatting = state.enableTextFormatting,
|
|
||||||
enableVoiceMessages = state.enableVoiceMessages,
|
enableVoiceMessages = state.enableVoiceMessages,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
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.RoomMembershipState
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
@ -51,8 +52,8 @@ fun MentionSuggestionsPickerView(
|
||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
roomName: String?,
|
roomName: String?,
|
||||||
roomAvatarData: AvatarData?,
|
roomAvatarData: AvatarData?,
|
||||||
memberSuggestions: ImmutableList<MentionSuggestion>,
|
memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
|
||||||
onSuggestionSelected: (MentionSuggestion) -> Unit,
|
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -62,8 +63,8 @@ fun MentionSuggestionsPickerView(
|
||||||
memberSuggestions,
|
memberSuggestions,
|
||||||
key = { suggestion ->
|
key = { suggestion ->
|
||||||
when (suggestion) {
|
when (suggestion) {
|
||||||
is MentionSuggestion.Room -> "@room"
|
is ResolvedMentionSuggestion.AtRoom -> "@room"
|
||||||
is MentionSuggestion.Member -> suggestion.roomMember.userId.value
|
is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomMemberSuggestionItemView(
|
private fun RoomMemberSuggestionItemView(
|
||||||
memberSuggestion: MentionSuggestion,
|
memberSuggestion: ResolvedMentionSuggestion,
|
||||||
roomId: String,
|
roomId: String,
|
||||||
roomName: String?,
|
roomName: String?,
|
||||||
roomAvatar: AvatarData?,
|
roomAvatar: AvatarData?,
|
||||||
onSuggestionSelected: (MentionSuggestion) -> Unit,
|
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
val avatarSize = AvatarSize.TimelineRoom
|
val avatarSize = AvatarSize.TimelineRoom
|
||||||
val avatarData = when (memberSuggestion) {
|
val avatarData = when (memberSuggestion) {
|
||||||
is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||||
is MentionSuggestion.Member -> AvatarData(
|
is ResolvedMentionSuggestion.Member -> AvatarData(
|
||||||
memberSuggestion.roomMember.userId.value,
|
memberSuggestion.roomMember.userId.value,
|
||||||
memberSuggestion.roomMember.displayName,
|
memberSuggestion.roomMember.displayName,
|
||||||
memberSuggestion.roomMember.avatarUrl,
|
memberSuggestion.roomMember.avatarUrl,
|
||||||
|
|
@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val title = when (memberSuggestion) {
|
val title = when (memberSuggestion) {
|
||||||
is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
|
is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||||
is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
|
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
val subtitle = when (memberSuggestion) {
|
val subtitle = when (memberSuggestion) {
|
||||||
is MentionSuggestion.Room -> "@room"
|
is ResolvedMentionSuggestion.AtRoom -> "@room"
|
||||||
is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
|
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
|
||||||
}
|
}
|
||||||
|
|
||||||
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
|
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
|
||||||
|
|
@ -159,9 +160,9 @@ internal fun MentionSuggestionsPickerViewPreview() {
|
||||||
roomName = "Room",
|
roomName = "Room",
|
||||||
roomAvatarData = null,
|
roomAvatarData = null,
|
||||||
memberSuggestions = persistentListOf(
|
memberSuggestions = persistentListOf(
|
||||||
MentionSuggestion.Room,
|
ResolvedMentionSuggestion.AtRoom,
|
||||||
MentionSuggestion.Member(roomMember),
|
ResolvedMentionSuggestion.Member(roomMember),
|
||||||
MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
||||||
),
|
),
|
||||||
onSuggestionSelected = {}
|
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.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
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.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||||
|
|
||||||
|
|
@ -45,7 +46,7 @@ object MentionSuggestionsProcessor {
|
||||||
roomMembersState: MatrixRoomMembersState,
|
roomMembersState: MatrixRoomMembersState,
|
||||||
currentUserId: UserId,
|
currentUserId: UserId,
|
||||||
canSendRoomMention: suspend () -> Boolean,
|
canSendRoomMention: suspend () -> Boolean,
|
||||||
): List<MentionSuggestion> {
|
): List<ResolvedMentionSuggestion> {
|
||||||
val members = roomMembersState.roomMembers()
|
val members = roomMembersState.roomMembers()
|
||||||
return when {
|
return when {
|
||||||
members.isNullOrEmpty() || suggestion == null -> {
|
members.isNullOrEmpty() || suggestion == null -> {
|
||||||
|
|
@ -78,7 +79,7 @@ object MentionSuggestionsProcessor {
|
||||||
roomMembers: List<RoomMember>?,
|
roomMembers: List<RoomMember>?,
|
||||||
currentUserId: UserId,
|
currentUserId: UserId,
|
||||||
canSendRoomMention: Boolean,
|
canSendRoomMention: Boolean,
|
||||||
): List<MentionSuggestion> {
|
): List<ResolvedMentionSuggestion> {
|
||||||
return if (roomMembers.isNullOrEmpty()) {
|
return if (roomMembers.isNullOrEmpty()) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -96,10 +97,10 @@ object MentionSuggestionsProcessor {
|
||||||
.filterUpTo(MAX_BATCH_ITEMS) { member ->
|
.filterUpTo(MAX_BATCH_ITEMS) { member ->
|
||||||
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
|
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
|
||||||
}
|
}
|
||||||
.map(MentionSuggestion::Member)
|
.map(ResolvedMentionSuggestion::Member)
|
||||||
|
|
||||||
if ("room".contains(query) && canSendRoomMention) {
|
if ("room".contains(query) && canSendRoomMention) {
|
||||||
listOf(MentionSuggestion.Room) + matchingMembers
|
listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers
|
||||||
} else {
|
} else {
|
||||||
matchingMembers
|
matchingMembers
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.mentions.MentionSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.Message
|
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface MessageComposerEvents {
|
sealed interface MessageComposerEvents {
|
||||||
data object ToggleFullScreenState : MessageComposerEvents
|
data object ToggleFullScreenState : MessageComposerEvents
|
||||||
data class SendMessage(val message: Message) : MessageComposerEvents
|
data object SendMessage : MessageComposerEvents
|
||||||
data class SendUri(val uri: Uri) : MessageComposerEvents
|
data class SendUri(val uri: Uri) : MessageComposerEvents
|
||||||
data object CloseSpecialMode : MessageComposerEvents
|
data object CloseSpecialMode : MessageComposerEvents
|
||||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||||
|
|
@ -45,5 +44,5 @@ sealed interface MessageComposerEvents {
|
||||||
data class Error(val error: Throwable) : MessageComposerEvents
|
data class Error(val error: Throwable) : MessageComposerEvents
|
||||||
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
|
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
|
||||||
data class SuggestionReceived(val suggestion: Suggestion?) : 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.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
|
@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
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.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
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.mentions.MentionSuggestionsProcessor
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
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.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
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.Message
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
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.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
|
@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
|
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)
|
@OptIn(FlowPreview::class)
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessageComposerState {
|
override fun present(): MessageComposerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
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) }
|
var isMentionsEnabled by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
|
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
|
||||||
|
|
@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
val richTextEditorState = richTextEditorStateFactory.create()
|
|
||||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||||
var showTextFormatting: Boolean by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
|
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
|
||||||
|
|
||||||
LaunchedEffect(messageComposerContext.composerMode) {
|
LaunchedEffect(messageComposerContext.composerMode) {
|
||||||
when (val modeValue = messageComposerContext.composerMode) {
|
when (val modeValue = messageComposerContext.composerMode) {
|
||||||
is MessageComposerMode.Edit ->
|
is MessageComposerMode.Edit ->
|
||||||
richTextEditorState.setHtml(modeValue.defaultContent)
|
if (showTextFormatting) {
|
||||||
|
richTextEditorState.setHtml(modeValue.defaultContent)
|
||||||
|
} else {
|
||||||
|
markdownTextEditorState.text.update(modeValue.defaultContent, true)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
|
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
|
||||||
LaunchedEffect(isMentionsEnabled) {
|
LaunchedEffect(isMentionsEnabled) {
|
||||||
if (!isMentionsEnabled) return@LaunchedEffect
|
if (!isMentionsEnabled) return@LaunchedEffect
|
||||||
val currentUserId = currentSessionIdHolder.current
|
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) {
|
fun handleEvents(event: MessageComposerEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
MessageComposerEvents.CloseSpecialMode -> {
|
MessageComposerEvents.CloseSpecialMode -> {
|
||||||
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
|
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
|
||||||
localCoroutineScope.launch {
|
localCoroutineScope.launch {
|
||||||
richTextEditorState.setHtml("")
|
textEditorState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||||
}
|
}
|
||||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
is MessageComposerEvents.SendMessage -> {
|
||||||
message = event.message,
|
val html = if (showTextFormatting) {
|
||||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
richTextEditorState.messageHtml
|
||||||
richTextEditorState = richTextEditorState,
|
} 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(
|
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = localMediaFactory.createFromUri(
|
localMedia = localMediaFactory.createFromUri(
|
||||||
|
|
@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
is MessageComposerEvents.InsertMention -> {
|
is MessageComposerEvents.InsertMention -> {
|
||||||
localCoroutineScope.launch {
|
localCoroutineScope.launch {
|
||||||
when (val mention = event.mention) {
|
if (showTextFormatting) {
|
||||||
is MentionSuggestion.Room -> {
|
when (val mention = event.mention) {
|
||||||
richTextEditorState.insertAtRoomMentionAtSuggestion()
|
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 -> {
|
} else if (markdownTextEditorState.currentMentionSuggestion != null) {
|
||||||
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
|
mentionSpanProvider?.let {
|
||||||
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
|
markdownTextEditorState.insertMention(
|
||||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
mention = event.mention,
|
||||||
|
mentionSpanProvider = it,
|
||||||
|
permalinkBuilder = permalinkBuilder,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
suggestionSearchTrigger.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
richTextEditorState = richTextEditorState,
|
textEditorState = textEditorState,
|
||||||
permalinkParser = permalinkParser,
|
permalinkParser = permalinkParser,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
mode = messageComposerContext.composerMode,
|
mode = messageComposerContext.composerMode,
|
||||||
|
|
@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private fun CoroutineScope.sendMessage(
|
private fun CoroutineScope.sendMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||||
richTextEditorState: RichTextEditorState,
|
textEditorState: TextEditorState,
|
||||||
) = launch {
|
) = launch {
|
||||||
val capturedMode = messageComposerContext.composerMode
|
val capturedMode = messageComposerContext.composerMode
|
||||||
val mentions = richTextEditorState.mentionsState?.let { state ->
|
val mentions = when (textEditorState) {
|
||||||
buildList {
|
is TextEditorState.Rich -> {
|
||||||
if (state.hasAtRoomMention) {
|
textEditorState.richTextEditorState.mentionsState?.let { state ->
|
||||||
add(Mention.AtRoom)
|
buildList {
|
||||||
}
|
if (state.hasAtRoomMention) {
|
||||||
for (userId in state.userIds) {
|
add(Mention.AtRoom)
|
||||||
add(Mention.User(UserId(userId)))
|
}
|
||||||
}
|
for (userId in state.userIds) {
|
||||||
|
add(Mention.User(UserId(userId)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.orEmpty()
|
||||||
}
|
}
|
||||||
}.orEmpty()
|
is TextEditorState.Markdown -> textEditorState.state.getMentions()
|
||||||
|
}
|
||||||
// Reset composer right away
|
// Reset composer right away
|
||||||
richTextEditorState.setHtml("")
|
textEditorState.reset()
|
||||||
updateComposerMode(MessageComposerMode.Normal)
|
updateComposerMode(MessageComposerMode.Normal)
|
||||||
when (capturedMode) {
|
when (capturedMode) {
|
||||||
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions)
|
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.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
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.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.ImmutableList
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
data class MessageComposerState(
|
data class MessageComposerState(
|
||||||
val richTextEditorState: RichTextEditorState,
|
val textEditorState: TextEditorState,
|
||||||
val permalinkParser: PermalinkParser,
|
val permalinkParser: PermalinkParser,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
|
|
@ -37,12 +37,10 @@ data class MessageComposerState(
|
||||||
val canShareLocation: Boolean,
|
val canShareLocation: Boolean,
|
||||||
val canCreatePoll: Boolean,
|
val canCreatePoll: Boolean,
|
||||||
val attachmentsState: AttachmentsState,
|
val attachmentsState: AttachmentsState,
|
||||||
val memberSuggestions: ImmutableList<MentionSuggestion>,
|
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
|
||||||
val currentUserId: UserId,
|
val currentUserId: UserId,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit,
|
val eventSink: (MessageComposerEvents) -> Unit,
|
||||||
) {
|
)
|
||||||
val hasFocus: Boolean = richTextEditorState.hasFocus
|
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface AttachmentsState {
|
sealed interface AttachmentsState {
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
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.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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMessageComposerState(
|
fun aMessageComposerState(
|
||||||
richTextEditorState: RichTextEditorState = aRichTextEditorState(),
|
textEditorState: TextEditorState = TextEditorState.Rich(aRichTextEditorState()),
|
||||||
isFullScreen: Boolean = false,
|
isFullScreen: Boolean = false,
|
||||||
mode: MessageComposerMode = MessageComposerMode.Normal,
|
mode: MessageComposerMode = MessageComposerMode.Normal,
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
|
|
@ -43,9 +43,9 @@ fun aMessageComposerState(
|
||||||
canShareLocation: Boolean = true,
|
canShareLocation: Boolean = true,
|
||||||
canCreatePoll: Boolean = true,
|
canCreatePoll: Boolean = true,
|
||||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||||
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
|
memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(),
|
||||||
) = MessageComposerState(
|
) = MessageComposerState(
|
||||||
richTextEditorState = richTextEditorState,
|
textEditorState = textEditorState,
|
||||||
permalinkParser = object : PermalinkParser {
|
permalinkParser = object : PermalinkParser {
|
||||||
override fun parse(uriString: String): PermalinkData = TODO()
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.textcomposer.TextComposer
|
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.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
|
|
@ -44,13 +43,12 @@ internal fun MessageComposerView(
|
||||||
state: MessageComposerState,
|
state: MessageComposerState,
|
||||||
voiceMessageState: VoiceMessageComposerState,
|
voiceMessageState: VoiceMessageComposerState,
|
||||||
subcomposing: Boolean,
|
subcomposing: Boolean,
|
||||||
enableTextFormatting: Boolean,
|
|
||||||
enableVoiceMessages: Boolean,
|
enableVoiceMessages: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
fun sendMessage(message: Message) {
|
fun sendMessage() {
|
||||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
state.eventSink(MessageComposerEvents.SendMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendUri(uri: Uri) {
|
fun sendUri(uri: Uri) {
|
||||||
|
|
@ -85,7 +83,7 @@ internal fun MessageComposerView(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
fun onRequestFocus() {
|
fun onRequestFocus() {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
state.richTextEditorState.requestFocus()
|
state.textEditorState.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +105,7 @@ internal fun MessageComposerView(
|
||||||
|
|
||||||
TextComposer(
|
TextComposer(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state.richTextEditorState,
|
state = state.textEditorState,
|
||||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||||
permalinkParser = state.permalinkParser,
|
permalinkParser = state.permalinkParser,
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
|
|
@ -118,7 +116,6 @@ internal fun MessageComposerView(
|
||||||
onResetComposerMode = ::onCloseSpecialMode,
|
onResetComposerMode = ::onCloseSpecialMode,
|
||||||
onAddAttachment = ::onAddAttachment,
|
onAddAttachment = ::onAddAttachment,
|
||||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||||
enableTextFormatting = enableTextFormatting,
|
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
onVoiceRecorderEvent = onVoiceRecorderEvent,
|
onVoiceRecorderEvent = onVoiceRecorderEvent,
|
||||||
onVoicePlayerEvent = onVoicePlayerEvent,
|
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||||
|
|
@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview(
|
||||||
modifier = Modifier.height(IntrinsicSize.Min),
|
modifier = Modifier.height(IntrinsicSize.Min),
|
||||||
state = state,
|
state = state,
|
||||||
voiceMessageState = aVoiceMessageComposerState(),
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
|
|
@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview(
|
||||||
modifier = Modifier.height(200.dp),
|
modifier = Modifier.height(200.dp),
|
||||||
state = state,
|
state = state,
|
||||||
voiceMessageState = aVoiceMessageComposerState(),
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
|
|
@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview(
|
||||||
modifier = Modifier.height(IntrinsicSize.Min),
|
modifier = Modifier.height(IntrinsicSize.Min),
|
||||||
state = aMessageComposerState(),
|
state = aMessageComposerState(),
|
||||||
voiceMessageState = state,
|
voiceMessageState = state,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@ import javax.inject.Inject
|
||||||
|
|
||||||
interface RichTextEditorStateFactory {
|
interface RichTextEditorStateFactory {
|
||||||
@Composable
|
@Composable
|
||||||
fun create(): RichTextEditorState
|
fun remember(): RichTextEditorState
|
||||||
}
|
}
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
||||||
@Composable
|
@Composable
|
||||||
override fun create(): RichTextEditorState {
|
override fun remember(): RichTextEditorState {
|
||||||
return rememberRichTextEditorState()
|
return rememberRichTextEditorState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -538,7 +538,7 @@ class MessagesPresenterTest {
|
||||||
// Initially the composer doesn't have focus, so we don't show the alert
|
// Initially the composer doesn't have focus, so we don't show the alert
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
// When the input field is focused we show the alert
|
// 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 ->
|
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
|
||||||
state.showReinvitePrompt
|
state.showReinvitePrompt
|
||||||
}.last()
|
}.last()
|
||||||
|
|
@ -561,7 +561,7 @@ class MessagesPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
initialState.composerState.richTextEditorState.requestFocus()
|
initialState.composerState.textEditorState.requestFocus()
|
||||||
val focusedState = awaitItem()
|
val focusedState = awaitItem()
|
||||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -576,7 +576,7 @@ class MessagesPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
initialState.composerState.richTextEditorState.requestFocus()
|
initialState.composerState.textEditorState.requestFocus()
|
||||||
val focusedState = awaitItem()
|
val focusedState = awaitItem()
|
||||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -781,7 +781,7 @@ class MessagesPresenterTest {
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||||
val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true)
|
val appPreferencesStore = InMemoryAppPreferencesStore()
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore()
|
val sessionPreferencesStore = InMemorySessionPreferencesStore()
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
|
@ -800,7 +800,10 @@ class MessagesPresenterTest {
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
permalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder = FakePermalinkBuilder(),
|
||||||
timelineController = TimelineController(matrixRoom),
|
timelineController = TimelineController(matrixRoom),
|
||||||
)
|
).apply {
|
||||||
|
showTextFormatting = true
|
||||||
|
isTesting = true
|
||||||
|
}
|
||||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||||
this,
|
this,
|
||||||
FakeVoiceRecorder(),
|
FakeVoiceRecorder(),
|
||||||
|
|
@ -853,7 +856,6 @@ class MessagesPresenterTest {
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
clipboardHelper = clipboardHelper,
|
clipboardHelper = clipboardHelper,
|
||||||
appPreferencesStore = appPreferencesStore,
|
|
||||||
featureFlagsService = FakeFeatureFlagService(),
|
featureFlagsService = FakeFeatureFlagService(),
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
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.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
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.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
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.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
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.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
|
|
@ -127,7 +127,7 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.isFullScreen).isFalse()
|
assertThat(initialState.isFullScreen).isFalse()
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(initialState.canShareLocation).isTrue()
|
assertThat(initialState.canShareLocation).isTrue()
|
||||||
|
|
@ -158,10 +158,10 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
initialState.richTextEditorState.setHtml("")
|
initialState.textEditorState.setHtml("")
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +170,7 @@ class MessageComposerPresenterTest {
|
||||||
val presenter = createPresenter(this)
|
val presenter = createPresenter(this)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
remember(state, state.textEditorState.messageHtml()) { state }
|
||||||
}.test {
|
}.test {
|
||||||
var state = awaitFirstItem()
|
var state = awaitFirstItem()
|
||||||
val mode = anEditMode()
|
val mode = anEditMode()
|
||||||
|
|
@ -178,11 +178,11 @@ class MessageComposerPresenterTest {
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
state = backToNormalMode(state, skipCount = 1)
|
state = backToNormalMode(state, skipCount = 1)
|
||||||
|
|
||||||
// The message that was being edited is cleared
|
// 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.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,11 +213,11 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
state.richTextEditorState.setHtml(A_REPLY)
|
state.textEditorState.setHtml(A_REPLY)
|
||||||
state = backToNormalMode(state)
|
state = backToNormalMode(state)
|
||||||
|
|
||||||
// The message typed while replying is not cleared
|
// 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.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send message`() = runTest {
|
fun `present - send message with rich text enabled`() = runTest {
|
||||||
val presenter = createPresenter(this)
|
val presenter = createPresenter(this)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
remember(state, state.textEditorState.messageHtml()) { state }
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||||
val messageSentState = awaitItem()
|
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 }
|
waitForPredicate { analyticsService.capturedEvents.size == 1 }
|
||||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
Composer(
|
Composer(
|
||||||
|
|
@ -278,23 +307,23 @@ class MessageComposerPresenterTest {
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
remember(state, state.textEditorState.messageHtml()) { state }
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
val mode = anEditMode()
|
val mode = anEditMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -328,23 +357,23 @@ class MessageComposerPresenterTest {
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
remember(state, state.textEditorState.messageHtml()) { state }
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -380,17 +409,17 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
val mode = aReplyMode()
|
val mode = aReplyMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
|
||||||
state.richTextEditorState.setHtml(A_REPLY)
|
state.textEditorState.setHtml(A_REPLY)
|
||||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY)
|
||||||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
state.eventSink.invoke(MessageComposerEvents.SendMessage)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -725,7 +754,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
|
fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
|
||||||
val presenter = createPresenter(this)
|
val presenter = createPresenter(this, isRichTextEditorEnabled = false)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -735,11 +764,12 @@ class MessageComposerPresenterTest {
|
||||||
val composerOptions = awaitItem()
|
val composerOptions = awaitItem()
|
||||||
assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
|
assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
|
||||||
composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
|
composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
|
||||||
awaitItem() // composer options closed
|
skipItems(2) // composer options closed
|
||||||
val showTextFormatting = awaitItem()
|
val showTextFormatting = awaitItem()
|
||||||
assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
|
assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(showTextFormatting.showTextFormatting).isTrue()
|
assertThat(showTextFormatting.showTextFormatting).isTrue()
|
||||||
showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
|
showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
|
||||||
|
skipItems(1)
|
||||||
val finished = awaitItem()
|
val finished = awaitItem()
|
||||||
assertThat(finished.showTextFormatting).isFalse()
|
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
|
// An empty suggestion returns the room and joined members that are not the current user
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||||
assertThat(awaitItem().memberSuggestions)
|
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
|
// A suggestion containing a part of "room" will also return the room mention
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
|
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
|
// A non-empty suggestion will return those joined members whose user id matches it
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
|
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
|
// A non-empty suggestion will return those joined members whose display name matches it
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
|
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
|
// If the suggestion isn't a mention, no suggestions are returned
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
|
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
|
||||||
|
|
@ -803,7 +833,7 @@ class MessageComposerPresenterTest {
|
||||||
room.givenCanTriggerRoomNotification(Result.success(false))
|
room.givenCanTriggerRoomNotification(Result.success(false))
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||||
assertThat(awaitItem().memberSuggestions)
|
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
|
// If room is a DM, `RoomMemberSuggestion.Room` is not returned
|
||||||
room.givenCanTriggerRoomNotification(Result.success(true))
|
room.givenCanTriggerRoomNotification(Result.success(true))
|
||||||
|
|
@ -844,7 +874,7 @@ class MessageComposerPresenterTest {
|
||||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
assertThat(awaitItem().memberSuggestions)
|
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()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.richTextEditorState.setHtml("Hey @bo")
|
initialState.textEditorState.setHtml("Hey @bo")
|
||||||
initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
|
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>")
|
.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
|
// Check intentional mentions on message sent
|
||||||
val mentionUser1 = listOf(A_USER_ID.value)
|
val mentionUser1 = listOf(A_USER_ID.value)
|
||||||
initialState.richTextEditorState.mentionsState = MentionsState(
|
(initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||||
userIds = mentionUser1,
|
userIds = mentionUser1,
|
||||||
roomIds = emptyList(),
|
roomIds = emptyList(),
|
||||||
roomAliases = emptyList(),
|
roomAliases = emptyList(),
|
||||||
hasAtRoomMention = false
|
hasAtRoomMention = false
|
||||||
)
|
)
|
||||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -908,14 +938,14 @@ class MessageComposerPresenterTest {
|
||||||
// Check intentional mentions on reply sent
|
// Check intentional mentions on reply sent
|
||||||
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
|
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
|
||||||
val mentionUser2 = listOf(A_USER_ID_2.value)
|
val mentionUser2 = listOf(A_USER_ID_2.value)
|
||||||
awaitItem().richTextEditorState.mentionsState = MentionsState(
|
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||||
userIds = mentionUser2,
|
userIds = mentionUser2,
|
||||||
roomIds = emptyList(),
|
roomIds = emptyList(),
|
||||||
roomAliases = emptyList(),
|
roomAliases = emptyList(),
|
||||||
hasAtRoomMention = false
|
hasAtRoomMention = false
|
||||||
)
|
)
|
||||||
|
|
||||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assert(replyMessageLambda)
|
assert(replyMessageLambda)
|
||||||
|
|
@ -926,14 +956,14 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
initialState.eventSink(MessageComposerEvents.SetMode(anEditMode()))
|
initialState.eventSink(MessageComposerEvents.SetMode(anEditMode()))
|
||||||
val mentionUser3 = listOf(A_USER_ID_3.value)
|
val mentionUser3 = listOf(A_USER_ID_3.value)
|
||||||
awaitItem().richTextEditorState.mentionsState = MentionsState(
|
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
|
||||||
userIds = mentionUser3,
|
userIds = mentionUser3,
|
||||||
roomIds = emptyList(),
|
roomIds = emptyList(),
|
||||||
roomAliases = emptyList(),
|
roomAliases = emptyList(),
|
||||||
hasAtRoomMention = false
|
hasAtRoomMention = false
|
||||||
)
|
)
|
||||||
|
|
||||||
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
initialState.eventSink(MessageComposerEvents.SendMessage)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assert(editMessageLambda)
|
assert(editMessageLambda)
|
||||||
|
|
@ -949,7 +979,7 @@ class MessageComposerPresenterTest {
|
||||||
val presenter = createPresenter(this)
|
val presenter = createPresenter(this)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
remember(state, state.textEditorState.messageHtml()) { state }
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri")))
|
initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri")))
|
||||||
|
|
@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest {
|
||||||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
|
isRichTextEditorEnabled: Boolean = true,
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
coroutineScope,
|
coroutineScope,
|
||||||
room,
|
room,
|
||||||
|
|
@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest {
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
timelineController = TimelineController(room),
|
timelineController = TimelineController(room),
|
||||||
)
|
).apply {
|
||||||
|
isTesting = true
|
||||||
|
showTextFormatting = isRichTextEditorEnabled
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
// Skip 2 item if Mentions feature is enabled, else 1
|
// 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 aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
|
||||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||||
|
|
||||||
private fun String.toMessage() = Message(
|
private suspend fun TextEditorState.setHtml(html: String) {
|
||||||
html = this,
|
(this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich")
|
||||||
markdown = this,
|
}
|
||||||
)
|
|
||||||
|
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 {
|
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
|
||||||
@Composable
|
@Composable
|
||||||
override fun create(): RichTextEditorState {
|
override fun remember(): RichTextEditorState {
|
||||||
return rememberRichTextEditorState("", fake = true)
|
return rememberRichTextEditorState("", fake = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
|
|
||||||
sealed interface AdvancedSettingsEvents {
|
sealed interface AdvancedSettingsEvents {
|
||||||
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
|
||||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
data object ChangeTheme : AdvancedSettingsEvents
|
data object ChangeTheme : AdvancedSettingsEvents
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AdvancedSettingsState {
|
override fun present(): AdvancedSettingsState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val isRichTextEditorEnabled by appPreferencesStore
|
|
||||||
.isRichTextEditorEnabledFlow()
|
|
||||||
.collectAsState(initial = false)
|
|
||||||
val isDeveloperModeEnabled by appPreferencesStore
|
val isDeveloperModeEnabled by appPreferencesStore
|
||||||
.isDeveloperModeEnabledFlow()
|
.isDeveloperModeEnabledFlow()
|
||||||
.collectAsState(initial = false)
|
.collectAsState(initial = false)
|
||||||
|
|
@ -54,9 +51,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
|
|
||||||
appPreferencesStore.setRichTextEditorEnabled(event.enabled)
|
|
||||||
}
|
|
||||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
||||||
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
|
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +67,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdvancedSettingsState(
|
return AdvancedSettingsState(
|
||||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||||
theme = theme,
|
theme = theme,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
|
|
||||||
data class AdvancedSettingsState(
|
data class AdvancedSettingsState(
|
||||||
val isRichTextEditorEnabled: Boolean,
|
|
||||||
val isDeveloperModeEnabled: Boolean,
|
val isDeveloperModeEnabled: Boolean,
|
||||||
val isSharePresenceEnabled: Boolean,
|
val isSharePresenceEnabled: Boolean,
|
||||||
val theme: Theme,
|
val theme: Theme,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||||
override val values: Sequence<AdvancedSettingsState>
|
override val values: Sequence<AdvancedSettingsState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aAdvancedSettingsState(),
|
aAdvancedSettingsState(),
|
||||||
aAdvancedSettingsState(isRichTextEditorEnabled = true),
|
|
||||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||||
aAdvancedSettingsState(showChangeThemeDialog = true),
|
aAdvancedSettingsState(showChangeThemeDialog = true),
|
||||||
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
|
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
|
||||||
|
|
@ -31,12 +30,10 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aAdvancedSettingsState(
|
fun aAdvancedSettingsState(
|
||||||
isRichTextEditorEnabled: Boolean = false,
|
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
isSendPublicReadReceiptsEnabled: Boolean = false,
|
isSendPublicReadReceiptsEnabled: Boolean = false,
|
||||||
showChangeThemeDialog: Boolean = false,
|
showChangeThemeDialog: Boolean = false,
|
||||||
) = AdvancedSettingsState(
|
) = AdvancedSettingsState(
|
||||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
|
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
|
||||||
theme = Theme.System,
|
theme = Theme.System,
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,6 @@ fun AdvancedSettingsView(
|
||||||
state.eventSink(AdvancedSettingsEvents.ChangeTheme)
|
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(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(text = stringResource(id = CommonStrings.action_view_source))
|
Text(text = stringResource(id = CommonStrings.action_view_source))
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class AdvancedSettingsPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitLastSequentialItem()
|
val initialState = awaitLastSequentialItem()
|
||||||
assertThat(initialState.isDeveloperModeEnabled).isFalse()
|
assertThat(initialState.isDeveloperModeEnabled).isFalse()
|
||||||
assertThat(initialState.isRichTextEditorEnabled).isFalse()
|
|
||||||
assertThat(initialState.showChangeThemeDialog).isFalse()
|
assertThat(initialState.showChangeThemeDialog).isFalse()
|
||||||
assertThat(initialState.isSharePresenceEnabled).isTrue()
|
assertThat(initialState.isSharePresenceEnabled).isTrue()
|
||||||
assertThat(initialState.theme).isEqualTo(Theme.System)
|
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
|
@Test
|
||||||
fun `present - share presence off on`() = runTest {
|
fun `present - share presence off on`() = runTest {
|
||||||
val presenter = createAdvancedSettingsPresenter()
|
val presenter = createAdvancedSettingsPresenter()
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ serialization_json = "1.6.3"
|
||||||
showkase = "1.0.2"
|
showkase = "1.0.2"
|
||||||
appyx = "1.4.0"
|
appyx = "1.4.0"
|
||||||
sqldelight = "2.0.2"
|
sqldelight = "2.0.2"
|
||||||
wysiwyg = "2.37.2"
|
wysiwyg = "2.37.3"
|
||||||
telephoto = "0.11.2"
|
telephoto = "0.11.2"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||||
|
|
||||||
class FakePermalinkBuilder(
|
class FakePermalinkBuilder(
|
||||||
private val result: () -> Result<String> = { Result.failure(Exception("Not implemented")) }
|
private val result: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) }
|
||||||
) : PermalinkBuilder {
|
) : PermalinkBuilder {
|
||||||
override fun permalinkForUser(userId: UserId): Result<String> {
|
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
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface AppPreferencesStore {
|
interface AppPreferencesStore {
|
||||||
suspend fun setRichTextEditorEnabled(enabled: Boolean)
|
|
||||||
fun isRichTextEditorEnabledFlow(): Flow<Boolean>
|
|
||||||
|
|
||||||
suspend fun setDeveloperModeEnabled(enabled: Boolean)
|
suspend fun setDeveloperModeEnabled(enabled: Boolean)
|
||||||
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
|
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
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.BuildMeta
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.di.AppScope
|
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 Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_preferences")
|
||||||
|
|
||||||
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
|
|
||||||
private val developerModeKey = booleanPreferencesKey("developerMode")
|
private val developerModeKey = booleanPreferencesKey("developerMode")
|
||||||
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
|
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
|
||||||
private val themeKey = stringPreferencesKey("theme")
|
private val themeKey = stringPreferencesKey("theme")
|
||||||
|
|
@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor(
|
||||||
) : AppPreferencesStore {
|
) : AppPreferencesStore {
|
||||||
private val store = context.dataStore
|
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) {
|
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
prefs[developerModeKey] = enabled
|
prefs[developerModeKey] = enabled
|
||||||
|
|
|
||||||
|
|
@ -21,24 +21,14 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class InMemoryAppPreferencesStore(
|
class InMemoryAppPreferencesStore(
|
||||||
isRichTextEditorEnabled: Boolean = false,
|
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
customElementCallBaseUrl: String? = null,
|
customElementCallBaseUrl: String? = null,
|
||||||
theme: String? = null,
|
theme: String? = null,
|
||||||
) : AppPreferencesStore {
|
) : AppPreferencesStore {
|
||||||
private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
|
|
||||||
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||||
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||||
private val theme = MutableStateFlow(theme)
|
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) {
|
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
||||||
isDeveloperModeEnabled.value = enabled
|
isDeveloperModeEnabled.value = enabled
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,14 @@ object TestTags {
|
||||||
val welcomeScreenTitle = TestTag("welcome_screen-title")
|
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.
|
* Message bubble.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.textcomposer"
|
namespace = "io.element.android.libraries.textcomposer"
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
@ -47,9 +50,13 @@ dependencies {
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
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 android.net.Uri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.requiredHeightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.components.media.createFakeWaveform
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
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.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
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.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.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
import io.element.android.libraries.testtags.TestTags
|
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.VoiceMessagePreview
|
||||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
|
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
|
||||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
|
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.components.textInputRoundedCornerShape
|
||||||
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
|
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.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
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.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
|
@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextComposer(
|
fun TextComposer(
|
||||||
state: RichTextEditorState,
|
state: TextEditorState,
|
||||||
voiceMessageState: VoiceMessageState,
|
voiceMessageState: VoiceMessageState,
|
||||||
permalinkParser: PermalinkParser,
|
permalinkParser: PermalinkParser,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
enableTextFormatting: Boolean,
|
|
||||||
enableVoiceMessages: Boolean,
|
enableVoiceMessages: Boolean,
|
||||||
currentUserId: UserId,
|
currentUserId: UserId,
|
||||||
onRequestFocus: () -> Unit,
|
onRequestFocus: () -> Unit,
|
||||||
onSendMessage: (Message) -> Unit,
|
onSendMessage: () -> Unit,
|
||||||
onResetComposerMode: () -> Unit,
|
onResetComposerMode: () -> Unit,
|
||||||
onAddAttachment: () -> Unit,
|
onAddAttachment: () -> Unit,
|
||||||
onDismissTextFormatting: () -> Unit,
|
onDismissTextFormatting: () -> Unit,
|
||||||
|
|
@ -122,9 +112,12 @@ fun TextComposer(
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
subcomposing: 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 onSendClicked = {
|
||||||
val html = if (enableTextFormatting) state.messageHtml else null
|
onSendMessage()
|
||||||
onSendMessage(Message(html = html, markdown = state.messageMarkdown))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val onPlayVoiceMessageClicked = {
|
val onPlayVoiceMessageClicked = {
|
||||||
|
|
@ -153,32 +146,57 @@ fun TextComposer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
|
val placeholder = if (composerMode.inThread) {
|
||||||
@Composable {
|
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||||
val mentionSpanProvider = rememberMentionSpanProvider(
|
} else {
|
||||||
currentUserId = currentUserId,
|
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||||
permalinkParser = permalinkParser,
|
}
|
||||||
)
|
val textInput: @Composable () -> Unit = when (state) {
|
||||||
TextInput(
|
is TextEditorState.Rich -> {
|
||||||
state = state,
|
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||||
subcomposing = subcomposing,
|
@Composable {
|
||||||
placeholder = if (composerMode.inThread) {
|
val mentionSpanProvider = rememberMentionSpanProvider(
|
||||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
currentUserId = currentUserId,
|
||||||
} else {
|
permalinkParser = permalinkParser,
|
||||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
)
|
||||||
},
|
TextInput(
|
||||||
composerMode = composerMode,
|
state = state.richTextEditorState,
|
||||||
onResetComposerMode = onResetComposerMode,
|
subcomposing = subcomposing,
|
||||||
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
|
placeholder = placeholder,
|
||||||
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
|
composerMode = composerMode,
|
||||||
onError = onError,
|
onResetComposerMode = onResetComposerMode,
|
||||||
onTyping = onTyping,
|
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
|
||||||
onRichContentSelected = onRichContentSelected,
|
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 {
|
val sendButton = @Composable {
|
||||||
SendButton(
|
SendButton(
|
||||||
canSendMessage = canSendMessage,
|
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 {
|
val sendOrRecordButton = when {
|
||||||
enableVoiceMessages && !canSendMessage ->
|
enableVoiceMessages && !canSendMessage ->
|
||||||
|
|
@ -217,8 +237,7 @@ fun TextComposer(
|
||||||
false -> sendVoiceButton
|
false -> sendVoiceButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else ->
|
else -> sendButton
|
||||||
sendButton
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val voiceRecording = @Composable {
|
val voiceRecording = @Composable {
|
||||||
|
|
@ -251,7 +270,7 @@ fun TextComposer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTextFormatting) {
|
if (showTextFormatting && textFormattingOptions != null) {
|
||||||
TextFormattingLayout(
|
TextFormattingLayout(
|
||||||
modifier = layoutModifier,
|
modifier = layoutModifier,
|
||||||
textInput = textInput,
|
textInput = textInput,
|
||||||
|
|
@ -282,14 +301,16 @@ fun TextComposer(
|
||||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||||
}
|
}
|
||||||
|
|
||||||
val menuAction = state.menuAction
|
|
||||||
val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived)
|
val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived)
|
||||||
LaunchedEffect(menuAction) {
|
if (state is TextEditorState.Rich) {
|
||||||
if (menuAction is MenuAction.Suggestion) {
|
val menuAction = state.richTextEditorState.menuAction
|
||||||
val suggestion = Suggestion(menuAction.suggestionPattern)
|
LaunchedEffect(menuAction) {
|
||||||
latestOnSuggestionReceived(suggestion)
|
if (menuAction is MenuAction.Suggestion) {
|
||||||
} else {
|
val suggestion = Suggestion(menuAction.suggestionPattern)
|
||||||
latestOnSuggestionReceived(null)
|
latestOnSuggestionReceived(suggestion)
|
||||||
|
} else {
|
||||||
|
latestOnSuggestionReceived(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -400,17 +421,13 @@ private fun TextFormattingLayout(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextInput(
|
private fun TextInputBox(
|
||||||
state: RichTextEditorState,
|
|
||||||
subcomposing: Boolean,
|
|
||||||
placeholder: String,
|
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
onResetComposerMode: () -> Unit,
|
onResetComposerMode: () -> Unit,
|
||||||
resolveRoomMentionDisplay: () -> TextDisplay,
|
placeholder: String,
|
||||||
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
showPlaceholder: () -> Boolean,
|
||||||
onError: (Throwable) -> Unit,
|
subcomposing: Boolean,
|
||||||
onTyping: (Boolean) -> Unit,
|
textInput: @Composable () -> Unit,
|
||||||
onRichContentSelected: ((Uri) -> Unit)?,
|
|
||||||
) {
|
) {
|
||||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
||||||
val borderColor = ElementTheme.colors.borderDisabled
|
val borderColor = ElementTheme.colors.borderDisabled
|
||||||
|
|
@ -431,11 +448,12 @@ private fun TextInput(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp)
|
.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,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
// Placeholder
|
// Placeholder
|
||||||
if (state.messageHtml.isEmpty()) {
|
if (showPlaceholder()) {
|
||||||
Text(
|
Text(
|
||||||
placeholder,
|
placeholder,
|
||||||
style = defaultTypography.copy(
|
style = defaultTypography.copy(
|
||||||
|
|
@ -446,155 +464,45 @@ private fun TextInput(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
RichTextEditor(
|
textInput()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ComposerModeView(
|
private fun TextInput(
|
||||||
|
state: RichTextEditorState,
|
||||||
|
subcomposing: Boolean,
|
||||||
|
placeholder: String,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
onResetComposerMode: () -> Unit,
|
onResetComposerMode: () -> Unit,
|
||||||
|
resolveRoomMentionDisplay: () -> TextDisplay,
|
||||||
|
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
||||||
|
onError: (Throwable) -> Unit,
|
||||||
|
onTyping: (Boolean) -> Unit,
|
||||||
|
onRichContentSelected: ((Uri) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
when (composerMode) {
|
TextInputBox(
|
||||||
is MessageComposerMode.Edit -> {
|
composerMode = composerMode,
|
||||||
EditingModeView(onResetComposerMode = onResetComposerMode)
|
onResetComposerMode = onResetComposerMode,
|
||||||
}
|
placeholder = placeholder,
|
||||||
is MessageComposerMode.Reply -> {
|
showPlaceholder = { state.messageHtml.isEmpty() },
|
||||||
ReplyToModeView(
|
subcomposing = subcomposing,
|
||||||
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(
|
RichTextEditor(
|
||||||
imageVector = CompoundIcons.Edit(),
|
state = state,
|
||||||
contentDescription = stringResource(CommonStrings.common_editing),
|
// Disable most of the editor functionality if it's just being measured for a subcomposition.
|
||||||
tint = ElementTheme.materialColors.secondary,
|
// This prevents it gaining focus and mutating the state.
|
||||||
|
registerStateUpdates = !subcomposing,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 8.dp)
|
.padding(top = 6.dp, bottom = 6.dp)
|
||||||
.size(16.dp),
|
.fillMaxWidth(),
|
||||||
)
|
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||||
Text(
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
stringResource(CommonStrings.common_editing),
|
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
onError = onError,
|
||||||
textAlign = TextAlign.Start,
|
onRichContentSelected = onRichContentSelected,
|
||||||
color = ElementTheme.materialColors.secondary,
|
onTyping = onTyping,
|
||||||
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)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -606,43 +514,41 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
items = persistentListOf(
|
items = persistentListOf(
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "", initialFocus = true),
|
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost"),
|
currentUserId = UserId("@alice:localhost"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message", initialFocus = true),
|
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(
|
TextEditorState.Markdown(
|
||||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
aMarkdownTextEditorState(
|
||||||
initialFocus = true
|
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||||
|
initialFocus = true
|
||||||
|
)
|
||||||
),
|
),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message without focus"),
|
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
|
|
@ -656,33 +562,32 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
internal fun TextComposerFormattingPreview() = ElementPreview {
|
internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(),
|
TextEditorState.Rich(aRichTextEditorState()),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message"),
|
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(
|
TextEditorState.Rich(
|
||||||
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
aRichTextEditorState(
|
||||||
|
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||||
|
)
|
||||||
),
|
),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
|
|
@ -694,10 +599,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||||
internal fun TextComposerEditPreview() = ElementPreview {
|
internal fun TextComposerEditPreview() = ElementPreview {
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
ATextComposer(
|
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,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
|
|
@ -711,7 +629,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
items = persistentListOf(
|
items = persistentListOf(
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(),
|
TextEditorState.Rich(aRichTextEditorState()),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -722,14 +640,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
"With several lines\n" +
|
"With several lines\n" +
|
||||||
"To preview larger textfields and long lines with overflow"
|
"To preview larger textfields and long lines with overflow"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(),
|
TextEditorState.Rich(aRichTextEditorState()),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
|
|
@ -740,14 +657,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
"With several lines\n" +
|
"With several lines\n" +
|
||||||
"To preview larger textfields and long lines with overflow"
|
"To preview larger textfields and long lines with overflow"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message"),
|
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
|
|
@ -761,14 +677,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
defaultContent = "image.jpg"
|
defaultContent = "image.jpg"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message"),
|
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -782,14 +697,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
defaultContent = "video.mp4"
|
defaultContent = "video.mp4"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message"),
|
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -803,14 +717,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
defaultContent = "logs.txt"
|
defaultContent = "logs.txt"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
aRichTextEditorState(initialText = "A message", initialFocus = true),
|
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -824,7 +737,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
defaultContent = "Shared location"
|
defaultContent = "Shared location"
|
||||||
),
|
),
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
|
|
@ -840,10 +752,9 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||||
fun VoicePreview(
|
fun VoicePreview(
|
||||||
voiceMessageState: VoiceMessageState
|
voiceMessageState: VoiceMessageState
|
||||||
) = ATextComposer(
|
) = ATextComposer(
|
||||||
aRichTextEditorState(initialFocus = true),
|
TextEditorState.Rich(aRichTextEditorState(initialFocus = true)),
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = voiceMessageState,
|
||||||
composerMode = MessageComposerMode.Normal,
|
composerMode = MessageComposerMode.Normal,
|
||||||
enableTextFormatting = true,
|
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
currentUserId = UserId("@alice:localhost")
|
currentUserId = UserId("@alice:localhost")
|
||||||
)
|
)
|
||||||
|
|
@ -902,23 +813,21 @@ private fun PreviewColumn(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ATextComposer(
|
private fun ATextComposer(
|
||||||
richTextEditorState: RichTextEditorState,
|
state: TextEditorState,
|
||||||
voiceMessageState: VoiceMessageState,
|
voiceMessageState: VoiceMessageState,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
enableTextFormatting: Boolean,
|
|
||||||
enableVoiceMessages: Boolean,
|
enableVoiceMessages: Boolean,
|
||||||
currentUserId: UserId,
|
currentUserId: UserId,
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
) {
|
) {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
state = richTextEditorState,
|
state = state,
|
||||||
showTextFormatting = showTextFormatting,
|
showTextFormatting = showTextFormatting,
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = voiceMessageState,
|
||||||
permalinkParser = object : PermalinkParser {
|
permalinkParser = object : PermalinkParser {
|
||||||
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
|
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
|
||||||
},
|
},
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
enableTextFormatting = enableTextFormatting,
|
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onRequestFocus = {},
|
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
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MentionSpan(
|
class MentionSpan(
|
||||||
|
val text: String,
|
||||||
|
val rawValue: String,
|
||||||
val type: Type,
|
val type: Type,
|
||||||
val backgroundColor: Int,
|
val backgroundColor: Int,
|
||||||
val textColor: Int,
|
val textColor: Int,
|
||||||
|
|
@ -39,29 +41,25 @@ class MentionSpan(
|
||||||
|
|
||||||
private var actualText: CharSequence? = null
|
private var actualText: CharSequence? = null
|
||||||
private var textWidth = 0
|
private var textWidth = 0
|
||||||
private var cachedRect: RectF = RectF()
|
|
||||||
private val backgroundPaint = Paint().apply {
|
private val backgroundPaint = Paint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
color = backgroundColor
|
color = backgroundColor
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
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
|
paint.typeface = typeface
|
||||||
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
|
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
|
||||||
return textWidth + startPadding + endPadding
|
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) {
|
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
|
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
||||||
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
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
|
val radius = rect.height() / 2
|
||||||
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
||||||
paint.color = textColor
|
paint.color = textColor
|
||||||
|
|
@ -69,24 +67,24 @@ class MentionSpan(
|
||||||
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
|
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!!
|
if (actualText != null) return actualText!!
|
||||||
return buildString {
|
return buildString {
|
||||||
val mentionText = text.orEmpty()
|
val mentionText = text.orEmpty()
|
||||||
when (type) {
|
when (type) {
|
||||||
Type.USER -> {
|
Type.USER -> {
|
||||||
if (start in mentionText.indices && mentionText[start] != '@') {
|
if (text.firstOrNull() != '@') {
|
||||||
append("@")
|
append("@")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Type.ROOM -> {
|
Type.ROOM -> {
|
||||||
if (start in mentionText.indices && mentionText[start] != '#') {
|
if (text.firstOrNull() != '#') {
|
||||||
append("#")
|
append("#")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
append(mentionText.substring(start, min(end, start + MAX_LENGTH)))
|
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
|
||||||
if (end - start > MAX_LENGTH) {
|
if (mentionText.length > MAX_LENGTH) {
|
||||||
append("…")
|
append("…")
|
||||||
}
|
}
|
||||||
actualText = this
|
actualText = this
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ class MentionSpanProvider(
|
||||||
permalinkData is PermalinkData.UserLink -> {
|
permalinkData is PermalinkData.UserLink -> {
|
||||||
val isCurrentUser = permalinkData.userId == currentSessionId
|
val isCurrentUser = permalinkData.userId == currentSessionId
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
text = text,
|
||||||
|
rawValue = permalinkData.userId.toString(),
|
||||||
type = MentionSpan.Type.USER,
|
type = MentionSpan.Type.USER,
|
||||||
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
||||||
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
||||||
|
|
@ -94,6 +96,8 @@ class MentionSpanProvider(
|
||||||
}
|
}
|
||||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
text = text,
|
||||||
|
rawValue = "@room",
|
||||||
type = MentionSpan.Type.USER,
|
type = MentionSpan.Type.USER,
|
||||||
backgroundColor = otherBackgroundColor,
|
backgroundColor = otherBackgroundColor,
|
||||||
textColor = otherTextColor,
|
textColor = otherTextColor,
|
||||||
|
|
@ -102,8 +106,22 @@ class MentionSpanProvider(
|
||||||
typeface = typeface.value,
|
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 -> {
|
else -> {
|
||||||
MentionSpan(
|
MentionSpan(
|
||||||
|
text = text,
|
||||||
|
rawValue = text,
|
||||||
type = MentionSpan.Type.ROOM,
|
type = MentionSpan.Type.ROOM,
|
||||||
backgroundColor = otherBackgroundColor,
|
backgroundColor = otherBackgroundColor,
|
||||||
textColor = otherTextColor,
|
textColor = otherTextColor,
|
||||||
|
|
@ -155,8 +173,8 @@ internal fun MentionSpanPreview() {
|
||||||
provider.setup()
|
provider.setup()
|
||||||
|
|
||||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||||
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
|
||||||
fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
|
||||||
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
|
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
|
||||||
AndroidView(factory = { context ->
|
AndroidView(factory = { context ->
|
||||||
TextView(context).apply {
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,13 +14,13 @@
|
||||||
* limitations under the License.
|
* 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 androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface MentionSuggestion {
|
sealed interface ResolvedMentionSuggestion {
|
||||||
data object Room : MentionSuggestion
|
data object AtRoom : ResolvedMentionSuggestion
|
||||||
data class Member(val roomMember: RoomMember) : MentionSuggestion
|
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
|
package io.element.android.libraries.textcomposer.impl.mentions
|
||||||
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -66,6 +67,14 @@ class MentionSpanProviderTest {
|
||||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
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
|
@Test
|
||||||
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
|
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
|
||||||
permalinkParser.givenResult(
|
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 {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
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()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") }
|
maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") }
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class KonsistPreviewTest {
|
||||||
"IconTitleSubtitleMoleculeWithResIconPreview",
|
"IconTitleSubtitleMoleculeWithResIconPreview",
|
||||||
"IconsCompoundPreview",
|
"IconsCompoundPreview",
|
||||||
"IconsOtherPreview",
|
"IconsOtherPreview",
|
||||||
|
"MarkdownTextComposerEditPreview",
|
||||||
"MentionSpanPreview",
|
"MentionSpanPreview",
|
||||||
"MessageComposerViewVoicePreview",
|
"MessageComposerViewVoicePreview",
|
||||||
"MessagesReactionButtonAddPreview",
|
"MessagesReactionButtonAddPreview",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4931140dafa7377d1583d4af9603337fbb3a0045ddbb348bf23a220bd77083d1
|
oid sha256:de588d3ef8770778779d09b2f883e4327c4cc3afce98d33aab32298e32ca070a
|
||||||
size 56388
|
size 44474
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:13cffd3bf3d2eb4d266b81dd05d40e05cfd4663851ea06e9c6c24374e457fb1d
|
oid sha256:342ef5ebb8ece155939816d6ba04298b5827dd1c125891986ee3d6389c75fe64
|
||||||
size 55859
|
size 43970
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:00cdf4f635352c11d572faae361c066986a6812f80124fe23ab142f8c87c077f
|
oid sha256:92ed07e7bea55582c88ca8c8f3918c1058a5f56a1f7a196fd8080c37bff6bc52
|
||||||
size 55888
|
size 35864
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c4a9814198e0a6be655ec1bfb8038071e29a8ee4959b5209936e6b63d92c4e07
|
oid sha256:4219e66ccebc52570ee2193b9a4b564a8c9c370c051a8c377e4ad12a9f3f8ffb
|
||||||
size 36289
|
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
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e9fd3a8cee62f283571b37670b4f064dd9f6c5195dec92b20c6ee3d53c9defae
|
oid sha256:706666dd4351887061879359d6947daef5ce74e552784b18bd0216b233ee6048
|
||||||
size 53103
|
size 41826
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6ad13ab2b02480a9d436fb62039139284af250aaaf806434f528307e6704162a
|
oid sha256:731f570f939af1f515f030f03420aeca39880100707e74e4ebe2e3433ac56a95
|
||||||
size 52797
|
size 41441
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1584885ecab5a8aa3bbf1f0b116d74d398043d0b12c905d69717bec5b1e80ea7
|
oid sha256:9d7cadd4ed9e918a52a7b7ab0c6416c0cb06b1d77900c68606c0f532a6b0647c
|
||||||
size 52819
|
size 31551
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:99acb52cafefefbf3a17ce759e85b821d949df9c5d37fce4038b0d99e22993b5
|
oid sha256:ffa635614cbf5a57c4b481c99eadaf56777342090fbc26441be3e413b16ff95b
|
||||||
size 32144
|
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