Feature : share live location (#6741)
* First live location sharing sending implementation * Simplify logic around canStop sharing * Add some debug logs around LiveLocationSharingService * Add LiveLocationException * Expose beaconId to identify the current share * Throttle live location instead of debouncing * Keep sync alive when sharing live location * Improve LiveLocation sharing * Show LiveLocationDisclaimer * Read minDistanceUpdate in LiveLocationSharingService * Set minDistanceUpdate in AdvancedSettings * Display banner in room when sharing live location * Fix tests around LiveLocationSharing * Ensure shares are properly restarted/stopped when app is re-launched * Ensure LLS data is cleared when session is removed * Update and fix LLS tests * Handle Start LLS in ui * Add check LLS permissions * Remove hardcoded strings * Fix quality and format * Create DeviceLocationProvider so we can share location data between sources (presenter/live location service) * Update screenshots * Fix warning * Do not try to stop if it was not sharing * Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)" This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8. * Tweak location provider config values * Address PR review remarks * Fix ktlint * Update screenshots * Fix some tests after merging develop * Adjust TimelineItemLocationView ui to match figma * Update screenshots * Documentation and cleanup * Remove temporary resource --------- Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org> Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
parent
0c657c258a
commit
e49e183178
145 changed files with 2913 additions and 278 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -278,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,
|
||||
|
|
@ -513,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,
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@ interface MessagesNavigator {
|
|||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToCurrentLiveLocation()
|
||||
fun close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,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()
|
||||
|
|
@ -239,6 +240,10 @@ class MessagesNode(
|
|||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToCurrentLiveLocation() {
|
||||
callback.navigateToCurrentLiveLocation()
|
||||
}
|
||||
|
||||
private fun displaySameRoomToast() {
|
||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -311,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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||
currentPinnedMessageIndex = 0,
|
||||
),
|
||||
),
|
||||
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
|
||||
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
|
||||
aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
|
|
@ -127,6 +128,7 @@ fun aMessagesState(
|
|||
hasThreads = false,
|
||||
hasUnreadThreads = false,
|
||||
),
|
||||
isCurrentlySharingLiveLocationInRoom: Boolean = false,
|
||||
eventSink: (MessagesEvent) -> Unit = {},
|
||||
) = MessagesState(
|
||||
roomId = RoomId("!id:domain"),
|
||||
|
|
@ -156,6 +158,7 @@ fun aMessagesState(
|
|||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
successorRoom = successorRoom,
|
||||
threads = threads,
|
||||
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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:00 AM",
|
||||
|
|
@ -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:00 AM",
|
||||
|
|
|
|||
|
|
@ -136,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()
|
||||
|
|
@ -248,6 +249,11 @@ class ThreadedMessagesNode(
|
|||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToCurrentLiveLocation() {
|
||||
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
|
||||
callback.navigateToCurrentLiveLocation()
|
||||
}
|
||||
|
||||
override fun close() = navigateUp()
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -783,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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class TimelineItemContentFactory(
|
|||
isActive = itemContent.isLive,
|
||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||
endTimestamp = itemContent.endTimestamp,
|
||||
isOwnUser = sessionId == sender
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue