Merge branch 'develop' of https://github.com/vector-im/element-x-android into feature/dla/emojibase_integration

This commit is contained in:
David Langley 2023-08-28 17:03:00 +01:00
commit 59d7947cc0
2118 changed files with 11048 additions and 4029 deletions

View file

@ -24,7 +24,7 @@ sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
object Dismiss : MessagesEvents
data object Dismiss : MessagesEvents
}
enum class InviteDialogAction {

View file

@ -75,7 +75,7 @@ class MessagesFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
data object Messages : NavTarget
@Parcelize
data class MediaViewer(
@ -100,7 +100,7 @@ class MessagesFlowNode @AssistedInject constructor(
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@Parcelize
object SendLocation : NavTarget
data object SendLocation : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()

View file

@ -47,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -277,6 +278,7 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
)
is TimelineItemPollContent, // TODO Polls: handle reply to
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,
is TimelineItemStateContent,

View file

@ -32,6 +32,7 @@ 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 kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
override val values: Sequence<MessagesState>
@ -71,6 +72,7 @@ fun aMessagesState() = MessagesState(
selectedEventId = null,
emojiProvider = Async.Uninitialized,
eventSink = {},
selectedEmoji = persistentSetOf(),
),
reactionSummaryState = ReactionSummaryState(
target = null,

View file

@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -53,6 +52,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -78,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.SnackbarHost
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -100,7 +101,11 @@ fun MessagesView(
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -135,8 +140,7 @@ fun MessagesView(
}
fun onMoreReactionsClicked(event: TimelineItem.Event) {
if (event.eventId == null) return
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event.eventId))
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
}
Scaffold(
@ -189,7 +193,7 @@ fun MessagesView(
onActionSelected = ::onActionSelected,
onCustomReactionClicked = { event ->
if (event.eventId == null) return@ActionListView
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event.eventId))
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClicked = ::onEmojiReactionClicked,
)
@ -231,7 +235,8 @@ private fun ReinviteDialog(state: MessagesState) {
@Composable
private fun AttachmentStateView(
state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onCancel: () -> Unit,
) {
when (state) {
AttachmentsState.None -> Unit
@ -244,7 +249,9 @@ private fun AttachmentStateView(
is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
text = stringResource(id = CommonStrings.common_sending),
isCancellable = true,
onDismissRequest = onCancel,
)
}
}

View file

@ -19,6 +19,6 @@ package io.element.android.features.messages.impl.actionlist
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents {
object Clear : ActionListEvents
data object Clear : ActionListEvents
data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents
}

View file

@ -28,7 +28,7 @@ data class ActionListState(
val eventSink: (ActionListEvents) -> Unit,
) {
sealed interface Target {
object None : Target
data object None : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,

View file

@ -62,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -73,7 +74,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
@ -176,7 +177,7 @@ private fun SheetContent(
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(14.dp))
Divider()
HorizontalDivider()
}
}
if (state.displayEmojiReactions) {
@ -187,7 +188,7 @@ private fun SheetContent(
onCustomReactionClicked = onCustomReactionClicked,
modifier = Modifier.fillMaxWidth(),
)
Divider()
HorizontalDivider()
}
}
items(
@ -236,6 +237,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
val textContent = remember(event.content) { formatter.format(event) }
when (event.content) {
is TimelineItemPollContent, // TODO Polls: handle summary
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemEncryptedContent,

View file

@ -28,11 +28,11 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward)
object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
data object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
}

View file

@ -20,6 +20,6 @@ import androidx.compose.runtime.Immutable
@Immutable
sealed interface AttachmentsPreviewEvents {
object SendAttachment : AttachmentsPreviewEvents
object ClearSendState : AttachmentsPreviewEvents
data object SendAttachment : AttachmentsPreviewEvents
data object ClearSendState : AttachmentsPreviewEvents
}

View file

@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
@ -50,10 +54,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = SendActionState.Idle
AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value = null
}
sendActionState.value = SendActionState.Idle
}
}
}
@ -72,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
is Attachment.Media -> {
sendMedia(
mediaAttachment = attachment,
sendActionState = sendActionState
sendActionState = sendActionState,
)
}
}
@ -81,10 +93,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
sendActionState: MutableState<SendActionState>,
) {
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
if (context.isActive) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
sendActionState.value = SendActionState.Sending.Processing
@ -93,13 +108,17 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mimeType = mediaAttachment.localMedia.info.mimeType,
compressIfPossible = mediaAttachment.compressIfPossible,
progressCallback = progressCallback
).fold(
onSuccess = {
sendActionState.value = SendActionState.Done
},
onFailure = {
sendActionState.value = SendActionState.Failure(it)
).getOrThrow()
}.fold(
onSuccess = {
sendActionState.value = SendActionState.Done
},
onFailure = { error ->
if (error is CancellationException) {
throw error
} else {
sendActionState.value = SendActionState.Failure(error)
}
)
}
}
)
}

View file

@ -25,13 +25,13 @@ data class AttachmentsPreviewState(
)
sealed interface SendActionState {
object Idle : SendActionState
data object Idle : SendActionState
sealed interface Sending : SendActionState {
object Processing : Sending
data object Processing : Sending
data class Uploading(val progress: Float) : Sending
}
data class Failure(val error: Throwable) : SendActionState
object Done : SendActionState
data object Done : SendActionState
}

