Refactor and redesign Location Sharing Screen (#5155)

* refactored the static location screen to the location sharing screen

# Conflicts:
#	UnitTests/Sources/LocationSharingScreenViewModelTests.swift

# Conflicts:
#	ElementX.xcodeproj/project.pbxproj

* implemented a custom pin with an overlayable view

* implemented the render of the user when the location is sender instead of the pin type

* removed description and body they are not used at all.

* reimplemented single button for sharing this or user location + implemented an experimental way to update annotations

* removed unnecessary @Suite description

* implemented a way to display the alert on top of the sheet and added a spinner to the center user location button

* fixed alerts strings

* fixed a failing test

* improved preview tests
This commit is contained in:
Mauro
2026-03-06 12:51:16 +01:00
committed by GitHub
parent 0c79b5404d
commit bbbb2e9dee
85 changed files with 917 additions and 626 deletions
@@ -64,6 +64,8 @@ final class AppSettings {
case elementCallBaseURLOverride
case voiceMessagePlaybackSpeed
// Feature flags
case publicSearchEnabled
case fuzzyRoomListSearchEnabled
@@ -76,9 +78,8 @@ final class AppSettings {
case linkPreviewsEnabled
case focusEventOnNotificationTap
case linkNewDeviceEnabled
case liveLocationSharingEnabled
case voiceMessagePlaybackSpeed
// Doug's tweaks 🔧
case hideUnreadMessagesBadge
case hideQuietNotificationAlerts
@@ -432,6 +433,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.linkNewDeviceEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkNewDeviceEnabled
@UserPreference(key: UserDefaultsKeys.liveLocationSharingEnabled, defaultValue: false, storageType: .userDefaults(store))
var liveLocationSharingEnabled
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType == .debug, storageType: .userDefaults(store))
var developerOptionsEnabled
}
@@ -85,8 +85,10 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.finished)
case .displayUser(let userID):
actionsSubject.send(.displayUser(userID: userID))
case .presentLocationViewer(let geoURI, let description):
presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController)
case .presentLocationViewer(let senderID, let geoURI):
presentMapNavigator(senderID: senderID,
geoURI: geoURI,
timelineController: timelineController)
case .displayMessageForwarding(let forwardingItem):
presentMessageForwarding(with: forwardingItem)
case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
@@ -98,16 +100,22 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.setRootCoordinator(coordinator)
}
private func presentMapNavigator(geoURI: GeoURI, description: String?, timelineController: TimelineControllerProtocol) {
private func presentMapNavigator(senderID: String?,
geoURI: GeoURI,
timelineController: TimelineControllerProtocol) {
let stackCoordinator = NavigationStackCoordinator()
let params = StaticLocationScreenCoordinatorParameters(interactionMode: .viewOnly(geoURI: geoURI, description: description),
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = StaticLocationScreenCoordinator(parameters: params)
let params = LocationSharingScreenCoordinatorParameters(interactionMode: .viewStatic(senderID: senderID,
geoURI: geoURI),
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
roomProxy: roomProxy,
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController,
mediaProvider: flowParameters.userSession.mediaProvider)
let coordinator = LocationSharingScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -703,8 +703,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentPollForm(let mode):
stateMachine.tryEvent(.presentPollForm(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentLocationViewer(_, let geoURI, let description):
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI, description: description)),
case .presentLocationViewer(let senderID, let geoURI):
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID, geoURI: geoURI)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentRoomMemberDetails(userID: let userID):
stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID)))
@@ -788,10 +788,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentPollForm(let mode):
stateMachine.tryEvent(.presentPollForm(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentLocationViewer(_, let geoURI, let description):
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI,
description: description)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentLocationViewer(let senderID, let geoURI):
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID,
geoURI: geoURI)),
userInfo: EventUserInfo(animated: animated,
timelineController: timelineController))
case .presentEmojiPicker(let itemID, let selectedEmojis):
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
@@ -1128,18 +1129,21 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentMapNavigator(interactionMode: StaticLocationInteractionMode,
private func presentMapNavigator(interactionMode: LocationSharingInteractionMode,
timelineController: TimelineControllerProtocol,
animated: Bool) {
let stackCoordinator = NavigationStackCoordinator()
let params = StaticLocationScreenCoordinatorParameters(interactionMode: interactionMode,
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = StaticLocationScreenCoordinator(parameters: params)
let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode,
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
roomProxy: roomProxy,
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController,
mediaProvider: flowParameters.userSession.mediaProvider)
let coordinator = LocationSharingScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -144,7 +144,7 @@ extension RoomFlowCoordinator {
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case dismissEmojiPicker
case presentMapNavigator(interactionMode: StaticLocationInteractionMode)
case presentMapNavigator(interactionMode: LocationSharingInteractionMode)
case dismissMapNavigator
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
+13 -3
View File
@@ -116,6 +116,8 @@ internal enum L10n {
internal static var a11yShowPassword: String { return L10n.tr("Localizable", "a11y_show_password") }
/// Start a call
internal static var a11yStartCall: String { return L10n.tr("Localizable", "a11y_start_call") }
/// Start a voice call
internal static var a11yStartVoiceCall: String { return L10n.tr("Localizable", "a11y_start_voice_call") }
/// Tombstoned room
internal static var a11yTombstonedRoom: String { return L10n.tr("Localizable", "a11y_tombstoned_room") }
/// User menu
@@ -584,6 +586,8 @@ internal enum L10n {
internal static var commonOpticIdIos: String { return L10n.tr("Localizable", "common_optic_id_ios") }
/// or
internal static var commonOr: String { return L10n.tr("Localizable", "common_or") }
/// Other options
internal static var commonOtherOptions: String { return L10n.tr("Localizable", "common_other_options") }
/// Password
internal static var commonPassword: String { return L10n.tr("Localizable", "common_password") }
/// People
@@ -908,8 +912,10 @@ internal enum L10n {
internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") }
/// Please grant the permission in the system settings.
internal static var dialogPermissionGeneric: String { return L10n.tr("Localizable", "dialog_permission_generic") }
/// To share your current location, Element needs location access. Go to Settings > Location.
internal static var dialogPermissionLocationDescriptionIos: String { return L10n.tr("Localizable", "dialog_permission_location_description_ios") }
/// To share your current location, %1$@ needs location access. Go to Settings > Location.
internal static func dialogPermissionLocationDescriptionIos(_ p1: Any) -> String {
return L10n.tr("Localizable", "dialog_permission_location_description_ios", String(describing: p1))
}
/// %1$@ does not have access to your location.
internal static func dialogPermissionLocationTitleIos(_ p1: Any) -> String {
return L10n.tr("Localizable", "dialog_permission_location_title_ios", String(describing: p1))
@@ -1062,6 +1068,8 @@ internal enum L10n {
internal static func notificationFallbackNContent(_ p1: Int) -> String {
return L10n.tr("Localizable", "notification_fallback_n_content", p1)
}
/// 📞 Incoming call
internal static var notificationIncomingAudioCall: String { return L10n.tr("Localizable", "notification_incoming_audio_call") }
/// 📹 Incoming call
internal static var notificationIncomingCall: String { return L10n.tr("Localizable", "notification_incoming_call") }
/// ** Failed to send - please open room
@@ -2238,6 +2246,8 @@ internal enum L10n {
internal static var screenOnboardingSignInWithQrCode: String { return L10n.tr("Localizable", "screen_onboarding_sign_in_with_qr_code") }
/// Create account
internal static var screenOnboardingSignUp: String { return L10n.tr("Localizable", "screen_onboarding_sign_up") }
/// Welcome back
internal static var screenOnboardingWelcomeBack: String { return L10n.tr("Localizable", "screen_onboarding_welcome_back") }
/// Welcome to the fastest %1$@ ever. Supercharged for speed and simplicity.
internal static func screenOnboardingWelcomeMessage(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_onboarding_welcome_message", String(describing: p1))
@@ -3157,7 +3167,7 @@ internal enum L10n {
internal static var screenShareOpenGoogleMaps: String { return L10n.tr("Localizable", "screen_share_open_google_maps") }
/// Open in OpenStreetMap
internal static var screenShareOpenOsmMaps: String { return L10n.tr("Localizable", "screen_share_open_osm_maps") }
/// Share this location
/// Share pinned location
internal static var screenShareThisLocationAction: String { return L10n.tr("Localizable", "screen_share_this_location_action") }
/// You’ve changed your password on another session
internal static var screenSignedOutReason1: String { return L10n.tr("Localizable", "screen_signed_out_reason_1") }
+38 -57
View File
@@ -84,55 +84,38 @@ enum UserAvatarSizeOnScreen {
case sendInviteConfirmation
case sessionVerification
case threadSummary
case map
var value: CGFloat {
switch self {
case .chats, .spaces:
return 32
case .timeline:
return 32
case .readReceipt:
return 16
case .readReceiptSheet:
return 32
16
case .spaceHeader:
return 20
case .completionSuggestions:
return 32
case .blockedUsers:
return 32
case .settings:
return 52
case .roomDetails:
return 44
case .roomMembersList:
return 32
case .roomChangeRoles:
return 56
case .startChat:
return 36
case .memberDetails:
return 96
case .inviteUsers:
return 52
case .editUserDetails:
return 96
case .dmDetails:
return 75
case .knockingUsersBannerStack:
return 28
case .knockingUserBanner:
return 32
case .knockingUserList:
return 52
case .mediaPreviewDetails:
return 32
case .sendInviteConfirmation:
return 64
case .sessionVerification:
return 52
20
case .threadSummary:
return 24
24
case .knockingUsersBannerStack:
28
case .chats, .spaces, .map,
.timeline, .readReceiptSheet, .completionSuggestions,
.blockedUsers, .roomMembersList, .knockingUserBanner,
.mediaPreviewDetails:
32
case .startChat:
36
case .roomDetails:
44
case .inviteUsers, .knockingUserList, .sessionVerification,
.settings:
52
case .roomChangeRoles:
56
case .sendInviteConfirmation:
64
case .dmDetails:
75
case .memberDetails, .editUserDetails:
96
}
}
}
@@ -160,24 +143,22 @@ enum RoomAvatarSizeOnScreen {
var value: CGFloat {
switch self {
case .chats, .spaces, .spaceSettings:
return 52
case .notificationSettings:
30
case .timeline, .leaveSpace, .roomDirectorySearch,
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
.spaceFilters:
return 32
case .notificationSettings:
return 30
case .messageForwarding, .globalSearch, .roomSelection, .spaceAddRooms:
return 36
case .spaceAddRoomsSelected:
return 52
32
case .messageForwarding, .globalSearch, .roomSelection,
.spaceAddRooms:
36
case .chats, .spaces, .spaceSettings,
.spaceAddRoomsSelected:
52
case .joinRoom, .spaceHeader:
64
case .details:
return 96
case .joinRoom:
return 64
case .spaceHeader:
return 64
96
}
}
}
@@ -11,23 +11,32 @@ import MapLibre
import SwiftUI
final class LocationAnnotation: NSObject, MLNAnnotation {
let coordinate: CLLocationCoordinate2D
let id: String
var coordinate: CLLocationCoordinate2D
let anchorPoint: CGPoint
let view: AnyView
var view: AnyView
// MARK: - Setup
init(coordinate: CLLocationCoordinate2D,
init(id: String = UUID().uuidString,
coordinate: CLLocationCoordinate2D,
anchorPoint: CGPoint = .init(x: 0.5, y: 0.5),
@ViewBuilder label: () -> some View) {
self.id = id
self.coordinate = coordinate
self.anchorPoint = anchorPoint
view = AnyView(label())
super.init()
}
func updateView(@ViewBuilder label: () -> some View) {
view = AnyView(label())
}
}
final class LocationAnnotationView: MLNUserLocationAnnotationView {
private var hostingController: UIHostingController<AnyView>?
// MARK: - Setup
override init(annotation: MLNAnnotation?, reuseIdentifier: String?) {
@@ -37,12 +46,21 @@ final class LocationAnnotationView: MLNUserLocationAnnotationView {
convenience init(annotation: LocationAnnotation) {
self.init(annotation: annotation, reuseIdentifier: "\(Self.self)")
let view: UIView = UIHostingController(rootView: annotation.view).view
let hostingController = UIHostingController(rootView: annotation.view)
self.hostingController = hostingController
let view: UIView = hostingController.view
view.backgroundColor = .clear
view.anchorPoint = annotation.anchorPoint
addSubview(view)
view.bounds.size = view.intrinsicContentSize
}
func updateContent(with view: AnyView) {
hostingController?.rootView = view
if let hostedView = hostingController?.view {
hostedView.bounds.size = hostedView.intrinsicContentSize
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
@@ -20,10 +20,10 @@ struct MapLibreMapView: UIViewRepresentable {
/// The initial map center
let mapCenter: CLLocationCoordinate2D
/// Map annotations
let annotations: [LocationAnnotation]
/// Map annotations keyed by a stable identifier (e.g. sender userID for user pins, UUID string for generic pins)
let annotations: [String: LocationAnnotation]
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [String: LocationAnnotation] = [:]) {
self.zoomLevel = zoomLevel
self.initialZoomLevel = initialZoomLevel
self.mapCenter = mapCenter
@@ -45,6 +45,7 @@ struct MapLibreMapView: UIViewRepresentable {
@Binding var error: MapLibreError?
/// Coordinate of the center of the map
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
@Binding var hasLoadedUserLocation: Bool
@Binding var isLocationAuthorized: Bool?
/// The radius of uncertainty for the location, measured in meters.
@Binding var geolocationUncertainty: CLLocationAccuracy?
@@ -70,6 +71,11 @@ struct MapLibreMapView: UIViewRepresentable {
mapView.styleURL = dynamicMapURL
}
// Update existing annotation views with fresh SwiftUI content.
// This handles the case where the annotation's view data changes after
// the annotation was initially placed (e.g. user avatar loads asynchronously).
updateAnnotations(in: mapView)
showUserLocation(in: mapView)
}
@@ -80,17 +86,52 @@ struct MapLibreMapView: UIViewRepresentable {
// MARK: - Private
private func setupMap(mapView: MLNMapView, with options: Options) {
mapView.addAnnotations(options.annotations)
mapView.addAnnotations(Array(options.annotations.values))
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
mapView.centerCoordinate = options.mapCenter
}
private func updateAnnotations(in mapView: MLNMapView) {
let existingByID = Dictionary(uniqueKeysWithValues:
(mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) })
let existingIDs = Set(existingByID.keys)
let updatedIDs = Set(options.annotations.keys)
// Remove annotations that are no longer present
let removedIDs = existingIDs.subtracting(updatedIDs)
if !removedIDs.isEmpty {
let toRemove = removedIDs.compactMap { existingByID[$0] }
mapView.removeAnnotations(toRemove)
}
// Add new annotations
let addedIDs = updatedIDs.subtracting(existingIDs)
if !addedIDs.isEmpty {
let toAdd = addedIDs.compactMap { options.annotations[$0] }
mapView.addAnnotations(toAdd)
}
// Update existing annotations that are still present
let keptIDs = existingIDs.intersection(updatedIDs)
for id in keptIDs {
guard let existingAnnotation = existingByID[id],
let updatedAnnotation = options.annotations[id] else {
continue
}
existingAnnotation.coordinate = updatedAnnotation.coordinate
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
annotationView.updateContent(with: updatedAnnotation.view)
}
}
}
private func makeMapView() -> MLNMapView {
let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light))
mapView.logoViewPosition = .topLeft
mapView.attributionButtonPosition = .topLeft
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
mapView.tintColor = .black
mapView.tintColor = .compound.iconAccentPrimary
mapView.allowsRotating = false
mapView.allowsTilting = false
return mapView
@@ -149,7 +190,8 @@ extension MapLibreMapView {
func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) {
guard let userLocation else { return }
mapLibreView.hasLoadedUserLocation = true
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
@@ -18,12 +18,12 @@ final class ShareToMapsAppActivity: UIActivity {
private let type: MapsAppType
private let location: CLLocationCoordinate2D
private let locationDescription: String?
private let senderName: String?
init(type: MapsAppType, location: CLLocationCoordinate2D, locationDescription: String?) {
init(type: MapsAppType, location: CLLocationCoordinate2D, senderName: String?) {
self.type = type
self.location = location
self.locationDescription = locationDescription
self.senderName = senderName
super.init()
}
@@ -44,20 +44,20 @@ final class ShareToMapsAppActivity: UIActivity {
}
override func prepare(withActivityItems activityItems: [Any]) {
UIApplication.shared.open(type.activityURL(for: location, locationDescription: locationDescription), options: [:]) { [weak self] result in
UIApplication.shared.open(type.activityURL(for: location, senderName: senderName), options: [:]) { [weak self] result in
self?.activityDidFinish(result)
}
}
}
extension ShareToMapsAppActivity.MapsAppType {
func activityURL(for location: CLLocationCoordinate2D, locationDescription: String?) -> URL {
func activityURL(for location: CLLocationCoordinate2D, senderName: String?) -> URL {
switch self {
case .apple:
var url: URL = "https://maps.apple.com/"
url.append(queryItems: [
.init(name: "ll", value: "\(location.latitude),\(location.longitude)"),
.init(name: "q", value: locationDescription ?? "Pin")
.init(name: "q", value: senderName ?? "Pin") // We need to provide a value or no marker is displayed.
])
return url
case .google:
@@ -0,0 +1,30 @@
//
// 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.
//
import SwiftUI
struct RowDivider: ViewModifier {
@Environment(\.pixelLength) var pixelLength: CGFloat
let alignment: Alignment
let horizontalInsets: CGFloat
func body(content: Content) -> some View {
content
.overlay(alignment: alignment) {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: pixelLength)
.padding(.trailing, -horizontalInsets)
}
}
}
extension View {
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat) -> some View {
modifier(RowDivider(alignment: alignment, horizontalInsets: horizontalInsets))
}
}
@@ -10,28 +10,84 @@ import Compound
import SwiftUI
struct LocationMarkerView: View {
private let pinColor: Color = .compound.iconOnSolidPrimary
private let pinInsets = EdgeInsets(top: 13, leading: 12, bottom: 15, trailing: 12)
var userProfile: UserProfileProxy?
var fillColor = Color.compound.bgCanvasDefault
var strokeColor = Color.compound.iconSecondaryAlpha
var dotColor = Color.compound.iconPrimary
@ScaledMetric var size: CGFloat = 42
var mediaProvider: MediaProviderProtocol?
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
private let circleRadius: CGFloat = 6 // in SVG space
var body: some View {
CompoundIcon(\.locationPinSolid)
.dynamicTypeSize(.large)
.foregroundStyle(pinColor)
.padding(pinInsets)
.background {
backgroundShape
.shadow(color: .black.opacity(0.2), radius: 4.1129, x: 0, y: 4.93548)
// Generated from the SVG
Canvas { context, canvasSize in
let scaleX = canvasSize.width / 42
let scaleY = canvasSize.height / 50
let pinPath = Path { p in
p.move(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY))
p.addCurve(to: CGPoint(x: 17.6875 * scaleX, y: 47.7581 * scaleY),
control1: CGPoint(x: 19.25 * scaleX, y: 49 * scaleY),
control2: CGPoint(x: 18.1458 * scaleX, y: 48.4825 * scaleY))
p.addCurve(to: CGPoint(x: 5.28125 * scaleX, y: 33.6313 * scaleY),
control1: CGPoint(x: 12.5833 * scaleX, y: 42.8525 * scaleY),
control2: CGPoint(x: 8.41667 * scaleX, y: 38.1332 * scaleY))
p.addCurve(to: CGPoint(x: 1 * scaleX, y: 21.3674 * scaleY),
control1: CGPoint(x: 2.82292 * scaleX, y: 29.2846 * scaleY),
control2: CGPoint(x: 1 * scaleX, y: 25.1863 * scaleY))
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 1 * scaleY),
control1: CGPoint(x: 1 * scaleX, y: 10.2109 * scaleY),
control2: CGPoint(x: 9.5 * scaleX, y: 1 * scaleY))
p.addCurve(to: CGPoint(x: 41 * scaleX, y: 21.3674 * scaleY),
control1: CGPoint(x: 32.5 * scaleX, y: 1 * scaleY),
control2: CGPoint(x: 41 * scaleX, y: 10.2109 * scaleY))
p.addCurve(to: CGPoint(x: 36.7188 * scaleX, y: 33.6313 * scaleY),
control1: CGPoint(x: 41 * scaleX, y: 25.1863 * scaleY),
control2: CGPoint(x: 39.1771 * scaleX, y: 29.2846 * scaleY))
p.addCurve(to: CGPoint(x: 24.3125 * scaleX, y: 47.7581 * scaleY),
control1: CGPoint(x: 33.5833 * scaleX, y: 38.1332 * scaleY),
control2: CGPoint(x: 29.4167 * scaleX, y: 42.8525 * scaleY))
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY),
control1: CGPoint(x: 23.3333 * scaleX, y: 48.4825 * scaleY),
control2: CGPoint(x: 21.5833 * scaleX, y: 49 * scaleY))
p.closeSubpath()
}
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
context.fill(pinPath, with: .color(fillColor))
// Dot
let dotPath = Path(ellipseIn: CGRect(x: (circleCenter.x - circleRadius) * scaleX,
y: (circleCenter.y - circleRadius) * scaleY,
width: circleRadius * 2 * scaleX,
height: circleRadius * 2 * scaleY))
context.fill(dotPath, with: .color(dotColor))
// Draw resolved symbol centered on the circle
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
let center = CGPoint(x: circleCenter.x * scaleX,
y: circleCenter.y * scaleY)
context.draw(symbol, at: center, anchor: .center)
}
}
var backgroundShape: some View {
Image(asset: Asset.Images.locationMarkerShape)
.resizable()
.aspectRatio(contentMode: .fill)
.foregroundStyle(.compound.iconPrimary)
} symbols: {
if let userProfile {
LoadableAvatarImage(url: userProfile.avatarURL,
name: userProfile.displayName,
contentID: userProfile.userID,
avatarSize: .user(on: .map),
mediaProvider: mediaProvider)
.overlay {
Circle().inset(by: 0.5).stroke(strokeColor)
}
.tag(0)
}
}
.frame(width: size, height: size * 50 / 42)
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
}
}
}
@@ -39,9 +95,19 @@ struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 30) {
LocationMarkerView()
LocationMarkerView()
.colorScheme(.dark)
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
mediaProvider: MediaProviderMock(configuration: .init()))
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
mediaProvider: MediaProviderMock(configuration: .init()))
.colorScheme(.dark)
}
.padding(16)
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
.previewLayout(.sizeThatFits)
}
}
@@ -14,6 +14,7 @@ struct ToolbarButton: View {
static let cancel = Role.cancel(title: L10n.actionCancel)
static let done = Role.confirm(title: L10n.actionDone)
static let save = Role.confirm(title: L10n.actionSave)
static let close = Role.cancel(title: L10n.actionClose)
case cancel(title: String)
case confirm(title: String)
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
"LocationRoomTimelineView_Previews" : LocationRoomTimelineView_Previews.self,
"LocationSharingScreen_Previews" : LocationSharingScreen_Previews.self,
"LoginScreen_Previews" : LoginScreen_Previews.self,
"LongPressWithFeedback_Previews" : LongPressWithFeedback_Previews.self,
"ManageAuthorizedSpacesScreen_Previews" : ManageAuthorizedSpacesScreen_Previews.self,
@@ -181,7 +182,6 @@ enum TestablePreviewsDictionary {
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
"StateRoomTimelineView_Previews" : StateRoomTimelineView_Previews.self,
"StaticLocationScreenViewer_Previews" : StaticLocationScreenViewer_Previews.self,
"StickerRoomTimelineView_Previews" : StickerRoomTimelineView_Previews.self,
"SwipeRightAction_Previews" : SwipeRightAction_Previews.self,
"SwipeToReplyView_Previews" : SwipeToReplyView_Previews.self,
@@ -33,12 +33,7 @@ struct HomeScreenRoomCell: View {
content
.padding(.vertical, verticalInsets)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: 1 / UIScreen.main.scale)
.padding(.trailing, -horizontalInsets)
}
.rowDivider(horizontalInsets: horizontalInsets)
}
.padding(.horizontal, horizontalInsets)
.accessibilityElement(children: .combine)
@@ -0,0 +1,70 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct LocationSharingScreenCoordinatorParameters {
let interactionMode: LocationSharingInteractionMode
let mapURLBuilder: MapTilerURLBuilderProtocol
let liveLocationSharingEnabled: Bool
let roomProxy: JoinedRoomProxyProtocol
let timelineController: TimelineControllerProtocol
let appMediator: AppMediatorProtocol
let analytics: AnalyticsService
let userIndicatorController: UserIndicatorControllerProtocol
let mediaProvider: MediaProviderProtocol
}
enum LocationSharingScreenCoordinatorAction {
case close
}
final class LocationSharingScreenCoordinator: CoordinatorProtocol {
private let parameters: LocationSharingScreenCoordinatorParameters
private let viewModel: LocationSharingScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<LocationSharingScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<LocationSharingScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: LocationSharingScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = LocationSharingScreenViewModel(interactionMode: parameters.interactionMode,
mapURLBuilder: parameters.mapURLBuilder,
liveLocationSharingEnabled: parameters.liveLocationSharingEnabled,
roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController,
analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController,
mediaProvider: parameters.mediaProvider)
}
// MARK: - Public
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .close:
actionsSubject.send(.close)
case .openSystemSettings:
parameters.appMediator.openAppSettings()
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(LocationSharingScreen(context: viewModel.context))
}
}
@@ -14,31 +14,44 @@ enum LocationSharingViewError: Error, Hashable {
case mapError(MapLibreError)
}
enum StaticLocationScreenViewModelAction {
enum LocationSharingScreenViewModelAction {
case close
case openSystemSettings
}
enum StaticLocationInteractionMode: Hashable {
enum LocationSharingInteractionMode: Hashable {
case picker
case viewOnly(geoURI: GeoURI, description: String? = nil)
case viewStatic(senderID: String?, geoURI: GeoURI)
var canShowAvatar: Bool {
switch self {
case .picker, .viewStatic(.some(_), _):
true
default:
false
}
}
}
struct StaticLocationScreenViewState: BindableState {
init(interactionMode: StaticLocationInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol) {
struct LocationSharingScreenViewState: BindableState {
init(interactionMode: LocationSharingInteractionMode,
mapURLBuilder: MapTilerURLBuilderProtocol,
showLiveLocationSharingButton: Bool) {
self.interactionMode = interactionMode
self.mapURLBuilder = mapURLBuilder
self.showLiveLocationSharingButton = showLiveLocationSharingButton
bindings.showsUserLocationMode = switch interactionMode {
case .picker: .showAndFollow
case .viewOnly: .show
case .viewStatic: .show
}
}
let interactionMode: StaticLocationInteractionMode
let interactionMode: LocationSharingInteractionMode
let mapURLBuilder: MapTilerURLBuilderProtocol
let showLiveLocationSharingButton: Bool
var bindings = StaticLocationScreenBindings(showsUserLocationMode: .hide)
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
/// Indicates whether the user is sharing his current location
var isSharingUserLocation: Bool {
@@ -50,21 +63,17 @@ struct StaticLocationScreenViewState: BindableState {
case .picker:
// middle point in Europe, to be used if the users location is not yet known
return .init(latitude: 49.843, longitude: 9.902056)
case .viewOnly(let geoURI, _):
case .viewStatic(_, let geoURI):
return .init(latitude: geoURI.latitude, longitude: geoURI.longitude)
}
}
var isLocationPickerMode: Bool {
interactionMode == .picker
}
var navigationTitle: String {
switch interactionMode {
case .picker:
return L10n.screenShareLocationTitle
case .viewOnly:
return L10n.screenViewLocationTitle
true
default:
false
}
}
@@ -72,10 +81,14 @@ struct StaticLocationScreenViewState: BindableState {
switch interactionMode {
case .picker:
return false
case .viewOnly:
case .viewStatic:
return true
}
}
var isLocationLoading: Bool {
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
}
var zoomLevel: Double {
15.0
@@ -85,27 +98,28 @@ struct StaticLocationScreenViewState: BindableState {
switch interactionMode {
case .picker:
return 2.7
case .viewOnly:
case .viewStatic:
return 15.0
}
}
var locationDescription: String? {
var userProfile: UserProfileProxy?
var shownUserProfile: UserProfileProxy? {
switch interactionMode {
case .picker:
return nil
case .viewOnly(_, let description):
return description
isSharingUserLocation ? userProfile : nil
case .viewStatic:
userProfile
}
}
}
struct StaticLocationScreenBindings {
struct LocationSharingScreenBindings {
var mapCenterLocation: CLLocationCoordinate2D?
var geolocationUncertainty: CLLocationAccuracy?
var showsUserLocationMode: ShowUserLocationMode
var hasLoadedUserLocation = false
var isLocationAuthorized: Bool?
/// Information describing the currently displayed alert.
@@ -127,7 +141,7 @@ struct StaticLocationScreenBindings {
var showShareSheet = false
}
enum StaticLocationScreenViewAction {
enum LocationSharingScreenViewAction {
case close
case selectLocation
case centerToUser
@@ -141,20 +155,18 @@ extension AlertInfo where T == LocationSharingViewError {
switch error {
case .missingAuthorization:
self.init(id: error,
title: L10n.dialogPermissionLocationTitleIos(InfoPlistReader.main.bundleDisplayName),
message: L10n.dialogPermissionLocationDescriptionIos,
title: L10n.dialogAllowAccess,
message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
case .mapError(.failedLoadingMap):
self.init(id: error,
title: "",
message: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
case .mapError(.failedLocatingUser):
self.init(id: error,
title: "",
message: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
title: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
}
@@ -9,31 +9,44 @@
import Combine
import Foundation
typealias StaticLocationScreenViewModelType = StateStoreViewModelV2<StaticLocationScreenViewState, StaticLocationScreenViewAction>
typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction>
class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLocationScreenViewModelProtocol {
class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, LocationSharingScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<StaticLocationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<StaticLocationScreenViewModelAction, Never> {
private let actionsSubject: PassthroughSubject<LocationSharingScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(interactionMode: StaticLocationInteractionMode,
init(interactionMode: LocationSharingInteractionMode,
mapURLBuilder: MapTilerURLBuilderProtocol,
liveLocationSharingEnabled: Bool,
roomProxy: JoinedRoomProxyProtocol,
timelineController: TimelineControllerProtocol,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
mediaProvider: MediaProviderProtocol) {
self.roomProxy = roomProxy
self.timelineController = timelineController
self.analytics = analytics
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(interactionMode: interactionMode, mapURLBuilder: mapURLBuilder))
super.init(initialViewState: .init(interactionMode: interactionMode,
mapURLBuilder: mapURLBuilder,
showLiveLocationSharingButton: liveLocationSharingEnabled),
mediaProvider: mediaProvider)
if interactionMode.canShowAvatar {
updateShownUserProfile(members: roomProxy.membersPublisher.value)
setupSubscriptions()
}
}
override func process(viewAction: StaticLocationScreenViewAction) {
override func process(viewAction: LocationSharingScreenViewAction) {
switch viewAction {
case .close:
actionsSubject.send(.close)
@@ -58,6 +71,32 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
// MARK: - Private
private func setupSubscriptions() {
roomProxy.membersPublisher.sink { [weak self] members in
self?.updateShownUserProfile(members: members)
}
.store(in: &cancellables)
}
private func updateShownUserProfile(members: [RoomMemberProxyProtocol]) {
switch state.interactionMode {
case .picker:
if let ownUser = members.first(where: { $0.userID == roomProxy.ownUserID }).map(UserProfileProxy.init) {
state.userProfile = ownUser
} else {
state.userProfile = .init(userID: roomProxy.ownUserID)
}
case .viewStatic(.some(let senderID), _):
if let sender = members.first(where: { $0.userID == senderID }).map(UserProfileProxy.init) {
state.userProfile = sender
} else {
state.userProfile = .init(userID: senderID)
}
default:
state.userProfile = nil
}
}
private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async {
guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage,
geoURI: geoURI,
@@ -9,7 +9,7 @@
import Combine
@MainActor
protocol StaticLocationScreenViewModelProtocol {
var actions: AnyPublisher<StaticLocationScreenViewModelAction, Never> { get }
var context: StaticLocationScreenViewModelType.Context { get }
protocol LocationSharingScreenViewModelProtocol {
var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> { get }
var context: LocationSharingScreenViewModelType.Context { get }
}
@@ -1,64 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct StaticLocationScreenCoordinatorParameters {
let interactionMode: StaticLocationInteractionMode
let mapURLBuilder: MapTilerURLBuilderProtocol
let timelineController: TimelineControllerProtocol
let appMediator: AppMediatorProtocol
let analytics: AnalyticsService
let userIndicatorController: UserIndicatorControllerProtocol
}
enum StaticLocationScreenCoordinatorAction {
case close
}
final class StaticLocationScreenCoordinator: CoordinatorProtocol {
private let parameters: StaticLocationScreenCoordinatorParameters
private let viewModel: StaticLocationScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<StaticLocationScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<StaticLocationScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: StaticLocationScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode,
mapURLBuilder: parameters.mapURLBuilder,
timelineController: parameters.timelineController,
analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController)
}
// MARK: - Public
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .close:
actionsSubject.send(.close)
case .openSystemSettings:
parameters.appMediator.openAppSettings()
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(StaticLocationScreen(context: viewModel.context))
}
}
@@ -0,0 +1,264 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector 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 LocationSharingScreen: View {
@Bindable var context: LocationSharingScreenViewModel.Context
var body: some View {
if context.viewState.isLocationPickerMode {
mainContent
.sheet(isPresented: .constant(true)) {
sharingOptionsSheet
.alert(item: $context.alertInfo)
}
} else {
mainContent
.alert(item: $context.alertInfo)
}
}
// MARK: - Private
private var mainContent: some View {
mapView
.ignoresSafeArea(edges: .bottom)
.track(screen: context.viewState.isLocationPickerMode ? .LocationSend : .LocationView)
.navigationTitle(L10n.screenViewLocationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
private var mapView: some View {
ZStack(alignment: .center) {
MapLibreMapView(mapURLBuilder: context.viewState.mapURLBuilder,
options: mapOptions,
showsUserLocationMode: $context.showsUserLocationMode,
error: $context.mapError,
mapCenterCoordinate: $context.mapCenterLocation,
hasLoadedUserLocation: $context.hasLoadedUserLocation,
isLocationAuthorized: $context.isLocationAuthorized,
geolocationUncertainty: $context.geolocationUncertainty) {
context.send(viewAction: .userDidPan)
}
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
if context.viewState.isLocationPickerMode {
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
}
}
.overlay(alignment: .topTrailing) {
centerToUserLocationButton
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: context.viewState.showShareAction ? .topBarLeading : .topBarTrailing) {
ToolbarButton(role: .close) {
context.send(viewAction: .close)
}
}
if context.viewState.showShareAction {
ToolbarItem(placement: .topBarTrailing) {
Button {
context.showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
}
.popover(isPresented: $context.showShareSheet) { shareSheet }
}
}
}
private var mapOptions: MapLibreMapView.Options {
var annotations: [String: LocationAnnotation] = [:]
if !context.viewState.isLocationPickerMode {
let id = context.viewState.shownUserProfile?.userID ?? UUID().uuidString
let annotation = LocationAnnotation(id: id,
coordinate: context.viewState.initialMapCenter,
anchorPoint: .bottomCenter) {
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
}
annotations[id] = annotation
}
return .init(zoomLevel: context.viewState.zoomLevel,
initialZoomLevel: context.viewState.initialZoomLevel,
mapCenter: context.viewState.initialMapCenter,
annotations: annotations)
}
private var mapSafeAreaEdges: Edge.Set {
context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom]
}
@ViewBuilder
private var centerToUseIcon: some View {
if context.viewState.isLocationLoading {
ProgressView()
.tint(.compound.iconPrimary)
.padding(13)
} else {
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator)
.foregroundStyle(.compound.iconPrimary)
.padding(13)
}
}
private var centerToUserLocationButton: some View {
Button {
context.send(viewAction: .centerToUser)
} label: {
if #available(iOS 26.0, *) {
centerToUseIcon
.glassEffect(.regular.interactive(), in: Circle())
.tint(.compound.bgCanvasDefault)
} else {
centerToUseIcon
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 6))
}
}
.disabled(context.viewState.isLocationLoading)
.dynamicTypeSize(.large)
.padding(13)
}
@ViewBuilder
private var shareSheet: some View {
let location = context.viewState.initialMapCenter
let senderName = context.viewState.shownUserProfile?.displayName
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, senderName: senderName)],
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, senderName: senderName) })
.edgesIgnoringSafeArea(.bottom)
.presentationDetents([.medium, .large])
.presentationCompactAdaptation(shareSheetCompactPresentation)
.presentationDragIndicator(.hidden)
}
private var shareSheetCompactPresentation: PresentationAdaptation {
if #available(iOS 26.0, *) {
.none // ShareLinks use a popover presentation on iOS 26, let it match that.
} else {
.sheet
}
}
@State private var sharingOptionsSheetHeight: CGFloat = .zero
private var sharingOptionsSheet: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
context.send(viewAction: .selectLocation)
} label: {
if context.viewState.isSharingUserLocation {
LocationSharingLabel(text: L10n.screenShareMyLocationAction,
icon: \.locationNavigatorCentred,
iconColor: .compound.iconSecondary)
} else {
LocationSharingLabel(text: L10n.screenShareThisLocationAction,
icon: \.locationNavigator,
iconColor: .compound.iconSecondary)
}
}
if context.viewState.showLiveLocationSharingButton {
Button { } label: {
LocationSharingLabel(text: L10n.actionShareLiveLocation,
icon: \.locationPinSolid,
iconColor: .compound.iconAccentPrimary)
}
}
}
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.padding(.top, 38)
.readHeight($sharingOptionsSheetHeight)
.interactiveDismissDisabled()
.presentationBackground(.compound.bgCanvasDefault)
.presentationBackgroundInteraction(.enabled)
.presentationDragIndicator(.hidden)
.presentationDetents([.height(sharingOptionsSheetHeight)])
}
}
private struct LocationSharingLabel: View {
let text: String
let icon: KeyPath<CompoundIcons, Image>
let iconColor: Color
var body: some View {
Label {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 14)
.rowDivider(alignment: .top, horizontalInsets: 16.0)
} icon: {
CompoundIcon(icon)
.foregroundStyle(iconColor)
}
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Previews
struct LocationSharingScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: "@dan:matrix.org", geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655)),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
liveLocationSharingEnabled: true,
roomProxy: JoinedRoomProxyMock(.init()),
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
static let pinViewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: nil, geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655)),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
liveLocationSharingEnabled: true,
roomProxy: JoinedRoomProxyMock(.init()),
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
static let pickerViewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
liveLocationSharingEnabled: true,
roomProxy: JoinedRoomProxyMock(.init()),
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
mediaProvider: MediaProviderMock(configuration: .init()))
static var previews: some View {
ElementNavigationStack {
LocationSharingScreen(context: pickerViewModel.context)
}
.previewDisplayName("Picker")
ElementNavigationStack {
LocationSharingScreen(context: viewModel.context)
}
.previewDisplayName("User Static Location")
ElementNavigationStack {
LocationSharingScreen(context: pinViewModel.context)
}
.previewDisplayName("Pin Static Location")
}
}
private extension CGPoint {
static let bottomCenter: Self = .init(x: 0.5, y: 1)
}
@@ -1,198 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector 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 StaticLocationScreen: View {
@Bindable var context: StaticLocationScreenViewModel.Context
var body: some View {
VStack(spacing: 0) {
if let locationDescription = context.viewState.locationDescription {
Text(locationDescription)
.lineLimit(2)
.foregroundColor(Color.compound.textPrimary)
.font(.compound.bodyMD)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
mapView
}
.track(screen: context.viewState.isLocationPickerMode ? .LocationSend : .LocationView)
.navigationTitle(context.viewState.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.alert(item: $context.alertInfo)
}
private var mapView: some View {
ZStack(alignment: .center) {
MapLibreMapView(mapURLBuilder: context.viewState.mapURLBuilder,
options: mapOptions,
showsUserLocationMode: $context.showsUserLocationMode,
error: $context.mapError,
mapCenterCoordinate: $context.mapCenterLocation,
isLocationAuthorized: $context.isLocationAuthorized,
geolocationUncertainty: $context.geolocationUncertainty) {
context.send(viewAction: .userDidPan)
}
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
if context.viewState.isLocationPickerMode {
LocationMarkerView()
}
}
.overlay(alignment: .bottomTrailing) {
centerToUserLocationButton
}
}
// MARK: - Private
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
closeButton
}
if context.viewState.showShareAction {
ToolbarItem(placement: .navigationBarTrailing) {
shareButton
.popover(isPresented: $context.showShareSheet) { shareSheet }
}
}
if context.viewState.isLocationPickerMode {
ToolbarItemGroup(placement: .bottomBar) {
selectLocationButton
Spacer()
}
}
}
private var mapOptions: MapLibreMapView.Options {
var annotations: [LocationAnnotation] = []
if context.viewState.isLocationPickerMode == false {
let annotation = LocationAnnotation(coordinate: context.viewState.initialMapCenter, anchorPoint: .bottomCenter) {
LocationMarkerView()
}
annotations.append(annotation)
}
return .init(zoomLevel: context.viewState.zoomLevel,
initialZoomLevel: context.viewState.initialZoomLevel,
mapCenter: context.viewState.initialMapCenter,
annotations: annotations)
}
private var mapSafeAreaEdges: Edge.Set {
context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom]
}
private var selectLocationButton: some View {
Button {
context.send(viewAction: .selectLocation)
} label: {
HStack(spacing: 8) {
CompoundIcon(\.shareIos)
Text(context.viewState.isSharingUserLocation ? L10n.screenShareMyLocationAction : L10n.screenShareThisLocationAction)
}
}
}
private var centerToUserLocationButton: some View {
Button {
context.send(viewAction: .centerToUser)
} label: {
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator)
.padding(8)
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 6))
}
.dynamicTypeSize(.large)
.padding(16)
}
private var closeButton: some View {
Button(L10n.actionCancel) {
context.send(viewAction: .close)
}
}
private var shareButton: some View {
Button {
context.showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
}
}
@ViewBuilder
private var shareSheet: some View {
let location = context.viewState.initialMapCenter
let locationDescription = context.viewState.locationDescription
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)],
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) })
.edgesIgnoringSafeArea(.bottom)
.presentationDetents([.medium, .large])
.presentationCompactAdaptation(shareSheetCompactPresentation)
.presentationDragIndicator(.hidden)
}
private var shareSheetCompactPresentation: PresentationAdaptation {
if #available(iOS 26.0, *) {
.none // ShareLinks use a popover presentation on iOS 26, let it match that.
} else {
.sheet
}
}
}
// MARK: - Previews
struct StaticLocationScreenViewer_Previews: PreviewProvider, TestablePreview {
static let viewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655)),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static let pickerViewModel = StaticLocationScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static let descriptionViewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655),
description: "Cool position"),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
ElementNavigationStack {
StaticLocationScreen(context: pickerViewModel.context)
}
.previewDisplayName("Picker")
ElementNavigationStack {
StaticLocationScreen(context: viewModel.context)
}
.previewDisplayName("View Only")
ElementNavigationStack {
StaticLocationScreen(context: descriptionViewModel.context)
}
.previewDisplayName("View Only (with description)")
}
}
private extension CGPoint {
static let bottomCenter: Self = .init(x: 0.5, y: 1)
}
@@ -26,7 +26,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
enum PinnedEventsTimelineScreenCoordinatorAction {
case dismiss
case displayUser(userID: String)
case presentLocationViewer(geoURI: GeoURI, description: String?)
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
}
@@ -87,8 +87,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .displayLocation(let senderID, let geoURI):
actionsSubject.send(.presentLocationViewer(senderID: senderID,
geoURI: geoURI))
case .viewInRoomTimeline(let eventID, let threadRootEventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
// These other actions will not be handled in this view
@@ -39,7 +39,7 @@ enum RoomScreenCoordinatorAction {
case presentRoomDetails
case presentLocationPicker
case presentPollForm(mode: PollFormMode)
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case presentRoomMemberDetails(userID: String)
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
@@ -137,8 +137,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .displayLocation(let senderID, let geoURI):
actionsSubject.send(.presentLocationViewer(senderID: senderID, geoURI: geoURI))
case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
case .displayThread(let itemID):
@@ -67,6 +67,8 @@ protocol DeveloperOptionsProtocol: AnyObject {
var linkPreviewsEnabled: Bool { get set }
var linkNewDeviceEnabled: Bool { get set }
var liveLocationSharingEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -76,6 +76,10 @@ struct DeveloperOptionsScreen: View {
Text("Can leak the device IP address when loading link metadata.")
.foregroundStyle(.compound.textCriticalPrimary)
}
Toggle(isOn: $context.liveLocationSharingEnabled) {
Text("Live location sharing")
}
}
Section("Join rules") {
@@ -33,7 +33,7 @@ enum ThreadTimelineScreenCoordinatorAction {
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case presentMediaUploadPreviewScreen(mediaURLs: [URL])
case presentLocationPicker
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
case presentPollForm(mode: PollFormMode)
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case presentRoomMemberDetails(userID: String)
@@ -118,10 +118,9 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker)
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(body: body,
geoURI: geoURI,
description: description))
case .displayLocation(let senderID, let geoURI):
actionsSubject.send(.presentLocationViewer(senderID: senderID,
geoURI: geoURI))
case .displayPollForm(let mode):
actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let mediaURLs):
@@ -536,7 +536,8 @@ class TimelineInteractionHandler {
switch timelineItem {
case let item as LocationRoomTimelineItem:
guard let geoURI = item.content.geoURI else { return .none }
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
return .displayLocation(senderID: item.content.kind == .sender ? item.sender.id : nil,
geoURI: geoURI)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem:
return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video])
@@ -23,7 +23,7 @@ enum TimelineViewModelAction {
case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayLocation(senderID: String?, geoURI: GeoURI)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case displayThread(itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction)
@@ -655,8 +655,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .displayLocation(let senderID, let geoURI):
actionsSubject.send(.displayLocation(senderID: senderID, geoURI: geoURI))
case .none:
break
}
@@ -705,20 +705,7 @@ private struct MockTimelineContent: View {
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782,
longitude: 12.496366),
description: "Location description description description description description description description description"),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
longitude: 12.496366)),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
@@ -16,7 +16,7 @@ struct LocationRoomTimelineView: View {
TimelineStyler(timelineItem: timelineItem) {
mainContent
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
.accessibilityLabel(L10n.commonSharedLocation)
.onTapGesture {
guard context.viewState.mapTilerConfiguration.isEnabled else { return }
context.send(viewAction: .mediaTapped(itemID: timelineItem.id))
@@ -27,41 +27,20 @@ struct LocationRoomTimelineView: View {
@ViewBuilder
private var mainContent: some View {
if let geoURI = timelineItem.content.geoURI {
VStack(alignment: .leading, spacing: 0) {
descriptionView
.frame(maxWidth: mapAspectRatio * mapMaxHeight, alignment: .leading)
MapLibreStaticMapView(geoURI: geoURI,
mapURLBuilder: context.viewState.mapTilerConfiguration,
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
LocationMarkerView()
}
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.clipped()
MapLibreStaticMapView(geoURI: geoURI,
mapURLBuilder: context.viewState.mapTilerConfiguration,
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
LocationMarkerView(userProfile: timelineItem.content.kind == .sender ? .init(sender: timelineItem.sender) : nil, mediaProvider: context.mediaProvider)
}
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.clipped()
} else {
FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
}
}
// MARK: - Private
private var accessibilityLabel: String {
if let description = timelineItem.content.description {
return "\(L10n.commonSharedLocation), \(description)"
}
return L10n.commonSharedLocation
}
@ViewBuilder
private var descriptionView: some View {
if let description = timelineItem.content.description, !description.isEmpty {
FormattedBodyText(text: description)
.padding(8)
}
}
private let mapAspectRatio: Double = 3 / 2
private let mapMaxHeight: Double = 300
@@ -107,9 +86,9 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description")))
geoURI: .init(latitude: 41.902782, longitude: 12.496366))))
LocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
@@ -117,7 +96,8 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"),
geoURI: .init(latitude: 41.902782, longitude: 12.496366),
kind: .pin),
properties: .init(replyDetails: .loaded(sender: .init(id: "Someone"),
eventID: "123",
eventContent: .message(.text(.init(body: "The thread content goes 'ere.")))),
@@ -25,7 +25,7 @@ enum TimelineControllerAction {
}
case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayLocation(senderID: String?, geoURI: GeoURI)
case none
}
@@ -6,16 +6,32 @@
// Please see LICENSE files in the repository root for full details.
//
import MatrixRustSDK
struct LocationRoomTimelineItemContent: Hashable {
enum Kind {
case sender
case pin
init(from asset: AssetType?) {
self = switch asset {
case .pin:
.pin
case .sender, .none:
.sender
}
}
}
let body: String
let geoURI: GeoURI?
let description: String?
let kind: Kind
init(body: String,
geoURI: GeoURI? = nil,
description: String? = nil) {
kind: Kind = .sender) {
self.body = body
self.geoURI = geoURI
self.description = description
self.kind = kind
}
}
@@ -583,7 +583,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildLocationTimelineItemContent(_ locationContent: LocationContent) -> LocationRoomTimelineItemContent {
LocationRoomTimelineItemContent(body: locationContent.body,
geoURI: .init(string: locationContent.geoUri),
description: locationContent.description)
kind: .init(from: locationContent.asset))
}
private func buildFileTimelineItemContent(_ messageContent: FileMessageContent) -> FileRoomTimelineItemContent {
@@ -44,6 +44,10 @@ struct UserProfileProxy: Equatable, Hashable {
avatarURL = sdkRoomHero.avatarUrl.flatMap(URL.init(string:))
}
init(member: RoomMemberProxyProtocol) {
self.init(member: RoomMemberDetails(withProxy: member))
}
/// A user is meant to be "verified" when the GET profile returns back either the display name or the avatar
/// If isn't we aren't sure that the related matrix id really exists.
var isVerified: Bool {