From 2d2295bcc1bedf70e5efceed80eb411a9e3f9358 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:50:42 +0200 Subject: [PATCH] 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 --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 8 + .../SwiftUI/Views/TopBannerModifier.swift | 55 ++++-- .../TestablePreviewsDictionary.swift | 1 + .../RoomScreen/RoomScreenCoordinator.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 4 + .../RoomScreen/RoomScreenViewModel.swift | 11 ++ .../View/KnockRequestsBannerView.swift | 11 +- .../View/LiveLocationSharingBannerView.swift | 39 +++++ .../PinnedItemsBannerView.swift | 6 +- .../Screens/RoomScreen/View/RoomScreen.swift | 26 ++- .../Screens/Timeline/TimelineModels.swift | 4 +- .../Screens/Timeline/TimelineViewModel.swift | 9 +- .../Timeline/TimelineViewModelProtocol.swift | 3 +- .../LiveLocationRoomTimelineView.swift | 6 +- .../Location/LiveLocationManager.swift | 24 ++- .../TimelineItems/RoomTimelineItemView.swift | 2 +- .../Sources/GeneratedPreviewTests.swift | 8 + ...annerView.Multiple-Requests-iPad-en-GB.png | 4 +- ...nnerView.Multiple-Requests-iPad-pseudo.png | 4 +- ...nerView.Multiple-Requests-iPhone-en-GB.png | 4 +- ...erView.Multiple-Requests-iPhone-pseudo.png | 4 +- ...gle-Request-No-Display-Name-iPad-en-GB.png | 4 +- ...le-Request-No-Display-Name-iPad-pseudo.png | 4 +- ...e-Request-No-Display-Name-iPhone-en-GB.png | 4 +- ...-Request-No-Display-Name-iPhone-pseudo.png | 4 +- ...tsBannerView.Single-Request-iPad-en-GB.png | 4 +- ...sBannerView.Single-Request-iPad-pseudo.png | 4 +- ...BannerView.Single-Request-iPhone-en-GB.png | 4 +- ...annerView.Single-Request-iPhone-pseudo.png | 4 +- ...le-Request-no-accept-action-iPad-en-GB.png | 4 +- ...e-Request-no-accept-action-iPad-pseudo.png | 4 +- ...-Request-no-accept-action-iPhone-en-GB.png | 4 +- ...Request-no-accept-action-iPhone-pseudo.png | 4 +- ....Single-Request-with-reason-iPad-en-GB.png | 4 +- ...Single-Request-with-reason-iPad-pseudo.png | 4 +- ...ingle-Request-with-reason-iPhone-en-GB.png | 4 +- ...ngle-Request-with-reason-iPhone-pseudo.png | 4 +- ...LocationSharingBannerView.iPad-en-GB-0.png | 3 + ...ocationSharingBannerView.iPad-pseudo-0.png | 3 + ...cationSharingBannerView.iPhone-en-GB-0.png | 3 + ...ationSharingBannerView.iPhone-pseudo-0.png | 3 + .../pinnedItemsBannerView.iPad-en-GB-0.png | 4 +- .../pinnedItemsBannerView.iPad-pseudo-0.png | 4 +- .../pinnedItemsBannerView.iPhone-en-GB-0.png | 4 +- .../pinnedItemsBannerView.iPhone-pseudo-0.png | 4 +- .../Sources/LiveLocationManagerTests.swift | 165 ++++++++++++++++++ 47 files changed, 413 insertions(+), 83 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png create mode 100644 UnitTests/Sources/LiveLocationManagerTests.swift diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index f441c3076..9a664276b 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -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") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d9a79d24c..ba4df58be 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; + 5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityTestsAppCoordinator.swift; sourceTree = ""; }; 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewController.swift; sourceTree = ""; }; @@ -2694,6 +2697,7 @@ C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = ""; }; C57DB49B8426AA721BF85D83 /* SpaceServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxyProtocol.swift; sourceTree = ""; }; C5AEB5907E24092D741718AF /* ResolveVerifiedUserSendFailureScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenCoordinator.swift; sourceTree = ""; }; + C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = ""; }; C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = ""; }; C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = ""; }; C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift index c9313837f..330658b81 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift @@ -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() } } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 1c9ce53c7..e213b00c4 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -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, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 94473fbb1..dceae5f49 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 15a6ea4bc..318dfb311 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -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. diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index e2baceff0..eefa64aed 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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) diff --git a/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift index 6fc062979..acdf5e253 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/KnockRequestsBannerView.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift new file mode 100644 index 000000000..a9880917c --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift @@ -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) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift index ff843187c..a101a213e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift @@ -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) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index e97be02a7..251d22738 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -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) }, diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index f1711a69f..3e852c643 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -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 = [] var bindings: TimelineViewStateBindings } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 4320da23c..10ebe5f4f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -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) diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift index de5f376db..1777242a8 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift @@ -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? } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift index 693f65dc2..a65300ffa 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift @@ -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 diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index 120df377b..22d26c76f 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -60,6 +60,12 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } func startLiveLocation(roomID: String, duration: Duration) async -> Result { + // 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 diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 5c038dfa5..72250e989 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -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) } } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 4177c4c75..3ea28663d 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -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. diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-en-GB.png index 46d8c6491..95688504a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cc21640dc10190ddfa345a83c8e4bedae2cca526f3a517980b917df1fd819c1 -size 92313 +oid sha256:1add71eec72332f3d0f4f56f45068eb44a27290d16b5699ae16d523180b21315 +size 30977 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-pseudo.png index 153e92d32..1b25cc94d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4119ef48dd28330cbe9989a5e2842795b2e14874a7e03d353267a7530041b475 -size 93248 +oid sha256:09e6a9bfffd30aed0d98e81d0f83363517948f42b178bdaa697839474c0b28d2 +size 31918 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-en-GB.png index ece0aab18..37169b39d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:123673378b70eba1774e7f738d0b9704344158dc3ac6a8316724628a6317e161 -size 50246 +oid sha256:183f69e4d8bff9139f39194fd86ab630806376f11b0d8e48b7fdbc34df5ba5cf +size 22773 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-pseudo.png index 2699af32a..ecaacf970 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Multiple-Requests-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3549c8ec426dbf2210ff9e7c0969ecdb7dc5b15d605d157b3754a9c09b0a8991 -size 49872 +oid sha256:2fb13539b94dc0e8ffec3949616b1f1d08b0c01cfeeca864d3a87f37cd7995a2 +size 23345 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-en-GB.png index 510baacd5..8bdc8751d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dca4b68102343046709e0d3430e0e2409b04f47fa6a35e8df489246be29500e2 -size 95109 +oid sha256:7ed343dc6fdfe8fe86931ac57bb5de221b893e67ea6740f09b03da2f0d5022f6 +size 33984 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-pseudo.png index 36d920680..0320ff0f1 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f20f61ab302e2a69373646f3e713e5bfe729d317ff19cf5362d2b67cf7b844d -size 98462 +oid sha256:c72a9cb9efee8db318182df426a39542100cb8f634646364f5ab2371e2eee743 +size 37324 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-en-GB.png index 36c64ff7d..efc5d0b66 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32cccc3b9096777d7385bb34b4f76d1ae023a4b4d96be3021dd2de7eae4b6526 -size 53001 +oid sha256:8df4c96c5f7ed1e16b00eed25ee489779b82108e616cf5fcd347b02479018fe2 +size 25336 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-pseudo.png index 37583f2fd..09df2f09f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-No-Display-Name-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6366953f27bdf503d01e7b8ff34396ac7ba5e514d93723aab6870229979a759 -size 61857 +oid sha256:41ae37d20af35cc9eafbc37a3a58551c8b08a7fce2f2e5c8b1016540d4a04391 +size 32666 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-en-GB.png index 19ef55087..2247fe51d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0ee54a8bd1364f456cedf7c8a5f6f0652d4e26f3d34bf3a14b8fa564ae41028 -size 97300 +oid sha256:1b7b864242e784d8ddc6f95be8f0e9e27c81b4fac495c587381b0602d520a8a5 +size 36897 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-pseudo.png index 566e6eeb3..e4c0df950 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cec2564a143dceb7ea8c6b23e1bb2f2f4626af08ae11dfe6b4365a4c95f96efa -size 102100 +oid sha256:435a74365d46ac1366bf2c6343d349cce8fec2766cb09b2d61c7f92187a4e285 +size 41658 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-en-GB.png index 77017eed8..523533cf2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af1e8fb046d261c1e7274cfd61033e1fef539b8f13af8b14045d36c237cf458c -size 52975 +oid sha256:410bbb9cf2020e28ae5da029aa7aa370c49351eb706692db7e1e976cde373a52 +size 25816 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-pseudo.png index 34dff8acb..787a4b085 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49301d759419c2418fe628957e6dbd14702a0625d6307c2dcc1f4a2dc1d715ef -size 63378 +oid sha256:573d09b7aba1e7a20a20090ad2baf4236f7024bdb7d266383a8ccbd68c525eec +size 35993 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-en-GB.png index abaaed0e5..c725b0421 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5a7b449241c47f37a05f16196e9f1478e861dba372cd740d8113471b4c64573 -size 92965 +oid sha256:4e4353be9b16318ec5774ed9ce19bcd7092dcb575958ed003ece8a7e8ce1e6f9 +size 32609 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-pseudo.png index 24c915836..ee195b37b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8c2022369fe6c681b6645fcc87485a390a548743b03ff4125f8aa01ff50985f -size 97357 +oid sha256:006f2ac00dc322f133db40b9c22d7e9a74bf06420487a333d01d4f12aa41ba77 +size 36956 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-en-GB.png index d5e34803b..a0faa71ed 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75f23e3dd857927ff19e65481a429f0b066543d938b6418cd742b2462d038817 -size 49340 +oid sha256:249790076d8003df15316cfa253a502fd44fb7dc66a6642dcf4cdcdab0b9d9e4 +size 22412 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-pseudo.png index c115a454f..a382fc66c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-no-accept-action-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c27af06d422419337c232ea0e67f23ae5efafc42d17986e56d141aa27ea71e4 -size 56198 +oid sha256:51461496517a802c737c245e510de941a176cf93f2759daf2c9e2e4053a41b36 +size 28381 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-en-GB.png index 3b6072b14..c73d4ab3c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1eca3d3966ed170902068948b0e06792f5a86a957bc5e9fa4ee12367db0ecfdc -size 110095 +oid sha256:b6316e6324dbd72e72dc0eb8cf09b80e7788c091746fbff2dd7f62220b146984 +size 52744 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-pseudo.png index 1ff7265eb..cf0b0e130 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3fbf972978286e7a63cbe2edbeb1c3af50ecb37752706307dc0b276a73d60fd -size 114871 +oid sha256:bf1476690cc3cccf2ce3966b6791510a44d65ba1370563fcc3a560b6fc548aba +size 57490 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-en-GB.png index 931fad9e3..915b2ae59 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e819430839323b67d25f477fe9de16caf0d4e3cdeb6950edcc105e6e1b95413a -size 65778 +oid sha256:2b94ac6d13906cddffe740a57b5c044adc9389a9d7514fe576d2e0e8080c923f +size 40671 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-pseudo.png index 9ac0f3989..757776767 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/knockRequestsBannerView.Single-Request-with-reason-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aff4b437d86ccd489cc49b675befcfb98660c4abfdca7d7e8a57d5f57877ce77 -size 75574 +oid sha256:7c637b790aff4bb5341be902d80681fc04618ac94816dc7f0f675048c0849645 +size 50880 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png new file mode 100644 index 000000000..e477b2a52 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae5fb963478cbc8abf7dc4732ea8f4295ba86efd9e917d2e167378a6e06520f6 +size 14937 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png new file mode 100644 index 000000000..01d678e0a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45a96c2f592cbc5cf7a2f1282679bc4b7d592e903df161f0e10c853b4401cd6e +size 18159 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png new file mode 100644 index 000000000..5ae8e9527 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:acc5d3b386e57b63e6e2386b96d3cde0eb1afa9b0cc8fc914e1495882e39b691 +size 10977 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png new file mode 100644 index 000000000..a9771462d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6126e63cf2456619c7e6d2212e6fbf4b6f5af7426df7241ca2039314827da64 +size 15642 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-en-GB-0.png index 50855d4a5..27a0c0320 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f727d2737e536d158ec5488ef7ae670bdf17b4f10789f560340b3c7b36cb2c76 -size 143061 +oid sha256:aaedf20685450b28deb7e16673a0f26a888fa345a55c603a68ac3d781e1aea42 +size 79416 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-pseudo-0.png index 4d41509e4..aca7eeb1b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:784b2e39ddcd2c1c04669c1e9dd00dddb9388b738afa283732bf389304d7b398 -size 170747 +oid sha256:4a8de9fae72c3bbcf8a5d205f989ecaa2c3d6313a4597a6b93027651e4cb2f3c +size 99670 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-en-GB-0.png index 7f05e0169..ee01bde3b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79abbb9b6c53da934a3451ed8d61d89c441500a5f6986e07c8fc9c32784afab6 -size 87380 +oid sha256:0f627cbfbd1c93ad7c77dd41ef3020f3c652ef53b64ebc51bfa30cd364538168 +size 52157 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-pseudo-0.png index bc6d21cca..3bc14c1c0 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedItemsBannerView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e196ada8c3f0f808868a80330e7a389d658519903cb51680d27ddfa4bd33ea60 -size 101585 +oid sha256:fc3309c3d3e7f290cc21e04d0cb239e8067e961bd4c4c56c9c102ec448852306 +size 57519 diff --git a/UnitTests/Sources/LiveLocationManagerTests.swift b/UnitTests/Sources/LiveLocationManagerTests.swift new file mode 100644 index 000000000..d323cd9d8 --- /dev/null +++ b/UnitTests/Sources/LiveLocationManagerTests.swift @@ -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 + } +}