View file

@ -96,7 +96,9 @@ private fun AttachmentSendStateView(
is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress)
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
text = stringResource(id = CommonStrings.common_sending),
isCancellable = true,
onDismissRequest = onDismissClicked,
)
}
is SendActionState.Failure -> {

View file

@ -21,9 +21,9 @@ import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
sealed interface ForwardMessagesEvents {
data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
// TODO remove to restore multi-selection
object RemoveSelectedRoom : ForwardMessagesEvents
object ToggleSearchActive : ForwardMessagesEvents
data object RemoveSelectedRoom : ForwardMessagesEvents
data object ToggleSearchActive : ForwardMessagesEvents
data class UpdateQuery(val query: String) : ForwardMessagesEvents
object ForwardEvent : ForwardMessagesEvents
object ClearError : ForwardMessagesEvents
data object ForwardEvent : ForwardMessagesEvents
data object ClearError : ForwardMessagesEvents
}

View file

@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogD
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
@ -161,7 +161,7 @@ fun ForwardMessagesView(
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
}
)
Divider(modifier = Modifier.fillMaxWidth())
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
@ -186,7 +186,7 @@ fun ForwardMessagesView(
state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary))
}
)
Divider(modifier = Modifier.fillMaxWidth())
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}

View file

@ -17,9 +17,9 @@
package io.element.android.features.messages.impl.media.viewer
sealed interface MediaViewerEvents {
object SaveOnDisk: MediaViewerEvents
object Share: MediaViewerEvents
object OpenWith: MediaViewerEvents
object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents
data object SaveOnDisk: MediaViewerEvents
data object Share: MediaViewerEvents
data object OpenWith: MediaViewerEvents
data object RetryLoading : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents
}

View file

@ -34,9 +34,6 @@ import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -64,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.SnackbarHost
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -99,15 +97,7 @@ fun MediaViewerView(
eventSink = state.eventSink
)
},
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
Column(
modifier = Modifier

View file

@ -21,19 +21,20 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable
sealed interface MessageComposerEvents {
object ToggleFullScreenState : MessageComposerEvents
data object ToggleFullScreenState : MessageComposerEvents
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents
object CloseSpecialMode : MessageComposerEvents
data object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: String) : MessageComposerEvents
object AddAttachment : MessageComposerEvents
object DismissAttachmentMenu : MessageComposerEvents
data object AddAttachment : MessageComposerEvents
data object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {
object FromGallery : PickAttachmentSource
object FromFiles : PickAttachmentSource
object PhotoFromCamera : PickAttachmentSource
object VideoFromCamera : PickAttachmentSource
object Location : PickAttachmentSource
data object FromGallery : PickAttachmentSource
data object FromFiles : PickAttachmentSource
data object PhotoFromCamera : PickAttachmentSource
data object VideoFromCamera : PickAttachmentSource
data object Location : PickAttachmentSource
}
data object CancelSendAttachment : MessageComposerEvents
}

View file

