Live Location Sharing Banner (#5370)

* Live Location Sharing Banner

# Conflicts:
#	ElementX.xcodeproj/project.pbxproj

* updated the top banner modifier and the top banner views

# Conflicts:
#	ElementX/Resources/Localizations/en-US.lproj/Localizable.strings
#	ElementX/Resources/Localizations/en.lproj/Localizable.strings

* improved the stopping function from the timeline item

* stop live location sharing before starting a new one.

* added some tests for LiveLocationManager

* pr suggestions
This commit is contained in:
Mauro
2026-04-10 14:50:42 +02:00
committed by GitHub
parent 148b292ca6
commit 922fcafcd5
47 changed files with 413 additions and 83 deletions
@@ -303,6 +303,10 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "LiveLocationRoomTimelineView_Previews")
}
func testLiveLocationSharingBannerView() async throws {
try await performAccessibilityAudit(named: "LiveLocationSharingBannerView_Previews")
}
func testLoadableImage() async throws {
try await performAccessibilityAudit(named: "LoadableImage_Previews")
}
+8
View File
@@ -860,6 +860,7 @@
92012C96039BC8C2CAEBA9E2 /* AuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671C338B7259DC5774816885 /* AuthenticationServiceTests.swift */; };
920DC020F18ABC88175114D3 /* SpaceListScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5557DDA438841AF5DC003D0B /* SpaceListScreenViewModelTests.swift */; };
9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; };
9223E5F2A2CE0AFFDFF0AFFB /* LiveLocationSharingBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */; };
92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; };
9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */; };
92919B6522B26B2681232EAC /* LiveLocationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5DF0E888F66652F8C4CEC5 /* LiveLocationManagerMock.swift */; };
@@ -1191,6 +1192,7 @@
CB498F4E27AA0545DCEF0F6F /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; };
CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */; };
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */; };
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */; };
CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; };
CB9FB2BEF313072C705AC9B5 /* SecurityAndPrivacyScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315C328FF40F84276364E66 /* SecurityAndPrivacyScreenViewModelTests.swift */; };
CBBBE597BE74A2DF68DE2209 /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDB7A9BB466C56614BB589D /* NotificationItemProxyProtocol.swift */; };
@@ -2107,6 +2109,7 @@
5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = "<group>"; };
5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = "<group>"; };
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = "<group>"; };
5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = "<group>"; };
5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = "<group>"; };
5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTestsAppCoordinator.swift; sourceTree = "<group>"; };
5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = "<group>"; };
@@ -2694,6 +2697,7 @@
C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = "<group>"; };
C57DB49B8426AA721BF85D83 /* SpaceServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxyProtocol.swift; sourceTree = "<group>"; };
C5AEB5907E24092D741718AF /* ResolveVerifiedUserSendFailureScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenCoordinator.swift; sourceTree = "<group>"; };
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = "<group>"; };
C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = "<group>"; };
C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = "<group>"; };
C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = "<group>"; };
@@ -4867,6 +4871,7 @@
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */,
C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */,
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */,
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */,
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */,
3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */,
@@ -5060,6 +5065,7 @@
isa = PBXGroup;
children = (
47F441A78A5CAA9E2937E463 /* KnockRequestsBannerView.swift */,
5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */,
F48A2FA6814F824ABB4C07F3 /* RoomCallControlsToolbar.swift */,
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */,
4137900E28201C314C835C11 /* RoomScreenFooterView.swift */,
@@ -7805,6 +7811,7 @@
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */,
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */,
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */,
149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */,
@@ -8378,6 +8385,7 @@
CD077E14FAADC444C5A80068 /* LiveLocationManagerProtocol.swift in Sources */,
C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */,
F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */,
9223E5F2A2CE0AFFDFF0AFFB /* LiveLocationSharingBannerView.swift in Sources */,
6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */,
D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */,
256D76972BA3254F7CB7F88B /* LocationAnnotation.swift in Sources */,
@@ -7,7 +7,7 @@
import SwiftUI
/// A single banner item to be displayed in a `topBanners` overlay.
/// An individual banner in the vertical stack of a `TopBannerLayer`.
struct TopBannerItem {
var banner: AnyView
var isVisible: Bool
@@ -18,18 +18,42 @@ struct TopBannerItem {
}
}
/// A Z-axis banner slot displayed in a `topBanners` overlay. Each slot may
/// contain one or more vertically stacked banners, each with its own
/// visibility. The slot's overall visibility is derived from whether any of
/// its vertical banners are visible. Later items in the `topBanners` array
/// are overlayed on top of earlier ones (Z-axis).
struct TopBannerLayer {
var verticalBanners: [TopBannerItem]
var isVisible: Bool {
verticalBanners.contains { $0.isVisible }
}
/// Convenience initialiser for a single-banner slot.
init(_ banner: some View, isVisible: Bool) {
verticalBanners = [TopBannerItem(banner, isVisible: isVisible)]
}
init(verticalBanners: [TopBannerItem]) {
self.verticalBanners = verticalBanners
}
}
extension View {
/// Overlays the given banner view at the top edge of this view, using a
/// slide from the top edge when `isVisible` is toggled.
func topBanner(_ banner: some View, isVisible: Bool, footer: some View = EmptyView()) -> some View {
topBanners([TopBannerItem(banner, isVisible: isVisible)], footer: footer)
topBanners([TopBannerLayer(banner, isVisible: isVisible)], footer: footer)
}
/// Overlays the given banner views at the top edge of this view. Each banner
/// slides from the top edge based on its own `isVisible` flag. Later items in
/// the array are overlayed on top of earlier ones. The footer is shared across
/// all banners and displayed below the topmost visible banner.
func topBanners(_ items: [TopBannerItem], footer: some View = EmptyView()) -> some View {
/// Overlays the given Z-axis banner slots at the top edge of this view.
/// Later items in the array are overlayed on top of earlier ones. Within
/// each slot, visible vertical banners are stacked in a VStack and slide
/// in/out from the top edge. The shadow and bottom padding are applied to
/// the VStack of each slot. The footer is shared and displayed below the
/// topmost visible slot.
func topBanners(_ items: [TopBannerLayer], footer: some View = EmptyView()) -> some View {
let anyBannerVisible = items.contains { $0.isVisible }
return overlay(alignment: .top) {
ZStack(alignment: .top) {
@@ -38,8 +62,19 @@ extension View {
ZStack(alignment: .top) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
if item.isVisible {
item.banner
.transition(.move(edge: .top))
VStack(spacing: 0) {
ForEach(Array(item.verticalBanners.enumerated()), id: \.offset) { _, vBanner in
if vBanner.isVisible {
vBanner.banner
.transition(.move(edge: .top))
}
}
}
.compositingGroup()
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
// To include the shadow in the size
.padding(.bottom, 28)
.transition(.move(edge: .top))
}
}
}
@@ -53,7 +88,7 @@ extension View {
.hidden()
.allowsHitTesting(false)
}
.animation(.elementDefault, value: items.map(\.isVisible))
.animation(.elementDefault, value: items.map { $0.verticalBanners.map(\.isVisible) })
.clipped()
}
}
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
"LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self,
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
"LiveLocationRoomTimelineView_Previews" : LiveLocationRoomTimelineView_Previews.self,
"LiveLocationSharingBannerView_Previews" : LiveLocationSharingBannerView_Previews.self,
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,
@@ -192,6 +192,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentThreadList)
case .displayThread(let threadRootEventID, let focussedEventID):
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
case .stopLiveLocationSharing:
Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() }
}
}
.store(in: &cancellables)
@@ -21,6 +21,7 @@ enum RoomScreenViewModelAction: Equatable {
case displayKnockRequests
case displayRoom(roomID: String, via: [String])
case displayMessageForwarding(MessageForwardingItem)
case stopLiveLocationSharing
}
enum RoomScreenViewAction {
@@ -34,6 +35,7 @@ enum RoomScreenViewAction {
case viewKnockRequests
case displaySuccessorRoom
case displayThreadList
case tappedStopLiveLocation
}
struct RoomScreenViewState: BindableState {
@@ -48,6 +50,8 @@ struct RoomScreenViewState: BindableState {
!pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
}
var isSharingLiveLocation = false
var canSendMessage = true
/// Whether or not starting a call is supported.
@@ -122,6 +122,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames)))
case .displayThreadList:
actionsSubject.send(.displayThreadList)
case .tappedStopLiveLocation:
actionsSubject.send(.stopLiveLocationSharing)
}
}
@@ -172,6 +174,15 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
appSettings.$knockingEnabled
.weakAssign(to: \.state.isKnockingEnabled, on: self)
.store(in: &cancellables)
appSettings.$liveLocationSharingEnabled
.combineLatest(appSettings.$liveLocationSharingTimeoutDatesByRoomID)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEnabled, timeoutDatesByRoomID in
guard let self else { return }
state.isSharingLiveLocation = isEnabled && timeoutDatesByRoomID.keys.contains(roomProxy.id)
}
.store(in: &cancellables)
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
@@ -28,9 +28,6 @@ struct KnockRequestsBannerView: View {
mainContent
.padding(16)
.background(.compound.bgCanvasDefaultLevel1, in: RoundedRectangle(cornerRadius: 12))
.compositingGroup()
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
.padding(.bottom, 28)
.padding(.horizontal, 16)
}
@@ -202,6 +199,14 @@ struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
]
static var previews: some View {
allPreviews
.padding()
.background(.gray)
.previewLayout(.sizeThatFits)
}
@ViewBuilder
static var allPreviews: some View {
KnockRequestsBannerView(requests: singleRequest) { } onAccept: { _ in } onViewAll: { }
.previewDisplayName("Single Request")
// swiftlint:disable:next trailing_closure
@@ -0,0 +1,39 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct LiveLocationSharingBannerView: View {
var onStop: () -> Void
var body: some View {
HStack(spacing: 9) {
CompoundIcon(\.locationPinSolid, size: .medium, relativeTo: .compound.bodyMDSemibold)
.foregroundColor(Color.compound.iconSuccessPrimary)
.accessibilityHidden(true)
Text(L10n.screenRoomLiveLocationBanner)
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textPrimary)
Spacer()
Button(L10n.actionStop, role: .destructive, action: onStop)
.buttonStyle(.compound(.primary, size: .small))
}
.padding(.vertical, 16)
.padding(.horizontal, 15)
.background(Color.compound.bgCanvasDefault)
.overlay(alignment: .top) { Color.compound.separatorPrimary.frame(height: 1) }
.overlay(alignment: .bottom) { Color.compound.separatorPrimary.frame(height: 1) }
}
}
struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
LiveLocationSharingBannerView { }
.previewLayout(.sizeThatFits)
}
}
@@ -23,9 +23,6 @@ struct PinnedItemsBannerView: View {
.padding(.vertical, 16)
.padding(.leading, 16)
.background(Color.compound.bgCanvasDefault)
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
// To include the shadow in the size
.padding(.bottom, 28)
}
private var mainButton: some View {
@@ -111,5 +108,8 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
onMainButtonTap: { },
onViewAllButtonTap: { })
}
.padding()
.background(.gray)
.previewLayout(.sizeThatFits)
}
}
@@ -35,17 +35,27 @@ struct RoomScreen: View {
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.topBanners([
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
// This can overlay on top of the pinnedItemsBanner
TopBannerItem(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
TopBannerLayer(verticalBanners: [
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
TopBannerItem(liveLocationBanner, isVisible: context.viewState.isSharingLiveLocation && !isVoiceOverEnabled)
]),
// This can overlay on top of the stacked banners
TopBannerLayer(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
], footer: dateBadge)
.safeAreaInset(edge: .top) {
// When VoiceOver is enabled, the table view isn't reversed and the scroll gestures
// don't trigger meaning the banner never hides itself and so the .overlay layout
// above permanently obscures the top of the timeline. So whenever VoiceOver is
// enabled we use a safe area inset to vertically stack it above the timeline.
if context.viewState.shouldShowPinnedEventsBanner, isVoiceOverEnabled {
pinnedItemsBanner
if context.viewState.shouldShowPinnedEventsBanner || context.viewState.isSharingLiveLocation, isVoiceOverEnabled {
VStack(spacing: 0) {
if context.viewState.shouldShowPinnedEventsBanner {
pinnedItemsBanner
}
if context.viewState.isSharingLiveLocation {
liveLocationBanner
}
}
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -76,6 +86,12 @@ struct RoomScreen: View {
.sentryTrace("\(Self.self)")
}
private var liveLocationBanner: some View {
LiveLocationSharingBannerView {
context.send(viewAction: .tappedStopLiveLocation)
}
}
private var pinnedItemsBanner: some View {
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
@@ -70,7 +70,7 @@ enum TimelineViewAction {
case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction)
case stopLiveLocationSharing
case stopLiveLocationSharing(TimelineItemIdentifier)
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String)
@@ -143,6 +143,8 @@ struct TimelineViewState: BindableState {
var mapTilerConfiguration: MapTilerConfiguration
var enableKeyShareOnInvite: Bool
var stoppedLiveLocationIDs: Set<TimelineItemIdentifier> = []
var bindings: TimelineViewStateBindings
}
@@ -198,8 +198,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
handlePollAction(pollAction)
case .handleAudioPlayerAction(let audioPlayerAction):
handleAudioPlayerAction(audioPlayerAction)
case .stopLiveLocationSharing:
Task { await userSession.liveLocationManager.stopLiveLocation(roomID: state.roomID) }
case .stopLiveLocationSharing(let id):
state.stoppedLiveLocationIDs.insert(id)
Task { await stopLiveLocationSharing() }
case .focusOnEventID(let eventID):
Task { await focusOnEvent(eventID: eventID) }
case .focusLive:
@@ -276,6 +277,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
}
func stopLiveLocationSharing() async {
await userSession.liveLocationManager.stopLiveLocation(roomID: roomProxy.id)
}
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? {
guard let content = await timelineController.messageEventContent(for: itemID) else { return nil }
return .init(id: itemID, roomID: roomProxy.id, content: content)
@@ -18,7 +18,8 @@ protocol TimelineViewModelProtocol {
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
/// Stops the current live location sharing
func stopLiveLocationSharing() async
/// Handles getting the content to forward an item given its item ID.
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem?
}
@@ -15,10 +15,10 @@ struct LiveLocationRoomTimelineView: View {
let timelineItem: LiveLocationRoomTimelineItem
private let currentDate: Date
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem) {
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem, isStopped: Bool = false) {
self.currentDate = currentDate
self.timelineItem = timelineItem
_hasExpired = State(initialValue: currentDate >= timelineItem.content.timeoutDate)
_hasExpired = State(initialValue: isStopped || currentDate >= timelineItem.content.timeoutDate)
}
/// A publisher that fires once when the timeoutDate is reached, setting `hasExpired` to true.
@@ -191,7 +191,7 @@ struct LiveLocationRoomTimelineView: View {
private func stop() {
hasExpired = true
context?.send(viewAction: .stopLiveLocationSharing)
context?.send(viewAction: .stopLiveLocationSharing(timelineItem.id))
}
// MARK: - Constants
@@ -60,6 +60,12 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
}
func startLiveLocation(roomID: String, duration: Duration) async -> Result<Void, LiveLocationManagerError> {
// Stop any existing session for this room first (e.g. one started from a different device)
// before starting a new one.
if appSettings.liveLocationSharingTimeoutDatesByRoomID[roomID] != nil {
await stopLiveLocation(roomID: roomID)
}
guard case .joined(let roomProxy) = await clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Failed to resolve joined room for identifier: \(roomID)")
return .failure(.roomNotJoined)
@@ -79,17 +85,23 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
}
func stopLiveLocation(roomID: String) async {
// Best effort: send the stop event to the room regardless of tracking state.
if let roomProxy = await resolveRoomProxy(for: roomID) {
var roomProxy: JoinedRoomProxyProtocol?
let cachedRoomProxy = activeRoomProxies[roomID]
appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID)
if let cachedRoomProxy {
roomProxy = cachedRoomProxy
// Best effort: send the stop event to the room regardless of tracking state.
} else if case let .joined(fetchedRoomProxy) = await clientProxy.roomForIdentifier(roomID) {
roomProxy = fetchedRoomProxy
}
if let roomProxy {
let result = await roomProxy.stopLiveLocationShare()
if case .failure(let error) = result {
MXLog.error("Failed to stop live location share in room \(roomID): \(error)")
}
}
// Always clean up locally.
appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID)
activeRoomProxies.removeValue(forKey: roomID)
}
// MARK: - CLLocationManagerDelegate
@@ -76,7 +76,7 @@ struct RoomTimelineItemView: View {
case .callNotification(let item):
CallNotificationRoomTimelineView(timelineItem: item)
case .liveLocation(let item):
LiveLocationRoomTimelineView(timelineItem: item)
LiveLocationRoomTimelineView(timelineItem: item, isStopped: context?.viewState.stoppedLiveLocationIDs.contains(item.id) ?? false)
}
}
@@ -603,6 +603,14 @@ extension PreviewTests {
}
}
@Test
func liveLocationSharingBannerView() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
for (index, preview) in LiveLocationSharingBannerView_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
@Test
func loadableImage() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4cc21640dc10190ddfa345a83c8e4bedae2cca526f3a517980b917df1fd819c1
size 92313
oid sha256:1add71eec72332f3d0f4f56f45068eb44a27290d16b5699ae16d523180b21315
size 30977
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4119ef48dd28330cbe9989a5e2842795b2e14874a7e03d353267a7530041b475
size 93248
oid sha256:09e6a9bfffd30aed0d98e81d0f83363517948f42b178bdaa697839474c0b28d2
size 31918
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:123673378b70eba1774e7f738d0b9704344158dc3ac6a8316724628a6317e161
size 50246
oid sha256:183f69e4d8bff9139f39194fd86ab630806376f11b0d8e48b7fdbc34df5ba5cf
size 22773
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3549c8ec426dbf2210ff9e7c0969ecdb7dc5b15d605d157b3754a9c09b0a8991
size 49872
oid sha256:2fb13539b94dc0e8ffec3949616b1f1d08b0c01cfeeca864d3a87f37cd7995a2
size 23345
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dca4b68102343046709e0d3430e0e2409b04f47fa6a35e8df489246be29500e2
size 95109
oid sha256:7ed343dc6fdfe8fe86931ac57bb5de221b893e67ea6740f09b03da2f0d5022f6
size 33984
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f20f61ab302e2a69373646f3e713e5bfe729d317ff19cf5362d2b67cf7b844d
size 98462
oid sha256:c72a9cb9efee8db318182df426a39542100cb8f634646364f5ab2371e2eee743
size 37324
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:32cccc3b9096777d7385bb34b4f76d1ae023a4b4d96be3021dd2de7eae4b6526
size 53001
oid sha256:8df4c96c5f7ed1e16b00eed25ee489779b82108e616cf5fcd347b02479018fe2
size 25336
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6366953f27bdf503d01e7b8ff34396ac7ba5e514d93723aab6870229979a759
size 61857
oid sha256:41ae37d20af35cc9eafbc37a3a58551c8b08a7fce2f2e5c8b1016540d4a04391
size 32666
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0ee54a8bd1364f456cedf7c8a5f6f0652d4e26f3d34bf3a14b8fa564ae41028
size 97300
oid sha256:1b7b864242e784d8ddc6f95be8f0e9e27c81b4fac495c587381b0602d520a8a5
size 36897
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cec2564a143dceb7ea8c6b23e1bb2f2f4626af08ae11dfe6b4365a4c95f96efa
size 102100
oid sha256:435a74365d46ac1366bf2c6343d349cce8fec2766cb09b2d61c7f92187a4e285
size 41658
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af1e8fb046d261c1e7274cfd61033e1fef539b8f13af8b14045d36c237cf458c
size 52975
oid sha256:410bbb9cf2020e28ae5da029aa7aa370c49351eb706692db7e1e976cde373a52
size 25816
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49301d759419c2418fe628957e6dbd14702a0625d6307c2dcc1f4a2dc1d715ef
size 63378
oid sha256:573d09b7aba1e7a20a20090ad2baf4236f7024bdb7d266383a8ccbd68c525eec
size 35993
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5a7b449241c47f37a05f16196e9f1478e861dba372cd740d8113471b4c64573
size 92965
oid sha256:4e4353be9b16318ec5774ed9ce19bcd7092dcb575958ed003ece8a7e8ce1e6f9
size 32609
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a8c2022369fe6c681b6645fcc87485a390a548743b03ff4125f8aa01ff50985f
size 97357
oid sha256:006f2ac00dc322f133db40b9c22d7e9a74bf06420487a333d01d4f12aa41ba77
size 36956
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75f23e3dd857927ff19e65481a429f0b066543d938b6418cd742b2462d038817
size 49340
oid sha256:249790076d8003df15316cfa253a502fd44fb7dc66a6642dcf4cdcdab0b9d9e4
size 22412
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c27af06d422419337c232ea0e67f23ae5efafc42d17986e56d141aa27ea71e4
size 56198
oid sha256:51461496517a802c737c245e510de941a176cf93f2759daf2c9e2e4053a41b36
size 28381
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1eca3d3966ed170902068948b0e06792f5a86a957bc5e9fa4ee12367db0ecfdc
size 110095
oid sha256:b6316e6324dbd72e72dc0eb8cf09b80e7788c091746fbff2dd7f62220b146984
size 52744
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3fbf972978286e7a63cbe2edbeb1c3af50ecb37752706307dc0b276a73d60fd
size 114871
oid sha256:bf1476690cc3cccf2ce3966b6791510a44d65ba1370563fcc3a560b6fc548aba
size 57490
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e819430839323b67d25f477fe9de16caf0d4e3cdeb6950edcc105e6e1b95413a
size 65778
oid sha256:2b94ac6d13906cddffe740a57b5c044adc9389a9d7514fe576d2e0e8080c923f
size 40671
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aff4b437d86ccd489cc49b675befcfb98660c4abfdca7d7e8a57d5f57877ce77
size 75574
oid sha256:7c637b790aff4bb5341be902d80681fc04618ac94816dc7f0f675048c0849645
size 50880
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae5fb963478cbc8abf7dc4732ea8f4295ba86efd9e917d2e167378a6e06520f6
size 14937
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:45a96c2f592cbc5cf7a2f1282679bc4b7d592e903df161f0e10c853b4401cd6e
size 18159
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acc5d3b386e57b63e6e2386b96d3cde0eb1afa9b0cc8fc914e1495882e39b691
size 10977
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6126e63cf2456619c7e6d2212e6fbf4b6f5af7426df7241ca2039314827da64
size 15642
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f727d2737e536d158ec5488ef7ae670bdf17b4f10789f560340b3c7b36cb2c76
size 143061
oid sha256:aaedf20685450b28deb7e16673a0f26a888fa345a55c603a68ac3d781e1aea42
size 79416
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:784b2e39ddcd2c1c04669c1e9dd00dddb9388b738afa283732bf389304d7b398
size 170747
oid sha256:4a8de9fae72c3bbcf8a5d205f989ecaa2c3d6313a4597a6b93027651e4cb2f3c
size 99670
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79abbb9b6c53da934a3451ed8d61d89c441500a5f6986e07c8fc9c32784afab6
size 87380
oid sha256:0f627cbfbd1c93ad7c77dd41ef3020f3c652ef53b64ebc51bfa30cd364538168
size 52157
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e196ada8c3f0f808868a80330e7a389d658519903cb51680d27ddfa4bd33ea60
size 101585
oid sha256:fc3309c3d3e7f290cc21e04d0cb239e8067e961bd4c4c56c9c102ec448852306
size 57519
@@ -0,0 +1,165 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import Foundation
import Testing
@MainActor
final class LiveLocationManagerTests {
private var clientProxy: ClientProxyMock!
private var manager: LiveLocationManager!
private let appSettings: AppSettings
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
clientProxy = ClientProxyMock(.init())
manager = LiveLocationManager(clientProxy: clientProxy, appSettings: appSettings)
}
deinit {
AppSettings.resetAllSettings()
}
// MARK: - startLiveLocation
@Test
func startLiveLocationWithNoExistingSession() async throws {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300))
try result.get()
#expect(roomProxy.startLiveLocationShareDurationCalled)
#expect(!roomProxy.stopLiveLocationShareCalled)
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] != nil)
}
@Test
func startLiveLocationWithExistingSessionStopsItFirst() async throws {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300)
var callOrder: [String] = []
roomProxy.stopLiveLocationShareClosure = {
callOrder.append("stop")
return .success(())
}
roomProxy.startLiveLocationShareDurationClosure = { _ in
callOrder.append("start")
return .success(())
}
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(600))
try result.get()
#expect(callOrder == ["stop", "start"])
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] != nil)
}
@Test
func startLiveLocationDoesNotStopSessionForOtherRoom() async {
let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] = Date().addingTimeInterval(300)
_ = await manager.startLiveLocation(roomID: "!room1:matrix.org", duration: .seconds(300))
#expect(!roomProxy.stopLiveLocationShareCalled)
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] != nil)
}
@Test
func startLiveLocationWhenRoomNotJoined() async {
clientProxy.roomForIdentifierClosure = { _ in nil }
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300))
#expect(throws: LiveLocationManagerError.roomNotJoined) { try result.get() }
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil)
}
@Test
func startLiveLocationWhenStartShareFails() async {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
roomProxy.startLiveLocationShareDurationReturnValue = .failure(.sdkError(RoomProxyMockError.generic))
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300))
#expect(throws: LiveLocationManagerError.startFailed) { try result.get() }
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil)
}
@Test
func startLiveLocationStoresTimeoutDate() async throws {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
let duration = Duration.seconds(300)
let beforeStart = Date()
_ = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: duration)
let afterStart = Date()
let storedTimeout = appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"]
let expectedMinTimeout = beforeStart.addingTimeInterval(TimeInterval(duration.seconds))
let expectedMaxTimeout = afterStart.addingTimeInterval(TimeInterval(duration.seconds))
try #expect((expectedMinTimeout...expectedMaxTimeout).contains(#require(storedTimeout)))
}
// MARK: - stopLiveLocation
@Test
func stopLiveLocationWhenSessionExists() async {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] = Date().addingTimeInterval(300)
await manager.stopLiveLocation(roomID: "!room:matrix.org")
#expect(roomProxy.stopLiveLocationShareCalled)
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room:matrix.org"] == nil)
}
@Test
func stopLiveLocationWhenNoSession() async {
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
await manager.stopLiveLocation(roomID: "!room:matrix.org")
#expect(roomProxy.stopLiveLocationShareCalled)
}
@Test
func stopLiveLocationDoesNotRemoveOtherSessions() async {
let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
appSettings.liveLocationSharingTimeoutDatesByRoomID["!room1:matrix.org"] = Date().addingTimeInterval(300)
appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] = Date().addingTimeInterval(300)
await manager.stopLiveLocation(roomID: "!room1:matrix.org")
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room1:matrix.org"] == nil)
#expect(appSettings.liveLocationSharingTimeoutDatesByRoomID["!room2:matrix.org"] != nil)
}
// MARK: - Private
private func makeRoomProxy(roomID: String) -> JoinedRoomProxyMock {
let roomProxy = JoinedRoomProxyMock(.init(id: roomID))
roomProxy.startLiveLocationShareDurationReturnValue = .success(())
roomProxy.stopLiveLocationShareReturnValue = .success(())
return roomProxy
}
}