reactively determine with a timer if the lls timeline item has expired or not.

Move mockToday410/420/430 to private Date extension in LiveLocationRoomTimelineView

Agent-Logs-Url: https://github.com/element-hq/element-x-ios/sessions/d2136882-1acc-455d-a363-815e0fb73e89

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

fix
This commit is contained in:
Mauro Romito
2026-03-30 16:31:13 +02:00
committed by Mauro
parent 07490dd4a9
commit d7f8d6d890
6 changed files with 110 additions and 42 deletions
@@ -79,12 +79,6 @@ 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
@@ -5,12 +5,41 @@
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
struct LiveLocationRoomTimelineView: View {
@Environment(\.timelineContext) private var context: TimelineViewModel.Context!
@State private var hasExpired: Bool
let timelineItem: LiveLocationRoomTimelineItem
private let currentDate: Date
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem) {
self.currentDate = currentDate
self.timelineItem = timelineItem
_hasExpired = State(initialValue: currentDate >= timelineItem.content.timeoutDate)
}
/// A publisher that fires once when the timeoutDate is reached, setting `hasExpired` to true.
private var timeoutPublisher: AnyPublisher<Void, Never> {
guard timelineItem.content.isLive else {
return Empty().eraseToAnyPublisher()
}
let delay = timelineItem.content.timeoutDate.timeIntervalSinceNow
guard delay > 0 else {
return Empty().eraseToAnyPublisher()
}
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
private var isLive: Bool {
timelineItem.content.isLive && !hasExpired
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
@@ -30,7 +59,7 @@ struct LiveLocationRoomTimelineView: View {
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.overlay {
if !timelineItem.content.isLive {
if !isLive {
RoundedRectangle(cornerRadius: 12)
.inset(by: 0.5)
.stroke(Color.compound.borderDisabled)
@@ -39,7 +68,7 @@ struct LiveLocationRoomTimelineView: View {
.onTapGesture {
guard context.viewState.mapTilerConfiguration.isEnabled,
timelineItem.content.lastGeoURI != nil,
timelineItem.content.isLive else {
isLive else {
return
}
context.send(viewAction: .mediaTapped(itemID: timelineItem.id))
@@ -48,19 +77,11 @@ struct LiveLocationRoomTimelineView: View {
@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)
if isLive {
liveContent
.onReceive(timeoutPublisher) { _ in
hasExpired = true
}
} else {
Image(asset: Asset.Images.mapBlurred)
.resizable()
}
} else {
ZStack {
Image(asset: Asset.Images.placeholderMap)
@@ -70,16 +91,32 @@ struct LiveLocationRoomTimelineView: View {
}
}
@ViewBuilder
private var liveContent: some View {
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()
}
}
private var liveLocationStateString: String {
timelineItem.content.isLive ? L10n.commonLiveLocation : L10n.commonLiveLocationEnded
isLive ? L10n.commonLiveLocation : L10n.commonLiveLocationEnded
}
private var liveLocationStateColor: Color {
timelineItem.content.isLive ? .compound.textPrimary : .compound.textSecondary
isLive ? .compound.textPrimary : .compound.textSecondary
}
private var liveLocationIconColor: Color {
if timelineItem.content.isLive {
if isLive {
timelineItem.content.lastGeoURI != nil ? .compound.iconAccentPrimary : .compound.iconSecondary
} else {
.compound.iconDisabled
@@ -87,7 +124,7 @@ struct LiveLocationRoomTimelineView: View {
}
private var liveLocationBackgroundColor: Color {
timelineItem.content.isLive ? .compound.bgCanvasDefault : .compound.bgSubtleSecondary
isLive ? .compound.bgCanvasDefault : .compound.bgSubtleSecondary
}
private var blurBackground: some View {
@@ -97,7 +134,7 @@ struct LiveLocationRoomTimelineView: View {
}
private var infoIcon: KeyPath<CompoundIcons, Image> {
if timelineItem.content.lastGeoURI != nil || !timelineItem.content.isLive {
if timelineItem.content.lastGeoURI != nil || !isLive {
\.locationPinSolid
} else {
\.spinner
@@ -112,7 +149,7 @@ struct LiveLocationRoomTimelineView: View {
.background(liveLocationBackgroundColor)
.cornerRadius(8)
.overlay {
if timelineItem.content.isLive {
if isLive {
RoundedRectangle(cornerRadius: 8)
.inset(by: 0.5)
.stroke(Color.compound.iconQuaternaryAlpha)
@@ -125,7 +162,7 @@ struct LiveLocationRoomTimelineView: View {
.foregroundStyle(liveLocationStateColor)
.font(.compound.bodySMSemibold)
if timelineItem.content.isLive {
if isLive {
Text(L10n.commonEndsAt(timelineItem.content.timeoutDate.formattedExpiration()))
.foregroundStyle(.compound.textPrimary)
.font(.compound.bodySM)
@@ -135,7 +172,7 @@ struct LiveLocationRoomTimelineView: View {
Spacer()
if timelineItem.content.isLive, timelineItem.isOutgoing {
if isLive, timelineItem.isOutgoing {
Button { } label: {
CompoundIcon(\.stop, size: .small, relativeTo: .compound.bodySMSemibold)
.foregroundStyle(.compound.iconOnSolidPrimary)
@@ -156,6 +193,26 @@ struct LiveLocationRoomTimelineView: View {
private let mapMaxHeight: Double = 300
}
private extension Date {
/// A fixed date representing today at 4:10 AM, used for mocks and previews.
static var mockToday410: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 10, second: 0, of: .now)!
}
/// A fixed date representing today at 4:20 AM, used for mocks and previews.
static var mockToday420: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 20, second: 0, of: .now)!
}
/// A fixed date representing today at 4:30 AM, used for mocks and previews.
static var mockToday430: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 30, second: 0, of: .now)!
}
}
struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
@@ -174,7 +231,8 @@ struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
@ViewBuilder
static var states: some View {
// No location yet (beacon not yet received)
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
@@ -185,7 +243,8 @@ struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
lastGeoURI: nil)))
// With a known location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
@@ -194,8 +253,9 @@ struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Expired live location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
// Stopped live location
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
@@ -205,8 +265,21 @@ struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Expired live location
LiveLocationRoomTimelineView(currentDate: .mockToday430,
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))))
// Replying to a live location
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
@@ -220,7 +293,8 @@ struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
eventContent: .liveLocation))))
// Replying to a live location when the content is not live
LiveLocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43d866593057761090bb2b96e39ed678833441aa870bac82ac269274a5d78f87
size 2062660
oid sha256:65ab004d023cb309220f9df6e9dc3126cfb31d252b4d7d6aba543ce2c10ad206
size 2183435
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:17a6b41858d52fce6ce52b4d7708a95b70e71ac6ea054a02baac31d3ba6ff9d9
size 2088606
oid sha256:be432c88671b1c907955e49c1b3bdd0ec8586bfd51d03950d4772588f4c671ed
size 2211947
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e08dbc6a8b42d40bf6ef8ef2fbfc9eea5f27934203543b2ea26f9f574dfbb759
size 892677
oid sha256:2b380179dcc7e86b16651dcae880071124088604f89a58ba69cde77f16deb641
size 947975
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e984b80f719db3626eca2185eb0cde375079c92ef4f52a8faf259df7e86a42d0
size 919828
oid sha256:ae4b809327dac37e277c55a989b1f97aa3cd3efd78b1782ee0f68c4058dffc48
size 979991