Display a badge for messages decrypted using shared keys. (#4937)

* feat: Show info badge for messages decrypted with shared keys

* tests: Add snapshots and tests for forwarder icon.

* fix: Update `EventBasedTimelineItemProtocol.hasStatusIcon`

* fix: Address review comments.

* fix: Use two translations for key forwarded dialog content.

* refactor: Rename `TimelineItemForwarder` to `TimelineItemKeyForwarder`.

Additionally adds a `[weak self]` guard to `encryptionForwarder` alert
handler.

* tests: Add `previewLayout` directive to `encryptionForwarder` previews

* refactor: Remove unused translations, use short-circuiting condition.
This commit is contained in:
Skye Elliot
2026-01-19 14:27:49 +00:00
committed by GitHub
parent 20e15f0e3e
commit a30ae24d16
19 changed files with 282 additions and 24 deletions
@@ -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 {
@@ -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)
})
}
}
@@ -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 {
@@ -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()))
}
}
}
@@ -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?
}
@@ -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)
}
}
}
@@ -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 = {
@@ -44,7 +44,7 @@ extension EventBasedTimelineItemProtocol {
}
var hasStatusIcon: Bool {
hasFailedToSend || properties.encryptionAuthenticity != nil
hasFailedToSend || properties.encryptionAuthenticity != nil || properties.encryptionForwarder != nil
}
var hasFailedToSend: Bool {
@@ -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,