mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Open Live Locations map on tapping on the banner (#5477)
* Implemented opening the LLS on our own user when tapping on the banner * added a test for moving the map as soon as the first update arrives. * updated preview tests
This commit is contained in:
@@ -26,7 +26,7 @@ enum LocationSharingScreenViewModelAction {
|
|||||||
enum LocationSharingInteractionMode: Hashable {
|
enum LocationSharingInteractionMode: Hashable {
|
||||||
case picker
|
case picker
|
||||||
case viewStatic(StaticLocationData)
|
case viewStatic(StaticLocationData)
|
||||||
case viewLive(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare)
|
case viewLive(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LocationSharingScreenViewState: BindableState {
|
struct LocationSharingScreenViewState: BindableState {
|
||||||
@@ -43,13 +43,17 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
case .viewStatic(let locationData):
|
case .viewStatic(let locationData):
|
||||||
.init(sender: locationData.sender)
|
.init(sender: locationData.sender)
|
||||||
case .viewLive(let sender, _):
|
case .viewLive(let sender, _):
|
||||||
.init(sender: sender)
|
if let sender {
|
||||||
|
.init(sender: sender)
|
||||||
|
} else {
|
||||||
|
.init(userID: ownUserID)
|
||||||
|
}
|
||||||
case .picker:
|
case .picker:
|
||||||
.init(userID: ownUserID)
|
.init(userID: ownUserID)
|
||||||
}
|
}
|
||||||
userProfiles = [initialProfile.userID: initialProfile]
|
userProfiles = [initialProfile.userID: initialProfile]
|
||||||
|
|
||||||
if case .viewLive(_, let initialLiveLocationShare) = interactionMode {
|
if case .viewLive(_, let initialLiveLocationShare) = interactionMode, let initialLiveLocationShare {
|
||||||
liveLocationShares = [initialLiveLocationShare]
|
liveLocationShares = [initialLiveLocationShare]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +116,12 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
case .viewStatic(let location):
|
case .viewStatic(let location):
|
||||||
.init(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude)
|
.init(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude)
|
||||||
case .viewLive(_, let initialLiveLocationShare):
|
case .viewLive(_, let initialLiveLocationShare):
|
||||||
.init(latitude: initialLiveLocationShare.geoURI?.latitude ?? 0,
|
if let initialLiveLocationShare {
|
||||||
longitude: initialLiveLocationShare.geoURI?.longitude ?? 0)
|
.init(latitude: initialLiveLocationShare.geoURI?.latitude ?? 0,
|
||||||
|
longitude: initialLiveLocationShare.geoURI?.longitude ?? 0)
|
||||||
|
} else {
|
||||||
|
.init(latitude: 49.843, longitude: 9.902056)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
private var authorizationStatusSubscription: AnyCancellable?
|
private var authorizationStatusSubscription: AnyCancellable?
|
||||||
// periphery:ignore - keep alive to keep receiving updates.
|
// periphery:ignore - keep alive to keep receiving updates.
|
||||||
private var liveLocationService: RoomLiveLocationServiceProtocol?
|
private var liveLocationService: RoomLiveLocationServiceProtocol?
|
||||||
|
private var needsCenteringOnFirstLiveLocationUpdate = false
|
||||||
|
|
||||||
init(interactionMode: LocationSharingInteractionMode,
|
init(interactionMode: LocationSharingInteractionMode,
|
||||||
mapURLBuilder: MapTilerURLBuilderProtocol,
|
mapURLBuilder: MapTilerURLBuilderProtocol,
|
||||||
@@ -56,7 +57,10 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
updateUserProfiles(members: roomProxy.membersPublisher.value)
|
updateUserProfiles(members: roomProxy.membersPublisher.value)
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
|
|
||||||
if case .viewLive = interactionMode {
|
if case .viewLive(_, let initialLiveLocation) = interactionMode {
|
||||||
|
if initialLiveLocation == nil {
|
||||||
|
needsCenteringOnFirstLiveLocationUpdate = true
|
||||||
|
}
|
||||||
Task { await setupLiveLocationSubscription() }
|
Task { await setupLiveLocationSubscription() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
.sink { [weak self] liveLocationsShares in
|
.sink { [weak self] liveLocationsShares in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
MXLog.info("Received live location shares update: \(liveLocationsShares.count) share(s)")
|
MXLog.info("Received live location shares update: \(liveLocationsShares.count) share(s)")
|
||||||
|
|
||||||
let ownUserID = roomProxy.ownUserID
|
let ownUserID = roomProxy.ownUserID
|
||||||
let isStoppingLiveLocation = state.isStoppingLiveLocation
|
let isStoppingLiveLocation = state.isStoppingLiveLocation
|
||||||
state.liveLocationShares = liveLocationsShares
|
state.liveLocationShares = liveLocationsShares
|
||||||
@@ -118,7 +123,15 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
if rhs.userID == ownUserID { return false }
|
if rhs.userID == ownUserID { return false }
|
||||||
return lhs.timestamp > rhs.timestamp
|
return lhs.timestamp > rhs.timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserProfiles(members: roomProxy.membersPublisher.value)
|
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)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
@@ -147,7 +160,9 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
state.userProfiles = [sender.userID: sender]
|
state.userProfiles = [sender.userID: sender]
|
||||||
case .viewLive(let sender, _):
|
case .viewLive(let sender, _):
|
||||||
var userIDs = Set(state.liveLocationShares.map(\.userID))
|
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
|
state.userProfiles = userIDs.reduce(into: [:]) { dict, userID in
|
||||||
if let member = members.first(where: { $0.userID == userID }) {
|
if let member = members.first(where: { $0.userID == userID }) {
|
||||||
dict[userID] = UserProfileProxy(member: member)
|
dict[userID] = UserProfileProxy(member: member)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ enum RoomScreenCoordinatorAction {
|
|||||||
case presentLocationPicker
|
case presentLocationPicker
|
||||||
case presentPollForm(mode: PollFormMode)
|
case presentPollForm(mode: PollFormMode)
|
||||||
case presentLocationViewer(StaticLocationData)
|
case presentLocationViewer(StaticLocationData)
|
||||||
case presentLiveLocationViewer(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare)
|
case presentLiveLocationViewer(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?)
|
||||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||||
case presentRoomMemberDetails(userID: String)
|
case presentRoomMemberDetails(userID: String)
|
||||||
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
|
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||||
@@ -197,6 +197,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
|||||||
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
|
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
|
||||||
case .stopLiveLocationSharing:
|
case .stopLiveLocationSharing:
|
||||||
Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() }
|
Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() }
|
||||||
|
case .displayLiveLocation:
|
||||||
|
actionsSubject.send(.presentLiveLocationViewer(sender: nil, initialLiveLocationShare: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ enum RoomScreenViewModelAction: Equatable {
|
|||||||
case displayRoom(roomID: String, via: [String])
|
case displayRoom(roomID: String, via: [String])
|
||||||
case displayMessageForwarding(MessageForwardingItem)
|
case displayMessageForwarding(MessageForwardingItem)
|
||||||
case stopLiveLocationSharing
|
case stopLiveLocationSharing
|
||||||
|
case displayLiveLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomScreenViewAction {
|
enum RoomScreenViewAction {
|
||||||
@@ -34,6 +35,7 @@ enum RoomScreenViewAction {
|
|||||||
case viewKnockRequests
|
case viewKnockRequests
|
||||||
case displaySuccessorRoom
|
case displaySuccessorRoom
|
||||||
case displayThreadList
|
case displayThreadList
|
||||||
|
case tappedOpenLiveLocation
|
||||||
case tappedStopLiveLocation
|
case tappedStopLiveLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
actionsSubject.send(.displayThreadList)
|
actionsSubject.send(.displayThreadList)
|
||||||
case .tappedStopLiveLocation:
|
case .tappedStopLiveLocation:
|
||||||
actionsSubject.send(.stopLiveLocationSharing)
|
actionsSubject.send(.stopLiveLocationSharing)
|
||||||
|
case .tappedOpenLiveLocation:
|
||||||
|
actionsSubject.send(.displayLiveLocation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,22 @@ import Compound
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LiveLocationSharingBannerView: View {
|
struct LiveLocationSharingBannerView: View {
|
||||||
|
var onTap: () -> Void
|
||||||
var onStop: () -> Void
|
var onStop: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 9) {
|
Button { onTap() } label: {
|
||||||
CompoundIcon(\.locationPinSolid, size: .medium, relativeTo: .compound.bodyMDSemibold)
|
HStack(spacing: 9) {
|
||||||
.foregroundColor(Color.compound.iconSuccessPrimary)
|
CompoundIcon(\.locationPinSolid, size: .medium, relativeTo: .compound.bodyMDSemibold)
|
||||||
.accessibilityHidden(true)
|
.foregroundColor(Color.compound.iconSuccessPrimary)
|
||||||
Text(L10n.screenRoomLiveLocationBanner)
|
.accessibilityHidden(true)
|
||||||
.font(.compound.bodyMDSemibold)
|
Text(L10n.screenRoomLiveLocationBanner)
|
||||||
.foregroundColor(.compound.textPrimary)
|
.font(.compound.bodyMDSemibold)
|
||||||
Spacer()
|
.foregroundColor(.compound.textPrimary)
|
||||||
Button(L10n.actionStop, role: .destructive, action: onStop)
|
Spacer()
|
||||||
.buttonStyle(.compound(.primary, size: .small))
|
Button(L10n.actionStop, role: .destructive, action: onStop)
|
||||||
|
.buttonStyle(.compound(.primary, size: .small))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 15)
|
||||||
@@ -33,7 +36,7 @@ struct LiveLocationSharingBannerView: View {
|
|||||||
|
|
||||||
struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview {
|
struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
LiveLocationSharingBannerView { }
|
LiveLocationSharingBannerView { } onStop: { }
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
private var liveLocationBanner: some View {
|
private var liveLocationBanner: some View {
|
||||||
LiveLocationSharingBannerView {
|
LiveLocationSharingBannerView {
|
||||||
|
context.send(viewAction: .tappedOpenLiveLocation)
|
||||||
|
} onStop: {
|
||||||
context.send(viewAction: .tappedStopLiveLocation)
|
context.send(viewAction: .tappedStopLiveLocation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:286c908b21be4e8932b45a93ef3a6f6c78fac4d4aae06c68345b79a3e061a2ca
|
oid sha256:1741b7989a06883d07a7bacb19008e4b0a4e9ab73122f07d495bd133680ff5ca
|
||||||
size 14928
|
size 15199
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a04f3632ea5cad7c43c0d809c26405e4e70489ccad0b7591644c1117103c3475
|
oid sha256:88d9fb25d56157114167e0ddc0b4bd95949c82de3d9c1f6cf68189dc011fa1b3
|
||||||
size 18155
|
size 17366
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7599140166ba4f130f5eb3ad1b6a1459f24aab7a906d4911263263b3d2ba4778
|
oid sha256:3f32e6bb7407d48c5e8635e408d2748e50c67a49243b91cf81d4ee8258664992
|
||||||
size 10974
|
size 11203
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:749ed4f6110a00b1b5cab1cf32729120d7da3a10f3f066ffb1398247c92a172c
|
oid sha256:899e4641a39b58bc3b919398ffa1fc4a4d067f1feff1ae8713ef347c934749bf
|
||||||
size 15637
|
size 15992
|
||||||
|
|||||||
@@ -379,6 +379,49 @@ final class LocationSharingScreenViewModelTests {
|
|||||||
#expect(annotations.first { $0.id == "@charlie:matrix.org" }?.kind == .liveUser(.init(userID: "@charlie:matrix.org", displayName: "Charlie")))
|
#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
|
// MARK: - Private
|
||||||
|
|
||||||
private func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) {
|
private func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user