mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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",
|
||||
|
||||
+236
@@ -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))))
|
||||
}
|
||||
}
|
||||
+5
-16
@@ -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: "")))))))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user