Merge branch 'develop' into feature/valere/call/decline_timeline_rendering

This commit is contained in:
Valere 2026-05-11 11:21:02 +02:00
commit a478d87fc3
995 changed files with 7864 additions and 3674 deletions

View file

@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}

View file

@ -143,6 +143,7 @@ class MessagesFlowNode(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
) : NavTarget
@Parcelize
@ -227,10 +228,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
@ -276,6 +278,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callData = CallData(
sessionId = sessionId,
@ -320,7 +326,11 @@ class MessagesFlowNode(
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
@ -414,10 +424,11 @@ class MessagesFlowNode(
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun handleEventClick(event: TimelineItem.Event) {
override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) {
processEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
canUseOverlay = canUseOverlay,
)
}
@ -456,10 +467,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
@ -505,6 +517,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callData = CallData(
sessionId = sessionId,
@ -547,6 +563,7 @@ class MessagesFlowNode(
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@ -556,6 +573,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemVideoContent -> {
@ -565,6 +583,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemFileContent -> {
@ -574,6 +593,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemAudioContent -> {
@ -583,6 +603,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemLocationContent -> {
@ -603,7 +624,11 @@ class MessagesFlowNode(
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
overlay.show(navTarget)
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
true
}
is NavTarget.LocationViewer -> {
@ -620,6 +645,7 @@ class MessagesFlowNode(
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
canUseOverlay: Boolean,
): NavTarget {
return NavTarget.MediaViewer(
mode = mode,
@ -628,6 +654,7 @@ class MessagesFlowNode(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
formattedCaption = content.formattedCaption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
@ -647,6 +674,7 @@ class MessagesFlowNode(
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
)
}

View file

@ -26,5 +26,6 @@ interface MessagesNavigator {
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToCurrentLiveLocation()
fun close()
}

View file

@ -68,6 +68,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
@ -115,7 +117,7 @@ class MessagesNode(
)
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@ -125,6 +127,7 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
@ -237,6 +240,10 @@ class MessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
callback.navigateToCurrentLiveLocation()
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
@ -247,6 +254,7 @@ class MessagesNode(
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@ -268,11 +276,11 @@ class MessagesNode(
onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event ->
if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event)
callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}

View file

@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.MessagesState.Threads
import io.element.android.features.messages.impl.actionlist.ActionListState
@ -79,6 +81,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -126,6 +129,7 @@ class MessagesPresenter(
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<MessagesState> {
@AssistedFactory
@ -172,6 +176,7 @@ class MessagesPresenter(
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
@ -260,6 +265,18 @@ class MessagesPresenter(
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
MessagesEvent.StopLiveLocationShare -> {
localCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
.onFailure {
Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
MessagesEvent.ShowLiveLocationShare -> {
navigator.navigateToCurrentLiveLocation()
}
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
coroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@ -272,6 +289,8 @@ class MessagesPresenter(
}
}
navigator.close()
}.invokeOnCompletion {
markingAsReadAndExiting.set(false)
}
}
}
@ -309,6 +328,7 @@ class MessagesPresenter(
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventSink = ::handleEvent,
)
}

View file

@ -58,6 +58,7 @@ data class MessagesState(
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null

View file

@ -44,6 +44,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@ -79,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(
timelineState = aTimelineState(
@ -94,8 +96,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
}
fun aMessagesState(
roomName: String? = "Room name",
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
roomName: String? = ROOM_NAME,
roomAvatar: AvatarData = AvatarData("!id:domain", ROOM_NAME, size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
@ -126,6 +128,7 @@ fun aMessagesState(
hasThreads = false,
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@ -155,6 +158,7 @@ fun aMessagesState(
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
eventSink = eventSink,
)

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.LiveLocationSharingBanner
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListView
@ -205,15 +206,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@ -250,8 +251,8 @@ fun MessagesView(
content = { padding ->
Box(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(padding)
.consumeWindowInsets(padding)
) {
MessagesViewContent(
state = state,
@ -288,10 +289,10 @@ fun MessagesView(
SuggestionsPickerView(
modifier = Modifier
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
@ -467,9 +468,9 @@ private fun MessagesViewContent(
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@ -520,25 +521,34 @@ private fun MessagesViewContent(
)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
Column {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
if (state.showLiveLocationShareBanner) {
LiveLocationSharingBanner(
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@ -587,9 +597,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
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.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",

View file

@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
@ -32,7 +33,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = USER_NAME_ALICE),
identityState = IdentityState.VerificationViolation,
),
),

View file

@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
override val values: Sequence<ResolveVerifiedUserSendFailureState>
@ -37,10 +38,10 @@ fun aResolveVerifiedUserSendFailureState(
eventSink = eventSink
)
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice.FromOther(
fun anUnsignedDeviceSendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.UnsignedDevice.FromOther(
userDisplayName = userDisplayName,
)
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
fun aChangedIdentitySendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.ChangedIdentity(
userDisplayName = userDisplayName,
)

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType.Ro
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomAlias
@ -198,7 +199,7 @@ internal fun SuggestionsPickerViewPreview() {
suggestions = persistentListOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = USER_NAME_BOB)),
ResolvedSuggestion.Alias(
roomAlias = anAlias,
roomId = RoomId("!room:matrix.org"),

View file

@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@ContributesNode(RoomScope::class)
@AssistedInject
@ -50,7 +52,7 @@ class PinnedMessagesListNode(
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event)
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink)
@ -103,6 +105,7 @@ class PinnedMessagesListNode(
@Composable
override fun View(modifier: Modifier) {
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@ -113,7 +116,9 @@ class PinnedMessagesListNode(
PinnedMessagesListView(
state = state,
onBackClick = ::navigateUp,
onEventClick = callback::handleEventClick,
onEventClick = {
callback.handleEventClick(it, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = {

View file

@ -68,6 +68,8 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
@ -124,7 +126,7 @@ class ThreadedMessagesNode(
}
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@ -134,6 +136,7 @@ class ThreadedMessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
@ -246,12 +249,18 @@ class ThreadedMessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
callback.navigateToCurrentLiveLocation()
}
override fun close() = navigateUp()
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@ -271,11 +280,11 @@ class ThreadedMessagesNode(
onEventContentClick = { isLive, event ->
timelineController?.let { controller ->
if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event)
callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}

View file

@ -43,6 +43,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
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.Scaffold
@ -303,7 +305,7 @@ internal fun ThreadsListViewPreview() {
ThreadsListView(
state = ThreadsListState(
roomId = RoomId("!room-id:server"),
roomName = "Room name",
roomName = ROOM_NAME,
roomAvatarUrl = null,
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false,
@ -360,7 +362,7 @@ fun aThreadListItem(
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = USER_NAME_ALICE, displayNameAmbiguous = false, avatarUrl = null),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
@ -94,6 +95,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@ -200,7 +202,9 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
}
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)

View file

@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_SENDER
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
@ -143,7 +145,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender",
senderDisplayName: String = USER_NAME_SENDER,
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@ -160,7 +162,7 @@ internal fun aTimelineItemEvent(
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
senderAvatar = AvatarData("@senderId:domain", USER_NAME_SENDER, size = AvatarSize.TimelineSender),
content = content,
reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
@ -253,7 +255,7 @@ internal fun aGroupedEvents(
}
internal fun aTimelineRoomInfo(
name: String = "Room name",
name: String = ROOM_NAME,
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(),

View file

@ -77,7 +77,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest

View file

@ -47,8 +47,8 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer
import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2

View file

@ -26,6 +26,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
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.core.UserId
@ -162,7 +163,7 @@ internal fun MessageShieldViewPreview() {
MessageShield.AuthenticityNotGuaranteed(false),
forwarder = UserId("@alice:example.com"),
forwarderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
),

View file

@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -47,9 +50,39 @@ fun TimelineEventTimestampView(
val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
val shield = event.messageShield
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
shield != null -> stringResource(CommonStrings.a11y_view_details)
hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
else -> null
}
val clickableModifier = remember(shield, hasError) {
when {
shield != null -> {
Modifier.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
}
}
hasError -> Modifier
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
else -> Modifier
}
}
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
// For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp))
.then(clickableModifier)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
@ -67,36 +100,22 @@ fun TimelineEventTimestampView(
color = tint,
)
if (hasError) {
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier
.size(15.dp, 18.dp)
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
modifier = Modifier.size(15.dp, 18.dp),
)
}
if (!isMessageRedacted) {
event.messageShield?.let { shield ->
shield?.let { shield ->
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier
.size(15.dp)
.clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
},
modifier = Modifier.size(15.dp),
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))

View file

@ -92,6 +92,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
@ -120,7 +121,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch
import kotlin.math.abs
@ -782,7 +783,7 @@ private fun MessageEventBubbleContent(
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.canStop
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
@ -863,7 +864,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = false,
),
@ -898,7 +899,7 @@ internal fun ThreadSummaryViewPreview() {
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = true,
),

View file

@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
@ -25,7 +27,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
Column {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Alice",
senderDisplayName = USER_NAME_ALICE,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@ -39,7 +41,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@ -54,7 +56,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(

View file

@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@Composable

View file

@ -47,7 +47,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit

View file

@ -54,10 +54,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
private const val TALL_IMAGE_RATIO_DIVISOR = 3
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
@ -79,7 +80,7 @@ fun TimelineItemImageView(
Modifier
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
@ -123,7 +124,14 @@ fun TimelineItemImageView(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
val width = content.width ?: 0
val height = content.height ?: 0
// if image is narrow and tall use DEFAULT_ASPECT_RATIO
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) {
DEFAULT_ASPECT_RATIO
} else {
content.aspectRatio ?: DEFAULT_ASPECT_RATIO
}
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
@ -200,3 +208,38 @@ internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
)
}
}
@PreviewsDayNight
@Composable
internal fun ATimelineItemEventRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 80,
height = 300,
aspectRatio = 80f / 300f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Narrow image with null aspectRatio",
width = 80,
height = 300,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}

View file

@ -13,14 +13,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -77,13 +75,14 @@ private fun LiveLocationOverlay(
Row(
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
.padding(horizontal = 8.dp, vertical = 8.dp),
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
// Ensure this Box uses same spacings than the Stop IconButton.
.minimumInteractiveComponentSize()
.size(32.dp)
.border(
width = 1.dp,
@ -120,7 +119,6 @@ private fun LiveLocationOverlay(
)
}
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) {
@ -140,13 +138,16 @@ private fun LiveLocationOverlay(
}
}
if (mode.isActive && mode.canStop) {
if (mode.canStopSharing) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
),
modifier = Modifier
.minimumInteractiveComponentSize()
.size(30.dp)
) {
Icon(
imageVector = CompoundIcons.Stop(),

View file

@ -64,7 +64,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link

View file

@ -52,7 +52,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.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
@ -86,13 +87,13 @@ internal fun TimelineItemRoomBeginningViewPreview() = ElementPreview {
)
TimelineItemRoomBeginningView(
predecessorRoom = null,
roomName = "Room Name",
roomName = ROOM_NAME,
isDm = isDm,
onPredecessorRoomClick = {},
)
TimelineItemRoomBeginningView(
predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org")),
roomName = "Room Name",
roomName = ROOM_NAME,
isDm = isDm,
onPredecessorRoomClick = {},
)

View file

@ -8,7 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@ -18,6 +20,12 @@ import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
*/
fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
mapOf(
Pair(
TimelineItemLocationContent::class,
TimelineItemPresenterFactory<TimelineItemLocationContent, TimelineItemLocationContent> { content ->
Presenter { content.ensureActiveLiveLocation() }
},
),
Pair(
TimelineItemVoiceContent::class,
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },

View file

@ -133,6 +133,7 @@ class TimelineItemContentFactory(
isActive = itemContent.isLive,
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
endTimestamp = itemContent.endTimestamp,
isOwnUser = sessionId == sender
),
)
}

View file

@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemAudioContent(),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemVoiceContent(),
aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = aStaticLocationMode()),
aTimelineItemPollContent(),
aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(),
@ -36,7 +36,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
mode = aLiveLocationMode(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
),
)
}

View file

@ -28,6 +28,8 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
filename = filename,
fileSize = 4 * 1024 * 1024L,
@ -38,8 +40,8 @@ fun aTimelineItemImageContent(
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,
blurhash = blurhash,
width = null,
height = 300,
width = width,
height = height,
thumbnailWidth = null,
thumbnailHeight = 150,
aspectRatio = aspectRatio,

View file

@ -72,9 +72,10 @@ data class TimelineItemLocationContent(
val isActive: Boolean,
val endsAt: String,
val endTimestamp: Long,
val canStop: Boolean = false,
val isOwnUser: Boolean,
) : Mode {
val isLoading = lastKnownLocation == null && isActive
val canStopSharing = isActive && isOwnUser
}
}

View file

@ -17,48 +17,44 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true,
lastKnownLocation = aLocation()
),
mode = aStaticLocationMode()
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null
),
mode = aLiveLocationMode(isActive = true, lastKnownLocation = null)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true, isOwnUser = false)
),
aTimelineItemLocationContent(
mode = aLiveLocationMode(isActive = false)
),
)
}
fun aLiveLocationMode(
isActive: Boolean,
isOwnUser: Boolean = true,
lastKnownLocation: Location? = aLocation(),
endsAt: String = "Ends at 12:34",
endTimestamp: Long = 0L,
): TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Live(
isActive = isActive,
endsAt = endsAt,
endTimestamp = endTimestamp,
isOwnUser = isOwnUser,
lastKnownLocation = lastKnownLocation
)
fun aStaticLocationMode(location: Location = aLocation()) = TimelineItemLocationContent.Mode.Static(location)
fun aTimelineItemLocationContent(
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
mode: TimelineItemLocationContent.Mode,
) = TimelineItemLocationContent(
senderId = senderId,
senderProfile = senderProfile,

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
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.Text
@ -91,9 +93,12 @@ internal fun MessagesViewTopBar(
modifier = titleModifier
)
val iconModifier = Modifier.size(16.dp)
when (dmUserIdentityState) {
IdentityState.Verified -> {
Icon(
modifier = iconModifier,
imageVector = CompoundIcons.Verified(),
tint = ElementTheme.colors.iconSuccessPrimary,
contentDescription = null,
@ -101,6 +106,7 @@ internal fun MessagesViewTopBar(
}
IdentityState.VerificationViolation -> {
Icon(
modifier = iconModifier,
imageVector = CompoundIcons.ErrorSolid(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
@ -112,11 +118,13 @@ internal fun MessagesViewTopBar(
when (sharedHistoryIcon) {
SharedHistoryIcon.NONE -> Unit
SharedHistoryIcon.SHARED -> Icon(
modifier = iconModifier,
imageVector = CompoundIcons.History(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_shared_history),
)
SharedHistoryIcon.WORLD_READABLE -> Icon(
modifier = iconModifier,
imageVector = CompoundIcons.UserProfileSolid(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_world_readable_history),
@ -150,7 +158,7 @@ private fun RoomAvatarAndNameRow(
)
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(start = 8.dp)
.semantics {
heading()
},
@ -168,9 +176,9 @@ private fun RoomAvatarAndNameRow(
internal fun MessagesViewTopBarPreview() = ElementPreview {
@Composable
fun AMessagesViewTopBar(
roomName: String? = "Room name",
roomName: String? = ROOM_NAME,
roomAvatar: AvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
size = AvatarSize.TimelineRoom,
),
isTombstoned: Boolean = false,

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -96,9 +97,9 @@ internal fun ThreadTopBar(
internal fun ThreadTopBarPreview() = ElementPreview {
@Composable
fun AThreadTopBar(
roomName: String? = "Room name",
roomName: String? = ROOM_NAME,
roomAvatarData: AvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
size = AvatarSize.TimelineRoom,
),
isTombstoned: Boolean = false,
@ -123,7 +124,7 @@ internal fun ThreadTopBarPreview() = ElementPreview {
HorizontalDivider()
AThreadTopBar(
roomAvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
url = "https://some-avatar.jpg",
size = AvatarSize.TimelineRoom,
),

View file

@ -9,6 +9,11 @@
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
import kotlinx.collections.immutable.toImmutableList
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
@ -22,7 +27,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
),
),
aTypingNotificationState(
@ -32,24 +37,24 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_CHARLIE),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_CHARLIE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_DAVID),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_EVE),
),
),
aTypingNotificationState(

View file

@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Optag video"</string>
<string name="screen_room_attachment_source_files">"Vedhæftning"</string>
<string name="screen_room_attachment_source_gallery">"Foto- og videobibliotek"</string>
<string name="screen_room_attachment_source_location">"Del lokation"</string>
<string name="screen_room_attachment_source_location">"Del placering"</string>
<string name="screen_room_attachment_source_poll">"Afstemning"</string>
<string name="screen_room_attachment_text_formatting">"Tekstformatering"</string>
<string name="screen_room_encrypted_history_banner">"Beskedhistorikken er i øjeblikket ikke tilgængelig."</string>

View file

@ -27,7 +27,7 @@
<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_location">"هم‌رسانی مکان"</string>
<string name="screen_room_attachment_source_poll">"نظرسنجی"</string>
<string name="screen_room_attachment_text_formatting">"قالب‌بندی متن"</string>
<string name="screen_room_encrypted_history_banner">"تاریخچه پیام درحال حاضر دردسترس نیست."</string>
@ -56,5 +56,13 @@
<string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string>
<string name="screen_room_timeline_upgraded_room_action">"دیدن پیام‌های قدیمی"</string>
<string name="screen_room_timeline_upgraded_room_message">"این اتاق ادامهٔ اتاقی دیگر است"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s، %2$s و %3$d سایر"</item>
<item quantity="other">"%1$s، %2$s و %3$d موارد دیگر"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s در حال تایپ است"</item>
<item quantity="other">"%1$s در حال تایپ هستند"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s و %2$s"</string>
</resources>

View file

@ -34,7 +34,7 @@
<string name="screen_room_attachment_source_camera_video">"Rekam video"</string>
<string name="screen_room_attachment_source_files">"Lampiran"</string>
<string name="screen_room_attachment_source_gallery">"Pustaka Foto &amp; Video"</string>
<string name="screen_room_attachment_source_location">"Lokasi"</string>
<string name="screen_room_attachment_source_location">"Membagi Lokasi"</string>
<string name="screen_room_attachment_source_poll">"Jajak pendapat"</string>
<string name="screen_room_attachment_text_formatting">"Pemformatan Teks"</string>
<string name="screen_room_encrypted_history_banner">"Riwayat pesan saat ini tidak tersedia di ruangan ini"</string>

View file

@ -34,7 +34,7 @@
<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_gallery">"アルバムの写真動画"</string>
<string name="screen_room_attachment_source_location">"場所を共有"</string>
<string name="screen_room_attachment_source_poll">"投票"</string>
<string name="screen_room_attachment_text_formatting">"書式設定"</string>

View file

@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Nagraj film"</string>
<string name="screen_room_attachment_source_files">"Załącznik"</string>
<string name="screen_room_attachment_source_gallery">"Zdjęcia i filmy"</string>
<string name="screen_room_attachment_source_location">"Lokalizacja"</string>
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
<string name="screen_room_attachment_source_poll">"Ankieta"</string>
<string name="screen_room_attachment_text_formatting">"Formatowanie tekstu"</string>
<string name="screen_room_encrypted_history_banner">"Historia wiadomości jest obecnie niedostępna."</string>

View file

@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Gravar vídeo"</string>
<string name="screen_room_attachment_source_files">"Anexo"</string>
<string name="screen_room_attachment_source_gallery">"Biblioteca de fotos e vídeos"</string>
<string name="screen_room_attachment_source_location">"Localização"</string>
<string name="screen_room_attachment_source_location">"Partilhar localização"</string>
<string name="screen_room_attachment_source_poll">"Sondagem"</string>
<string name="screen_room_attachment_text_formatting">"Formatação de texto"</string>
<string name="screen_room_encrypted_history_banner">"De momento, o histórico de mensagens está indisponível."</string>

View file

@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Nahrať video"</string>
<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_location">"Zdieľať polohu"</string>
<string name="screen_room_attachment_source_poll">"Anketa"</string>
<string name="screen_room_attachment_text_formatting">"Formátovanie textu"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>

View file

@ -35,7 +35,7 @@
<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_location">"Поділитися місцеперебуванням"</string>
<string name="screen_room_attachment_source_poll">"Опитування"</string>
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>

View file

@ -21,13 +21,13 @@
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"无法上传该文件。"</string>
<string name="screen_media_upload_preview_error_failed_processing">"处理要上传的媒体失败,请重试。"</string>
<string name="screen_media_upload_preview_error_failed_sending">"上传媒体失败,请重试。"</string>
<string name="screen_media_upload_preview_error_too_large_message">"允许的最大文件大小为%1$s 。"</string>
<string name="screen_media_upload_preview_error_too_large_message">"允许的最大文件大小为 %1$s。"</string>
<string name="screen_media_upload_preview_error_too_large_title">"文件太大,无法上传"</string>
<string name="screen_media_upload_preview_item_count">"第 %1$d 个项目,共 %2$d 个"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"优化图像质量"</string>
<string name="screen_media_upload_preview_processing">"处理中…"</string>
<string name="screen_report_content_block_user">"屏蔽用户"</string>
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有息"</string>
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有息"</string>
<string name="screen_report_content_explanation">"此消息将举报给服务器管理员。他们无法读取任何加密消息。"</string>
<string name="screen_report_content_hint">"举报此内容的理由"</string>
<string name="screen_room_attachment_source_camera">"相机"</string>

View file

@ -28,6 +28,7 @@ class FakeMessagesNavigator(
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val closeLambda: () -> Unit = { lambdaError() },
private val navigateToCurrentLiveLocationLambda: () -> Unit = { lambdaError() },
) : MessagesNavigator {
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo)
@ -65,6 +66,10 @@ class FakeMessagesNavigator(
navigateToDeveloperSettingsLambda()
}
override fun navigateToCurrentLiveLocation() {
navigateToCurrentLiveLocationLambda()
}
override fun close() {
closeLambda()
}

View file

@ -13,6 +13,7 @@ package io.element.android.features.messages.impl
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
@ -120,6 +121,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class MessagesPresenterTest {
@ -140,6 +142,39 @@ class MessagesPresenterTest {
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.showReinvitePrompt).isFalse()
assertThat(initialState.showLiveLocationShareBanner).isFalse()
}
}
@Test
fun `present - exposes live location sharing banner visibility for current room`() = runTest {
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
startShareLambda = { _, _ -> Result.success(Unit) },
)
liveLocationShareManager.startShare(A_ROOM_ID, 60.seconds)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
assertThat(state.showLiveLocationShareBanner).isTrue()
}
}
@Test
fun `present - stop live location share delegates to manager for current room`() = runTest {
val stopShareLambda = lambdaRecorder<RoomId, Result<Unit>> { Result.success(Unit) }
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
stopShareLambda = stopShareLambda
)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
state.eventSink(MessagesEvent.StopLiveLocationShare)
advanceUntilIdle()
assert(stopShareLambda)
.isCalledOnce()
.with(value(A_ROOM_ID))
}
}
@ -1347,6 +1382,7 @@ class MessagesPresenterTest {
actionListEventSink: (ActionListEvent) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
): MessagesPresenter {
return MessagesPresenter(
navigator = navigator,
@ -1376,6 +1412,7 @@ class MessagesPresenterTest {
featureFlagService = featureFlagService,
addRecentEmoji = addRecentEmoji,
markAsFullyRead = markAsFullyRead,
liveLocationShareManager = liveLocationShareManager,
sessionCoroutineScope = backgroundScope,
)
}

View file

@ -643,6 +643,44 @@ class MessagesViewTest {
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
}
@Test
fun `live location banner is visible when current room is sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = true)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertExists()
}
@Test
fun `live location banner is hidden when current room is not sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = false)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertDoesNotExist()
}
@Test
fun `clicking stop on live location banner emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.action_stop)
eventsRecorder.assertSingle(MessagesEvent.StopLiveLocationShare)
}
@Test
fun `clicking live location banner emit expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.screen_room_live_location_banner)
eventsRecorder.assertSingle(MessagesEvent.ShowLiveLocationShare)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
@ -1012,6 +1013,7 @@ class TimelinePresenterTest {
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@ -1030,6 +1032,7 @@ class TimelinePresenterTest {
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
)
}
}