[Voice messages] Add voice recording UI (#1546)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.api
|
||||
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
/**
|
||||
* Hoist-able state of the message composer.
|
||||
|
||||
@@ -55,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
@@ -67,6 +68,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
@@ -75,7 +78,7 @@ 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.room.canRedactAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -84,6 +87,7 @@ import timber.log.Timber
|
||||
class MessagesPresenter @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val composerPresenter: MessageComposerPresenter,
|
||||
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
|
||||
private val timelinePresenter: TimelinePresenter,
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
private val customReactionPresenter: CustomReactionPresenter,
|
||||
@@ -95,6 +99,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
@@ -107,6 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
override fun present(): MessagesState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val composerState = composerPresenter.present()
|
||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||
val timelineState = timelinePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
@@ -145,6 +151,11 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
|
||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(featureFlagsService) {
|
||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||
}
|
||||
|
||||
fun handleEvents(event: MessagesEvents) {
|
||||
when (event) {
|
||||
is MessagesEvents.HandleAction -> {
|
||||
@@ -177,6 +188,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToRedact = userHasPermissionToRedact,
|
||||
composerState = composerState,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
@@ -187,6 +199,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
inviteProgress = inviteProgress.value,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
@@ -36,6 +37,7 @@ data class MessagesState(
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToRedact: Boolean,
|
||||
val composerState: MessageComposerState,
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
@@ -46,5 +48,6 @@ data class MessagesState(
|
||||
val inviteProgress: Async<Unit>,
|
||||
val showReinvitePrompt: Boolean,
|
||||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -25,11 +25,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@@ -60,6 +61,7 @@ fun aMessagesState() = MessagesState(
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal("Hello"),
|
||||
),
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState = aTimelineState().copy(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
),
|
||||
@@ -82,5 +84,6 @@ fun aMessagesState() = MessagesState(
|
||||
inviteProgress = Async.Uninitialized,
|
||||
showReinvitePrompt = false,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -317,8 +317,10 @@ private fun MessagesViewContent(
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
|
||||
@@ -47,8 +47,8 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
|
||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||
|
||||
@@ -24,17 +24,24 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MessageComposerView(
|
||||
internal fun MessageComposerView(
|
||||
state: MessageComposerState,
|
||||
voiceMessageState: VoiceMessageComposerState,
|
||||
subcomposing: Boolean,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun sendMessage(message: Message) {
|
||||
@@ -64,9 +71,14 @@ fun MessageComposerView(
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||
subcomposing = subcomposing,
|
||||
onRequestFocus = ::onRequestFocus,
|
||||
onSendMessage = ::sendMessage,
|
||||
@@ -76,24 +88,49 @@ fun MessageComposerView(
|
||||
onAddAttachment = ::onAddAttachment,
|
||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessageComposerViewPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = ElementPreview {
|
||||
internal fun MessageComposerViewPreview(
|
||||
@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(200.dp),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessageComposerViewVoicePreview(
|
||||
@PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = aMessageComposerState(),
|
||||
voiceMessageState = state,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
|
||||
sealed class VoiceMessageComposerEvents {
|
||||
data class RecordButtonEvent(
|
||||
val pressEvent: PressEvent
|
||||
): VoiceMessageComposerEvents()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
|
||||
@Composable
|
||||
override fun present(): VoiceMessageComposerState {
|
||||
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
|
||||
|
||||
fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
|
||||
PressEvent.PressStart -> {
|
||||
// TODO start the recording
|
||||
voiceMessageState = VoiceMessageState.Recording
|
||||
}
|
||||
PressEvent.LongPressEnd -> {
|
||||
// TODO finish the recording
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
// TODO discard the recording and show the 'hold to record' tooltip
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun handleEvents(event: VoiceMessageComposerEvents) {
|
||||
when (event) {
|
||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
|
||||
@Stable
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
|
||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
eventSink = {},
|
||||
)
|
||||
@@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
@@ -73,7 +74,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
@@ -619,6 +620,7 @@ class MessagesPresenterTest {
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
@@ -634,6 +636,7 @@ class MessagesPresenterTest {
|
||||
return MessagesPresenter(
|
||||
room = matrixRoom,
|
||||
composerPresenter = messageComposerPresenter,
|
||||
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
|
||||
timelinePresenter = timelinePresenter,
|
||||
actionListPresenter = actionListPresenter,
|
||||
customReactionPresenter = customReactionPresenter,
|
||||
@@ -645,6 +648,7 @@ class MessagesPresenterTest {
|
||||
navigator = navigator,
|
||||
clipboardHelper = clipboardHelper,
|
||||
preferencesStore = preferencesStore,
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.messages.voicemessages
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceMessageComposerPresenterTest {
|
||||
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - recording state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - abort recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - finish recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
}
|
||||
}
|
||||
private fun createPresenter() = VoiceMessageComposerPresenter()
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
package io.element.android.features.messages.test
|
||||
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
class MessageComposerContextFake(
|
||||
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
|
||||
|
||||
@@ -37,4 +37,8 @@ dependencies {
|
||||
api(libs.matrix.richtexteditor.compose)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ 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.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,8 +47,8 @@ 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.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -61,9 +63,15 @@ import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
||||
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
||||
import io.element.android.libraries.textcomposer.components.RecordButton
|
||||
import io.element.android.libraries.textcomposer.components.RecordingProgress
|
||||
import io.element.android.libraries.textcomposer.components.SendButton
|
||||
import io.element.android.libraries.textcomposer.components.TextFormatting
|
||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.RichTextEditor
|
||||
@@ -74,8 +82,10 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
@Composable
|
||||
fun TextComposer(
|
||||
state: RichTextEditorState,
|
||||
voiceMessageState: VoiceMessageState,
|
||||
composerMode: MessageComposerMode,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showTextFormatting: Boolean = false,
|
||||
subcomposing: Boolean = false,
|
||||
@@ -84,6 +94,7 @@ fun TextComposer(
|
||||
onResetComposerMode: () -> Unit = {},
|
||||
onAddAttachment: () -> Unit = {},
|
||||
onDismissTextFormatting: () -> Unit = {},
|
||||
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
) {
|
||||
val onSendClicked = {
|
||||
@@ -118,16 +129,34 @@ fun TextComposer(
|
||||
)
|
||||
}
|
||||
|
||||
val canSendMessage by remember { derivedStateOf { state.messageHtml.isNotEmpty() } }
|
||||
val sendButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = state.messageHtml.isNotEmpty(),
|
||||
canSendMessage = canSendMessage,
|
||||
onClick = onSendClicked,
|
||||
composerMode = composerMode,
|
||||
)
|
||||
}
|
||||
val recordButton = @Composable {
|
||||
RecordButton(
|
||||
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
||||
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
||||
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
||||
)
|
||||
}
|
||||
|
||||
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||
|
||||
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
|
||||
sendButton
|
||||
} else {
|
||||
recordButton
|
||||
}
|
||||
|
||||
val recordingProgress = @Composable {
|
||||
RecordingProgress()
|
||||
}
|
||||
|
||||
if (showTextFormatting) {
|
||||
TextFormattingLayout(
|
||||
modifier = layoutModifier,
|
||||
@@ -136,14 +165,16 @@ fun TextComposer(
|
||||
DismissTextFormattingButton(onClick = onDismissTextFormatting)
|
||||
},
|
||||
textFormatting = textFormattingOptions,
|
||||
sendButton = sendButton
|
||||
sendButton = sendButton,
|
||||
)
|
||||
} else {
|
||||
StandardLayout(
|
||||
voiceMessageState = voiceMessageState,
|
||||
modifier = layoutModifier,
|
||||
composerOptionsButton = composerOptionsButton,
|
||||
textInput = textInput,
|
||||
sendButton = sendButton
|
||||
endButton = sendOrRecordButton,
|
||||
recordingProgress = recordingProgress,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,33 +189,45 @@ fun TextComposer(
|
||||
|
||||
@Composable
|
||||
private fun StandardLayout(
|
||||
voiceMessageState: VoiceMessageState,
|
||||
textInput: @Composable () -> Unit,
|
||||
composerOptionsButton: @Composable () -> Unit,
|
||||
sendButton: @Composable () -> Unit,
|
||||
recordingProgress: @Composable () -> Unit,
|
||||
endButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
|
||||
) {
|
||||
composerOptionsButton()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
textInput()
|
||||
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
recordingProgress()
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
|
||||
) {
|
||||
composerOptionsButton()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
textInput()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||
) {
|
||||
sendButton()
|
||||
endButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,18 +481,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||
{
|
||||
TextComposer(
|
||||
RichTextEditorState("", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
@@ -457,18 +504,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
initialFocus = true
|
||||
),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message without focus", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -480,23 +531,29 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState("", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}))
|
||||
}
|
||||
@@ -507,10 +564,12 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}))
|
||||
}
|
||||
@@ -521,6 +580,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState(""),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -533,11 +593,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
},
|
||||
{
|
||||
TextComposer(
|
||||
RichTextEditorState(""),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
@@ -550,10 +612,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
@@ -569,10 +633,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -588,10 +654,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -607,10 +675,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
@@ -626,6 +696,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.utils.PressState
|
||||
import io.element.android.libraries.textcomposer.utils.PressStateEffects
|
||||
import io.element.android.libraries.textcomposer.utils.rememberPressState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun RecordButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pressState = rememberPressState()
|
||||
|
||||
PressStateEffects(
|
||||
pressState = pressState.value,
|
||||
onPressStart = onPressStart,
|
||||
onLongPressEnd = onLongPressEnd,
|
||||
onTap = onTap,
|
||||
)
|
||||
|
||||
RecordButtonView(
|
||||
isPressed = pressState.value is PressState.Pressing,
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
coroutineScope.launch {
|
||||
when (event.type) {
|
||||
PointerEventType.Press -> pressState.press()
|
||||
PointerEventType.Release -> pressState.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordButtonView(
|
||||
isPressed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp.applyScaleUp()),
|
||||
resourceId = if (isPressed) {
|
||||
CommonDrawables.ic_compound_mic_on_solid
|
||||
} else {
|
||||
CommonDrawables.ic_compound_mic_on_outline
|
||||
},
|
||||
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordButtonPreview() = ElementPreview {
|
||||
Row {
|
||||
RecordButtonView(isPressed = false)
|
||||
RecordButtonView(isPressed = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
internal fun RecordingProgress(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
|
||||
.heightIn(26.dp)
|
||||
|
||||
,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
// TODO Replace with timer UI
|
||||
Text(
|
||||
text = "Recording...", // Not localized because it is a placeholder
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordingProgressPreview() {
|
||||
RecordingProgress()
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
@Composable
|
||||
internal fun textInputRoundedCornerShape(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
data class Message(
|
||||
val html: String?,
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
sealed class PressEvent {
|
||||
data object PressStart: PressEvent()
|
||||
data object Tapped: PressEvent()
|
||||
data object LongPressEnd: PressEvent()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
sealed class VoiceMessageState {
|
||||
data object Idle: VoiceMessageState()
|
||||
data object Recording: VoiceMessageState()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
/**
|
||||
* State of a press gesture.
|
||||
*/
|
||||
internal sealed class PressState {
|
||||
data class Idle(
|
||||
val lastPress: Pressing?
|
||||
) : PressState()
|
||||
|
||||
sealed class Pressing : PressState()
|
||||
data object Tapping : Pressing()
|
||||
data object LongPressing : Pressing()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* React to [PressState] changes.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PressStateEffects(
|
||||
pressState: PressState,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressStart: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
) {
|
||||
LaunchedEffect(pressState) {
|
||||
when (pressState) {
|
||||
is PressState.Idle ->
|
||||
when (pressState.lastPress) {
|
||||
PressState.Tapping -> onTap()
|
||||
PressState.LongPressing -> onLongPressEnd()
|
||||
null -> {} // Do nothing
|
||||
}
|
||||
is PressState.LongPressing -> onLongPressStart()
|
||||
PressState.Tapping -> onPressStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
internal fun rememberPressState(
|
||||
longPressTimeoutMillis: Long = LocalViewConfiguration.current.longPressTimeoutMillis,
|
||||
): PressStateHolder {
|
||||
return remember(longPressTimeoutMillis) {
|
||||
PressStateHolder(longPressTimeoutMillis = longPressTimeoutMillis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State machine that keeps track of the pressed state.
|
||||
*
|
||||
* When a press is started, the state will transition through:
|
||||
* [PressState.Idle] -> [PressState.Tapping] -> ...
|
||||
*
|
||||
* If a press is held for a longer time, the state will continue through:
|
||||
* ... -> [PressState.LongPressing] -> ...
|
||||
*
|
||||
* When the press is released the states will then transition back to idle.
|
||||
* ... -> [PressState.Idle]
|
||||
*
|
||||
* Whether a press should be considered a tap or a long press can be determined by
|
||||
* looking at the last press when in the idle state.
|
||||
*
|
||||
* @see [PressStateEffects]
|
||||
* @see [rememberPressState]
|
||||
*/
|
||||
internal class PressStateHolder(
|
||||
private val longPressTimeoutMillis: Long,
|
||||
) : State<PressState> {
|
||||
private var state: PressState by mutableStateOf(PressState.Idle(lastPress = null))
|
||||
|
||||
override val value: PressState
|
||||
get() = state
|
||||
|
||||
private var longPressTimer: Job? = null
|
||||
|
||||
suspend fun press() = coroutineScope {
|
||||
when (state) {
|
||||
is PressState.Idle -> {
|
||||
state = PressState.Tapping
|
||||
}
|
||||
is PressState.Pressing ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
|
||||
longPressTimer = launch {
|
||||
delay(longPressTimeoutMillis)
|
||||
yield()
|
||||
|
||||
if (isActive && state == PressState.Tapping) {
|
||||
state = PressState.LongPressing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
longPressTimer?.cancel()
|
||||
longPressTimer = null
|
||||
when (val lastState = state) {
|
||||
is PressState.Pressing ->
|
||||
state = PressState.Idle(lastPress = lastState)
|
||||
is PressState.Idle ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.textcomposer.utils.PressState.Idle
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class PressStateHolderTest {
|
||||
companion object {
|
||||
const val LONG_PRESS_TIMEOUT_MILLIS = 1L
|
||||
}
|
||||
@Test
|
||||
fun `it starts in idle state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press, it moves to tapping state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release after short delay, it moves through tap states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
advanceTimeBy(1.milliseconds) // wait for the long press timeout which should not be triggered
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.Tapping))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when hold, it moves through long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.LongPressing)
|
||||
stateHolder.release()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.LongPressing))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release and repress, it doesn't enter long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press1 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
val press2 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press1.await()
|
||||
press2.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press twice without releasing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.press()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release without first pressing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release twice without pressing, it doesn't throw an error `() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.release()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
private fun createStateHolder() =
|
||||
PressStateHolder(
|
||||
LONG_PRESS_TIMEOUT_MILLIS,
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="a11y_delete">"Delete"</string>
|
||||
<string name="a11y_hide_password">"Hide password"</string>
|
||||
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
|
||||
<string name="a11y_notifications_muted">"Muted"</string>
|
||||
<string name="a11y_pause">"Pause"</string>
|
||||
<string name="a11y_play">"Play"</string>
|
||||
<string name="a11y_poll">"Poll"</string>
|
||||
<string name="a11y_poll_end">"Ended poll"</string>
|
||||
<string name="a11y_send_files">"Send files"</string>
|
||||
<string name="a11y_show_password">"Show password"</string>
|
||||
<string name="a11y_user_menu">"User menu"</string>
|
||||
<string name="a11y_voice_message_record">"Record voice message. Double tap and hold to record. Release to end recording."</string>
|
||||
<string name="action_accept">"Accept"</string>
|
||||
<string name="action_add_to_timeline">"Add to timeline"</string>
|
||||
<string name="action_back">"Back"</string>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user