@ -47,9 +47,13 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@SingleIn(RoomScope::class)
@ -100,6 +104,7 @@ class MessageComposerPresenter @Inject constructor(
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
@ -112,7 +117,12 @@ class MessageComposerPresenter @Inject constructor(
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
is AttachmentsState.Sending.Processing -> {
ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment(
attachmentStateValue.attachments.first(),
attachmentsState,
)
}
else -> Unit
}
}
@ -169,6 +179,12 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value == null
}
}
}
}
@ -212,13 +228,13 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
attachmentState: MutableState<AttachmentsState>,
) = launch {
when (attachment) {
is Attachment.Media -> {
) = when (attachment) {
is Attachment.Media -> {
launch {
sendMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
attachmentState = attachmentState
attachmentState = attachmentState,
)
}
}
@ -259,20 +275,27 @@ class MessageComposerPresenter @Inject constructor(
uri: Uri,
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) {
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
if (context.isActive) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback)
.onSuccess {
attachmentState.value = AttachmentsState.None
}
.onFailure {
val snackbarMessage = SnackbarMessage(sendAttachmentError(it))
snackbarDispatcher.post(snackbarMessage)
attachmentState.value = AttachmentsState.None
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow()
}
.onSuccess {
attachmentState.value = AttachmentsState.None
}
.onFailure { cause ->
attachmentState.value = AttachmentsState.None
if (cause is CancellationException) {
throw cause
} else {
val snackbarMessage = SnackbarMessage(sendAttachmentError(cause))
snackbarDispatcher.post(snackbarMessage)
}
}
}

View file

@ -37,7 +37,7 @@ data class MessageComposerState(
@Immutable
sealed interface AttachmentsState {
object None : AttachmentsState
data object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
sealed interface Sending : AttachmentsState {
data class Processing(val attachments: ImmutableList<Attachment>) : Sending

View file

@ -55,7 +55,7 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
}
Box {
Box(modifier = modifier) {
AttachmentsBottomSheet(
state = state,
onSendLocationClicked = onSendLocationClicked,
@ -69,8 +69,7 @@ fun MessageComposerView(
onAddAttachment = ::onAddAttachment,
onFocusChanged = ::onFocusChanged,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text,
modifier = modifier
composerText = state.text
)
}
}

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.report
sealed interface ReportMessageEvents {
data class UpdateReason(val reason: String) : ReportMessageEvents
object ToggleBlockUser : ReportMessageEvents
object Report : ReportMessageEvents
object ClearError : ReportMessageEvents
data object ToggleBlockUser : ReportMessageEvents
data object Report : ReportMessageEvents
data object ClearError : ReportMessageEvents
}

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
sealed interface TimelineEvents {
object LoadMore : TimelineEvents
data object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
}

View file

@ -106,6 +106,7 @@ internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: TransactionId? = null,
isMine: Boolean = false,
senderDisplayName: String = "Sender",
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
@ -123,7 +124,7 @@ internal fun aTimelineItemEvent(
reactionsState = timelineItemReactions,
sentTime = "12:34",
isMine = isMine,
senderDisplayName = "Sender",
senderDisplayName = senderDisplayName,
groupPosition = groupPosition,
localSendState = sendState,
inReplyTo = inReplyTo,
@ -141,11 +142,13 @@ fun aTimelineItemReactions(
reactions = buildList {
repeat(count) { index ->
val key = emojis[index % emojis.size]
add(anAggregatedReaction(
key = key,
count = index + 1,
isHighlighted = isHighlighted
))
add(
anAggregatedReaction(
key = key,
count = index + 1,
isHighlighted = isHighlighted
)
)
}
}.toPersistentList()
)

View file

@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -152,7 +153,7 @@ private fun ContentToPreview(state: BubbleState) {
) {
MessageEventBubble(
state = state,
interactionSource = MutableInteractionSource(),
interactionSource = remember { MutableInteractionSource() },
) {
// Render the state as a text to better understand the previews
Box(

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@ -81,13 +82,13 @@ private fun ContentToPreview() {
Column {
MessageStateEventContainer(
isHighlighted = false,
interactionSource = MutableInteractionSource(),
interactionSource = remember { MutableInteractionSource() },
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
MessageStateEventContainer(
isHighlighted = true,
interactionSource = MutableInteractionSource(),
interactionSource = remember { MutableInteractionSource() },
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}

View file

@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Error
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -63,7 +64,7 @@ fun TimelineEventTimestampView(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(bounded = false),
interactionSource = MutableInteractionSource()
interactionSource = remember { MutableInteractionSource() }
)
} else {
Modifier

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -68,9 +69,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
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.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -340,6 +343,8 @@ private fun MessageSenderInformation(
Spacer(modifier = Modifier.width(4.dp))
Text(
text = sender,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
style = ElementTheme.typography.fontBodyMdMedium,
)
@ -355,11 +360,15 @@ private fun MessageEventBubbleContent(
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
modifier: Modifier = Modifier
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
) {
val isMediaItem = event.content is TimelineItemImageContent
|| event.content is TimelineItemVideoContent
|| event.content is TimelineItemLocationContent
val timestampPosition = when (event.content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
// Long clicks are not not automatically propagated from a `clickable`
@ -382,96 +391,97 @@ private fun MessageEventBubbleContent(
@Composable
fun ContentAndTimestampView(
overlayTimestamp: Boolean,
timestampPosition: TimestampPosition,
modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier,
timestampModifier: Modifier = Modifier,
) {
if (overlayTimestamp) {
Box(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
)
}
} else {
Box(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = timestampModifier
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
when (timestampPosition) {
TimestampPosition.Overlay ->
Box(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = timestampModifier
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd)
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
)
}
TimestampPosition.Aligned ->
Box(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = timestampModifier
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
TimestampPosition.Below ->
Column(modifier) {
ContentView(modifier = contentModifier)
TimelineEventTimestampView(
event = event,
onClick = onTimestampClicked,
onLongClick = ::onTimestampLongClick,
modifier = timestampModifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
/** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */
@Composable
fun SimpleMediaItemLayout(modifier: Modifier = Modifier) {
ContentAndTimestampView(overlayTimestamp = true, modifier = modifier)
}
/** Used for every other type of message, groups the different components in a Column with some space between them. */
/** Groups the different components in a Column with some space between them. */
@Composable
fun CommonLayout(
inReplyToDetails: InReplyTo.Ready?,
modifier: Modifier = Modifier
) {
var modifierWithPadding: Modifier = Modifier
var contentModifier: Modifier = Modifier
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
if (inReplyToDetails != null) {
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
val text = textForInReplyTo(inReplyToDetails)
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
)
}
val modifierWithPadding = if (isMediaItem) {
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
} else {
Modifier
}
val contentModifier = if (isMediaItem) {
Modifier.clip(RoundedCornerShape(12.dp))
} else {
if (inReplyToDetails != null) {
Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
} else {
Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
when {
inReplyToDetails != null -> {
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
val text = textForInReplyTo(inReplyToDetails)
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
modifier = Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
)
if (timestampPosition == TimestampPosition.Overlay) {
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
} else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
}
}
timestampPosition != TimestampPosition.Overlay -> {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
}
ContentAndTimestampView(
overlayTimestamp = isMediaItem,
timestampPosition = timestampPosition,
contentModifier = contentModifier,
modifier = modifierWithPadding,
)
}
}
if (isMediaItem && replyToDetails == null) {
SimpleMediaItemLayout()
} else {
CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier)
}
CommonLayout(inReplyToDetails = replyToDetails, modifier = bubbleModifier)
}
@Composable
@ -786,3 +796,45 @@ private fun ContentWithManyReactionsToPreview() {
}
}
}
// Note: no need for light/dark variant for this preview
@Preview
@Composable
internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
TimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
)
}
// Note: no need for light/dark variant for this preview, we only look at the timestamp position
@Preview
@Composable
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
TimelineItemEventRow(
event = aTimelineItemEvent(content = aTimelineItemPollContent()),
isHighlighted = false,
canReply = true,
onClick = {},
onLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
)
}

View file

@ -0,0 +1,41 @@
/*
* 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.timeline.components
enum class TimestampPosition {
/**
* Timestamp should overlay the timeline event content (eg. image).
*/
Overlay,
/**
* Timestamp should be aligned with the timeline event content if this is possible (eg. text).
*/
Aligned,
/**
* Timestamp should always be rendered below the timeline event content (eg. poll).
*/
Below;
companion object {
/**
* Default timestamp position for timeline event contents.
*/
val Default: TimestampPosition = Aligned
}
}

View file

@ -57,8 +57,9 @@ fun CustomReactionBottomSheet(
) {
EmojiPicker(
onEmojiSelected = ::onEmojiSelectedDismiss,
modifier = Modifier.fillMaxSize(),
emojiProvider = emojiProvider,
modifier = Modifier.fillMaxSize()
selectedEmojis = state.selectedEmoji,
)
}
}

View file

@ -16,9 +16,9 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface CustomReactionEvents {
data class ShowCustomReactionSheet(val eventId: EventId) : CustomReactionEvents
data class ShowCustomReactionSheet(val event: TimelineItem.Event?) : CustomReactionEvents
object DismissCustomReactionSheet : CustomReactionEvents
}

View file

@ -29,20 +29,23 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.launch
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject
class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionState> {
@Composable
override fun present(): CustomReactionState {
var selectedEventId by remember { mutableStateOf<EventId?>(null) }
var selectedEvent by remember { mutableStateOf<TimelineItem.Event?>(null) }
var emojiState: Async<EmojibaseStore> by remember {
mutableStateOf(Async.Uninitialized)
}
val localCoroutineScope = rememberCoroutineScope()
val context = LocalContext.current
fun handleShowCustomReactionSheet(eventId: EventId) {
selectedEventId = eventId
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
selectedEvent = event
emojiState = Async.Loading()
localCoroutineScope.launch {
emojiState = Async.Success(EmojibaseDatasource().load(context))
@ -50,17 +53,17 @@ class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionSt
}
fun handleDismissCustomReactionSheet() {
selectedEventId = null
selectedEvent = null
emojiState = Async.Uninitialized
}
fun handleEvents(event: CustomReactionEvents) {
when (event) {
is CustomReactionEvents.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.eventId)
is CustomReactionEvents.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event)
is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
}
}
return CustomReactionState(selectedEventId = selectedEventId, emojiProvider = emojiState, eventSink = ::handleEvents)
val selectedEmoji = selectedEvent?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
return CustomReactionState(selectedEventId = selectedEvent?.eventId, emojiProvider = emojiState, selectedEmoji = selectedEmoji, eventSink = ::handleEvents)
}
}

View file

@ -19,9 +19,11 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState(
val selectedEventId: EventId?,
val emojiProvider: Async<EmojibaseStore>,
val selectedEmoji: ImmutableSet<String>,
val eventSink: (CustomReactionEvents) -> Unit,
)

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -31,6 +32,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@ -41,6 +43,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
@ -52,18 +55,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EmojiPicker(
onEmojiSelected: (Emoji) -> Unit,
emojiProvider: EmojibaseStore,
modifier: Modifier = Modifier,
emojiProvider: EmojibaseStore,
selectedEmojis: ImmutableSet<String>,
) {
val coroutineScope = rememberCoroutineScope()
val categories = remember { emojiProvider.categories }
val pagerState = rememberPagerState()
val pagerState = rememberPagerState(pageCount = { emojiProvider.categories.size })
Column(modifier) {
TabRow(
selectedTabIndex = pagerState.currentPage,
@ -95,12 +101,20 @@ fun EmojiPicker(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 40.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(emojis, key = { it.unicode }) { item ->
val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
ElementTheme.colors.bgActionPrimaryRest
} else {
Color.Transparent
}
Box(
modifier = Modifier
.size(40.dp)
.background(backgroundColor, CircleShape)
.clickable(
enabled = true,
onClick = { onEmojiSelected(item) },
@ -136,7 +150,8 @@ internal fun EmojiPickerDarkPreview() {
private fun ContentToPreview() {
EmojiPicker(
onEmojiSelected = {},
modifier = Modifier.fillMaxWidth(),
emojiProvider = EmojibaseDatasource().load(LocalContext.current),
modifier = Modifier.fillMaxWidth()
selectedEmojis = persistentSetOf("😀", "😄", "😃")
)
}

View file

@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -90,5 +91,10 @@ fun TimelineItemEventContentView(
content = content,
modifier = modifier
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
onAnswerSelected = {},
modifier = modifier,
)
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider
import io.element.android.features.poll.api.PollContentView
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TimelineItemPollView(
content: TimelineItemPollContent,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
PollContentView(
question = content.question,
answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind,
isPollEnded = content.isEnded,
onAnswerSelected = onAnswerSelected,
modifier = modifier,
)
}
@DayNightPreviews
@Composable
internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) =
ElementPreview {
TimelineItemPollView(
content = content,
onAnswerSelected = {},
)
}

View file

@ -16,10 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify.PHONE_NUMBERS
import android.text.util.Linkify.WEB_URLS
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -28,20 +24,16 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.text.util.LinkifyCompat
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable
@ -69,15 +61,11 @@ fun TimelineItemTextView(
}
} else {
Box(modifier) {
val linkStyle = SpanStyle(
color = LinkColor,
)
val styledText = remember(content.body) {
content.body.linkify(linkStyle) + extraPadding.getStr(16.sp).toAnnotatedString()
val textWithPadding = remember(content.body) {
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
}
ClickableLinkText(
text = styledText,
linkAnnotationTag = "URL",
text = textWithPadding,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
@ -86,31 +74,6 @@ fun TimelineItemTextView(
}
}
private fun String.linkify(
linkStyle: SpanStyle,
) = buildAnnotatedString {
append(this@linkify)
val spannable = SpannableString(this@linkify)
LinkifyCompat.addLinks(spannable, WEB_URLS or PHONE_NUMBERS)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
for (span in spans) {
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
addStyle(
start = start,
end = end,
style = linkStyle,
)
addStringAnnotation(
tag = "URL",
annotation = span.url,
start = start,
end = end
)
}
}
@Preview
@Composable
internal fun TimelineItemTextViewLightPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) =
@ -125,7 +88,7 @@ internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextB
fun ContentToPreview(content: TimelineItemTextBasedContent) {
TimelineItemTextView(
content = content,
interactionSource = MutableInteractionSource(),
interactionSource = remember { MutableInteractionSource() },
extraPadding = ExtraPadding(nbChars = 8),
)
}

View file

@ -50,5 +50,7 @@ open class DocumentProvider : PreviewParameterProvider<Document> {
// "<pre>pre</pre>",
"<mx-reply><blockquote><a href=\\\"https://matrix.to/#/!roomId/\$eventId?via=matrix.org\\\">In reply to</a> " +
"<a href=\\\"https://matrix.to/#/@alice:matrix.org\\\">@alice:matrix.org</a><br>original message</blockquote></mx-reply>reply",
"<ol><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ol>",
"<ul><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ul>",
).map { Jsoup.parse(it) }
}

View file

@ -14,12 +14,17 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalLayoutApi::class)
package io.element.android.features.messages.impl.timeline.components.html
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -29,6 +34,8 @@ import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
@ -46,7 +53,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -95,18 +101,15 @@ private fun HtmlBody(
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
) = FlowRow(
mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.Start),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
) {
var sameRow = true
while (sameRow && nodes.hasNext()) {
when (val node = nodes.next()) {
is TextNode -> {
if (!node.isBlank) {
Text(
text = node.text(),
color = MaterialTheme.colorScheme.primary,
)
ClickableLinkText(text = node.text(), interactionSource = interactionSource)
}
}
is Element -> {
@ -399,22 +402,14 @@ private fun HtmlOrderedList(
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
) {
var number = 1
val delimiter = "."
HtmlListItems(
list = orderedList,
marker = { index -> "$index$delimiter" },
modifier = modifier,
onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString {
append("${number++}$delimiter ${it.text()}")
}
HtmlText(
text = text, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
)
}
@Composable
@ -428,42 +423,52 @@ private fun HtmlUnorderedList(
val marker = ""
HtmlListItems(
list = unorderedList,
marker = { marker },
modifier = modifier,
onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString {
append("$marker ${it.text()}")
}
HtmlText(
text = text, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
)
}
@Composable
private fun HtmlListItems(
list: Element,
marker: (Int) -> String,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
content: @Composable (node: TextNode) -> Unit = {}
) {
Column(modifier = modifier) {
for (node in list.children()) {
for (innerNode in node.childNodes()) {
when (innerNode) {
is TextNode -> {
if (!innerNode.isBlank) content(innerNode)
for ((index, node) in list.children().withIndex()) {
val areAllChildrenInline = node.childNodes().all { it is TextNode || it is Element && it.isInline() }
if (areAllChildrenInline) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
appendInlineChildrenElements(node.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(text = text, interactionSource = remember { MutableInteractionSource() })
} else {
for (innerNode in node.childNodes()) {
when (innerNode) {
is TextNode -> {
if (!innerNode.isBlank) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
}
HtmlText(
text = text, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
}
is Element -> HtmlBlock(
element = innerNode,
modifier = Modifier.padding(start = 4.dp),
onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
is Element -> HtmlBlock(
element = innerNode,
modifier = Modifier.padding(start = 4.dp),
onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
}
}
@ -576,8 +581,7 @@ private fun HtmlText(
) {
val inlineContentMap = persistentMapOf<String, InlineTextContent>()
ClickableLinkText(
text = text,
linkAnnotationTag = "URL",
annotatedString = text,
style = style,
modifier = modifier,
inlineContent = inlineContentMap,
@ -599,5 +603,5 @@ internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class)
@Composable
private fun ContentToPreview(document: Document) {
HtmlDocument(document, MutableInteractionSource())
HtmlDocument(document, remember { MutableInteractionSource() })
}

View file

@ -20,6 +20,6 @@ import io.element.android.features.messages.impl.timeline.model.AggregatedReacti
import io.element.android.libraries.matrix.api.core.EventId
sealed interface ReactionSummaryEvents {
object Clear : ReactionSummaryEvents
data object Clear : ReactionSummaryEvents
data class ShowReactionSummary(val eventId: EventId, val reactions: List<AggregatedReaction>, val selectedKey: String) : ReactionSummaryEvents
}

View file

@ -107,7 +107,7 @@ private fun SheetContent(
summary.reactions.indexOfFirst { it.key == selectedReactionKey }
}
}
val pagerState = rememberPagerState(initialPage = selectedReactionIndex)
val pagerState = rememberPagerState(initialPage = selectedReactionIndex, pageCount = { summary.reactions.size })
val reactionListState = rememberLazyListState()
LaunchedEffect(pagerState.currentPage) {
@ -123,7 +123,8 @@ private fun SheetContent(
.fillMaxWidth()
.fillMaxHeight()
) {
LazyRow(state = reactionListState,
LazyRow(
state = reactionListState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 12.dp)
) {
@ -140,7 +141,7 @@ private fun SheetContent(
)
}
}
HorizontalPager(state = pagerState, pageCount = summary.reactions.size) { page ->
HorizontalPager(state = pagerState) { page ->
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(summary.reactions[page].senders) { sender ->

View file

@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
object RetrySend : RetrySendMenuEvents
object RemoveFailed : RetrySendMenuEvents
object Dismiss: RetrySendMenuEvents
data object RetrySend : RetrySendMenuEvents
data object RemoveFailed : RetrySendMenuEvents
data object Dismiss: RetrySendMenuEvents
}

View file

@ -89,7 +89,7 @@ class TimelineItemsFactory @Inject constructor(
this.timelineItems.emit(result)
}
private fun buildAndCacheItem(
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
): TimelineItem? {

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@ -35,6 +36,7 @@ class TimelineItemContentFactory @Inject constructor(
private val messageFactory: TimelineItemContentMessageFactory,
private val redactedMessageFactory: TimelineItemContentRedactedFactory,
private val stickerFactory: TimelineItemContentStickerFactory,
private val pollFactory: TimelineItemContentPollFactory,
private val utdFactory: TimelineItemContentUTDFactory,
private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory,
private val profileChangeFactory: TimelineItemContentProfileChangeFactory,
@ -43,7 +45,7 @@ class TimelineItemContentFactory @Inject constructor(
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory
) {
fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return when (val itemContent = eventTimelineItem.content) {
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
@ -53,6 +55,7 @@ class TimelineItemContentFactory @Inject constructor(
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent
}

View file

@ -0,0 +1,77 @@
/*
* 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.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import javax.inject.Inject
class TimelineItemContentPollFactory @Inject constructor(
private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService,
) {
suspend fun create(content: PollContent): TimelineItemEventContent {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
// Todo Move this computation to the matrix rust sdk
val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val isEndedPoll = content.endTime != null
val winnerIds = if (!isEndedPoll) {
emptyList()
} else {
content.answers
.map { answer -> answer.id }
.groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count
.maxByOrNull { (votes, _) -> votes } // Keep max voted answers
?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted
?.value
.orEmpty()
}
val answerItems = content.answers.map { answer ->
val answerVoteCount = content.votes[answer.id]?.size ?: 0
val isSelected = answer.id in myVotes
val isWinner = answer.id in winnerIds
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = !isEndedPoll,
isWinner = isWinner,
isDisclosed = content.kind.isDisclosed || isEndedPoll,
votesCount = answerVoteCount,
percentage = percentage,
)
}
return TimelineItemPollContent(
question = content.question,
answerItems = answerItems,
votes = content.votes,
pollKind = content.kind,
isEnded = isEndedPoll,
)
}
}

View file

@ -38,7 +38,7 @@ class TimelineItemEventFactory @Inject constructor(
private val matrixClient: MatrixClient,
) {
fun create(
suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
@ -33,6 +34,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@ -55,6 +57,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
@ -74,6 +77,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
is MessageContent,
RedactedContent,
is StickerContent,
is PollContent,
is UnableToDecryptContent -> true
is FailedToParseStateContent,
is ProfileChangeContent,

View file

@ -40,22 +40,22 @@ sealed interface TimelineItemGroupPosition {
/**
* The event is part of a group of events from the same sender and is the first sent Event.
*/
object First : TimelineItemGroupPosition
data object First : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is neither the first nor the last sent Event.
*/
object Middle : TimelineItemGroupPosition
data object Middle : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is the last sent Event.
*/
object Last : TimelineItemGroupPosition
data object Last : TimelineItemGroupPosition
/**
* The event is not part of a group of events. Sender of previous event is different, and sender of next event is different.
*/
object None : TimelineItemGroupPosition
data object None : TimelineItemGroupPosition
/**
* Return true if the previous sender of the event is a different sender.

View file

@ -45,6 +45,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemFileContent,
is TimelineItemImageContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVideoContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2022 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.timeline.model.event
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollKind
data class TimelineItemPollContent(
val question: String,
val answerItems: List<PollAnswerItem>,
val votes: Map<String, List<UserId>>,
val pollKind: PollKind,
val isEnded: Boolean,
) : TimelineItemEventContent {
override val type: String = "TimelineItemPollContent"
}

View file

@ -0,0 +1,39 @@
/*
* 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.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.aPollAnswerItemList
import io.element.android.libraries.matrix.api.poll.PollKind
open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineItemPollContent> {
override val values: Sequence<TimelineItemPollContent>
get() = sequenceOf(
aTimelineItemPollContent(),
aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed),
)
}
fun aTimelineItemPollContent(): TimelineItemPollContent {
return TimelineItemPollContent(
pollKind = PollKind.Disclosed,
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
isEnded = false,
votes = emptyMap(),
)
}

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
@ -47,6 +48,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent, // Todo Polls: handle summary
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

View file

@ -5,19 +5,43 @@
<item quantity="few">"%1$d změny místnosti"</item>
<item quantity="other">"%1$d změn místnosti"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one">"%1$d další"</item>
<item quantity="few">"%1$d další"</item>
<item quantity="other">"%1$d dalších"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
<string name="screen_room_attachment_source_files">"Příloha"</string>
<string name="screen_room_attachment_source_gallery">"Knihovna fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_attachment_source_poll">"Hlasování"</string>
<string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
<string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string>
<string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string>
<string name="screen_room_message_copied">"Zpráva zkopírována"</string>
<string name="screen_room_no_permission_to_post">"Nemáte oprávnění zveřejňovat příspěvky v této místnosti"</string>
<string name="screen_room_notification_settings_allow_custom">"Povolit vlastní nastavení"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Zapnutím této funkce přepíšete výchozí nastavení"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Upozornit mě v tomto chatu na"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Můžete změnit ve vašem %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"globální nastavení"</string>
<string name="screen_room_notification_settings_default_setting_title">"Výchozí nastavení"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Odebrat vlastní nastavení"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string>
<string name="screen_room_reactions_show_more">"Zobrazit více"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odeslat znovu"</string>
<string name="screen_room_retry_send_menu_title">"Vaši zprávu se nepodařilo odeslat"</string>
<string name="screen_room_timeline_add_reaction">"Přidat emoji"</string>
<string name="screen_room_timeline_less_reactions">"Zobrazit méně"</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_retry_send_menu_remove_action">"Odstranit"</string>
</resources>

View file

@ -10,16 +10,32 @@
<string name="screen_room_attachment_source_files">"Anhang"</string>
<string name="screen_room_attachment_source_gallery">"Foto- &amp; Video-Bibliothek"</string>
<string name="screen_room_attachment_source_location">"Standort"</string>
<string name="screen_room_attachment_source_poll">"Umfrage"</string>
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist in diesem Raum derzeit nicht verfügbar"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string>
<string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string>
<string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string>
<string name="screen_room_message_copied">"Nachricht kopiert"</string>
<string name="screen_room_no_permission_to_post">"Du bist keine Berechtigung, um in diesem Raum zu posten"</string>
<string name="screen_room_notification_settings_allow_custom">"Benutzerdefinierte Einstellung zulassen"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Das Aktivieren dieser Option wird die Standardeinstellungen überschreiben."</string>
<string name="screen_room_notification_settings_custom_settings_title">"Benachrichtige mich in diesem Chat für"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Du kannst es in deinem %1$s ändern."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Globale Einstellungen"</string>
<string name="screen_room_notification_settings_default_setting_title">"Standardeinstellung"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Benutzerdefinierte Einstellung entfernen"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Wiederherstellung des Standardmodus fehlgeschlagen. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In diesem Raum, benachrichtige mich für"</string>
<string name="screen_room_reactions_show_less">"Weniger anzeigen"</string>
<string name="screen_room_reactions_show_more">"Mehr anzeigen"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Erneut senden"</string>
<string name="screen_room_retry_send_menu_title">"Ihre Nachricht konnte nicht gesendet werden"</string>
<string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string>
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_error_failed_processing_media">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut."</string>
<string name="screen_room_retry_send_menu_remove_action">"Entfernen"</string>
</resources>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d изменение в комнате"</item>
<item quantity="few">"%1$d изменения в комнате"</item>
<item quantity="many">"%1$d изменений в комнате"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one">"И ещё %1$d"</item>
<item quantity="few">"И ещё %1$d"</item>
<item quantity="many">"И ещё %1$d"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Камера"</string>
<string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string>
<string name="screen_room_attachment_source_camera_video">"Записать видео"</string>
<string name="screen_room_attachment_source_files">"Вложение"</string>
<string name="screen_room_attachment_source_gallery">"Фото и видео"</string>
<string name="screen_room_attachment_source_location">"Местоположение"</string>
<string name="screen_room_attachment_source_poll">"Опрос"</string>
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
<string name="screen_room_invite_again_alert_title">"Вы одни в этой комнате"</string>
<string name="screen_room_message_copied">"Сообщение скопировано"</string>
<string name="screen_room_no_permission_to_post">"У вас нет разрешения публиковать сообщения в этой комнате"</string>
<string name="screen_room_notification_settings_allow_custom">"Разрешить пользовательские настройки"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Включение этого параметра отменяет настройки по умолчанию"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Уведомить меня в этом чате"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Вы можете изменить его в своем %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Основные Настройки"</string>
<string name="screen_room_notification_settings_default_setting_title">"Настройка по умолчанию"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Удалить пользовательскую настройку"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Произошла ошибка при загрузке настроек уведомлений."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string>
<string name="screen_room_reactions_show_less">"Показать меньше"</string>
<string name="screen_room_reactions_show_more">"Показать больше"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Отправить снова"</string>
<string name="screen_room_retry_send_menu_title">"Не удалось отправить ваше сообщение"</string>
<string name="screen_room_timeline_add_reaction">"Добавить эмодзи"</string>
<string name="screen_room_timeline_less_reactions">"Показать меньше"</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_room_retry_send_menu_remove_action">"Удалить"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_room_attachment_source_files">"Príloha"</string>
<string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_attachment_source_poll">"Anketa"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string>
<string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string>
@ -28,11 +29,13 @@
<string name="screen_room_notification_settings_default_setting_footnote">"Môžete to zmeniť vo svojich %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"všeobecných nastaveniach"</string>
<string name="screen_room_notification_settings_default_setting_title">"Predvolené nastavenie"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Odstrániť vlastné nastavenie"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Pri načítavaní nastavení oznámení došlo k chybe."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V tejto miestnosti ma upozorniť na"</string>
<string name="screen_room_reactions_show_less">"Zobraziť menej"</string>
<string name="screen_room_reactions_show_more">"Zobraziť viac"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="other">"%1$d 個聊天室變更"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="other">"還有 %1$d 個"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"照相機"</string>
<string name="screen_room_attachment_source_camera_photo">"拍照"</string>
<string name="screen_room_attachment_source_camera_video">"錄影"</string>
<string name="screen_room_attachment_source_files">"附件"</string>
<string name="screen_room_attachment_source_location">"位置"</string>
<string name="screen_room_attachment_source_poll">"投票"</string>
<string name="screen_room_invite_again_alert_title">"此聊天室只有您一個人"</string>
<string name="screen_room_message_copied">"訊息已複製"</string>
<string name="screen_room_no_permission_to_post">"您沒有權限在此聊天室傳送訊息"</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"全域設定"</string>
<string name="screen_room_notification_settings_default_setting_title">"預設"</string>
<string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string>
<string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string>
<string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"只限提及與關鍵字"</string>
<string name="screen_room_retry_send_menu_send_again_action">"重傳"</string>
<string name="screen_room_retry_send_menu_title">"無法傳送您的訊息"</string>
<string name="screen_room_retry_send_menu_remove_action">"移除"</string>
</resources>

View file

@ -13,6 +13,7 @@
<string name="screen_room_attachment_source_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_attachment_source_poll">"Poll"</string>
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
@ -25,11 +26,13 @@
<string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string>
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Remove custom setting"</string>
<string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_room_reactions_show_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>

View file

@ -21,7 +21,6 @@ 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.analytics.test.FakeAnalyticsService
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.InviteDialogAction
@ -69,6 +68,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers

View file

@ -94,6 +94,21 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - dismissing the progress dialog stops media upload`() = runTest {
val presenter = anAttachmentsPreviewPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
initialState.eventSink(AttachmentsPreviewEvents.ClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
}
}
private fun anAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseStateFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentMessageFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentProfileChangeFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRedactedFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRoomMembershipFactory
@ -35,6 +36,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -42,6 +44,7 @@ import kotlinx.coroutines.test.TestScope
internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient()
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
@ -49,14 +52,15 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
),
matrixClient = FakeMatrixClient(),
matrixClient = matrixClient,
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -24,7 +24,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@ -55,6 +54,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -500,6 +500,23 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - CancelSendAttachment stops media upload`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
sendingState.eventSink(MessageComposerEvents.CancelSendAttachment)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None)
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
@ -534,5 +551,6 @@ fun anEditMode(
message: String = A_MESSAGE,
transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -20,6 +20,8 @@ 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.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -38,11 +40,27 @@ class CustomReactionPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(AN_EVENT_ID))
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(aTimelineItemEvent(eventId = AN_EVENT_ID)))
assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID)
initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
assertThat(awaitItem().selectedEventId).isNull()
}
}
@Test
fun `present - handle selected emojis`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
val reactions = aTimelineItemReactions(count = 1, isHighlighted = true)
val key = reactions.reactions.first().key
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)))
val stateWithSelectedEmojis = awaitItem()
assertThat(stateWithSelectedEmojis.selectedEventId).isEqualTo(AN_EVENT_ID)
assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key)
}
}
}