diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 56c677b60..7acf089ab 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ 17BC15DA08A52587466698C5 /* RoomMessageEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E815FF3CC5E5A355E3A25E /* RoomMessageEventStringBuilder.swift */; }; 1801F1467ABCEA080419E150 /* preview_avatar_user.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 87FC42213E86E8182CFD3A49 /* preview_avatar_user.jpg */; }; 182D532B736178A1DED9F76E /* ReportRoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11FCAE847556719BBE7A0882 /* ReportRoomScreenModels.swift */; }; + 18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */; }; 18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; 18978C9438206828C1D5AF2A /* test_animated_image.gif in Resources */ = {isa = PBXBuildFile; fileRef = 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */; }; 18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */; }; @@ -187,6 +188,7 @@ 21F29351EDD7B2A5534EE862 /* SecureBackupKeyBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD558A898847C179E4B7A237 /* SecureBackupKeyBackupScreen.swift */; }; 22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 22B380C579C148BA0BFB5952 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3557ACB95D0F666EF5AF0CE /* Secrets.swift */; }; + 22BA593A5E19D8D3DE2FAA6F /* TimelineEventContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */; }; 22C5483D01EEB290B8339817 /* HomeScreenInviteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FC33C3F6BF597E095CE9FA /* HomeScreenInviteCell.swift */; }; 2335D1AB954C151FD8779F45 /* RoomPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0096BC5DA86AF6B6E5742AC /* RoomPermissionsTests.swift */; }; 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */; }; @@ -655,6 +657,7 @@ 7F7EA51A9A43125A8CB6AC90 /* NotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D560DDA3B20C82766ACFAD /* NotificationSettingsScreenViewModel.swift */; }; 7F825CBD857D65DC986087BA /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F54FA7C5CB7B342EF9B9B2F /* NoticeRoomTimelineView.swift */; }; 7F941B063C94E1718DFC2CF3 /* RoomChangeRolesScreenRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E6EB7960BC9D0F7396B3BD /* RoomChangeRolesScreenRow.swift */; }; + 7F99B6DDBEC5D5E6752C7276 /* TimelineThreadSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF2E6ADAE685F4109B1FE795 /* TimelineThreadSummaryView.swift */; }; 7FED77802940EA7DF4D0D3A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */; }; 7FF27DA70D833CFC5724EFC5 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = C07EA60CAB296D7726210F5B /* MatrixRustSDK */; }; 7FF6E1FBE6E9517FD29A1D8E /* RoomChangeRolesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A5C34C4E4268EF65D171EF /* RoomChangeRolesScreenModels.swift */; }; @@ -1606,6 +1609,7 @@ 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsViewModelTests.swift; sourceTree = ""; }; 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineView.swift; sourceTree = ""; }; 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; + 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventContent.swift; sourceTree = ""; }; 303D9438EFB481F57A366E82 /* EncryptionResetPasswordScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenViewModel.swift; sourceTree = ""; }; 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = ""; }; 306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenModels.swift; sourceTree = ""; }; @@ -2099,6 +2103,7 @@ 981663D961C94270FA035FD0 /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 989D7380D9C86B3A10D30B13 /* AppLockSetupPINScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModelTests.swift; sourceTree = ""; }; 989FC684408B31A677F5538B /* CompletionSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionView.swift; sourceTree = ""; }; + 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemThreadSummary.swift; sourceTree = ""; }; 997BF045585AF6DB2EBC5755 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinator.swift; sourceTree = ""; }; 9A028783CFFF861C5E44FFB1 /* BadgeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeLabel.swift; sourceTree = ""; }; @@ -2206,6 +2211,7 @@ AEB5FF7A09B79B0C6B528F7C /* SFNumberedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFNumberedListView.swift; sourceTree = ""; }; AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilderTests.swift; sourceTree = ""; }; AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderTests.swift; sourceTree = ""; }; + AF2E6ADAE685F4109B1FE795 /* TimelineThreadSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineThreadSummaryView.swift; sourceTree = ""; }; AF848B41DAF1066F3054D4A1 /* SessionVerificationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenModels.swift; sourceTree = ""; }; AF8548D48512127CCC17C520 /* PollRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineView.swift; sourceTree = ""; }; AFEF489B8E2450E2BA1A314E /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/SAS.strings; sourceTree = ""; }; @@ -3075,6 +3081,14 @@ path = View; sourceTree = ""; }; + 27256E3989A52CF8EEB8AF5F /* Threads */ = { + isa = PBXGroup; + children = ( + AF2E6ADAE685F4109B1FE795 /* TimelineThreadSummaryView.swift */, + ); + path = Threads; + sourceTree = ""; + }; 2774D635E78D8B98390EA694 /* Resources */ = { isa = PBXGroup; children = ( @@ -4141,7 +4155,9 @@ 955336CBD5ED73C792D1F580 /* EncryptionAuthenticity.swift */, 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */, 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */, + 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */, BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */, + 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */, ); path = TimelineItemContent; sourceTree = ""; @@ -6077,6 +6093,7 @@ 7F6468993CB943953DC355A6 /* Replies */, 308FE2283B9803DBBB05602C /* Style */, 13CC0A77B6E5EEDF881C5E8B /* Supplementary */, + 27256E3989A52CF8EEB8AF5F /* Threads */, CC2B160BAFD5FEAC106F89F3 /* TimelineItemViews */, ); path = View; @@ -7674,6 +7691,7 @@ B47213F07A67CE3A8D49CEC9 /* TimelineControllerFactoryProtocol.swift in Sources */, FA53FA227FFBE469AFF32F71 /* TimelineControllerProtocol.swift in Sources */, 798BF3072137833FBD3F4C96 /* TimelineDeliveryStatusView.swift in Sources */, + 22BA593A5E19D8D3DE2FAA6F /* TimelineEventContent.swift in Sources */, 109AEB7D33C4497727AFB87F /* TimelineInteractionHandler.swift in Sources */, E6FA87F773424B27614B23E9 /* TimelineItemAccessibilityModifier.swift in Sources */, F6767F17D538578B370DD805 /* TimelineItemBubbleBackground.swift in Sources */, @@ -7689,6 +7707,7 @@ F3ECA377FF77E81A4F1FA062 /* TimelineItemSendInfoLabel.swift in Sources */, 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, + 18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, 3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */, 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */, @@ -7714,6 +7733,7 @@ F2D5C0E1351DA7BD16867629 /* TimelineStyle.swift in Sources */, 7A0D335D38ECA095A575B4F7 /* TimelineStyler.swift in Sources */, 6583A95947E78767736CB51A /* TimelineTableViewController.swift in Sources */, + 7F99B6DDBEC5D5E6752C7276 /* TimelineThreadSummaryView.swift in Sources */, 67EFF46180B939CBF389AECD /* TimelineView.swift in Sources */, 98EE4259A4A49BC757BA442C /* TimelineViewModel.swift in Sources */, F8B2F5CBCF2A0E0798E8D646 /* TimelineViewModelProtocol.swift in Sources */, @@ -8635,7 +8655,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 25.04.14; + version = 25.04.16; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d2124922..f3f120897 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -158,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "1f43ea9360f43ecd3d6674048265823709c96f1a", - "version" : "25.4.14" + "revision" : "895ba54c171859cace965e77b6fe2f64919ecb27", + "version" : "25.4.16" } }, { diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 5c942372d..78046aa92 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -58,6 +58,7 @@ final class AppSettings { case knockingEnabled case reportRoomEnabled case reportInviteEnabled + case threadsEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -338,6 +339,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.reportInviteEnabled, defaultValue: false, storageType: .userDefaults(store)) var reportInviteEnabled + @UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store)) + var threadsEnabled + #endif // MARK: - Shared diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index 4efb6fc04..fe60ea90f 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -20353,6 +20353,82 @@ open class TaskHandleSDKMock: MatrixRustSDK.TaskHandle, @unchecked Sendable { } } } +open class ThreadSummarySDKMock: MatrixRustSDK.ThreadSummary, @unchecked Sendable { + init() { + super.init(noPointer: .init()) + } + + public required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + fatalError("init(unsafeFromRawPointer:) has not been implemented") + } + + fileprivate var pointer: UnsafeMutableRawPointer! + + //MARK: - latestEvent + + var latestEventUnderlyingCallsCount = 0 + open var latestEventCallsCount: Int { + get { + if Thread.isMainThread { + return latestEventUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = latestEventUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + latestEventUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + latestEventUnderlyingCallsCount = newValue + } + } + } + } + open var latestEventCalled: Bool { + return latestEventCallsCount > 0 + } + + var latestEventUnderlyingReturnValue: ThreadSummaryLatestEventDetails! + open var latestEventReturnValue: ThreadSummaryLatestEventDetails! { + get { + if Thread.isMainThread { + return latestEventUnderlyingReturnValue + } else { + var returnValue: ThreadSummaryLatestEventDetails? = nil + DispatchQueue.main.sync { + returnValue = latestEventUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + latestEventUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + latestEventUnderlyingReturnValue = newValue + } + } + } + } + open var latestEventClosure: (() -> ThreadSummaryLatestEventDetails)? + + open override func latestEvent() -> ThreadSummaryLatestEventDetails { + latestEventCallsCount += 1 + if let latestEventClosure = latestEventClosure { + return latestEventClosure() + } else { + return latestEventReturnValue + } + } +} open class TimelineSDKMock: MatrixRustSDK.Timeline, @unchecked Sendable { init() { super.init(noPointer: .init()) diff --git a/ElementX/Sources/Mocks/SDK/EventTimelineItem.swift b/ElementX/Sources/Mocks/SDK/EventTimelineItem.swift index a0fc4c1c6..c602eecfd 100644 --- a/ElementX/Sources/Mocks/SDK/EventTimelineItem.swift +++ b/ElementX/Sources/Mocks/SDK/EventTimelineItem.swift @@ -14,7 +14,11 @@ struct EventTimelineItemSDKMockConfiguration { var sender = "" var senderProfile: ProfileDetails? var isOwn = false - var content: TimelineItemContent = .msgLike(content: .init(kind: .redacted, reactions: [], threadRoot: nil, inReplyTo: nil)) + var content: TimelineItemContent = .msgLike(content: .init(kind: .redacted, + reactions: [], + inReplyTo: nil, + threadRoot: nil, + threadSummary: nil)) } extension EventTimelineItem { @@ -44,8 +48,9 @@ extension EventTimelineItem { isEdited: false, mentions: nil)), reactions: [], + inReplyTo: nil, threadRoot: nil, - inReplyTo: nil)) + threadSummary: nil)) return .init(configuration: .init(content: content)) } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index c48e5df1e..95307984c 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -78,6 +78,7 @@ enum UserAvatarSizeOnScreen { case mediaPreviewDetails case sendInviteConfirmation case sessionVerification + case threadSummary var value: CGFloat { switch self { @@ -119,6 +120,8 @@ enum UserAvatarSizeOnScreen { return 64 case .sessionVerification: return 52 + case .threadSummary: + return 28 } } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift index f73918fa1..4d61a3caa 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift @@ -313,6 +313,7 @@ struct ComposerToolbar: View { // MARK: - Previews struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { + static let viewModel = TimelineViewModel.mock static let wysiwygViewModel = WysiwygComposerViewModel() static let composerViewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()), wysiwygViewModel: wysiwygViewModel, @@ -352,6 +353,7 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { ComposerToolbar.replyLoadingPreviewMock(isLoading: true) ComposerToolbar.replyLoadingPreviewMock(isLoading: false) } + .environmentObject(viewModel.context) .previewDisplayName("Reply") VStack(spacing: 8) { diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift index 3bd01d029..aa7f3e6b2 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift @@ -345,6 +345,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .editCaption)) } .padding(.horizontal) + .environmentObject(viewModel.context) ScrollView { VStack(spacing: 8) { diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 210dd6938..82b7ca1d8 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -46,6 +46,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var knockingEnabled: Bool { get set } var reportRoomEnabled: Bool { get set } var reportInviteEnabled: Bool { get set } + var threadsEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 32bca9660..99610f988 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -30,7 +30,13 @@ struct DeveloperOptionsScreen: View { } } } - + + Section("General") { + Toggle(isOn: $context.threadsEnabled) { + Text("Threads") + } + } + Section("Room List") { Toggle(isOn: $context.publicSearchEnabled) { Text("Public search") diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index a1cddae55..c9951510a 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -59,6 +59,7 @@ enum TimelineViewAction { case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) case displayEmojiPicker(itemID: TimelineItemIdentifier) case displayReadReceipts(itemID: TimelineItemIdentifier) + case displayThread(itemID: TimelineItemIdentifier) case handlePasteOrDrop(provider: NSItemProvider) case handlePollAction(TimelineViewPollAction) @@ -102,6 +103,7 @@ struct TimelineViewState: BindableState { var canCurrentUserKick = false var canCurrentUserBan = false var isViewSourceEnabled: Bool + var areThreadsEnabled: Bool var hideTimelineMedia: Bool // The `pinnedEventIDs` are used only to determine if an item is already pinned or not. @@ -238,3 +240,56 @@ enum ScrollDirection: Equatable { case top case bottom } + +extension TimelineViewState { + /// The string shown as the message preview. + /// + /// This converts the formatted body to a plain string to remove formatting + /// and render with a consistent font size. This conversion is done to avoid + /// showing markdown characters in the preview for messages with formatting. + func buildMessagePreview(formattedBody: AttributedString?, plainBody: String) -> String { + guard let formattedBody, + let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else { + return plainBody + } + + let range = NSRange(location: 0, length: attributedString.length) + attributedString.enumerateAttributes(in: range) { attributes, range, _ in + if let userID = attributes[.MatrixUserID] as? String { + if let displayName = members[userID]?.displayName { + attributedString.replaceCharacters(in: range, with: "@\(displayName)") + } else { + attributedString.replaceCharacters(in: range, with: userID) + } + } + + if attributes[.MatrixAllUsersMention] as? Bool == true { + attributedString.replaceCharacters(in: range, with: PillUtilities.atRoom) + } + + if let roomAlias = attributes[.MatrixRoomAlias] as? String { + let roomName = roomNameForAliasResolver?(roomAlias) + attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomAlias)) + } + + if let roomID = attributes[.MatrixRoomID] as? String { + let roomName = roomNameForIDResolver?(roomID) + attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomID)) + } + + if let eventOnRoomID = attributes[.MatrixEventOnRoomID] as? EventOnRoomIDAttribute.Value { + let roomID = eventOnRoomID.roomID + let roomName = roomNameForIDResolver?(roomID) + attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: roomID)) + } + + if let eventOnRoomAlias = attributes[.MatrixEventOnRoomAlias] as? EventOnRoomAliasAttribute.Value { + let roomAlias = eventOnRoomAlias.alias + let roomName = roomNameForAliasResolver?(roomAlias) + attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: eventOnRoomAlias.alias)) + } + } + + return attributedString.string + } +} diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index bd46f265e..88a22bf9a 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -102,6 +102,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), ownUserID: roomProxy.ownUserID, isViewSourceEnabled: appSettings.viewSourceEnabled, + areThreadsEnabled: appSettings.threadsEnabled, hideTimelineMedia: hideTimelineMedia, pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs, emojiProvider: emojiProvider, @@ -182,14 +183,16 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) case .handleTimelineItemMenuAction(let itemID, let action): timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID) - case .tappedOnSenderDetails(userID: let userID): + case .tappedOnSenderDetails(let userID): handleTappedOnSenderDetails(userID: userID) case .displayEmojiPicker(let itemID): timelineInteractionHandler.displayEmojiPicker(for: itemID) case .displayReactionSummary(let itemID, let key): displayReactionSummary(for: itemID, selectedKey: key) - case .displayReadReceipts(itemID: let itemID): + case .displayReadReceipts(let itemID): displayReadReceipts(for: itemID) + case .displayThread: + break case .handlePasteOrDrop(let provider): timelineInteractionHandler.handlePasteOrDrop(provider) case .handlePollAction(let pollAction): diff --git a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift index de4bf7d16..f0d5af1c1 100644 --- a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift @@ -131,12 +131,12 @@ struct TimelineReplyView: View { .cornerRadius(icon?.cornerRadii ?? 0.0, corners: .allCorners) VStack(alignment: .leading, spacing: 2) { - Text(sender.displayName ?? sender.id) + Text(sender.disambiguatedDisplayName ?? sender.id) .font(.compound.bodySMSemibold) .foregroundColor(.compound.textPrimary) - .accessibilityLabel(L10n.commonInReplyTo(sender.displayName ?? sender.id)) + .accessibilityLabel(L10n.commonInReplyTo(sender.disambiguatedDisplayName ?? sender.id)) - Text(messagePreview) + Text(context.viewState.buildMessagePreview(formattedBody: formattedBody, plainBody: plainBody)) .font(.compound.bodyMD) .foregroundColor(.compound.textSecondary) .tint(.compound.textLinkExternal) @@ -175,56 +175,6 @@ struct TimelineReplyView: View { } } } - - /// The string shown as the message preview. - /// - /// This converts the formatted body to a plain string to remove formatting - /// and render with a consistent font size. This conversion is done to avoid - /// showing markdown characters in the preview for messages with formatting. - private var messagePreview: String { - guard let formattedBody, - let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else { - return plainBody - } - - let range = NSRange(location: 0, length: attributedString.length) - attributedString.enumerateAttributes(in: range) { attributes, range, _ in - if let userID = attributes[.MatrixUserID] as? String { - if let displayName = context.viewState.members[userID]?.displayName { - attributedString.replaceCharacters(in: range, with: "@\(displayName)") - } else { - attributedString.replaceCharacters(in: range, with: userID) - } - } - - if attributes[.MatrixAllUsersMention] as? Bool == true { - attributedString.replaceCharacters(in: range, with: PillUtilities.atRoom) - } - - if let roomAlias = attributes[.MatrixRoomAlias] as? String { - let roomName = context.viewState.roomNameForAliasResolver?(roomAlias) - attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomAlias)) - } - - if let roomID = attributes[.MatrixRoomID] as? String { - let roomName = context.viewState.roomNameForIDResolver?(roomID) - attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomID)) - } - - if let eventOnRoomID = attributes[.MatrixEventOnRoomID] as? EventOnRoomIDAttribute.Value { - let roomID = eventOnRoomID.roomID - let roomName = context.viewState.roomNameForIDResolver?(roomID) - attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: roomID)) - } - - if let eventOnRoomAlias = attributes[.MatrixEventOnRoomAlias] as? EventOnRoomAliasAttribute.Value { - let roomAlias = eventOnRoomAlias.alias - let roomName = context.viewState.roomNameForAliasResolver?(roomAlias) - attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: eventOnRoomAlias.alias)) - } - } - return attributedString.string - } } } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 033f6f994..344c9bd55 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -119,6 +119,13 @@ struct TimelineItemBubbledStylerView: View { // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } } + + if let threadSummary = timelineItem.properties.threadSummary { + TimelineThreadSummaryView(threadSummary: threadSummary) { + context.send(viewAction: .displayThread(itemID: timelineItem.id)) + } + .padding(5) + } } } @@ -340,10 +347,14 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .padding(.bottom, 20) replies .previewDisplayName("Replies") - threads + threadDecorator .previewDisplayName("Thread decorator") .previewLayout(.fixed(width: 390, height: 1700)) .padding(.bottom, 20) + threadSummary + .previewDisplayName("Thread summary") + .previewLayout(.fixed(width: 390, height: 1700)) + .padding(.bottom, 20) encryptionAuthenticity .previewDisplayName("Encryption Indicators") pinned @@ -395,13 +406,25 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .environment(\.timelineContext, viewModel.context) } - static var threads: some View { + static var threadDecorator: some View { ScrollView { MockTimelineContent(isThreaded: true) } .environmentObject(viewModel.context) .environment(\.timelineContext, viewModel.context) } + + static var threadSummary: some View { + ScrollView { + let threadSummary = TimelineItemThreadSummary.loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.text(.init(body: "This is a threaded message")))) + + MockTimelineContent(threadSummary: threadSummary) + } + .environmentObject(viewModelWithPins.context) + .environment(\.timelineContext, viewModel.context) + } static var pinned: some View { ScrollView { @@ -493,6 +516,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview private struct MockTimelineContent: View { var isThreaded = false var isPinned = false + var threadSummary: TimelineItemThreadSummary? var body: some View { RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: makeItemIdentifier(), @@ -502,7 +526,9 @@ private struct MockTimelineContent: View { canBeRepliedTo: true, sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)), + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary)), groupStyle: .single)) AudioRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), @@ -517,7 +543,9 @@ private struct MockTimelineContent: View { source: nil, fileSize: nil, contentType: nil), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded))) + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary))) FileRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), timestamp: .mock, @@ -531,7 +559,9 @@ private struct MockTimelineContent: View { fileSize: nil, thumbnailSource: nil, contentType: nil), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded))) + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary))) ImageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), timestamp: .mock, @@ -542,7 +572,9 @@ private struct MockTimelineContent: View { content: .init(filename: "image.jpg", imageInfo: .mockImage, thumbnailInfo: nil), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded))) + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary))) LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), timestamp: .mock, @@ -554,7 +586,9 @@ private struct MockTimelineContent: View { geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded))) + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary))) LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), timestamp: .mock, @@ -564,7 +598,9 @@ private struct MockTimelineContent: View { sender: .init(id: "Bob"), content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded))) + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary))) VoiceMessageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(), timestamp: .mock, @@ -578,7 +614,9 @@ private struct MockTimelineContent: View { source: nil, fileSize: nil, contentType: nil), - properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)), + properties: .init(replyDetails: replyDetails, + isThreaded: isThreaded, + threadSummary: threadSummary)), playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), title: L10n.commonVoiceMessage, duration: 10, diff --git a/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift new file mode 100644 index 000000000..f2fc9e5bd --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift @@ -0,0 +1,277 @@ +// +// Copyright 2023, 2024 New Vector 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 TimelineThreadSummaryView: View { + let threadSummary: TimelineItemThreadSummary + var onTap: (() -> Void)? + + var body: some View { + Button { + onTap?() + } label: { + content + } + } + + @ViewBuilder + private var content: some View { + switch threadSummary { + case .loaded(let senderID, let sender, let latestEventContent): + switch latestEventContent { + case .message(let content): + switch content { + case .audio(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.caption ?? content.filename, + formattedBody: content.formattedCaption) + case .emote(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.body, + formattedBody: content.formattedBody) + case .file(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.caption ?? content.filename, + formattedBody: content.formattedCaption) + case .image(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.caption ?? content.filename, + formattedBody: content.formattedCaption) + case .notice(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.body, + formattedBody: content.formattedBody) + case .text(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.body, + formattedBody: content.formattedBody) + case .video(let content): + ThreadView(senderID: senderID, + sender: sender, + plainBody: content.caption ?? content.filename, + formattedBody: content.formattedCaption) + case .voice: + ThreadView(senderID: senderID, + sender: sender, + plainBody: L10n.commonVoiceMessage, + formattedBody: nil) + case .location: + ThreadView(senderID: senderID, + sender: sender, + plainBody: L10n.commonSharedLocation, + formattedBody: nil) + } + case .poll(let question): + ThreadView(senderID: senderID, + sender: sender, + plainBody: question, + formattedBody: nil) + case .redacted: + ThreadView(senderID: senderID, + sender: sender, + plainBody: L10n.commonMessageRemoved, + formattedBody: nil) + } + default: + LoadingThreadView() + } + } + + private struct LoadingThreadView: View { + var body: some View { + ThreadView(senderID: "@alice:matrix.org", sender: nil, plainBody: "Hello world", formattedBody: nil) + .redacted(reason: .placeholder) + } + } + + private struct ThreadView: View { + @EnvironmentObject private var context: TimelineViewModel.Context + + let senderID: String + let sender: TimelineItemSender? + let plainBody: String + let formattedBody: AttributedString? + + var body: some View { + HStack(spacing: 8) { + CompoundIcon(\.threads, size: .xSmall, relativeTo: .compound.bodyXS) + .foregroundColor(.compound.iconSecondary) + + LoadableAvatarImage(url: sender?.avatarURL, + name: sender?.displayName, + contentID: sender?.id, + avatarSize: .user(on: .threadSummary), + mediaProvider: context.mediaProvider) + + Text(sender?.disambiguatedDisplayName ?? senderID) + .font(.compound.bodyXSSemibold) + .foregroundColor(.compound.textPrimary) + .accessibilityLabel(L10n.commonInReplyTo(sender?.disambiguatedDisplayName ?? senderID)) + + Text(context.viewState.buildMessagePreview(formattedBody: formattedBody, plainBody: plainBody)) + .font(.compound.bodyXS) + .foregroundColor(.compound.textSecondary) + .tint(.compound.textLinkExternal) + .lineLimit(2) + } + .padding(.vertical, 4.0) + .padding(.horizontal, 8.0) + .background(Color.compound.bgSubtlePrimary) + .cornerRadius(8) + } + } +} + +struct TimelineThreadSummaryView_Previews: PreviewProvider, TestablePreview { + static let viewModel = TimelineViewModel.mock + + static let attributedStringWithMention = { + var attributedString = AttributedString("To be replaced") + attributedString.userID = "@alice:matrix.org" + return attributedString + }() + + static let attributedStringWithAtRoomMention = { + var attributedString = AttributedString("to be replaced") + attributedString.allUsersMention = true + return attributedString + }() + + static let attributedStringWithRoomAliasMention = { + var attributedString = AttributedString("to be replaced") + attributedString.roomAlias = "#room:matrix.org" + return attributedString + }() + + static let attributedStringWithRoomIDMention = { + var attributedString = AttributedString("to be replaced") + attributedString.roomID = "!room:matrix.org" + return attributedString + }() + + static let attributedStringWithEventOnRoomIDMention = { + var attributedString = AttributedString("to be replaced") + attributedString.eventOnRoomID = .init(roomID: "!room:matrix.org", eventID: "$event") + return attributedString + }() + + static let attributedStringWithEventOnRoomAliasMention = { + var attributedString = AttributedString("to be replaced") + attributedString.eventOnRoomAlias = .init(alias: "#room:matrix.org", eventID: "$event") + return attributedString + }() + + static var previewItems: [TimelineThreadSummaryView] { + [ + TimelineThreadSummaryView(threadSummary: .notLoaded), + + TimelineThreadSummaryView(threadSummary: .loading), + + TimelineThreadSummaryView(threadSummary: .error(message: "Error")), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.text(.init(body: "This is a threaded message"))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "Hello world"))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.audio(.init(filename: "audio.m4a", + caption: "Some audio", + duration: 0, + waveform: nil, + source: nil, + fileSize: nil, + contentType: nil))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.file(.init(filename: "file.txt", + caption: "Some file", + source: nil, + fileSize: nil, + thumbnailSource: nil, + contentType: nil))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.image(.init(filename: "image.jpg", + caption: "Some image", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.video(.init(filename: "video.mp4", + caption: "Some video", + videoInfo: .mockVideo, + thumbnailInfo: .mockVideoThumbnail))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.location(.init(body: ""))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.voice(.init(filename: "voice-message.ogg", + caption: "Some voice message", + duration: 0, + waveform: nil, + source: nil, + fileSize: nil, + contentType: nil))))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .poll(question: "Do you like polls?"))), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .redacted)), + + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomAliasMention))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomIDMention))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomIDMention))))), + TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomAliasMention))))) + ] + } + + static var previews: some View { + VStack(alignment: .leading, spacing: 20) { + ForEach(0.. Result { switch await roomProxy.timeline.getLoadedReplyDetails(eventID: eventID) { case .success(let replyDetails): - return .success(timelineItemfactory.buildReply(details: replyDetails)) + return .success(timelineItemfactory.buildTimelineItemReply(replyDetails)) case .failure(let error): MXLog.error("Could not load reply: \(error)") return .failure(.failedToLoadReply) diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift index 5b81f55c6..1803fea9e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift @@ -13,6 +13,8 @@ struct RoomTimelineItemProperties: Hashable { var replyDetails: TimelineItemReplyDetails? // Whether it's part of a thread or not var isThreaded = false + // Information about the thread this message is the root of, if any + var threadSummary: TimelineItemThreadSummary? /// Whether the item has been edited. var isEdited = false /// The aggregated reactions that have been sent for this item. diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift new file mode 100644 index 000000000..c87e2b15b --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineEventContent.swift @@ -0,0 +1,14 @@ +// +// Copyright 2025 New Vector 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 + +enum TimelineEventContent: Hashable { + case message(EventBasedMessageTimelineItemContentType) + case poll(question: String) + case redacted +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift index 76859d2d0..12aeba7a5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift @@ -25,9 +25,3 @@ enum TimelineItemReplyDetails: Hashable { } } } - -enum TimelineEventContent: Hashable { - case message(EventBasedMessageTimelineItemContentType) - case poll(question: String) - case redacted -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemThreadSummary.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemThreadSummary.swift new file mode 100644 index 000000000..18bd5dc8a --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemThreadSummary.swift @@ -0,0 +1,15 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import MatrixRustSDK + +enum TimelineItemThreadSummary: Hashable { + case notLoaded + case loading + case loaded(senderID: String, sender: TimelineItemSender, latestEventContent: TimelineEventContent) + case error(message: String) +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift index 2c0b2e77b..c800c6a24 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift @@ -17,13 +17,13 @@ struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatabl let sender: TimelineItemSender let content: LocationRoomTimelineItemContent + + var properties = RoomTimelineItemProperties() var body: String { content.body } - var properties = RoomTimelineItemProperties() - var contentType: EventBasedMessageTimelineItemContentType { .location(content) } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 7a13d68bb..080ac728d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -114,8 +114,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shouldBoost: eventItemProxy.shouldBoost, sender: eventItemProxy.sender, content: buildTextTimelineItemContent(textMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -136,8 +137,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shouldBoost: eventItemProxy.shouldBoost, sender: eventItemProxy.sender, content: buildImageTimelineItemContent(imageMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -158,8 +160,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shouldBoost: eventItemProxy.shouldBoost, sender: eventItemProxy.sender, content: buildVideoTimelineItemContent(videoMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -180,8 +183,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shouldBoost: eventItemProxy.shouldBoost, sender: eventItemProxy.sender, content: buildAudioTimelineItemContent(audioMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -201,8 +205,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, content: buildAudioTimelineItemContent(audioMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -223,8 +228,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shouldBoost: eventItemProxy.shouldBoost, sender: eventItemProxy.sender, content: buildFileTimelineItemContent(fileMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -244,8 +250,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, content: buildNoticeTimelineItemContent(noticeMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -265,8 +272,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: emoteMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -286,8 +294,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, content: buildLocationTimelineItemContent(locationMessageContent), - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: messageContent.isEdited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -312,8 +321,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { sender: eventItemProxy.sender, imageInfo: imageInfo, blurhash: info.blurhash, - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + 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), @@ -370,8 +380,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isEditable: eventItemProxy.isEditable, canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), isEdited: edited, reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -389,8 +400,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isEditable: eventItemProxy.isEditable, canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), - isThreaded: messageLikeContent.threadRoot != nil)) + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), + isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary))) } private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -441,8 +453,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isEditable: eventItemProxy.isEditable, canBeRepliedTo: eventItemProxy.canBeRepliedTo, sender: eventItemProxy.sender, - properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo), - isThreaded: messageLikeContent.threadRoot != nil)) + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), + isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary))) } // MARK: - Message events content @@ -628,6 +641,46 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { shieldState.flatMap(EncryptionAuthenticity.init) } + private func buildTimelineItemThreadSummary(_ threadSummary: MatrixRustSDK.ThreadSummary?) -> TimelineItemThreadSummary? { + guard let threadSummary else { return nil } + + switch threadSummary.latestEvent() { + case .unavailable: + return .notLoaded + case .pending: + return .loading + case .ready(let senderID, let senderProfile, let content): + let sender = buildTimelineItemSender(senderID: senderID, senderProfile: senderProfile) + + let latestEventContent: TimelineEventContent = switch content { + case .msgLike(let messageLikeContent): + switch messageLikeContent.kind { + case .message(let messageContent): + .message(buildMessageTimelineItemContent(messageType: messageContent.msgType, + senderID: senderID, + senderDisplayName: sender.displayName)) + case .poll(let question, _, _, _, _, _, _): + .poll(question: question) + case .sticker(let body, _, _): + .message(.text(.init(body: body))) + case .redacted: + .redacted + default: + .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + default: + .message(.text(.init(body: L10n.commonUnsupportedEvent))) + } + + return .loaded(senderID: senderID, + sender: sender, + latestEventContent: latestEventContent) + + case .error(let message): + return .error(message: message) + } + } + // MARK: - Other Events private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -707,7 +760,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { // MARK: - Reply details - func buildReply(details: InReplyToDetails) -> TimelineItemReply { + private func buildTimelineItemReplyDetails(_ details: MatrixRustSDK.InReplyToDetails?) -> TimelineItemReplyDetails? { + guard let details else { + return nil + } + + return buildTimelineItemReply(details).details + } + + func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply { let isThreaded = details.event().isThreaded switch details.event() { case .unavailable: @@ -715,19 +776,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .pending: return .init(details: .loading(eventID: details.eventId()), isThreaded: isThreaded) case let .ready(timelineItem, senderID, senderProfile): - let sender: TimelineItemSender - switch senderProfile { - case let .ready(displayName, isDisplayNameAmbiguous, avatarUrl): - sender = TimelineItemSender(id: senderID, - displayName: displayName, - isDisplayNameAmbiguous: isDisplayNameAmbiguous, - avatarURL: avatarUrl.flatMap(URL.init(string:))) - default: - sender = TimelineItemSender(id: senderID, - displayName: nil, - isDisplayNameAmbiguous: false, - avatarURL: nil) - } + let sender = buildTimelineItemSender(senderID: senderID, senderProfile: senderProfile) let replyContent: TimelineEventContent @@ -735,7 +784,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .msgLike(let messageLikeContent): switch messageLikeContent.kind { case .message(let messageContent): - return .init(details: timelineItemReplyDetails(sender: sender, eventID: details.eventId(), messageType: messageContent.msgType), isThreaded: isThreaded) + let replyContent = buildMessageTimelineItemContent(messageType: messageContent.msgType, + senderID: sender.id, + senderDisplayName: sender.displayName) + return .init(details: .loaded(sender: sender, + eventID: details.eventId(), + eventContent: .message(replyContent)), + isThreaded: isThreaded) case .poll(let question, _, _, _, _, _, _): replyContent = .poll(question: question) case .sticker(let body, _, _): @@ -755,45 +810,48 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } } - private func buildReplyToDetailsFromDetailsIfAvailable(details: InReplyToDetails?) -> TimelineItemReplyDetails? { - guard let details else { - return nil + // MARK: - Helpers + + private func buildTimelineItemSender(senderID: String, senderProfile: ProfileDetails?) -> TimelineItemSender { + switch senderProfile { + case let .ready(displayName, isDisplayNameAmbiguous, avatarUrl): + return TimelineItemSender(id: senderID, + displayName: displayName, + isDisplayNameAmbiguous: isDisplayNameAmbiguous, + avatarURL: avatarUrl.flatMap(URL.init(string:))) + default: + return TimelineItemSender(id: senderID, + displayName: nil, + isDisplayNameAmbiguous: false, + avatarURL: nil) } - - return buildReply(details: details).details } - private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails { - let replyContent: EventBasedMessageTimelineItemContentType - + private func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType { switch messageType { case .audio(let content): if content.voice != nil { - replyContent = .voice(buildAudioTimelineItemContent(content)) + .voice(buildAudioTimelineItemContent(content)) } else { - replyContent = .audio(buildAudioTimelineItemContent(content)) + .audio(buildAudioTimelineItemContent(content)) } case .emote(let content): - replyContent = .emote(buildEmoteTimelineItemContent(senderDisplayName: sender.displayName, senderID: sender.id, messageContent: content)) + .emote(buildEmoteTimelineItemContent(senderDisplayName: senderDisplayName, senderID: senderID, messageContent: content)) case .file(let content): - replyContent = .file(buildFileTimelineItemContent(content)) + .file(buildFileTimelineItemContent(content)) case .image(let content): - replyContent = .image(buildImageTimelineItemContent(content)) + .image(buildImageTimelineItemContent(content)) case .notice(let content): - replyContent = .notice(buildNoticeTimelineItemContent(content)) + .notice(buildNoticeTimelineItemContent(content)) case .text(let content): - replyContent = .text(buildTextTimelineItemContent(content)) + .text(buildTextTimelineItemContent(content)) case .video(let content): - replyContent = .video(buildVideoTimelineItemContent(content)) + .video(buildVideoTimelineItemContent(content)) case .location(let content): - replyContent = .location(buildLocationTimelineItemContent(content)) + .location(buildLocationTimelineItemContent(content)) case .other, .none: - replyContent = .text(.init(body: L10n.commonUnsupportedEvent)) + .text(.init(body: L10n.commonUnsupportedEvent)) } - - return .loaded(sender: sender, - eventID: eventID, - eventContent: .message(replyContent)) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index 1065f51fd..17fe9c84b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -11,5 +11,5 @@ import MatrixRustSDK protocol RoomTimelineItemFactoryProtocol { func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol? - func buildReply(details: InReplyToDetails) -> TimelineItemReply + func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index bec7294ef..03a622860 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1001,6 +1001,12 @@ extension PreviewTests { } } + func testTimelineThreadSummaryView() async throws { + for (index, preview) in TimelineThreadSummaryView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + func testTimelineView() async throws { for (index, preview) in TimelineView_Previews._allPreviews.enumerated() { try await assertSnapshots(matching: preview, step: index) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-en-GB.png new file mode 100644 index 000000000..d7b9b2a00 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0332b38d12d948ea7891f21403c6ffd524abbf9cf3dcb9840dd75219c948fc1f +size 2542610 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-pseudo.png new file mode 100644 index 000000000..37d80bce4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7ead73a4370302fde4c9811dcee87e318294ce1d93f3134a907db3695d2aa50 +size 2540695 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-en-GB.png new file mode 100644 index 000000000..6bc04b863 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a64d7a96d7b3318cb74c6c139a70f755fdfb2c1e3e7cb01f76f1bc47eaae0c2 +size 1248263 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-pseudo.png new file mode 100644 index 000000000..8ce0ff7d8 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Thread-summary-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe424bc4fb3bfd022030e9e6a40019be65fc6f098f8583573fe02f2a2e3f2a2e +size 1245475 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png new file mode 100644 index 000000000..835bdd8bb --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:def40a09d9521734f58296a959107a0cb5d8dd3ad8ea23dc6d01dfbf298aba93 +size 215724 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png new file mode 100644 index 000000000..01a06b94c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d960cd717a6bd446e2743727248f65b8ee3550557564a94b8768f7639d65931 +size 220359 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-en-GB-0.png new file mode 100644 index 000000000..9144edd2e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dcebc6129401b306a56627361515c900269c7d7449361e53c3b7c0e4c4a5bb7 +size 149719 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-pseudo-0.png new file mode 100644 index 000000000..9b2ed3453 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineThreadSummaryView.iPhone-16-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901983ae900d41fdf9b8d733d8a2e05c919e17922e75cf1bc6c91413db82fc70 +size 147986 diff --git a/UnitTests/Sources/RoomEventStringBuilderTests.swift b/UnitTests/Sources/RoomEventStringBuilderTests.swift index f45947fb8..e6fee3cdb 100644 --- a/UnitTests/Sources/RoomEventStringBuilderTests.swift +++ b/UnitTests/Sources/RoomEventStringBuilderTests.swift @@ -56,8 +56,9 @@ class RoomEventStringBuilderTests: XCTestCase { isEdited: false, mentions: nil)), reactions: [], + inReplyTo: nil, threadRoot: nil, - inReplyTo: nil)))), + threadSummary: nil)))), uniqueID: .init("0")) } diff --git a/project.yml b/project.yml index 104dc3d7f..bb14c697a 100644 --- a/project.yml +++ b/project.yml @@ -59,7 +59,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 25.04.14 + exactVersion: 25.04.16 # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios