diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a4884a372..2483d20e2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -270,6 +270,7 @@ 2D2D8A53B35BE8D8A01449C6 /* PinnedEventsBannerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */; }; 2D38D39B1789B91AE69F477F /* PhotoLibraryManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */; }; 2D45A04699BB6BA3B3A0CB9A /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; }; + 2D76463CE5A9238B5BB5F393 /* TimelineItemKeyForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */; }; 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; }; 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; @@ -2734,6 +2735,7 @@ DADECBBB672497BCD4822468 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; DB08D1F7C27A8C24EF81073C /* MapURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapURLs.swift; sourceTree = ""; }; + DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemKeyForwarder.swift; sourceTree = ""; }; DBEDCEC9D908C19C63D24395 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = ""; }; DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -4615,6 +4617,7 @@ 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */, 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */, 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */, + DB6F137B69DCB59353E37B80 /* TimelineItemKeyForwarder.swift */, BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */, 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */, ); @@ -8674,6 +8677,7 @@ 79959F8E45C3749997482A7F /* TimelineItemBubbledStylerView.swift in Sources */, A808DC3F72D15C6C5A52317E /* TimelineItemDebugView.swift in Sources */, 877D3CE8680536DB430DE6A2 /* TimelineItemIdentifier.swift in Sources */, + 2D76463CE5A9238B5BB5F393 /* TimelineItemKeyForwarder.swift in Sources */, C0B97FFEC0083F3A36609E61 /* TimelineItemMacContextMenu.swift in Sources */, 6C98153D60FF9B648C166C27 /* TimelineItemMenu.swift in Sources */, AE07F215EBC2B9CBF17AA54B /* TimelineItemMenuAction.swift in Sources */, diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 4177d5cd4..0ab827c67 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -138,6 +138,8 @@ struct TimelineViewState: BindableState { var linkMetadataProvider: LinkMetadataProviderProtocol? var mapTilerConfiguration: MapTilerConfiguration + + var enableKeyShareOnInvite: Bool var bindings: TimelineViewStateBindings } @@ -196,6 +198,7 @@ enum TimelineAlertInfoType: Hashable { case pollEndConfirmation(String) case sendingFailed case encryptionAuthenticity(String) + case encryptionForwarder(String) } struct RoomMemberState { diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index be5b9356b..47b0cb12e 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -107,6 +107,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { emojiProvider: emojiProvider, linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider, mapTilerConfiguration: appSettings.mapTilerConfiguration, + enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite, bindings: .init(reactionsCollapsed: [:])), mediaProvider: userSession.mediaProvider) @@ -674,6 +675,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { actionsSubject.send(.displayResolveSendFailure(failure: failure, sendHandle: sendHandle)) + } else if let forwarderMessage = eventTimelineItem.properties.encryptionForwarder?.message { + displayAlert(.encryptionForwarder(forwarderMessage)) } else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message { displayAlert(.encryptionAuthenticity(authenticityMessage)) } @@ -1003,6 +1006,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { state.bindings.alertInfo = .init(id: type, title: message, primaryButton: .init(title: L10n.actionOk, action: nil)) + case .encryptionForwarder(let message): + state.bindings.alertInfo = .init(id: type, + title: message, + primaryButton: .init(title: L10n.actionOk, action: nil), + secondaryButton: .init(title: L10n.actionLearnMore) { [weak self] in + guard let self else { return } + appMediator.open(appSettings.historySharingDetailsURL) + }) } } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index b7e1ec240..f88825f1d 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -322,11 +322,18 @@ private extension View { } } +private extension TimelineItemKeyForwarder { + static var test: TimelineItemKeyForwarder { + TimelineItemKeyForwarder(id: "@alice:matrix.org", displayName: "alice") + } +} + // MARK: - Previews struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview { static let viewModel: TimelineViewModel = { let appSettings = AppSettings() + appSettings.enableKeyShareOnInvite = true appSettings.threadsEnabled = true let roomProxy = JoinedRoomProxyMock(.init()) @@ -385,6 +392,9 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .padding(.bottom, 20) encryptionAuthenticity .previewDisplayName("Encryption Indicators") + encryptionForwarder + .previewLayout(.sizeThatFits) + .previewDisplayName("Encryption Forwarder Info") pinned .previewDisplayName("Pinned messages") .previewLayout(.fixed(width: 390, height: 1150)) @@ -540,6 +550,82 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .environmentObject(viewModel.context) .environment(\.timelineContext, viewModel.context) } + + static var encryptionForwarder: some View { + VStack(spacing: 0) { + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "whoever"), + content: .init(body: "A long message that should be on multiple lines."), + properties: RoomTimelineItemProperties(isEdited: true, encryptionForwarder: .test)), + groupStyle: .single)) + + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "whoever"), + content: .init(body: "A long message that should be on multiple lines."), + properties: RoomTimelineItemProperties(encryptionForwarder: .test)), + groupStyle: .single)) + + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "whoever"), + content: .init(body: "Short message"), + properties: RoomTimelineItemProperties(encryptionForwarder: .test)), + groupStyle: .first)) + + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "whoever"), + content: .init(body: "Message goes Here"), + properties: RoomTimelineItemProperties(encryptionForwarder: .test)), + groupStyle: .last)) + + ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "Bob"), + content: .init(filename: "other.png", + imageInfo: .mockImage, + thumbnailInfo: nil), + properties: RoomTimelineItemProperties(encryptionForwarder: .test))) + + VoiceMessageRoomTimelineView(timelineItem: .init(id: .randomEvent, + timestamp: .mock, + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: ""), + content: .init(filename: "audio.ogg", + duration: 100, + waveform: EstimatedWaveform.mockWaveform, + source: nil, + fileSize: nil, + contentType: nil), + properties: RoomTimelineItemProperties(isThreaded: true, + encryptionForwarder: .test)), + playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), + title: L10n.commonVoiceMessage, + duration: 10, + waveform: EstimatedWaveform.mockWaveform)) + } + .environmentObject(viewModel.context) + .environment(\.timelineContext, viewModel.context) + } } private struct MockTimelineContent: View { diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift index 9f1fe47f7..9fd3fbdb1 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift @@ -15,7 +15,8 @@ extension View { adjustedDeliveryStatus: TimelineItemDeliveryStatus?, context: TimelineViewModel.Context) -> some View { modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem, - adjustedDeliveryStatus: adjustedDeliveryStatus), + adjustedDeliveryStatus: adjustedDeliveryStatus, + enableKeyShareOnInvite: context.viewState.enableKeyShareOnInvite), context: context)) } } @@ -60,6 +61,7 @@ private struct TimelineItemSendInfoLabel: View { switch sendInfo.status { case .sendingFailed: \.errorSolid case .encryptionAuthenticity(let authenticity): authenticity.icon + case .encryptionForwarder: \.info case .none: nil } } @@ -68,6 +70,7 @@ private struct TimelineItemSendInfoLabel: View { switch sendInfo.status { case .sendingFailed: L10n.commonSendingFailed case .encryptionAuthenticity(let authenticity): authenticity.message + case .encryptionForwarder(let forwarder): forwarder.message case .none: nil } } @@ -111,7 +114,11 @@ private struct TimelineItemSendInfoLabel: View { /// All the data needed to render a timeline item's send info label. private struct TimelineItemSendInfo { - enum Status { case sendingFailed, encryptionAuthenticity(EncryptionAuthenticity) } + enum Status { + case sendingFailed + case encryptionAuthenticity(EncryptionAuthenticity) + case encryptionForwarder(TimelineItemKeyForwarder) + } /// Describes how the content and the send info should be arranged inside a bubble enum LayoutType { @@ -131,6 +138,8 @@ private struct TimelineItemSendInfo { .compound.textCriticalPrimary case .encryptionAuthenticity(let authenticity): authenticity.foregroundStyle + case .encryptionForwarder: + .compound.textSecondary case .none: .compound.textSecondary } @@ -138,7 +147,7 @@ private struct TimelineItemSendInfo { } private extension TimelineItemSendInfo { - init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?) { + init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?, enableKeyShareOnInvite: Bool) { itemID = timelineItem.id localizedString = timelineItem.localizedSendInfo @@ -146,6 +155,8 @@ private extension TimelineItemSendInfo { .sendingFailed } else if let authenticity = timelineItem.properties.encryptionAuthenticity { .encryptionAuthenticity(authenticity) + } else if enableKeyShareOnInvite, let forwarder = timelineItem.properties.encryptionForwarder { + .encryptionForwarder(forwarder) } else { nil } @@ -184,6 +195,12 @@ private extension EncryptionAuthenticity { } } +private extension TimelineItemKeyForwarder { + static var test: TimelineItemKeyForwarder { + TimelineItemKeyForwarder(id: "@alice:matrix.org", displayName: "alice") + } +} + // MARK: - Previews struct TimelineItemSendInfoLabel_Previews: PreviewProvider, TestablePreview { @@ -208,6 +225,10 @@ struct TimelineItemSendInfoLabel_Previews: PreviewProvider, TestablePreview { localizedString: "09:47 AM", status: .encryptionAuthenticity(.sentInClear(color: .red)), layoutType: .horizontal())) + TimelineItemSendInfoLabel(sendInfo: .init(itemID: .randomEvent, + localizedString: "09:47 AM", + status: .encryptionForwarder(.test), + layoutType: .horizontal())) } } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift index 9b2d0006b..08bf3542f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift @@ -26,4 +26,6 @@ struct RoomTimelineItemProperties: Hashable { var orderedReadReceipts: [ReadReceipt] = [] /// Authenticity warnings for item's sent in encrypted rooms. var encryptionAuthenticity: EncryptionAuthenticity? + /// Information about the forwarder of the keys used to decrypt this message. + var encryptionForwarder: TimelineItemKeyForwarder? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemKeyForwarder.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemKeyForwarder.swift new file mode 100644 index 000000000..3f4a4b766 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemKeyForwarder.swift @@ -0,0 +1,38 @@ +// +// 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 MatrixRustSDK +import SwiftUI + +struct TimelineItemKeyForwarder: Identifiable, Hashable { + let id: String + let displayName: String? + + init(id: String, displayName: String? = nil) { + self.id = id + self.displayName = displayName + } + + init(forwarderID: String, forwarderProfile: ProfileDetails) { + switch forwarderProfile { + case let .ready(displayName, _, _): + self.init(id: forwarderID, + displayName: displayName) + default: + self.init(id: forwarderID, + displayName: nil) + } + } + + var message: String { + if let displayName { + L10n.cryptoEventKeyForwardedKnownProfileDialogContent(displayName, id) + } else { + L10n.cryptoEventKeyForwardedUnknownProfileDialogContent(id) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index dd275edfb..4c4fc33b5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -110,6 +110,13 @@ class EventTimelineItemProxy { lazy var sender = TimelineItemSender(senderID: item.sender, senderProfile: item.senderProfile) + lazy var forwarder: TimelineItemKeyForwarder? = { + guard let forwarderID = item.forwarder, let forwarderProfile = item.forwarderProfile else { + return nil + } + return TimelineItemKeyForwarder(forwarderID: forwarderID, forwarderProfile: forwarderProfile) + }() + lazy var timestamp = Date(timeIntervalSince1970: TimeInterval(item.timestamp / 1000)) lazy var debugInfo: TimelineItemDebugInfo = { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 7cf00953c..7c099b350 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -44,7 +44,7 @@ extension EventBasedTimelineItemProtocol { } var hasStatusIcon: Bool { - hasFailedToSend || properties.encryptionAuthenticity != nil + hasFailedToSend || properties.encryptionAuthenticity != nil || properties.encryptionForwarder != nil } var hasFailedToSend: Bool { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 10373d3d4..31a051b5a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -126,7 +126,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -149,7 +150,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -172,7 +174,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -195,7 +198,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildVoiceTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -217,7 +221,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -240,7 +245,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -262,7 +268,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -284,7 +291,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildLocationTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -306,7 +314,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildGalleryTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -329,7 +338,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -355,7 +365,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildPollTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -415,7 +426,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { reactions: buildAggregatedReactions(messageLikeContent.reactions), deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState))) + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + encryptionForwarder: eventItemProxy.forwarder)) } private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-en-GB.png new file mode 100644 index 000000000..855aa8f88 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55908ffa46570a989039410f9e056afdb8211ce421247d3e19104b7218293b93 +size 1203308 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-pseudo.png new file mode 100644 index 000000000..059b9a9d7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d91e86dae36030cd61951adbd820e87f0b928ba7a050975972e9a6ecd9a0812 +size 1205155 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-en-GB.png new file mode 100644 index 000000000..ab38ec04d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:022c8ee2dc977a2f9e8bdac90432dfc65d6ac23e5b2712f402753aeb1a0d0ff3 +size 613179 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-pseudo.png new file mode 100644 index 000000000..93c86999f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Encryption-Forwarder-Info-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef7c7d66a44a77fb0cd155b09e4fd83d7f0f360ed2aa511f22b914be83ad9dd +size 615896 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-en-GB-0.png index 1295ff6b8..7c5186b0a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9d1903ddcd00dc637ebee2d55038c6b9415341bc0f1ef46d2432c47ecd2ae2b -size 81888 +oid sha256:d7848594961700544347776b4ec346e87dff5848a0f99e468e0a0df7b173ccfc +size 85032 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-pseudo-0.png index 1295ff6b8..7c5186b0a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9d1903ddcd00dc637ebee2d55038c6b9415341bc0f1ef46d2432c47ecd2ae2b -size 81888 +oid sha256:d7848594961700544347776b4ec346e87dff5848a0f99e468e0a0df7b173ccfc +size 85032 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-en-GB-0.png index 301238689..6014c9be9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae635e987f00becec9a2c643f37a217ff5d188fa368e71358efb2d5118d7069b -size 39296 +oid sha256:ebd41dc454e46f31a4c1b84bd5309191f52b2f0d181f9c77f9bf2b584ac6d7b7 +size 42173 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-pseudo-0.png index 301238689..6014c9be9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemSendInfoLabel.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae635e987f00becec9a2c643f37a217ff5d188fa368e71358efb2d5118d7069b -size 39296 +oid sha256:ebd41dc454e46f31a4c1b84bd5309191f52b2f0d181f9c77f9bf2b584ac6d7b7 +size 42173 diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index e5ceb90fa..0a6eb703a 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -527,6 +527,36 @@ class TimelineViewModelTests: XCTestCase { try await deferred.fulfill() } + // MARK: - Tap Actions + + func testTapSendInfoEncryptionAuthentictyDisplaysAlert() { + // Given a room with an event whose authenticity could not be verified + let items = [TextRoomTimelineItem(eventID: "t1", encryptionAuthenticity: .verificationViolation(color: .red))] + let timelineController = MockTimelineController() + timelineController.timelineItems = items + let viewModel = makeViewModel(timelineController: timelineController) + + XCTAssertNil(viewModel.state.bindings.alertInfo) + + viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id)) + + XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "Encrypted by a previously-verified user.") + } + + func testTapSendInfoEncryptionForwarderDisplaysAlert() { + // Given a room with an event whose key was forwarded + let items = [TextRoomTimelineItem(eventID: "t1", keyForwarder: .test)] + let timelineController = MockTimelineController() + timelineController.timelineItems = items + let viewModel = makeViewModel(timelineController: timelineController) + + XCTAssertNil(viewModel.state.bindings.alertInfo) + + viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id)) + + XCTAssertEqual(viewModel.state.bindings.alertInfo?.title, "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.") + } + // MARK: - Helpers private func makeViewModel(roomProxy: JoinedRoomProxyProtocol? = nil, @@ -579,6 +609,32 @@ private extension TextRoomTimelineItem { } } +private extension TextRoomTimelineItem { + init(eventID: String, keyForwarder: TimelineItemKeyForwarder) { + self.init(id: .event(uniqueID: .init(UUID().uuidString), eventOrTransactionID: .eventID(eventID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: ""), + content: .init(body: "Hello, World!"), + properties: RoomTimelineItemProperties(encryptionForwarder: keyForwarder)) + } +} + +private extension TextRoomTimelineItem { + init(eventID: String, encryptionAuthenticity: EncryptionAuthenticity) { + self.init(id: .event(uniqueID: .init(UUID().uuidString), eventOrTransactionID: .eventID(eventID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: ""), + content: .init(body: "Hello, World!"), + properties: RoomTimelineItemProperties(encryptionAuthenticity: encryptionAuthenticity)) + } +} + private extension TimelineItemSender { init(with proxy: RoomMemberProxyMock) { self.init(id: proxy.userID, @@ -587,3 +643,9 @@ private extension TimelineItemSender { avatarURL: proxy.avatarURL) } } + +private extension TimelineItemKeyForwarder { + static var test: TimelineItemKeyForwarder { + TimelineItemKeyForwarder(id: "@alice:matrix.org", displayName: "alice") + } +}