diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 728919633..3af559547 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -26,7 +26,7 @@ enum LocationSharingScreenViewModelAction { enum LocationSharingInteractionMode: Hashable { case picker case viewStatic(StaticLocationData) - case viewLive(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare) + case viewLive(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?) } struct LocationSharingScreenViewState: BindableState { @@ -43,13 +43,17 @@ struct LocationSharingScreenViewState: BindableState { case .viewStatic(let locationData): .init(sender: locationData.sender) case .viewLive(let sender, _): - .init(sender: sender) + if let sender { + .init(sender: sender) + } else { + .init(userID: ownUserID) + } case .picker: .init(userID: ownUserID) } userProfiles = [initialProfile.userID: initialProfile] - if case .viewLive(_, let initialLiveLocationShare) = interactionMode { + if case .viewLive(_, let initialLiveLocationShare) = interactionMode, let initialLiveLocationShare { liveLocationShares = [initialLiveLocationShare] } @@ -112,8 +116,12 @@ struct LocationSharingScreenViewState: BindableState { case .viewStatic(let location): .init(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude) case .viewLive(_, let initialLiveLocationShare): - .init(latitude: initialLiveLocationShare.geoURI?.latitude ?? 0, - longitude: initialLiveLocationShare.geoURI?.longitude ?? 0) + if let initialLiveLocationShare { + .init(latitude: initialLiveLocationShare.geoURI?.latitude ?? 0, + longitude: initialLiveLocationShare.geoURI?.longitude ?? 0) + } else { + .init(latitude: 49.843, longitude: 9.902056) + } } } diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift index 5578be484..76fc56647 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift @@ -29,6 +29,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati private var authorizationStatusSubscription: AnyCancellable? // periphery:ignore - keep alive to keep receiving updates. private var liveLocationService: RoomLiveLocationServiceProtocol? + private var needsCenteringOnFirstLiveLocationUpdate = false init(interactionMode: LocationSharingInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol, @@ -56,7 +57,10 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati updateUserProfiles(members: roomProxy.membersPublisher.value) setupSubscriptions() - if case .viewLive = interactionMode { + if case .viewLive(_, let initialLiveLocation) = interactionMode { + if initialLiveLocation == nil { + needsCenteringOnFirstLiveLocationUpdate = true + } Task { await setupLiveLocationSubscription() } } } @@ -109,6 +113,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati .sink { [weak self] liveLocationsShares in guard let self else { return } MXLog.info("Received live location shares update: \(liveLocationsShares.count) share(s)") + let ownUserID = roomProxy.ownUserID let isStoppingLiveLocation = state.isStoppingLiveLocation state.liveLocationShares = liveLocationsShares @@ -118,7 +123,15 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati if rhs.userID == ownUserID { return false } return lhs.timestamp > rhs.timestamp } + updateUserProfiles(members: roomProxy.membersPublisher.value) + + if needsCenteringOnFirstLiveLocationUpdate, + let liveLocation = state.liveLocationShares.first, + let geoURI = liveLocation.geoURI { + needsCenteringOnFirstLiveLocationUpdate = false + context.send(viewAction: .setMapCenter(.init(latitude: geoURI.latitude, longitude: geoURI.longitude))) + } } .store(in: &cancellables) } @@ -147,7 +160,9 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati state.userProfiles = [sender.userID: sender] case .viewLive(let sender, _): var userIDs = Set(state.liveLocationShares.map(\.userID)) - userIDs.insert(sender.id) + if let senderID = sender?.id { + userIDs.insert(senderID) + } state.userProfiles = userIDs.reduce(into: [:]) { dict, userID in if let member = members.first(where: { $0.userID == userID }) { dict[userID] = UserProfileProxy(member: member) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index d7af45926..ce6dd7c50 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -40,7 +40,7 @@ enum RoomScreenCoordinatorAction { case presentLocationPicker case presentPollForm(mode: PollFormMode) case presentLocationViewer(StaticLocationData) - case presentLiveLocationViewer(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare) + case presentLiveLocationViewer(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?) case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) case presentRoomMemberDetails(userID: String) case presentMessageForwarding(forwardingItem: MessageForwardingItem) @@ -197,6 +197,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID)) case .stopLiveLocationSharing: Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() } + case .displayLiveLocation: + actionsSubject.send(.presentLiveLocationViewer(sender: nil, initialLiveLocationShare: nil)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 10321cb0b..0381f74d9 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -21,6 +21,7 @@ enum RoomScreenViewModelAction: Equatable { case displayRoom(roomID: String, via: [String]) case displayMessageForwarding(MessageForwardingItem) case stopLiveLocationSharing + case displayLiveLocation } enum RoomScreenViewAction { @@ -34,6 +35,7 @@ enum RoomScreenViewAction { case viewKnockRequests case displaySuccessorRoom case displayThreadList + case tappedOpenLiveLocation case tappedStopLiveLocation } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 907084335..b90318d8f 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -124,6 +124,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actionsSubject.send(.displayThreadList) case .tappedStopLiveLocation: actionsSubject.send(.stopLiveLocationSharing) + case .tappedOpenLiveLocation: + actionsSubject.send(.displayLiveLocation) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift index a9880917c..c6f7496f5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/LiveLocationSharingBannerView.swift @@ -9,19 +9,22 @@ import Compound import SwiftUI struct LiveLocationSharingBannerView: View { + var onTap: () -> Void 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)) + Button { onTap() } label: { + 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) @@ -33,7 +36,7 @@ struct LiveLocationSharingBannerView: View { struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview { static var previews: some View { - LiveLocationSharingBannerView { } + LiveLocationSharingBannerView { } onStop: { } .previewLayout(.sizeThatFits) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 23b17547d..c97918480 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -88,6 +88,8 @@ struct RoomScreen: View { private var liveLocationBanner: some View { LiveLocationSharingBannerView { + context.send(viewAction: .tappedOpenLiveLocation) + } onStop: { context.send(viewAction: .tappedStopLiveLocation) } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png index cf6f05641..2e09201ec 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:286c908b21be4e8932b45a93ef3a6f6c78fac4d4aae06c68345b79a3e061a2ca -size 14928 +oid sha256:1741b7989a06883d07a7bacb19008e4b0a4e9ab73122f07d495bd133680ff5ca +size 15199 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png index 0b9f37fde..782dbf265 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a04f3632ea5cad7c43c0d809c26405e4e70489ccad0b7591644c1117103c3475 -size 18155 +oid sha256:88d9fb25d56157114167e0ddc0b4bd95949c82de3d9c1f6cf68189dc011fa1b3 +size 17366 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png index 4d56e1af7..dcde4ca50 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7599140166ba4f130f5eb3ad1b6a1459f24aab7a906d4911263263b3d2ba4778 -size 10974 +oid sha256:3f32e6bb7407d48c5e8635e408d2748e50c67a49243b91cf81d4ee8258664992 +size 11203 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png index 2a452aec4..a4b30f7f0 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationSharingBannerView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:749ed4f6110a00b1b5cab1cf32729120d7da3a10f3f066ffb1398247c92a172c -size 15637 +oid sha256:899e4641a39b58bc3b919398ffa1fc4a4d067f1feff1ae8713ef347c934749bf +size 15992 diff --git a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift index fcd0a03e9..86ebedf94 100644 --- a/UnitTests/Sources/LocationSharingScreenViewModelTests.swift +++ b/UnitTests/Sources/LocationSharingScreenViewModelTests.swift @@ -379,6 +379,49 @@ final class LocationSharingScreenViewModelTests { #expect(annotations.first { $0.id == "@charlie:matrix.org" }?.kind == .liveUser(.init(userID: "@charlie:matrix.org", displayName: "Charlie"))) } + @Test + func viewLiveFromBannerAwaitsFirstShareThenCentersOnIt() async throws { + // Simulates opening from the banner: no sender info and no initial share are available yet. + // The VM should wait for the first live location update and then center on the first share, + // which is assumed to belong to the own user. + let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([]) + + let liveLocationServiceMock = RoomLiveLocationServiceMock() + liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher() + + let roomProxyMock = JoinedRoomProxyMock(.init(members: .allMembers)) + roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock + + viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil), + mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, + liveLocationSharingEnabled: true, + roomProxy: roomProxyMock, + timelineController: MockTimelineController(timelineProxy: TimelineProxyMock(.init())), + liveLocationManager: LiveLocationManagerMock(.init()), + analytics: ServiceLocator.shared.analytics, + userIndicatorController: UserIndicatorControllerMock(), + mediaProvider: MediaProviderMock(configuration: .init())) + + // Initially no annotations and no map center since sender and share are both nil. + #expect(context.viewState.annotations.isEmpty) + #expect(context.mapCenterLocation == nil) + + // Once the first update arrives, the VM populates annotations and centers the map on the first share. + let ownUserShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 51.5, longitude: -0.1) + let deferred = deferFulfillment(context.observe(\.viewState.annotations)) { !$0.isEmpty } + liveLocationsSubject.send([ownUserShare]) + try await deferred.fulfill() + + #expect(context.viewState.annotations.count == 1) + #expect(context.viewState.annotations.first?.id == RoomMemberProxyMock.mockMe.userID) + #expect(context.viewState.annotations.first?.coordinate.latitude == 51.5) + #expect(context.viewState.annotations.first?.coordinate.longitude == -0.1) + + // The map should have been centered on the first received share's coordinates. + #expect(context.mapCenterLocation?.latitude == 51.5) + #expect(context.mapCenterLocation?.longitude == -0.1) + } + // MARK: - Private private func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) {