mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +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:
@@ -291,6 +291,10 @@ extension AccessibilityTests {
|
||||
try await performAccessibilityAudit(named: "LinkNewDeviceScreen_Previews")
|
||||
}
|
||||
|
||||
func testLiveLocationRoomTimelineView() async throws {
|
||||
try await performAccessibilityAudit(named: "LiveLocationRoomTimelineView_Previews")
|
||||
}
|
||||
|
||||
func testLoadableImage() async throws {
|
||||
try await performAccessibilityAudit(named: "LoadableImage_Previews")
|
||||
}
|
||||
|
||||
@@ -1092,6 +1092,7 @@
|
||||
BD782053BE4C3D2F0BDE5699 /* ServiceLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F95CADD0A5DBD76B990FCB /* ServiceLocator.swift */; };
|
||||
BDA68E8D95B2B24B28825B8B /* LoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C368CAB3063EF275357ECD4 /* LoginScreenViewModel.swift */; };
|
||||
BDC4EB54CC3036730475CB8B /* QRCodeLoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E7E9B7FEAB6169D960C206 /* QRCodeLoginScreenViewModelTests.swift */; };
|
||||
BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */; };
|
||||
BDED6DA7AD1E76018C424143 /* LegalInformationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C34667458773B02AB5FB0B2 /* LegalInformationScreenViewModel.swift */; };
|
||||
BDFF0AEBF57B5B124062DAEF /* GeneratedAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CEB4590CCF70F0E3C0B171 /* GeneratedAccessibilityTests.swift */; };
|
||||
BE011C4473B9A8F12CBFE92A /* UserDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F58D94C936A1B731D78DE1 /* UserDetailsEditScreenViewModelTests.swift */; };
|
||||
@@ -1153,6 +1154,7 @@
|
||||
C85C7A201E4CFDA477ACEBEB /* AppLockSetupSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */; };
|
||||
C8A9C595038AFA2D707AC8C1 /* NotificationPermissionsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E69F67D2A70ABD08CA6D54 /* NotificationPermissionsScreenViewModelProtocol.swift */; };
|
||||
C8BD80891BAD688EF2C15CDB /* MediaUploadPreviewScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74DD0855F2F76D47E5555082 /* MediaUploadPreviewScreenCoordinator.swift */; };
|
||||
C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */; };
|
||||
C8D1D18E22672D48C11A5366 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; };
|
||||
C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */; };
|
||||
C8E11A335456FCF94A744E6E /* SpaceFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */; };
|
||||
@@ -1429,6 +1431,7 @@
|
||||
F777C6FEE7D106136E2ED2B2 /* MessageForwardingScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */; };
|
||||
F78BAD28482A467287A9A5A3 /* EventBasedMessageTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0900BBF0A5D5D775E917C70 /* EventBasedMessageTimelineItemProtocol.swift */; };
|
||||
F7932A3F075B0D3F24DEECB5 /* VoiceMessagePreviewComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE807361805463F5AEDD1CA /* VoiceMessagePreviewComposer.swift */; };
|
||||
F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */; };
|
||||
F7ADDB3A8FBD95268B71D11C /* ManageAuthorizedSpacesScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726E901DF76393E335FD7E8E /* ManageAuthorizedSpacesScreenViewModel.swift */; };
|
||||
F7BC744FFA7FE248FAE7F570 /* UserIndicatorToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57C8022B8A871A1DCD1750A /* UserIndicatorToastView.swift */; };
|
||||
F7D709D7ECABE46641BB8B6B /* PHGPostHogProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEEAE1BFAACD6C96B6DB731 /* PHGPostHogProtocol.swift */; };
|
||||
@@ -2028,6 +2031,7 @@
|
||||
52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenModels.swift; sourceTree = "<group>"; };
|
||||
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = "<group>"; };
|
||||
5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
529513218340CC8419273165 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = "<group>"; };
|
||||
52F5EE5DE3B55D59299DB5BC /* AppLockSetupBiometricsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
@@ -2081,6 +2085,7 @@
|
||||
5CEEAE1BFAACD6C96B6DB731 /* PHGPostHogProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogProtocol.swift; sourceTree = "<group>"; };
|
||||
5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = "<group>"; };
|
||||
5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewScrollView.swift; sourceTree = "<group>"; };
|
||||
5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = "<group>"; };
|
||||
5E33FD32BBC44D703C7AE4F9 /* TextBasedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = "<group>"; };
|
||||
@@ -2327,6 +2332,7 @@
|
||||
89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetTests.swift; sourceTree = "<group>"; };
|
||||
89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormButtonStyles.swift; sourceTree = "<group>"; };
|
||||
8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerConfiguration.swift; sourceTree = "<group>"; };
|
||||
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersStateTests.swift; sourceTree = "<group>"; };
|
||||
8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainMentionBuilder.swift; sourceTree = "<group>"; };
|
||||
@@ -3140,6 +3146,7 @@
|
||||
children = (
|
||||
76A46ABD27628CB5FC402541 /* Backports.swift */,
|
||||
F03AEAA2F66E796C365EFD58 /* ElementNavigationStack.swift */,
|
||||
5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */,
|
||||
D2C513A6CD99E6C3C163DA1E /* RowDivider.swift */,
|
||||
693E16574C6F7F9FA1015A8C /* Search.swift */,
|
||||
832397B5C3D00A4BF52C5F0B /* ShouldScrollOnKeyboardDidShow.swift */,
|
||||
@@ -5218,6 +5225,7 @@
|
||||
216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */,
|
||||
3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */,
|
||||
B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */,
|
||||
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */,
|
||||
1B2AC540DE619B36832A5DB5 /* LocationRoomTimelineItem.swift */,
|
||||
CD6613DE16AD26B3A74DA1F5 /* LocationRoomTimelineItemContent.swift */,
|
||||
421E716C521F96D24ECE69B3 /* NoticeRoomTimelineItem.swift */,
|
||||
@@ -6251,6 +6259,7 @@
|
||||
C258C9C815272911A5B132C3 /* FormattedBodyText.swift */,
|
||||
59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */,
|
||||
C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */,
|
||||
52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */,
|
||||
ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */,
|
||||
3F54FA7C5CB7B342EF9B9B2F /* NoticeRoomTimelineView.swift */,
|
||||
4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */,
|
||||
@@ -8274,6 +8283,8 @@
|
||||
A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */,
|
||||
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */,
|
||||
866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */,
|
||||
C8D0AC22E03F652118A2BB73 /* LiveLocationRoomTimelineItem.swift in Sources */,
|
||||
F7977C53B2B1D73030C69761 /* LiveLocationRoomTimelineView.swift in Sources */,
|
||||
6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */,
|
||||
D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */,
|
||||
256D76972BA3254F7CB7F88B /* LocationAnnotation.swift in Sources */,
|
||||
@@ -8448,6 +8459,7 @@
|
||||
128FFD8A3D85845F9A927F47 /* PollRoomTimelineView.swift in Sources */,
|
||||
1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */,
|
||||
DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */,
|
||||
BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */,
|
||||
6793E75E3EBE48EBB8F857AF /* ProcessInfo.swift in Sources */,
|
||||
69DE29C3E3180BB17D840690 /* ProgressCursorModifier.swift in Sources */,
|
||||
C7ABEBECDC513F7887DACF66 /* ProgressMaskModifier.swift in Sources */,
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "placeholderMap.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -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",
|
||||
|
||||
+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: "")))))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +579,14 @@ extension PreviewTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveLocationRoomTimelineView() async throws {
|
||||
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
|
||||
for (index, preview) in LiveLocationRoomTimelineView_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadableImage() async throws {
|
||||
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4c8ec3fa76dfde7b52c37fb7349b1be4ac326f4a82ca854f5cc7694e0b63a58
|
||||
size 2062156
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da8862b27011b05259ea038c13b8ccb7b1ae1f72187a9db11624c940f087f5d3
|
||||
size 2088241
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da93cfeea45c3b2f4d3a96ecba3d1e091b7749deb3fdc52a67130fe5b65731cd
|
||||
size 892730
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71e2e99f99fad5143cf775f97a797bc49ddbe7ed33e96f9e828ea5861a49611d
|
||||
size 920079
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c00139208711b767a8f19c563bc7cef80314c3382ce5ce7d481a71d5ef70df1a
|
||||
size 69042
|
||||
oid sha256:965ec0dec6eec0315c2fd4bd248a7cedefc8df3f3245e8c1bf6726821a93702e
|
||||
size 93586
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c00139208711b767a8f19c563bc7cef80314c3382ce5ce7d481a71d5ef70df1a
|
||||
size 69042
|
||||
oid sha256:965ec0dec6eec0315c2fd4bd248a7cedefc8df3f3245e8c1bf6726821a93702e
|
||||
size 93586
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5dcaa6876ed9b9ef600f3c8327aed5a1b5e87cfc3990994d2b44d89973597c2f
|
||||
size 51341
|
||||
oid sha256:03302f8b6ec0e92addac5a265c299e146c8ee1895885c37988ec5d5ec4c5330d
|
||||
size 68356
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5dcaa6876ed9b9ef600f3c8327aed5a1b5e87cfc3990994d2b44d89973597c2f
|
||||
size 51341
|
||||
oid sha256:03302f8b6ec0e92addac5a265c299e146c8ee1895885c37988ec5d5ec4c5330d
|
||||
size 68356
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71845ef91726086a8b88816e8535cbf5bbdd82e0de0d5a0ca9ebca75c376944b
|
||||
size 1227270
|
||||
oid sha256:d15de26595447324d07c37743b11dad007f642ac89a3e7e3c48cd780bc4ecb81
|
||||
size 1294587
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a8b87ca66a0447bf808b4ef2d67569f1a88fbc798073eefd56fcb39d4f126d3
|
||||
size 1228934
|
||||
oid sha256:18fc1f6ab7d0fb4594ab1967673562b47de74b5279d1cd48d38184d871a35b16
|
||||
size 1297733
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:edeeccdfbfb21821ad3eb56836d92e78e2f36516ffbf8cdd9ab7b068a9b2b68c
|
||||
size 580022
|
||||
oid sha256:ea260f8c6615aca8ea3f3afcad493e1698a8c4112ea521312bdaab043499957d
|
||||
size 574431
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:410475fefb08e8e59d5f5e6c3b25a6ce8d220dd819a51708ead024754f4ece73
|
||||
size 580183
|
||||
oid sha256:17b2a4d650fe20eca470d66dd074921413cdaf2cfbe5b7623f10765b47674096
|
||||
size 576073
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8a1327623af061fac5614b2688e5e2dc16e57675638d787dc1fba994318051e
|
||||
size 211360
|
||||
oid sha256:558ff66d70e97ddfcbaf1916b039134a6ffddaacb3f4e16b0780a31d3bfed534
|
||||
size 223886
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:80c7b216910c4b80ac13545263df7e79717ed2b5626ef91b6a2597fc997dcd10
|
||||
size 218931
|
||||
oid sha256:1044b0a38c25644eec5c7d5e2524105fb11010784a41000ca4aeeb6f03066218
|
||||
size 233697
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de60a3b1503467a33fcc30d4659b12d98e81ef6ba16b41dd19570ad28f189f77
|
||||
size 148033
|
||||
oid sha256:ae2983a16bfd1fd75070f08ae1e9e26113abbeb86f639dc2bb32aeace210ed7e
|
||||
size 156980
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dd63447086e8ab914350a896927e72889226f5bc2cdb5142cc52ac31f7e6f1b8
|
||||
size 153209
|
||||
oid sha256:3716823dde3657e305057be6ea28162987a1f0a51e77228179a971abcd2c8d68
|
||||
size 160766
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4adc90edca8173a1808ac32a7b7dc314d79d1bde7e27699791b730ad73418282
|
||||
size 254752
|
||||
oid sha256:d78940b09bda4d73309eb18d4cd2cef3b05fc21e0d57c54c312d12228b50a62a
|
||||
size 270072
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2d4c7e9c9a817bb857565ecff34d5b2a271c2e8dca590a0f94ebd6abb31b6722
|
||||
size 318924
|
||||
oid sha256:602ac6b35ae377ef95b02af47e209b0f89c092898718b59056aeca6efcbd3afa
|
||||
size 339392
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b494f40f2dd355953f8544e25f4acdafd639868060f61fb0172f9cc78c50e40c
|
||||
size 170440
|
||||
oid sha256:7b69d937574343a3e37ddf53e80ac0ab518ebdf82781b269b220b668eab3fc84
|
||||
size 181150
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f9044f399e2b303f0a2c4577f5f6e6d619e5e1d5268a4e440278d6657ddb47e3
|
||||
size 213517
|
||||
oid sha256:64fc0a5e968ed98efdb584ce42229edb158a854b61da11ca7c5891d1a957077f
|
||||
size 226316
|
||||
|
||||
@@ -12845,6 +12845,136 @@ open class QrCodeDataSDKMock: MatrixRustSDK.QrCodeData, @unchecked Sendable {
|
||||
{
|
||||
}
|
||||
|
||||
//MARK: - baseUrl
|
||||
|
||||
open var baseUrlUnderlyingCallsCount = 0
|
||||
open var baseUrlCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return baseUrlUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = baseUrlUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
baseUrlUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
baseUrlUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var baseUrlCalled: Bool {
|
||||
return baseUrlCallsCount > 0
|
||||
}
|
||||
|
||||
open var baseUrlUnderlyingReturnValue: String?
|
||||
open var baseUrlReturnValue: String? {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return baseUrlUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: String?? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = baseUrlUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
baseUrlUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
baseUrlUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var baseUrlClosure: (() -> String?)?
|
||||
|
||||
open override func baseUrl() -> String? {
|
||||
baseUrlCallsCount += 1
|
||||
if let baseUrlClosure = baseUrlClosure {
|
||||
return baseUrlClosure()
|
||||
} else {
|
||||
return baseUrlReturnValue
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - intent
|
||||
|
||||
open var intentUnderlyingCallsCount = 0
|
||||
open var intentCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return intentUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = intentUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
intentUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
intentUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var intentCalled: Bool {
|
||||
return intentCallsCount > 0
|
||||
}
|
||||
|
||||
open var intentUnderlyingReturnValue: QrCodeIntent!
|
||||
open var intentReturnValue: QrCodeIntent! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return intentUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: QrCodeIntent? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = intentUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
intentUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
intentUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var intentClosure: (() -> QrCodeIntent)?
|
||||
|
||||
open override func intent() -> QrCodeIntent {
|
||||
intentCallsCount += 1
|
||||
if let intentClosure = intentClosure {
|
||||
return intentClosure()
|
||||
} else {
|
||||
return intentReturnValue
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - serverName
|
||||
|
||||
open var serverNameUnderlyingCallsCount = 0
|
||||
|
||||
@@ -42,6 +42,25 @@ struct DateTests {
|
||||
#expect(theMillennium.formattedMinimal() == theMillennium.formatted(.dateTime.year().day().month()))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expirationDateFormatting() throws {
|
||||
// Within 24 hours → show time only
|
||||
let in23Hours = try #require(calendar.date(byAdding: .hour, value: 23, to: .now))
|
||||
#expect(in23Hours.formattedExpiration() == in23Hours.formatted(date: .omitted, time: .shortened))
|
||||
|
||||
// Just over the 24h boundary → show day and month
|
||||
let in25Hours = try #require(calendar.date(byAdding: .hour, value: 25, to: .now))
|
||||
#expect(in25Hours.formattedExpiration() == in25Hours.formatted(.dateTime.day().month()))
|
||||
|
||||
// Several months away → show day and month
|
||||
let inSixMonths = try #require(calendar.date(byAdding: .month, value: 6, to: .now))
|
||||
#expect(inSixMonths.formattedExpiration() == inSixMonths.formatted(.dateTime.day().month()))
|
||||
|
||||
// More than a year away → show year only
|
||||
let inTwoYears = try #require(calendar.date(byAdding: .year, value: 2, to: .now))
|
||||
#expect(inTwoYears.formattedExpiration() == inTwoYears.formatted(.dateTime.year()))
|
||||
}
|
||||
|
||||
@Test
|
||||
func dateSeparatorFormatting() throws {
|
||||
let today = try #require(calendar.date(byAdding: DateComponents(hour: 9, minute: 30), to: startOfToday))
|
||||
|
||||
Reference in New Issue
Block a user