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 caa96c8c48
commit 2d2295bcc1
47 changed files with 413 additions and 83 deletions

View File

@@ -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")
}

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 */,

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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) },

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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?
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae5fb963478cbc8abf7dc4732ea8f4295ba86efd9e917d2e167378a6e06520f6
size 14937

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:45a96c2f592cbc5cf7a2f1282679bc4b7d592e903df161f0e10c853b4401cd6e
size 18159

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acc5d3b386e57b63e6e2386b96d3cde0eb1afa9b0cc8fc914e1495882e39b691
size 10977

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6126e63cf2456619c7e6d2212e6fbf4b6f5af7426df7241ca2039314827da64
size 15642

View File

@@ -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
}
}