Browse Source

[Voice messages] Add voice recording UI (#1546)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/1559/head
jonnyandrew 11 months ago committed by GitHub
parent
commit
12404fab78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
  2. 2
      features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
  3. 15
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  4. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
  5. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
  6. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  7. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt
  8. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
  9. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
  10. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
  11. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
  12. 43
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
  13. 25
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt
  14. 64
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt
  15. 27
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt
  16. 34
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt
  17. 6
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
  18. 4
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
  19. 89
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt
  20. 2
      features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt
  21. 4
      libraries/textcomposer/impl/build.gradle.kts
  22. 83
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  23. 108
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordButton.kt
  24. 74
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt
  25. 2
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt
  26. 2
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextInputRoundedCornerShape.kt
  27. 2
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt
  28. 2
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt
  29. 23
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/PressEvent.kt
  30. 22
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt
  31. 31
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressState.kt
  32. 47
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressStateEffects.kt
  33. 101
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressStateHolder.kt
  34. 111
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/utils/PressStateHolderTest.kt
  35. 4
      libraries/ui-strings/src/main/res/values/localazy.xml
  36. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerView-D-4_4_null_0,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerView-N-4_5_null_0,NEXUS_5,1.0,en].png
  38. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewVoice-D-5_5_null_0,NEXUS_5,1.0,en].png
  39. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewVoice-N-5_6_null_0,NEXUS_5,1.0,en].png
  40. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_0,NEXUS_5,1.0,en].png
  41. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_1,NEXUS_5,1.0,en].png
  42. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_2,NEXUS_5,1.0,en].png
  43. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_3,NEXUS_5,1.0,en].png
  44. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_4,NEXUS_5,1.0,en].png
  45. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_5,NEXUS_5,1.0,en].png
  46. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_0,NEXUS_5,1.0,en].png
  47. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_1,NEXUS_5,1.0,en].png
  48. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_2,NEXUS_5,1.0,en].png
  49. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_3,NEXUS_5,1.0,en].png
  50. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_4,NEXUS_5,1.0,en].png
  51. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_5,NEXUS_5,1.0,en].png
  52. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-D-25_25_null,NEXUS_5,1.0,en].png
  53. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-N-25_26_null,NEXUS_5,1.0,en].png
  54. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_0,NEXUS_5,1.0,en].png
  55. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_1,NEXUS_5,1.0,en].png
  56. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_2,NEXUS_5,1.0,en].png
  57. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_0,NEXUS_5,1.0,en].png
  58. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_1,NEXUS_5,1.0,en].png
  59. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_2,NEXUS_5,1.0,en].png
  60. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-D-27_27_null,NEXUS_5,1.0,en].png
  61. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-N-27_28_null,NEXUS_5,1.0,en].png
  62. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_0,NEXUS_5,1.0,en].png
  63. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_1,NEXUS_5,1.0,en].png
  64. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_2,NEXUS_5,1.0,en].png
  65. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_0,NEXUS_5,1.0,en].png
  66. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_1,NEXUS_5,1.0,en].png
  67. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_2,NEXUS_5,1.0,en].png
  68. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_0,NEXUS_5,1.0,en].png
  69. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_1,NEXUS_5,1.0,en].png
  70. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_2,NEXUS_5,1.0,en].png
  71. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_0,NEXUS_5,1.0,en].png
  72. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_1,NEXUS_5,1.0,en].png
  73. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_2,NEXUS_5,1.0,en].png
  74. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-D-30_30_null,NEXUS_5,1.0,en].png
  75. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-N-30_31_null,NEXUS_5,1.0,en].png
  76. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-31_31_null_0,NEXUS_5,1.0,en].png
  77. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-31_31_null_1,NEXUS_5,1.0,en].png
  78. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-31_32_null_0,NEXUS_5,1.0,en].png
  79. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-31_32_null_1,NEXUS_5,1.0,en].png
  80. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-33_33_null_0,NEXUS_5,1.0,en].png
  81. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-33_33_null_1,NEXUS_5,1.0,en].png
  82. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-33_34_null_0,NEXUS_5,1.0,en].png
  83. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-33_34_null_1,NEXUS_5,1.0,en].png
  84. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-32_32_null_0,NEXUS_5,1.0,en].png
  85. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-32_32_null_1,NEXUS_5,1.0,en].png
  86. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-32_33_null_0,NEXUS_5,1.0,en].png
  87. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-32_33_null_1,NEXUS_5,1.0,en].png
  88. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-D-34_34_null,NEXUS_5,1.0,en].png
  89. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-N-34_35_null,NEXUS_5,1.0,en].png
  90. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-D-35_35_null,NEXUS_5,1.0,en].png
  91. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-N-35_36_null,NEXUS_5,1.0,en].png
  92. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_0,NEXUS_5,1.0,en].png
  93. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_1,NEXUS_5,1.0,en].png
  94. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_2,NEXUS_5,1.0,en].png
  95. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_3,NEXUS_5,1.0,en].png
  96. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_4,NEXUS_5,1.0,en].png
  97. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_5,NEXUS_5,1.0,en].png
  98. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_0,NEXUS_5,1.0,en].png
  99. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_1,NEXUS_5,1.0,en].png
  100. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_2,NEXUS_5,1.0,en].png
  101. Some files were not shown because too many files have changed in this diff Show More

2
features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt

@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType @@ -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

2
features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt

@ -16,7 +16,7 @@ @@ -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.

