Merge branch 'develop' into feature/fga/waiting_ss_room
This commit is contained in:
commit
10c2859fac
249 changed files with 3147 additions and 677 deletions
|
|
@ -29,7 +29,9 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||
|
|
@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
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.TimelineItemVideoContent
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
|
|
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
) : BackstackNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
|
|
@ -83,7 +87,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
data class AttachmentPreview(val attachment: Attachment) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||
data class LocationViewer(val location: Location, val description: String?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ForwardEvent(val eventId: EventId) : NavTarget
|
||||
|
|
@ -117,7 +124,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
callback?.onUserDataClicked(userId)
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
||||
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.LocationViewer -> {
|
||||
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
|
||||
showLocationEntryPoint.createNode(this, buildContext, inputs)
|
||||
}
|
||||
is NavTarget.EventDebugInfo -> {
|
||||
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||
|
|
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
)
|
||||
backstack.push(navTarget)
|
||||
}
|
||||
is TimelineItemLocationContent -> {
|
||||
val navTarget = NavTarget.LocationViewer(
|
||||
location = event.content.location,
|
||||
description = event.content.description,
|
||||
)
|
||||
backstack.push(navTarget)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
interface MessagesNavigator {
|
||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClicked(eventId: EventId)
|
||||
fun onReportContentClicked(eventId: EventId, senderId: UserId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
fun onEventClicked(event: TimelineItem.Event)
|
||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||
fun onUserDataClicked(userId: UserId)
|
||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
||||
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClicked(eventId: EventId)
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun onSendLocationClicked()
|
||||
|
|
@ -83,7 +83,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
private fun onUserDataClicked(userId: UserId) {
|
||||
callback?.onUserDataClicked(userId)
|
||||
}
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
|
||||
callback?.onReportMessage(eventId, senderId)
|
||||
}
|
||||
|
||||
|
||||
private fun onSendLocationClicked() {
|
||||
callback?.onSendLocationClicked()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,15 +226,19 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
room.redactEvent(event.eventId)
|
||||
if (event.failedToSend) {
|
||||
// If the message hasn't been sent yet, just cancel it
|
||||
event.transactionId?.let { room.cancelSend(it) }
|
||||
} else if (event.eventId != null) {
|
||||
room.redactEvent(event.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||
if (targetEvent.eventId == null) return
|
||||
val composerMode = MessageComposerMode.Edit(
|
||||
targetEvent.eventId,
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
|
||||
targetEvent.transactionId,
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvents.SetMode(composerMode)
|
||||
|
|
@ -287,7 +291,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
|||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
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.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import timber.log.Timber
|
||||
|
|
@ -126,6 +126,9 @@ fun MessagesView(
|
|||
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
|
||||
}
|
||||
|
||||
fun onMoreReactionsClicked(event: TimelineItem.Event): Unit =
|
||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
|
|
@ -150,12 +153,16 @@ fun MessagesView(
|
|||
onMessageLongClicked = ::onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onTimestampClicked = { event ->
|
||||
if (event.sendState is EventSendState.SendingFailed) {
|
||||
if (event.localSendState is LocalEventSendState.SendingFailed) {
|
||||
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
|
||||
}
|
||||
},
|
||||
onReactionClicked = ::onEmojiReactionClicked,
|
||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
|
|
@ -237,10 +244,12 @@ fun MessagesViewContent(
|
|||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
|
@ -258,6 +267,8 @@ fun MessagesViewContent(
|
|||
onUserDataClicked = onUserDataClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ package io.element.android.features.messages.impl.actionlist
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -28,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -45,6 +48,10 @@ class ActionListPresenter @Inject constructor(
|
|||
mutableStateOf(ActionListState.Target.None)
|
||||
}
|
||||
|
||||
val displayEmojiReactions by remember {
|
||||
derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.isRemote == true }
|
||||
}
|
||||
|
||||
fun handleEvents(event: ActionListEvents) {
|
||||
when (event) {
|
||||
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
||||
|
|
@ -54,6 +61,7 @@ class ActionListPresenter @Inject constructor(
|
|||
|
||||
return ActionListState(
|
||||
target = target.value,
|
||||
displayEmojiReactions = displayEmojiReactions,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
@ -62,21 +70,28 @@ class ActionListPresenter @Inject constructor(
|
|||
target.value = ActionListState.Target.Loading(timelineItem)
|
||||
val actions =
|
||||
when (timelineItem.content) {
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemRedactedContent -> {
|
||||
if (buildMeta.isDebuggable) {
|
||||
listOf(TimelineItemAction.Developer)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
is TimelineItemStateContent -> {
|
||||
buildList {
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
add(TimelineItemAction.Copy)
|
||||
if (buildMeta.isDebuggable) {
|
||||
add(TimelineItemAction.Developer)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> buildList<TimelineItemAction> {
|
||||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
if (timelineItem.isMine) {
|
||||
if (timelineItem.isRemote) {
|
||||
// Can only reply or forward messages already uploaded to the server
|
||||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (timelineItem.isMine && timelineItem.isTextMessage) {
|
||||
add(TimelineItemAction.Edit)
|
||||
}
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
|
|
@ -93,6 +108,10 @@ class ActionListPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||
if (actions.isNotEmpty()) {
|
||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||
} else {
|
||||
target.value = ActionListState.Target.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
@Immutable
|
||||
data class ActionListState(
|
||||
val target: Target,
|
||||
val displayEmojiReactions: Boolean,
|
||||
val eventSink: (ActionListEvents) -> Unit,
|
||||
) {
|
||||
sealed interface Target {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.actionlist
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
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.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
||||
|
|
@ -28,44 +29,67 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
|
||||
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||
override val values: Sequence<ActionListState>
|
||||
get() = sequenceOf(
|
||||
anActionListState(),
|
||||
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemImageContent()),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemVideoContent()),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemFileContent()),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemLocationContent()),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
)
|
||||
get() {
|
||||
val reactionsState = aTimelineItemReactions(1, isHighlighted = true)
|
||||
return sequenceOf(
|
||||
anActionListState(),
|
||||
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent().copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
),
|
||||
displayEmojiReactions = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun anActionListState() = ActionListState(
|
||||
target = ActionListState.Target.None,
|
||||
displayEmojiReactions = true,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.actionlist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -46,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -78,7 +80,9 @@ import io.element.android.libraries.designsystem.theme.components.hide
|
|||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -175,13 +179,16 @@ private fun SheetContent(
|
|||
Divider()
|
||||
}
|
||||
}
|
||||
item {
|
||||
EmojiReactionsRow(
|
||||
onEmojiReactionClicked = onEmojiReactionClicked,
|
||||
onCustomReactionClicked = onCustomReactionClicked,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Divider()
|
||||
if (state.displayEmojiReactions) {
|
||||
item {
|
||||
EmojiReactionsRow(
|
||||
highlightedEmojis = target.event.reactionsState.highlightedKeys,
|
||||
onEmojiReactionClicked = onEmojiReactionClicked,
|
||||
onCustomReactionClicked = onCustomReactionClicked,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = actions,
|
||||
|
|
@ -320,6 +327,7 @@ private val emojiRippleRadius = 24.dp
|
|||
|
||||
@Composable
|
||||
internal fun EmojiReactionsRow(
|
||||
highlightedEmojis: ImmutableList<String>,
|
||||
onEmojiReactionClicked: (String) -> Unit,
|
||||
onCustomReactionClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -333,7 +341,8 @@ internal fun EmojiReactionsRow(
|
|||
"👍", "👎", "🔥", "❤️", "👏"
|
||||
)
|
||||
for (emoji in defaultEmojis) {
|
||||
EmojiButton(emoji, onEmojiReactionClicked)
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
EmojiButton(emoji, isHighlighted, onEmojiReactionClicked)
|
||||
}
|
||||
|
||||
Icon(
|
||||
|
|
@ -356,19 +365,34 @@ internal fun EmojiReactionsRow(
|
|||
@Composable
|
||||
private fun EmojiButton(
|
||||
emoji: String,
|
||||
isHighlighted: Boolean,
|
||||
onClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 28.dp.toSp(),
|
||||
modifier = modifier.clickable(
|
||||
enabled = true,
|
||||
onClick = { onClicked(emoji) },
|
||||
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
val backgroundColor = if (isHighlighted) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.background(backgroundColor, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
emoji,
|
||||
fontSize = 28.dp.toSp(),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onClicked(emoji) },
|
||||
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
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.Text
|
||||
|
|
@ -129,3 +131,12 @@ internal fun AttachmentSourcePickerMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
||||
AttachmentSourcePickerMenu(
|
||||
eventSink = {},
|
||||
onSendLocationClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,10 +196,11 @@ class MessageComposerPresenter @Inject constructor(
|
|||
composerMode.setToNormal()
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||
is MessageComposerMode.Edit -> room.editMessage(
|
||||
capturedMode.eventId,
|
||||
text
|
||||
)
|
||||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, text)
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -44,7 +46,7 @@ private const val backPaginationPageSize = 50
|
|||
|
||||
class TimelinePresenter @Inject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
room: MatrixRoom,
|
||||
private val room: MatrixRoom,
|
||||
) : Presenter<TimelineState> {
|
||||
|
||||
private val timeline = room.timeline
|
||||
|
|
@ -62,6 +64,9 @@ class TimelinePresenter @Inject constructor(
|
|||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState)
|
||||
|
|
@ -92,6 +97,7 @@ class TimelinePresenter @Inject constructor(
|
|||
|
||||
return TimelineState(
|
||||
highlightedEventId = highlightedEventId.value,
|
||||
canReply = userHasPermissionToSendMessage,
|
||||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
eventSink = ::handleEvents
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
data class TimelineState(
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val highlightedEventId: EventId?,
|
||||
val canReply: Boolean,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
|||
timelineItems = timelineItems,
|
||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||
highlightedEventId = null,
|
||||
canReply = true,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
|||
isMine = false,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
sendState = EventSendState.SendingFailed("Message failed to send"),
|
||||
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = false,
|
||||
|
|
@ -81,7 +82,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
|||
isMine = true,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
sendState = EventSendState.SendingFailed("Message failed to send"),
|
||||
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = true,
|
||||
|
|
@ -111,7 +112,7 @@ internal fun aTimelineItemEvent(
|
|||
isMine: Boolean = false,
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
sendState: EventSendState = EventSendState.Sent(eventId),
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
|
||||
inReplyTo: InReplyTo? = null,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||
|
|
@ -128,7 +129,7 @@ internal fun aTimelineItemEvent(
|
|||
isMine = isMine,
|
||||
senderDisplayName = "Sender",
|
||||
groupPosition = groupPosition,
|
||||
sendState = sendState,
|
||||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
)
|
||||
|
|
@ -138,10 +139,12 @@ fun aTimelineItemReactions(
|
|||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
): TimelineItemReactions {
|
||||
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||
return TimelineItemReactions(
|
||||
reactions = buildList {
|
||||
repeat(count) {
|
||||
add(AggregatedReaction(key = "👍", count = 1 + it, isHighlighted = isHighlighted))
|
||||
repeat(count) { index ->
|
||||
val key = emojis[index % emojis.size]
|
||||
add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted))
|
||||
}
|
||||
}.toPersistentList()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ fun TimelineView(
|
|||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onReachedLoadMore() {
|
||||
|
|
@ -120,12 +122,15 @@ fun TimelineView(
|
|||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
highlightedItem = state.highlightedEventId?.value,
|
||||
canReply = state.canReply,
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onReactionClick = onReactionClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
if (index == state.timelineItems.lastIndex) {
|
||||
onReachedLoadMore()
|
||||
|
|
@ -145,12 +150,15 @@ fun TimelineView(
|
|||
fun TimelineItemRow(
|
||||
timelineItem: TimelineItem,
|
||||
highlightedItem: String?,
|
||||
canReply: Boolean,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (timelineItem) {
|
||||
|
|
@ -161,32 +169,27 @@ fun TimelineItemRow(
|
|||
)
|
||||
}
|
||||
is TimelineItem.Event -> {
|
||||
fun onClick() {
|
||||
onClick(timelineItem)
|
||||
}
|
||||
|
||||
fun onLongClick() {
|
||||
onLongClick(timelineItem)
|
||||
}
|
||||
|
||||
if (timelineItem.content is TimelineItemStateContent) {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = ::onClick,
|
||||
onLongClick = ::onLongClick,
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRow(
|
||||
event = timelineItem,
|
||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||
onClick = ::onClick,
|
||||
onLongClick = ::onLongClick,
|
||||
canReply = canReply,
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onUserDataClick = onUserDataClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -215,12 +218,15 @@ fun TimelineItemRow(
|
|||
TimelineItemRow(
|
||||
timelineItem = subGroupEvent,
|
||||
highlightedItem = highlightedItem,
|
||||
canReply = false,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -322,5 +328,7 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
|
|||
onUserDataClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onMoreReactionsClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
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.Surface
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun MessagesMoreReactionsButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val buttonColor = ElementTheme.colors.bgSubtleSecondary
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.background(Color.Transparent)
|
||||
// Outer border, same colour as background
|
||||
.border(
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(corner = CornerSize(14.dp))
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||
// Clip click indicator inside the outer border
|
||||
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.clickable(onClick = onClick)
|
||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||
color = buttonColor
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AddReaction,
|
||||
contentDescription = "Add emoji",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
// Same size as the line height of reaction emoji text
|
||||
.size(with(LocalDensity.current) { 20.sp.toDp() })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesMoreReactionsButtonLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesMoreReactionsButtonDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
MessagesMoreReactionsButton(onClick = {})
|
||||
}
|
||||
|
|
@ -17,9 +17,9 @@
|
|||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
|
|
@ -45,35 +46,47 @@ import io.element.android.libraries.theme.ElementTheme
|
|||
|
||||
@Composable
|
||||
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
// First Surface is to render a border with the same background color as the background
|
||||
val buttonColor = if (reaction.isHighlighted) {
|
||||
ElementTheme.colors.bgSubtlePrimary
|
||||
} else {
|
||||
ElementTheme.colors.bgSubtleSecondary
|
||||
}
|
||||
val borderColor = if (reaction.isHighlighted) {
|
||||
ElementTheme.colors.borderInteractivePrimary
|
||||
} else {
|
||||
buttonColor
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier.clickable(onClick = onClick::invoke),
|
||||
// TODO Should use compound.bgSubtlePrimary
|
||||
color = ElementTheme.legacyColors.gray300,
|
||||
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(corner = CornerSize(14.dp)),
|
||||
modifier = modifier
|
||||
.background(Color.Transparent)
|
||||
// Outer border, same colour as background
|
||||
.border(
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||
shape = RoundedCornerShape(corner = CornerSize(14.dp))
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||
// Clip click indicator inside the outer border
|
||||
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.clickable(onClick = onClick)
|
||||
// Inner border, to highlight when selected
|
||||
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||
color = buttonColor
|
||||
) {
|
||||
Box(modifier = Modifier.padding(2.dp)) {
|
||||
val reactionModifier = if (reaction.isHighlighted) {
|
||||
Modifier
|
||||
// TODO Check the color, should use compound.borderInteractivePrimary
|
||||
.border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Row(
|
||||
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = reaction.key, fontSize = 15.sp)
|
||||
if (reaction.count > 1) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = reaction.key, fontSize = 15.sp, lineHeight = 20.sp
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.VectorIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
||||
/**
|
||||
* A swipe indicator that appears when swiping to reply to a message.
|
||||
*
|
||||
* @param swipeProgress the progress of the swipe, between 0 and X. When swipeProgress >= 1 the swipe will be detected.
|
||||
* @param modifier the modifier to apply to this Composable root.
|
||||
*/
|
||||
@Composable
|
||||
fun RowScope.ReplySwipeIndicator(
|
||||
swipeProgress: () -> Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Icon(
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.graphicsLayer {
|
||||
translationX = 36.dp.toPx() * swipeProgress().coerceAtMost(1f)
|
||||
alpha = swipeProgress()
|
||||
},
|
||||
contentDescription = null,
|
||||
resourceId = VectorIcons.Reply,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ReplySwipeIndicatorLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ReplySwipeIndicatorDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
for (i in 0..8) {
|
||||
Row { ReplySwipeIndicator(swipeProgress = { i / 8f }) }
|
||||
}
|
||||
Row { ReplySwipeIndicator(swipeProgress = { 1.5f }) }
|
||||
Row { ReplySwipeIndicator(swipeProgress = { 2f }) }
|
||||
Row { ReplySwipeIndicator(swipeProgress = { 3f }) }
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|||
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.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
|
@ -55,7 +55,7 @@ fun TimelineEventTimestampView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formattedTime = event.sentTime
|
||||
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
|
||||
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
|
||||
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
|
||||
val clickModifier = if (hasMessageSendingFailed) {
|
||||
|
|
|
|||
|
|
@ -20,19 +20,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
|
||||
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
|
||||
override val values: Sequence<TimelineItem.Event>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemEvent(),
|
||||
// Sending failed
|
||||
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")),
|
||||
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")),
|
||||
// Edited
|
||||
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
|
||||
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
|
||||
aTimelineItemEvent().copy(
|
||||
sendState = EventSendState.SendingFailed("AN_ERROR"),
|
||||
localSendState = LocalEventSendState.SendingFailed("AN_ERROR"),
|
||||
content = aTimelineItemTextContent().copy(isEdited = true),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
|
|
@ -33,7 +35,13 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DismissDirection
|
||||
import androidx.compose.material3.DismissState
|
||||
import androidx.compose.material3.DismissValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismiss
|
||||
import androidx.compose.material3.rememberDismissState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -46,8 +54,13 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.constraintlayout.compose.ConstrainScope
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
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.event.TimelineItemEventContentView
|
||||
|
|
@ -86,12 +99,15 @@ import org.jsoup.Jsoup
|
|||
fun TimelineItemEventRow(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
canReply: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
|
@ -100,77 +116,55 @@ fun TimelineItemEventRow(
|
|||
onUserDataClick(event.senderId)
|
||||
}
|
||||
|
||||
fun onReactionClicked(emoji: String) =
|
||||
onReactionClick(emoji, event)
|
||||
|
||||
fun inReplyToClicked() {
|
||||
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
||||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
// To avoid using negative offset, we display in this Box a column with:
|
||||
// - Spacer to give room to the Sender information if they must be displayed;
|
||||
// - The message bubble;
|
||||
// - Spacer for the reactions if there are some.
|
||||
// Then the Sender information and the reactions are displayed on top of it.
|
||||
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column {
|
||||
if (event.showSenderInformation) {
|
||||
Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
|
||||
if (canReply) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
onSwipeToReply()
|
||||
}
|
||||
// Do not dismiss the message, return false!
|
||||
false
|
||||
}
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
MessageEventBubble(
|
||||
state = bubbleState,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {
|
||||
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
||||
},
|
||||
directions = setOf(DismissDirection.StartToEnd),
|
||||
dismissContent = {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
}
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
// Align to the top of the box
|
||||
if (event.showSenderInformation) {
|
||||
MessageSenderInformation(
|
||||
event.safeSenderName,
|
||||
event.senderAvatar,
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.clickable(onClick = ::onUserDataClicked)
|
||||
)
|
||||
}
|
||||
// Align to the bottom of the box
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = event.reactionsState,
|
||||
onReactionClicked = ::onReactionClicked,
|
||||
modifier = Modifier
|
||||
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
|
||||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
|
||||
if (event.groupPosition.isNew()) {
|
||||
|
|
@ -180,13 +174,113 @@ fun TimelineItemEventRow(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemEventRowContent(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClicked: () -> Unit,
|
||||
onUserDataClicked: () -> Unit,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
|
||||
end.linkTo(parent.end)
|
||||
} else {
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
|
||||
ConstraintLayout(
|
||||
modifier = modifier
|
||||
.wrapContentHeight()
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
val (sender, message, reactions) = createRefs()
|
||||
|
||||
// Sender
|
||||
val avatarStrokeSize = 3.dp
|
||||
if (event.showSenderInformation) {
|
||||
MessageSenderInformation(
|
||||
event.safeSenderName,
|
||||
event.senderAvatar,
|
||||
avatarStrokeSize,
|
||||
Modifier
|
||||
.constrainAs(sender) {
|
||||
top.linkTo(parent.top)
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.zIndex(1f)
|
||||
.clickable(onClick = onUserDataClicked)
|
||||
)
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
MessageEventBubble(
|
||||
modifier = Modifier
|
||||
.constrainAs(message) {
|
||||
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp)
|
||||
this.linkStartOrEnd(event)
|
||||
},
|
||||
state = bubbleState,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClicked,
|
||||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = event.reactionsState,
|
||||
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||
modifier = Modifier
|
||||
.constrainAs(reactions) {
|
||||
top.linkTo(message.bottom, margin = (-4).dp)
|
||||
this.linkStartOrEnd(event)
|
||||
}
|
||||
.zIndex(1f)
|
||||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DismissState.toSwipeProgress(): Float {
|
||||
return when (targetValue) {
|
||||
DismissValue.Default -> 0f
|
||||
DismissValue.DismissedToEnd -> progress * 3
|
||||
DismissValue.DismissedToStart -> progress * 3
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
senderAvatar: AvatarData,
|
||||
avatarStrokeSize: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val avatarStrokeSize = 3.dp
|
||||
val avatarStrokeColor = MaterialTheme.colorScheme.background
|
||||
val avatarSize = senderAvatar.size.dp
|
||||
Box(
|
||||
|
|
@ -449,30 +543,36 @@ private fun ContentToPreview() {
|
|||
content = aTimelineItemTextContent().copy(
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
)
|
||||
),
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = it,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
)
|
||||
),
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -492,7 +592,7 @@ internal fun TimelineItemEventRowWithReplyDarkPreview() =
|
|||
private fun ContentToPreviewWithReply() {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach {
|
||||
val replyContent = if(it) {
|
||||
val replyContent = if (it) {
|
||||
// Short
|
||||
"Message which are being replied."
|
||||
} else {
|
||||
|
|
@ -509,12 +609,15 @@ private fun ContentToPreviewWithReply() {
|
|||
inReplyTo = aInReplyToReady(replyContent)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
|
|
@ -525,12 +628,15 @@ private fun ContentToPreviewWithReply() {
|
|||
inReplyTo = aInReplyToReady(replyContent)
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -578,14 +684,56 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
|||
senderDisplayName = if (useDocument) "Document case" else "Text case",
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
||||
ElementPreviewLight { ContentWithManyReactionsToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithManyReactionsDarkPreview() =
|
||||
ElementPreviewDark { ContentWithManyReactionsToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentWithManyReactionsToPreview() {
|
||||
Column {
|
||||
listOf(false, true).forEach { isMine ->
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemTextContent().copy(
|
||||
body = "A couple of multi-line messages with many reactions attached." +
|
||||
" One sent by me and another from someone else."
|
||||
),
|
||||
timelineItemReactions = aTimelineItemReactions(count = 20),
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
||||
|
|
@ -29,13 +30,16 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|||
@Composable
|
||||
fun TimelineItemReactionsView(
|
||||
reactionsState: TimelineItemReactions,
|
||||
mainAxisAlignment: FlowMainAxisAlignment,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onReactionClicked: (emoji: String) -> Unit
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
mainAxisSpacing = 2.dp,
|
||||
crossAxisSpacing = 8.dp,
|
||||
mainAxisSpacing = 4.dp,
|
||||
crossAxisSpacing = 4.dp,
|
||||
mainAxisAlignment = mainAxisAlignment,
|
||||
) {
|
||||
reactionsState.reactions.forEach { reaction ->
|
||||
MessagesReactionButton(
|
||||
|
|
@ -43,6 +47,9 @@ fun TimelineItemReactionsView(
|
|||
onClick = { onReactionClicked(reaction.key) }
|
||||
)
|
||||
}
|
||||
MessagesMoreReactionsButton(
|
||||
onClick = onMoreReactionsClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +67,8 @@ internal fun TimelineItemReactionsViewDarkPreview() =
|
|||
private fun ContentToPreview() {
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = aTimelineItemReactions(),
|
||||
onReactionClicked = { }
|
||||
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||
onReactionClicked = {},
|
||||
onMoreReactionsClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ val noExtraPadding = ExtraPadding(0)
|
|||
@Composable
|
||||
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
||||
val formattedTime = sentTime
|
||||
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed
|
||||
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
|
||||
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||
|
||||
var strLen = 6
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -28,21 +30,31 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun TimelineItemLocationView(
|
||||
content: TimelineItemLocationContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
StaticMapView(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.body
|
||||
)
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
content.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
StaticMapView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class EventDebugInfoNode @AssistedInject constructor(
|
|||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
data class Inputs(
|
||||
val eventId: EventId,
|
||||
val eventId: EventId?,
|
||||
val timelineItemDebugInfo: TimelineItemDebugInfo,
|
||||
) : NodeInputs
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun EventDebugInfoView(
|
||||
eventId: EventId,
|
||||
eventId: EventId?,
|
||||
model: String,
|
||||
originalJson: String?,
|
||||
latestEditedJson: String?,
|
||||
|
|
@ -99,7 +99,7 @@ fun EventDebugInfoView(
|
|||
item {
|
||||
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Event ID:")
|
||||
CopyableText(text = eventId.value)
|
||||
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
item {
|
||||
|
|
@ -142,7 +142,7 @@ private fun CollapsibleSection(
|
|||
)
|
||||
}
|
||||
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
|
||||
CopyableText(text = text)
|
||||
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.factories.event
|
||||
|
||||
import io.element.android.features.location.api.parseGeoUri
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
|
|
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
)
|
||||
}
|
||||
is LocationMessageType -> {
|
||||
val location = parseGeoUri(messageType.geoUri)
|
||||
val location = Location.fromGeoUri(messageType.geoUri)
|
||||
if (location == null) {
|
||||
TimelineItemTextContent(
|
||||
body = messageType.body,
|
||||
|
|
@ -79,6 +79,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
TimelineItemLocationContent(
|
||||
body = messageType.body,
|
||||
location = location,
|
||||
description = messageType.description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ 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.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.text.DateFormat
|
||||
|
|
@ -83,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
sentTime = sentTime,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
|
||||
localSendState = currentTimelineItem.event.localSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||
debugInfo = currentTimelineItem.event.debugInfo,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ package io.element.android.features.messages.impl.timeline.model
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ sealed interface TimelineItem {
|
|||
val isMine: Boolean = false,
|
||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
val reactionsState: TimelineItemReactions,
|
||||
val sendState: EventSendState,
|
||||
val localSendState: LocalEventSendState?,
|
||||
val inReplyTo: InReplyTo?,
|
||||
val debugInfo: TimelineItemDebugInfo,
|
||||
) : TimelineItem {
|
||||
|
|
@ -69,6 +70,12 @@ sealed interface TimelineItem {
|
|||
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||
|
||||
val safeSenderName: String = senderDisplayName ?: senderId.value
|
||||
|
||||
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
|
||||
|
||||
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
|
||||
|
||||
val isRemote = eventId != null
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@
|
|||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
data class TimelineItemReactions(
|
||||
val reactions: ImmutableList<AggregatedReaction>
|
||||
)
|
||||
) {
|
||||
val highlightedKeys: ImmutableList<String>
|
||||
get() = reactions
|
||||
.filter { it.isHighlighted }
|
||||
.map { it.key }
|
||||
.toPersistentList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
aTimelineItemFileContent("A file.pdf"),
|
||||
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent("Location description"),
|
||||
aTimelineItemNoticeContent(),
|
||||
aTimelineItemRedactedContent(),
|
||||
aTimelineItemTextContent(),
|
||||
|
|
|
|||
|
|
@ -23,14 +23,16 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
override val values: Sequence<TimelineItemLocationContent>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent("This is a description!"),
|
||||
)
|
||||
}
|
||||
|
||||
fun aTimelineItemLocationContent() = TimelineItemLocationContent(
|
||||
fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent(
|
||||
body = "User location geo:52.2445,0.7186;u=5000",
|
||||
location = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
)
|
||||
),
|
||||
description = description,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@
|
|||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
||||
<string name="screen_room_attachment_source_location">"Location"</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>
|
||||
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
||||
<string name="screen_room_message_copied">"Message copied"</string>
|
||||
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</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>
|
||||
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class FakeMessagesNavigator : MessagesNavigator {
|
|||
var onReportContentClickedCount = 0
|
||||
private set
|
||||
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
onShowEventDebugInfoClickedCount++
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
|||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -62,7 +65,6 @@ class ActionListPresenterTest {
|
|||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
)
|
||||
)
|
||||
|
|
@ -88,7 +90,6 @@ class ActionListPresenterTest {
|
|||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
)
|
||||
)
|
||||
|
|
@ -184,7 +185,6 @@ class ActionListPresenterTest {
|
|||
persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Developer,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -195,6 +195,63 @@ class ActionListPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for a state item in debug build`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val stateEvent = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemStateEventContent(),
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
|
||||
// val loadingState = awaitItem()
|
||||
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
stateEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
)
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for a state item in non-debuggable build`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val stateEvent = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemStateEventContent(),
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
|
||||
// val loadingState = awaitItem()
|
||||
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
stateEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
)
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute message in non-debuggable build`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
|
|
@ -226,6 +283,62 @@ class ActionListPresenterTest {
|
|||
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute message with no actions`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||
)
|
||||
val redactedEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemRedactedContent,
|
||||
)
|
||||
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
|
||||
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent))
|
||||
awaitItem().run {
|
||||
assertThat(target).isEqualTo(ActionListState.Target.None)
|
||||
assertThat(displayEmojiReactions).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute not sent message`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
eventId = null, // No event id, so it's not sent yet
|
||||
isMine = true,
|
||||
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||
)
|
||||
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(successState.displayEmojiReactions).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,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.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
|
|
@ -38,6 +38,7 @@ internal fun aMessageEvent(
|
|||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||
inReplyTo: InReplyTo? = null,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
) = TimelineItem.Event(
|
||||
id = eventId?.value.orEmpty(),
|
||||
eventId = eventId,
|
||||
|
|
@ -48,7 +49,7 @@ internal fun aMessageEvent(
|
|||
sentTime = "",
|
||||
isMine = isMine,
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
||||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
|||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_REPLY
|
||||
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
|
|
@ -193,7 +195,7 @@ class MessageComposerPresenterTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `present - edit message`() = runTest {
|
||||
fun `present - edit sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
|
|
@ -219,7 +221,38 @@ class MessageComposerPresenterTest {
|
|||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageParameter).isEqualTo(ANOTHER_MESSAGE)
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - edit not sent message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -474,6 +507,10 @@ class MessageComposerPresenterTest {
|
|||
)
|
||||
}
|
||||
|
||||
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
||||
fun anEditMode(
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
message: String = A_MESSAGE,
|
||||
transactionId: String? = 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)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
|
|
@ -42,7 +42,7 @@ class TimelineItemGrouperTest {
|
|||
senderDisplayName = "",
|
||||
content = TimelineItemStateEventContent(body = "a state event"),
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
||||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
inReplyTo = null,
|
||||
debugInfo = aTimelineItemDebugInfo(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue