diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 20447fc3dd..653e6e4ad7 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -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") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 04982b2226..2888e9d576 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = ""; }; 5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineItem.swift; sourceTree = ""; }; + 52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineView.swift; sourceTree = ""; }; 529513218340CC8419273165 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = ""; }; 52F5EE5DE3B55D59299DB5BC /* AppLockSetupBiometricsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelTests.swift; sourceTree = ""; }; @@ -2081,6 +2085,7 @@ 5CEEAE1BFAACD6C96B6DB731 /* PHGPostHogProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogProtocol.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D53754227CEBD06358956D7 /* PinnedEventsTimelineScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenCoordinator.swift; sourceTree = ""; }; + 5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewScrollView.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5E33FD32BBC44D703C7AE4F9 /* TextBasedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineItem.swift; sourceTree = ""; }; 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = ""; }; @@ -2327,6 +2332,7 @@ 89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetTests.swift; sourceTree = ""; }; 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormButtonStyles.swift; sourceTree = ""; }; 8A1F2AAA3F0F2B72D2FFE4D0 /* MapTilerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerConfiguration.swift; sourceTree = ""; }; + 8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationRoomTimelineItem.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AE0C9653870803E4F91F474 /* RoomListFiltersStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFiltersStateTests.swift; sourceTree = ""; }; 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainMentionBuilder.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/Contents.json new file mode 100644 index 0000000000..e1f125cdc1 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholderMap.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/placeholderMap.pdf b/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/placeholderMap.pdf new file mode 100644 index 0000000000..bc269577a2 Binary files /dev/null and b/ElementX/Resources/Assets.xcassets/images/location/placeholderMap.imageset/placeholderMap.pdf differ diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index b1023cc18e..c3e0ffbc68 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -226,7 +226,7 @@ "common_empty_file" = "Empty file"; "common_encryption" = "Encryption"; "common_encryption_enabled" = "Encryption enabled"; -"common_ends_in_time_ios" = "Ends %1$@"; +"common_ends_at" = "Ends at %1$@"; "common_enter_your_pin" = "Enter your PIN"; "common_error" = "Error"; "common_everyone" = "Everyone"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index e4f0136df7..52e8adf1e8 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -226,7 +226,7 @@ "common_empty_file" = "Empty file"; "common_encryption" = "Encryption"; "common_encryption_enabled" = "Encryption enabled"; -"common_ends_in_time_ios" = "Ends %1$@"; +"common_ends_at" = "Ends at %1$@"; "common_enter_your_pin" = "Enter your PIN"; "common_error" = "Error"; "common_everyone" = "Everyone"; diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index 6413040dac..084ee0ab83 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -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") diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 20a1f3221b..6352a72b1b 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -500,9 +500,9 @@ internal enum L10n { internal static var commonEncryption: String { return L10n.tr("Localizable", "common_encryption") } /// Encryption enabled internal static var commonEncryptionEnabled: String { return L10n.tr("Localizable", "common_encryption_enabled") } - /// Ends %1$@ - internal static func commonEndsInTimeIos(_ p1: Any) -> String { - return L10n.tr("Localizable", "common_ends_in_time_ios", String(describing: p1)) + /// Ends at %1$@ + internal static func commonEndsAt(_ p1: Any) -> String { + return L10n.tr("Localizable", "common_ends_at", String(describing: p1)) } /// Enter your PIN internal static var commonEnterYourPin: String { return L10n.tr("Localizable", "common_enter_your_pin") } diff --git a/ElementX/Sources/Other/Extensions/Date.swift b/ElementX/Sources/Other/Extensions/Date.swift index a41b52131f..d0fce41cdd 100644 --- a/ElementX/Sources/Other/Extensions/Date.swift +++ b/ElementX/Sources/Other/Extensions/Date.swift @@ -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 diff --git a/ElementX/Sources/Other/MapLibre/MapLibreStaticMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreStaticMapView.swift index 289d691245..82a7de44ba 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreStaticMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreStaticMapView.swift @@ -34,6 +34,19 @@ struct MapLibreStaticMapView: 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, diff --git a/ElementX/Sources/Other/SwiftUI/PreviewScrollView.swift b/ElementX/Sources/Other/SwiftUI/PreviewScrollView.swift new file mode 100644 index 0000000000..d6324c9689 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/PreviewScrollView.swift @@ -0,0 +1,25 @@ +// +// 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 + +/// 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: View { + var content: () -> Content + + var body: some View { + if ProcessInfo.isRunningTests { + content() + } else { + ScrollView { + content() + } + } + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/LocationMarkerView.swift b/ElementX/Sources/Other/SwiftUI/Views/LocationMarkerView.swift index e623d7b118..c9f065313f 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/LocationMarkerView.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/LocationMarkerView.swift @@ -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)) diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index f4c4dbec24..d20d7cf2a5 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -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, diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 22476a8b55..5764b035e3 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -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 + } + } +} diff --git a/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift b/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift index f0aa263da5..90dc21bb30 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/LocationSharingScreen.swift @@ -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) diff --git a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationSheet.swift b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationSheet.swift index f80d517ae0..f67ddf9b2f 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationSheet.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationSheet.swift @@ -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) diff --git a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift index 67ff191ec5..6ec96ee148 100644 --- a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift @@ -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", diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index b565f16ccf..27b59b764a 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -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 diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift index 0cc65be2d0..85e580885a 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift @@ -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 { diff --git a/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift index 2b81be9776..73c6222e2b 100644 --- a/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift @@ -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", diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift new file mode 100644 index 0000000000..323842abe4 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift @@ -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 { + 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)))) + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LocationRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LocationRoomTimelineView.swift index 81b298c029..316e47478d 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LocationRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LocationRoomTimelineView.swift @@ -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: ""))))))) } } diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index fdeea79181..66d0a121e9 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -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) }) - }() + } } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift index 866aee84a4..c33fe742a4 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift @@ -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) diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift index cb5aa679d8..60aa87dc8a 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift @@ -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) diff --git a/ElementX/Sources/Services/Timeline/GeoURI.swift b/ElementX/Sources/Services/Timeline/GeoURI.swift index 6a2ed5885c..85a0684b01 100644 --- a/ElementX/Sources/Services/Timeline/GeoURI.swift +++ b/ElementX/Sources/Services/Timeline/GeoURI.swift @@ -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:(?-?\d+(?:\.\d+)?),(?-?\d+(?:\.\d+)?)(?:;u=(?\d+(?:\.\d+)?))?/ + static let standard: Self = /geo:(?-?\d+(?:\.\d+)?),(?-?\d+(?:\.\d+)?)(?:,-?\d+(?:\.\d+)?)?(?:;u=(?\d+(?:\.\d+)?))?/ } extension GeoURI { diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift index f2978735e7..7983eaa2f2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift @@ -11,5 +11,6 @@ import Foundation enum TimelineEventContent: Hashable { case message(EventBasedMessageTimelineItemContentType) case poll(question: String) + case liveLocation case redacted } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LiveLocationRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LiveLocationRoomTimelineItem.swift new file mode 100644 index 0000000000..2fd0b7b3f4 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LiveLocationRoomTimelineItem.swift @@ -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) } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 310cb3c9cd..ddcff3d64f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -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))) } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 92d47a5f48..5c038dfa5d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -75,6 +75,8 @@ struct RoomTimelineItemView: View { CallInviteRoomTimelineView(timelineItem: item) case .callNotification(let item): CallNotificationRoomTimelineView(timelineItem: item) + case .liveLocation(let item): + LiveLocationRoomTimelineView(timelineItem: item) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index 15d7c3d788..730af675ce 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -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 } } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index e773a6cdfe..a7aae746bb 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -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. diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-en-GB.png new file mode 100644 index 0000000000..47398e52f7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4c8ec3fa76dfde7b52c37fb7349b1be4ac326f4a82ca854f5cc7694e0b63a58 +size 2062156 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-pseudo.png new file mode 100644 index 0000000000..05d9ad02de --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da8862b27011b05259ea038c13b8ccb7b1ae1f72187a9db11624c940f087f5d3 +size 2088241 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-en-GB.png new file mode 100644 index 0000000000..6417f10543 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da93cfeea45c3b2f4d3a96ecba3d1e091b7749deb3fdc52a67130fe5b65731cd +size 892730 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-pseudo.png new file mode 100644 index 0000000000..083abe2807 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71e2e99f99fad5143cf775f97a797bc49ddbe7ed33e96f9e828ea5861a49611d +size 920079 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-en-GB-0.png index 5a5a1484f1..0872f2071b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c00139208711b767a8f19c563bc7cef80314c3382ce5ce7d481a71d5ef70df1a -size 69042 +oid sha256:965ec0dec6eec0315c2fd4bd248a7cedefc8df3f3245e8c1bf6726821a93702e +size 93586 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-pseudo-0.png index 5a5a1484f1..0872f2071b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c00139208711b767a8f19c563bc7cef80314c3382ce5ce7d481a71d5ef70df1a -size 69042 +oid sha256:965ec0dec6eec0315c2fd4bd248a7cedefc8df3f3245e8c1bf6726821a93702e +size 93586 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-en-GB-0.png index fbc577fab1..340ef10bf1 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5dcaa6876ed9b9ef600f3c8327aed5a1b5e87cfc3990994d2b44d89973597c2f -size 51341 +oid sha256:03302f8b6ec0e92addac5a265c299e146c8ee1895885c37988ec5d5ec4c5330d +size 68356 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-pseudo-0.png index fbc577fab1..340ef10bf1 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationMarkerView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5dcaa6876ed9b9ef600f3c8327aed5a1b5e87cfc3990994d2b44d89973597c2f -size 51341 +oid sha256:03302f8b6ec0e92addac5a265c299e146c8ee1895885c37988ec5d5ec4c5330d +size 68356 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-en-GB.png index 2a92b09b90..71808fe5eb 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71845ef91726086a8b88816e8535cbf5bbdd82e0de0d5a0ca9ebca75c376944b -size 1227270 +oid sha256:d15de26595447324d07c37743b11dad007f642ac89a3e7e3c48cd780bc4ecb81 +size 1294587 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-pseudo.png index 54d5a0eca7..dd91051417 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a8b87ca66a0447bf808b4ef2d67569f1a88fbc798073eefd56fcb39d4f126d3 -size 1228934 +oid sha256:18fc1f6ab7d0fb4594ab1967673562b47de74b5279d1cd48d38184d871a35b16 +size 1297733 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-en-GB.png index 6cfaeb3204..c750fd95be 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:edeeccdfbfb21821ad3eb56836d92e78e2f36516ffbf8cdd9ab7b068a9b2b68c -size 580022 +oid sha256:ea260f8c6615aca8ea3f3afcad493e1698a8c4112ea521312bdaab043499957d +size 574431 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-pseudo.png index cce704459e..460c985b6c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/locationRoomTimelineView.Bubbles-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:410475fefb08e8e59d5f5e6c3b25a6ce8d220dd819a51708ead024754f4ece73 -size 580183 +oid sha256:17b2a4d650fe20eca470d66dd074921413cdaf2cfbe5b7623f10765b47674096 +size 576073 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-en-GB-0.png index b80e068c7d..a165d624db 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8a1327623af061fac5614b2688e5e2dc16e57675638d787dc1fba994318051e -size 211360 +oid sha256:558ff66d70e97ddfcbaf1916b039134a6ffddaacb3f4e16b0780a31d3bfed534 +size 223886 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-pseudo-0.png index d4dfc77917..9ee8218c95 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80c7b216910c4b80ac13545263df7e79717ed2b5626ef91b6a2597fc997dcd10 -size 218931 +oid sha256:1044b0a38c25644eec5c7d5e2524105fb11010784a41000ca4aeeb6f03066218 +size 233697 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-en-GB-0.png index a10b2d008c..681cf32065 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de60a3b1503467a33fcc30d4659b12d98e81ef6ba16b41dd19570ad28f189f77 -size 148033 +oid sha256:ae2983a16bfd1fd75070f08ae1e9e26113abbeb86f639dc2bb32aeace210ed7e +size 156980 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-pseudo-0.png index 5475684e75..b64b961dc3 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineReplyView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd63447086e8ab914350a896927e72889226f5bc2cdb5142cc52ac31f7e6f1b8 -size 153209 +oid sha256:3716823dde3657e305057be6ea28162987a1f0a51e77228179a971abcd2c8d68 +size 160766 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png index 7c577f4748..bea55be4cd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4adc90edca8173a1808ac32a7b7dc314d79d1bde7e27699791b730ad73418282 -size 254752 +oid sha256:d78940b09bda4d73309eb18d4cd2cef3b05fc21e0d57c54c312d12228b50a62a +size 270072 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png index 788d834361..0d18d6da6e 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d4c7e9c9a817bb857565ecff34d5b2a271c2e8dca590a0f94ebd6abb31b6722 -size 318924 +oid sha256:602ac6b35ae377ef95b02af47e209b0f89c092898718b59056aeca6efcbd3afa +size 339392 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-en-GB-0.png index 9ba62517ab..c9acddfa98 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b494f40f2dd355953f8544e25f4acdafd639868060f61fb0172f9cc78c50e40c -size 170440 +oid sha256:7b69d937574343a3e37ddf53e80ac0ab518ebdf82781b269b220b668eab3fc84 +size 181150 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-pseudo-0.png index 857faf3536..484f260d4a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9044f399e2b303f0a2c4577f5f6e6d619e5e1d5268a4e440278d6657ddb47e3 -size 213517 +oid sha256:64fc0a5e968ed98efdb584ce42229edb158a854b61da11ca7c5891d1a957077f +size 226316 diff --git a/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift b/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift index f96c29ff1e..dfaa645cf9 100644 --- a/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift +++ b/SDKMocks/Sources/Generated/SDKGeneratedMocks.swift @@ -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 diff --git a/UnitTests/Sources/DateTests.swift b/UnitTests/Sources/DateTests.swift index 6b4debaeff..d2d5ded798 100644 --- a/UnitTests/Sources/DateTests.swift +++ b/UnitTests/Sources/DateTests.swift @@ -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))