Render the live location timeline item (#5209)

* created the data for the timeline item

# Conflicts:
#	ElementX/Sources/Other/Logging/Tracing.swift
#	ElementX/Sources/Services/Authentication/AuthenticationService.swift
#	ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift

* Implemented the LLS timeline item

* updated preview tests

* pr and design suggestions

* previewscrollview is debug only
This commit is contained in:
Mauro
2026-03-24 10:34:00 +01:00
committed by GitHub
parent 610c296125
commit b8d337e4ce
51 changed files with 799 additions and 93 deletions
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholderMap.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+1
View File
@@ -37,6 +37,7 @@ internal enum Asset {
internal static let launchBackground = ImageAsset(name: "images/launch-background")
internal static let locationMarkerShape = ImageAsset(name: "images/location-marker-shape")
internal static let mapBlurred = ImageAsset(name: "images/mapBlurred")
internal static let placeholderMap = ImageAsset(name: "images/placeholderMap")
internal static let mediaPause = ImageAsset(name: "images/media-pause")
internal static let mediaPlay = ImageAsset(name: "images/media-play")
internal static let notificationsPromptGraphic = ImageAsset(name: "images/notifications-prompt-graphic")
@@ -34,6 +34,25 @@ extension Date {
}
}
/// The date of an expiration formatted with the minimal necessary units given how long in the future it is.
func formattedExpiration() -> String {
let calendar = Calendar.current
let now = Date.now
guard let tomorrow = calendar.date(byAdding: .hour, value: 24, to: now),
let oneYearFromNow = calendar.date(byAdding: .year, value: 1, to: now) else {
return formatted(date: .omitted, time: .shortened)
}
if self < tomorrow {
return formatted(date: .omitted, time: .shortened)
} else if self < oneYearFromNow {
return formatted(.dateTime.day().month())
} else {
return formatted(.dateTime.year())
}
}
/// Similar to ``formattedMinimal`` but returning "Today" instead of the time and
/// including the year when it the date is from a previous year (rather than over a year ago).
func formattedDateSeparator() -> String {
@@ -60,6 +79,12 @@ extension Date {
formatted(date: .omitted, time: .shortened)
}
/// A fixed date representing today at 4:20 AM, used for mocks and previews.
static var mockToday420: Date {
// swiftlint:disable:next force_unwrap
Calendar.current.date(bySettingHour: 4, minute: 20, second: 0, of: .now)!
}
/// A fixed date used for mocks, previews etc.
static var mock: Date {
DateComponents(calendar: .current, year: 2007, month: 1, day: 9, hour: 9, minute: 41).date ?? .now
@@ -34,6 +34,19 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
self.pinAnnotationView = pinAnnotationView()
}
init(geoURI: GeoURI,
mapURLBuilder: MapTilerURLBuilderProtocol,
attributionPlacement: MapTilerAttributionPlacement = .bottomLeft,
mapSize: CGSize,
@ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.init(coordinates: .init(latitude: geoURI.latitude, longitude: geoURI.longitude),
zoomLevel: 15,
attributionPlacement: attributionPlacement,
mapURLBuilder: mapURLBuilder,
mapSize: mapSize,
pinAnnotationView: pinAnnotationView)
}
var body: some View {
GeometryReader { geometry in
if let url = mapURLBuilder.staticMapTileImageURL(for: colorScheme.mapStyle,
@@ -0,0 +1,27 @@
//
// 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
#if DEBUG
/// Only use in Previews! This useful scroll view allows you to still have a scroll view when previewing in Xcode
/// but ignores it when running the tests, which allows you to still use it's content directly in preview tests
/// and render the preview with `sizeThatFits` layout.
struct PreviewScrollView<Content: View>: View {
var content: () -> Content
var body: some View {
if ProcessInfo.isRunningTests {
content()
} else {
ScrollView {
content()
}
}
}
}
#endif
@@ -10,16 +10,10 @@ import Compound
import SwiftUI
struct LocationMarkerView: View {
var userProfile: UserProfileProxy?
var fillColor = Color.compound.bgCanvasDefault
var strokeColor = Color.compound.iconSecondaryAlpha
var dotColor = Color.compound.iconPrimary
var kind: LocationMarkerKind
@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 {
// Generated from the SVG
Canvas { context, canvasSize in
@@ -55,7 +49,7 @@ struct LocationMarkerView: View {
p.closeSubpath()
}
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
context.stroke(pinPath, with: .color(externalStrokeColor), lineWidth: 2 * scaleX)
context.fill(pinPath, with: .color(fillColor))
// Dot
@@ -66,20 +60,20 @@ struct LocationMarkerView: View {
context.fill(dotPath, with: .color(dotColor))
// Draw resolved symbol centered on the circle
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
if kind.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)
}
} symbols: {
if let userProfile {
if let userProfile = kind.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)
Circle().inset(by: 0.5).stroke(internalStrokeColor)
}
.tag(0)
}
@@ -89,22 +83,77 @@ struct LocationMarkerView: View {
dimensions[.bottom]
}
}
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
private let circleRadius: CGFloat = 6 // in SVG space
private var fillColor: Color {
switch kind {
case .pin, .staticUser:
.compound.bgCanvasDefault
case .liveUser:
.compound.iconAccentPrimary
case .placeholder:
.compound.bgSubtleSecondary
}
}
private var externalStrokeColor: Color {
switch kind {
case .pin, .staticUser:
.compound.iconSecondaryAlpha
case .liveUser:
.compound.iconAccentPrimary
case .placeholder:
.compound.iconDisabled
}
}
private var internalStrokeColor: Color {
switch kind {
case .pin, .staticUser:
.compound.iconSecondaryAlpha
case .liveUser:
.compound.bgCanvasDefault
case .placeholder:
.compound.iconDisabled
}
}
private var dotColor: Color {
switch kind {
case .placeholder:
.compound.iconDisabled
default:
.compound.iconPrimary
}
}
}
struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 30) {
LocationMarkerView()
// Placeholder
LocationMarkerView(kind: .placeholder)
LocationMarkerView()
.colorScheme(.dark)
// Pin (no user)
LocationMarkerView(kind: .pin)
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
// Static user with avatar
LocationMarkerView(kind: .staticUser(.mockDan),
mediaProvider: MediaProviderMock(configuration: .init()))
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
// Static user without avatar
LocationMarkerView(kind: .staticUser(.init(userID: "@someone:matrix.org",
displayName: "Someone")))
// Live user with avatar
LocationMarkerView(kind: .liveUser(.mockDan),
mediaProvider: MediaProviderMock(configuration: .init()))
.colorScheme(.dark)
// Live user without avatar
LocationMarkerView(kind: .liveUser(.init(userID: "@someone:matrix.org",
displayName: "Someone")))
}
.padding(16)
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
@@ -80,6 +80,7 @@ enum TestablePreviewsDictionary {
"LeaveSpaceView_Previews" : LeaveSpaceView_Previews.self,
"LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self,
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
"LiveLocationRoomTimelineView_Previews" : LiveLocationRoomTimelineView_Previews.self,
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,
@@ -35,6 +35,13 @@ struct LocationSharingScreenViewState: BindableState {
self.showLiveLocationSharingButton = showLiveLocationSharingButton
self.ownUserID = ownUserID
userProfile = switch interactionMode {
case .viewStatic(let locationData):
.init(sender: locationData.sender)
case .picker:
.init(userID: ownUserID)
}
bindings.showsUserLocationMode = switch interactionMode {
case .picker: .showAndFollow
case .viewStatic: .show
@@ -45,6 +52,11 @@ struct LocationSharingScreenViewState: BindableState {
let mapURLBuilder: MapTilerURLBuilderProtocol
let showLiveLocationSharingButton: Bool
let ownUserID: String
var userProfile: UserProfileProxy
var isOwnUser: Bool {
userProfile.userID == ownUserID
}
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
@@ -91,14 +103,12 @@ struct LocationSharingScreenViewState: BindableState {
}
}
var userProfile: UserProfileProxy?
var locationMarkerUserProfile: UserProfileProxy? {
var locationMarkerKind: LocationMarkerKind {
switch interactionMode {
case .picker:
isSharingUserLocation ? userProfile : nil
isSharingUserLocation ? .staticUser(userProfile) : .pin
case .viewStatic(let location):
location.kind == .sender ? userProfile : nil
location.kind == .sender ? .staticUser(userProfile) : .pin
}
}
}
@@ -160,3 +170,37 @@ extension AlertInfo where T == LocationSharingViewError {
}
}
}
enum LocationMarkerKind {
case pin
case staticUser(UserProfileProxy)
case liveUser(UserProfileProxy)
case placeholder
var id: String {
switch self {
case .pin, .placeholder:
UUID().uuidString
case .staticUser(let profile), .liveUser(let profile):
profile.userID
}
}
var displayName: String? {
switch self {
case .pin, .placeholder:
nil
case .staticUser(let profile), .liveUser(let profile):
profile.displayName
}
}
var userProfile: UserProfileProxy? {
switch self {
case .pin, .placeholder:
nil
case .staticUser(let profile), .liveUser(let profile):
profile
}
}
}
@@ -56,7 +56,7 @@ struct LocationSharingScreen: View {
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
if context.viewState.isLocationPickerMode {
LocationMarkerView(userProfile: context.viewState.locationMarkerUserProfile, mediaProvider: context.mediaProvider)
LocationMarkerView(kind: context.viewState.locationMarkerKind, mediaProvider: context.mediaProvider)
}
}
.overlay(alignment: .topTrailing) {
@@ -76,13 +76,13 @@ struct LocationSharingScreen: View {
private var mapOptions: MapLibreMapView.Options {
var annotations: [String: LocationAnnotation] = [:]
if !context.viewState.isLocationPickerMode {
let id = context.viewState.locationMarkerUserProfile?.userID ?? UUID().uuidString
let annotation = LocationAnnotation(id: id,
let kind = context.viewState.locationMarkerKind
let annotation = LocationAnnotation(id: kind.id,
coordinate: context.viewState.initialMapCenter,
anchorPoint: .bottomCenter) {
LocationMarkerView(userProfile: context.viewState.locationMarkerUserProfile, mediaProvider: context.mediaProvider)
LocationMarkerView(kind: kind, mediaProvider: context.mediaProvider)
}
annotations[id] = annotation
annotations[kind.id] = annotation
}
return .init(zoomLevel: context.viewState.zoomLevel,
@@ -129,7 +129,7 @@ struct LocationSharingScreen: View {
@ViewBuilder
private var shareSheet: some View {
let location = context.viewState.initialMapCenter
let senderName = context.viewState.locationMarkerUserProfile?.displayName ?? context.viewState.locationMarkerUserProfile?.userID
let senderName = context.viewState.locationMarkerKind.displayName ?? context.viewState.locationMarkerKind.userProfile?.userID
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, senderName: senderName)],
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, senderName: senderName) })
.ignoresSafeArea(edges: .bottom)
@@ -34,13 +34,12 @@ struct StaticLocationSheet: View {
.font(.compound.bodyLGSemibold)
.padding(.bottom, 25)
.padding(.top, 29)
if case let .viewStatic(location) = context.viewState.interactionMode,
let userProfile = context.viewState.userProfile {
if case let .viewStatic(location) = context.viewState.interactionMode {
Button {
context.showShareSheet = true
} label: {
UserLocationCell(profile: userProfile,
isOwnUser: userProfile.userID == context.viewState.ownUserID,
UserLocationCell(profile: context.viewState.userProfile,
isOwnUser: context.viewState.isOwnUser,
isUserLocation: location.kind == .sender,
timestamp: location.timestamp,
mediaProvider: context.mediaProvider)
@@ -73,6 +73,11 @@ struct TimelineReplyView: View {
plainBody: question,
formattedBody: nil,
icon: .init(kind: .icon(\.polls), cornerRadii: iconCornerRadii))
case .liveLocation:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLiveLocation,
formattedBody: nil,
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
case .redacted:
ReplyView(sender: sender,
plainBody: L10n.commonMessageRemoved,
@@ -283,6 +288,11 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
eventID: "123",
eventContent: .message(.location(.init(body: ""))))),
TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .liveLocation)),
TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
@@ -271,6 +271,9 @@ private extension EventBasedTimelineItemProtocol {
return locationTimelineItem.content.geoURI == nil ||
properties.replyDetails != nil ||
properties.isThreaded ? defaultInsets : .zero
case let liveLocationTimelineItem as LiveLocationRoomTimelineItem:
return properties.replyDetails != nil ||
properties.isThreaded ? defaultInsets : .zero
default:
return defaultInsets
}
@@ -278,7 +281,7 @@ private extension EventBasedTimelineItemProtocol {
var contentCornerRadius: CGFloat {
switch self {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem:
case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem, is LiveLocationRoomTimelineItem:
return properties.replyDetails != nil || properties.isThreaded ? 8 : .zero
default:
return .zero
@@ -32,7 +32,7 @@ private struct TimelineItemSendInfoModifier: ViewModifier {
AnyLayout(HStackLayout(alignment: .bottom, spacing: spacing))
case .vertical(let spacing):
AnyLayout(GridLayout(alignment: .leading, verticalSpacing: spacing))
case .overlay:
case .overlay, .hidden:
AnyLayout(ZStackLayout(alignment: .bottomTrailing))
}
}
@@ -93,6 +93,8 @@ private struct TimelineItemSendInfoLabel: View {
content
.gridColumnAlignment(.trailing)
}
case .hidden:
EmptyView()
}
}
@@ -125,6 +127,7 @@ private struct TimelineItemSendInfo {
case horizontal(spacing: CGFloat = 4)
case vertical(spacing: CGFloat = 4)
case overlay(capsuleStyle: Bool)
case hidden
}
let itemID: TimelineItemIdentifier
@@ -164,6 +167,8 @@ private extension TimelineItemSendInfo {
layoutType = switch timelineItem {
case is TextBasedRoomTimelineItem:
.overlay(capsuleStyle: false)
case let liveLocationTimelineItem as LiveLocationRoomTimelineItem:
liveLocationTimelineItem.layout
case let message as EventBasedMessageTimelineItemProtocol:
switch message {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
@@ -186,6 +191,16 @@ private extension TimelineItemSendInfo {
}
}
private extension LiveLocationRoomTimelineItem {
var layout: TimelineItemSendInfo.LayoutType {
if content.isLive, isOutgoing {
.hidden
} else {
.overlay(capsuleStyle: true)
}
}
}
@MainActor
private extension EncryptionAuthenticity {
var foregroundStyle: SwiftUI.Color {
@@ -89,6 +89,12 @@ struct TimelineThreadSummaryView: View {
plainBody: question,
formattedBody: nil,
numberOfReplies: numberOfReplies)
case .liveLocation:
ThreadView(senderID: senderID,
sender: sender,
plainBody: L10n.commonSharedLiveLocation,
formattedBody: nil,
numberOfReplies: numberOfReplies)
case .redacted:
ThreadView(senderID: senderID,
sender: sender,
@@ -256,6 +262,11 @@ struct TimelineThreadSummaryView_Previews: PreviewProvider, TestablePreview {
latestEventContent: .message(.location(.init(body: ""))),
numberOfReplies: 42)),
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
latestEventContent: .liveLocation,
numberOfReplies: 42)),
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
latestEventContent: .message(.voice(.init(filename: "voice-message.ogg",
@@ -0,0 +1,236 @@
//
// 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 Compound
import SwiftUI
struct LiveLocationRoomTimelineView: View {
@Environment(\.timelineContext) private var context: TimelineViewModel.Context!
let timelineItem: LiveLocationRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
mainContent
}
}
private var mainContent: some View {
ZStack(alignment: .bottom) {
mapView
.clipped()
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonSharedLiveLocation)
liveLocationInfoView
}
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.overlay {
if !timelineItem.content.isLive {
RoundedRectangle(cornerRadius: 12)
.inset(by: 0.5)
.stroke(Color.compound.borderDisabled)
}
}
.onTapGesture {
guard context.viewState.mapTilerConfiguration.isEnabled,
timelineItem.content.lastGeoURI != nil,
timelineItem.content.isLive else {
return
}
context.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
}
@ViewBuilder
private var mapView: some View {
if timelineItem.content.isLive {
if let geoURI = timelineItem.content.lastGeoURI {
MapLibreStaticMapView(geoURI: geoURI,
mapURLBuilder: context.viewState.mapTilerConfiguration,
attributionPlacement: .topLeft,
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
LocationMarkerView(kind: .liveUser(.init(sender: timelineItem.sender)),
mediaProvider: context.mediaProvider)
}
} else {
Image(asset: Asset.Images.mapBlurred)
.resizable()
}
} else {
ZStack {
Image(asset: Asset.Images.placeholderMap)
.resizable()
LocationMarkerView(kind: .placeholder)
}
}
}
private var liveLocationStateString: String {
timelineItem.content.isLive ? L10n.commonLiveLocation : L10n.commonLiveLocationEnded
}
private var liveLocationStateColor: Color {
timelineItem.content.isLive ? .compound.textPrimary : .compound.textSecondary
}
private var liveLocationIconColor: Color {
if timelineItem.content.isLive {
timelineItem.content.lastGeoURI != nil ? .compound.iconAccentPrimary : .compound.iconSecondary
} else {
.compound.iconDisabled
}
}
private var liveLocationBackgroundColor: Color {
timelineItem.content.isLive ? .compound.bgCanvasDefault : .compound.bgSubtleSecondary
}
private var blurBackground: some View {
Color.compound.bgCanvasDefault
.opacity(0.8)
.background(.ultraThinMaterial)
}
private var infoIcon: KeyPath<CompoundIcons, Image> {
if timelineItem.content.lastGeoURI != nil || !timelineItem.content.isLive {
\.locationPinSolid
} else {
\.spinner
}
}
private var liveLocationInfoView: some View {
HStack(spacing: 8) {
CompoundIcon(infoIcon, size: .medium, relativeTo: .compound.bodySMSemibold)
.foregroundStyle(liveLocationIconColor)
.padding(4)
.background(liveLocationBackgroundColor)
.cornerRadius(8)
.overlay {
if timelineItem.content.isLive {
RoundedRectangle(cornerRadius: 8)
.inset(by: 0.5)
.stroke(Color.compound.iconQuaternaryAlpha)
}
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 0) {
Text(liveLocationStateString)
.foregroundStyle(liveLocationStateColor)
.font(.compound.bodySMSemibold)
if timelineItem.content.isLive {
Text(L10n.commonEndsAt(timelineItem.content.timeoutDate.formattedExpiration()))
.foregroundStyle(.compound.textPrimary)
.font(.compound.bodySM)
}
}
.accessibilityElement(children: .combine)
Spacer()
if timelineItem.content.isLive, timelineItem.isOutgoing {
Button { } label: {
CompoundIcon(\.stop, size: .small, relativeTo: .compound.bodySMSemibold)
.foregroundStyle(.compound.iconOnSolidPrimary)
.padding(5)
.background(Color.compound.bgCriticalPrimary, in: Circle())
.accessibilityLabel(L10n.actionStop)
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(blurBackground)
}
// MARK: - Private
private let mapAspectRatio: Double = 3 / 2
private let mapMaxHeight: Double = 300
}
struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
PreviewScrollView {
VStack(spacing: 8) {
states
}
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.previewDisplayName("Bubbles")
}
@ViewBuilder
static var states: some View {
// No location yet (beacon not yet received)
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob"),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: nil)))
// With a known location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Expired live location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: false,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Replying to a live location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366)),
properties: .init(replyDetails: .loaded(sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
eventID: "123",
eventContent: .liveLocation))))
// Replying to a live location when the content is not live
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: false,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366)),
properties: .init(replyDetails: .loaded(sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
eventID: "123",
eventContent: .liveLocation))))
}
}
@@ -30,7 +30,7 @@ struct LocationRoomTimelineView: View {
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,
LocationMarkerView(kind: timelineItem.content.kind == .sender ? .staticUser(.init(sender: timelineItem.sender)) : .pin,
mediaProvider: context.mediaProvider)
}
.frame(maxHeight: mapMaxHeight)
@@ -47,28 +47,18 @@ struct LocationRoomTimelineView: View {
private let mapMaxHeight: Double = 300
}
private extension MapLibreStaticMapView {
init(geoURI: GeoURI, mapURLBuilder: MapTilerURLBuilderProtocol, mapSize: CGSize, @ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.init(coordinates: .init(latitude: geoURI.latitude, longitude: geoURI.longitude),
zoomLevel: 15,
attributionPlacement: .bottomLeft,
mapURLBuilder: mapURLBuilder,
mapSize: mapSize,
pinAnnotationView: pinAnnotationView)
}
}
struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
ScrollView {
PreviewScrollView {
VStack(spacing: 8) {
states
}
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.previewDisplayName("Bubbles")
}
@@ -99,9 +89,8 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366),
kind: .pin),
properties: .init(replyDetails: .loaded(sender: .init(id: "Someone"),
properties: .init(replyDetails: .loaded(sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "The thread content goes 'ere.")))),
isThreaded: true)))
eventContent: .message(.location(.init(body: "")))))))
}
}
@@ -88,7 +88,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
let openRoomSpan = analyticsService.signpost.addSpan(.timelineLoad, toTransaction: .openRoom)
timeline = try await TimelineProxy(timeline: room.timelineWithConfiguration(configuration: .init(focus: .live(hideThreadedEvents: appSettings.threadsEnabled),
filter: .eventFilter(filter: excludedEventsFilter),
filter: .eventFilter(filter: Self.excludedEventsFilter(appSettings: appSettings)),
internalIdPrefix: nil,
dateDividerMode: .daily,
trackReadReceipts: .messageLikeEvents,
@@ -792,7 +792,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
}
}
private let excludedEventsFilter: TimelineEventFilter = {
private static func excludedEventsFilter(appSettings: AppSettings) -> TimelineEventFilter {
var stateEventFilters: [StateEventType] = [.roomAliases,
.roomCanonicalAlias,
.roomGuestAccess,
@@ -808,6 +808,10 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
.policyRuleServer,
.policyRuleUser]
if !appSettings.liveLocationSharingEnabled {
stateEventFilters.append(.beaconInfo)
}
return .excludeEventTypes(eventTypes: stateEventFilters.map { FilterTimelineEventType.state(eventType: $0) })
}()
}
}
@@ -60,11 +60,10 @@ struct RoomEventStringBuilder {
default: L10n.commonWaitingForDecryptionKey
}
return prefix(errorMessage, with: displayName, isOutgoing: isOutgoing)
case .liveLocation:
return messageEventStringBuilder.buildAttributedStringForLiveLocation(senderDisplayName: displayName, isOutgoing: isOutgoing)
case .other:
return nil // We shouldn't receive these without asking for custom event types.
case .liveLocation:
// TODO: Implement
return nil
}
case .failedToParseMessageLike, .failedToParseState:
return prefix(L10n.commonUnsupportedEvent, with: displayName, isOutgoing: isOutgoing)
@@ -78,6 +78,19 @@ struct RoomMessageEventStringBuilder {
}
}
func buildAttributedStringForLiveLocation(senderDisplayName: String, isOutgoing: Bool) -> AttributedString {
var message = AttributedString(L10n.commonSharedLiveLocation)
if destination == .pinnedEvent {
message.bold()
}
if destination == .roomList {
return prefix(message, with: isOutgoing ? L10n.commonYou : senderDisplayName)
} else {
return message
}
}
private func buildMessage(for destination: Destination, caption: String?, type: String) -> AttributedString {
guard let caption else {
return AttributedString(type)
@@ -70,7 +70,7 @@ struct GeoURI: Hashable {
private typealias RegexGeoURI = Regex<(Substring, latitude: Substring, longitude: Substring, uncertainty: Substring?)>
private extension RegexGeoURI {
static let standard: Self = /geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?/
static let standard: Self = /geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:,-?\d+(?:\.\d+)?)?(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?/
}
extension GeoURI {
@@ -11,5 +11,6 @@ import Foundation
enum TimelineEventContent: Hashable {
case message(EventBasedMessageTimelineItemContentType)
case poll(question: String)
case liveLocation
case redacted
}
@@ -0,0 +1,41 @@
//
// 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 Foundation
import MatrixRustSDK
struct LiveLocationRoomTimelineItem: EventBasedTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier
/// Always empty just here for protocol conformance
var body: String {
""
}
let timestamp: Date
let isOutgoing: Bool
let isEditable: Bool
let canBeRepliedTo: Bool
let sender: TimelineItemSender
let content: LiveLocationRoomTimelineItemContent
var properties = RoomTimelineItemProperties()
}
struct LiveLocationRoomTimelineItemContent: Equatable {
let isLive: Bool
let timeoutDate: Date
let lastGeoURI: GeoURI?
}
extension LiveLocationRoomTimelineItemContent {
init(from content: MatrixRustSDK.LiveLocationContent, timestamp: Date) {
isLive = content.isLive
timeoutDate = timestamp.addingTimeInterval(Double(content.timeoutMs) / 1000)
lastGeoURI = content.locations.last.flatMap { GeoURI(string: $0.geoUri) }
}
}
@@ -41,11 +41,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return buildRedactedTimelineItem(eventItemProxy, messageLikeContent, isOutgoing)
case .unableToDecrypt(let encryptedMessage):
return buildEncryptedTimelineItem(eventItemProxy, messageLikeContent, encryptedMessage, isOutgoing)
case .liveLocation(let content):
return buildLiveLocationTimelineItem(eventItemProxy, messageLikeContent, content, isOutgoing)
case .other:
return nil // We shouldn't receive these without asking for custom event types.
case .liveLocation:
// TODO: Implement
return nil
}
case .failedToParseMessageLike(let eventType, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing)
@@ -433,6 +432,27 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
encryptionForwarder: eventItemProxy.forwarder))
}
private func buildLiveLocationTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ messageLikeContent: MsgLikeContent,
_ liveLocationContent: LiveLocationContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
LiveLocationRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
content: .init(from: liveLocationContent, timestamp: eventItemProxy.timestamp),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts),
encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState),
encryptionForwarder: eventItemProxy.forwarder))
}
private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ messageLikeContent: MsgLikeContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
@@ -850,6 +870,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
replyContent = .message(.text(.init(body: body)))
case .redacted:
replyContent = .redacted
case .liveLocation:
replyContent = .liveLocation
default:
replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent)))
}
@@ -75,6 +75,8 @@ struct RoomTimelineItemView: View {
CallInviteRoomTimelineView(timelineItem: item)
case .callNotification(let item):
CallNotificationRoomTimelineView(timelineItem: item)
case .liveLocation(let item):
LiveLocationRoomTimelineView(timelineItem: item)
}
}
@@ -64,6 +64,7 @@ enum RoomTimelineItemType: Equatable {
case voice(VoiceMessageRoomTimelineItem)
case callInvite(CallInviteRoomTimelineItem)
case callNotification(CallNotificationRoomTimelineItem)
case liveLocation(LiveLocationRoomTimelineItem)
init(item: RoomTimelineItemProtocol) {
switch item {
@@ -111,6 +112,8 @@ enum RoomTimelineItemType: Equatable {
self = .callInvite(item)
case let item as CallNotificationRoomTimelineItem:
self = .callNotification(item)
case let item as LiveLocationRoomTimelineItem:
self = .liveLocation(item)
default:
fatalError("Unknown timeline item")
}
@@ -139,7 +142,8 @@ enum RoomTimelineItemType: Equatable {
.poll(let item as RoomTimelineItemProtocol),
.voice(let item as RoomTimelineItemProtocol),
.callInvite(let item as RoomTimelineItemProtocol),
.callNotification(let item as RoomTimelineItemProtocol):
.callNotification(let item as RoomTimelineItemProtocol),
.liveLocation(let item as RoomTimelineItemProtocol):
return item.id
}
}