Skip to content

[Voice messages] Add voice recording UI #1546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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> {

Expand All @@ -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()
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -177,6 +188,7 @@ class MessagesPresenter @AssistedInject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedact = userHasPermissionToRedact,
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
actionListState = actionListState,
customReactionState = customReactionState,
Expand All @@ -187,6 +199,7 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
eventSink = { handleEvents(it) }
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -46,5 +48,6 @@ data class MessagesState(
val inviteProgress: Async<Unit>,
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val eventSink: (MessagesEvents) -> Unit
)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,6 +61,7 @@ fun aMessagesState() = MessagesState(
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),
voiceMessageComposerState = aVoiceMessageComposerState(),
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
Expand All @@ -82,5 +84,6 @@ fun aMessagesState() = MessagesState(
inviteProgress = Async.Uninitialized,
showReinvitePrompt = false,
enableTextFormatting = true,
enableVoiceMessages = true,
eventSink = {}
)
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
Loading