15
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -55,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -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 @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -187,6 +199,7 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
eventSink = { handleEvents(it) }
)
}

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState @@ -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( @@ -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( @@ -46,5 +48,6 @@ data class MessagesState(
val inviteProgress: Async<Unit>,
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val eventSink: (MessagesEvents) -> Unit
)

5
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

@ -25,11 +25,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact @@ -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( @@ -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( @@ -82,5 +84,6 @@ fun aMessagesState() = MessagesState(
inviteProgress = Async.Uninitialized,
showReinvitePrompt = false,
enableTextFormatting = true,
enableVoiceMessages = true,
eventSink = {}
)

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

@ -317,8 +317,10 @@ private fun MessagesViewContent( @@ -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(),
)

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt

@ -23,7 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding @@ -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)

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt

@ -17,8 +17,8 @@ @@ -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 {

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt

@ -47,8 +47,8 @@ import io.element.android.libraries.mediapickers.api.PickerProvider @@ -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

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer @@ -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

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt

@ -17,7 +17,7 @@ @@ -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> {

43
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt

@ -24,17 +24,24 @@ import androidx.compose.runtime.rememberCoroutineScope @@ -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( @@ -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( @@ -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,
)
}

25
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt

@ -0,0 +1,25 @@ @@ -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()
}

64
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt

@ -0,0 +1,64 @@ @@ -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) }
)
}
}

27
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt

@ -0,0 +1,27 @@ @@ -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,
)

34
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt

@ -0,0 +1,34 @@ @@ -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 = {},
)

6
features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt

@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -645,6 +648,7 @@ class MessagesPresenterTest {
navigator = navigator,
clipboardHelper = clipboardHelper,
preferencesStore = preferencesStore,
featureFlagsService = FakeFeatureFlagService(),
dispatchers = coroutineDispatchers,
)
}

4
features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt

@ -58,8 +58,8 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor @@ -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

89
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt

@ -0,0 +1,89 @@ @@ -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()
}

2
features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt

@ -17,7 +17,7 @@ @@ -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)

4
libraries/textcomposer/impl/build.gradle.kts

@ -37,4 +37,8 @@ dependencies { @@ -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)
}

83
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt

@ -37,6 +37,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape @@ -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 @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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,15 +189,26 @@ fun TextComposer( @@ -158,15 +189,26 @@ 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,
) {
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)
@ -180,11 +222,12 @@ private fun StandardLayout( @@ -180,11 +222,12 @@ private fun StandardLayout(
) {
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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -626,6 +696,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
),
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
)
})
)

108
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordButton.kt

@ -0,0 +1,108 @@ @@ -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)
}
}

74
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt

@ -0,0 +1,74 @@ @@ -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()
}

2
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt

@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.text.applyScaleUp @@ -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

2
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextInputRoundedCornerShape.kt

@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape @@ -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(

2
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt → libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt

@ -14,7 +14,7 @@ @@ -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?,

2
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt → libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt

@ -14,7 +14,7 @@ @@ -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

23
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/PressEvent.kt

@ -0,0 +1,23 @@ @@ -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()
}

22
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt

@ -0,0 +1,22 @@ @@ -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()
}

31
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressState.kt

@ -0,0 +1,31 @@ @@ -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()
}

47
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressStateEffects.kt

@ -0,0 +1,47 @@ @@ -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()
}
}
}

101
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/utils/PressStateHolder.kt

@ -0,0 +1,101 @@ @@ -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")
}
}
}

111
libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/utils/PressStateHolderTest.kt

@ -0,0 +1,111 @@ @@ -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,
)
}

4
libraries/ui-strings/src/main/res/values/localazy.xml

@ -1,13 +1,17 @@ @@ -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>

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerView-D-4_4_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerView-N-4_5_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewVoice-D-5_5_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewVoice-N-5_6_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_4,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-5_5_null_5,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-D-6_6_null_5,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_4,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-5_6_null_5,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.report_null_ReportMessageView-N-6_7_null_5,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-D-24_24_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-D-25_25_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-N-24_25_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.customreaction_null_EmojiPicker-N-25_26_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-25_25_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-25_25_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-25_25_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-D-26_26_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-25_26_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-25_26_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-25_26_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemAudioView-N-26_27_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-D-26_26_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-D-27_27_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-N-26_27_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemEncryptedView-N-27_28_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-27_27_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-27_27_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-27_27_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-D-28_28_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-27_28_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-27_28_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-27_28_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemFileView-N-28_29_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-28_28_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-28_28_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-28_28_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-D-29_29_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-28_29_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-28_29_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-28_29_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemImageView-N-29_30_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-D-29_29_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-D-30_30_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-N-29_30_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemInformativeView-N-30_31_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-30_30_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-31_31_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-30_30_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-D-31_31_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-30_31_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-31_32_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-30_31_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemLocationView-N-31_32_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-32_32_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-33_33_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-32_32_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-D-33_33_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-32_33_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-33_34_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-32_33_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollCreatorView-N-33_34_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-31_31_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-32_32_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-31_31_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-D-32_32_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-31_32_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-32_33_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-31_32_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemPollView-N-32_33_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-D-33_33_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-D-34_34_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-N-33_34_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemRedactedView-N-34_35_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-D-34_34_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-D-35_35_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-N-34_35_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemStateView-N-35_36_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_4,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-35_35_null_5,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-D-36_36_null_5,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-35_36_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-35_36_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-35_36_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemTextView-N-36_37_null_2,NEXUS_5,1.0,en].png

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save