Add Encryption Authenticity explanations. (#3116)

This commit is contained in:
Doug 2024-08-06 10:45:46 +01:00 committed by GitHub
parent 6a45ffc939
commit e667be0f43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 224 additions and 103 deletions

View File

@ -58,6 +58,7 @@ struct RoomInviterLabel: View {
avatarSize: .custom(16),
imageProvider: imageProvider)
.alignmentGuide(.firstTextBaseline) { $0[.bottom] * 0.8 }
.accessibilityHidden(true)
Text(inviter.attributedInviteText)
}

View File

@ -57,14 +57,18 @@ struct HomeScreenInviteCell: View {
private var mainContent: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
textualContent
badge
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
textualContent
badge
}
inviterView
.padding(.top, 6)
.padding(.trailing, 16)
}
inviterView
.padding(.top, 6)
.padding(.trailing, 16)
.fixedSize(horizontal: false, vertical: true)
.accessibilityElement(children: .combine)
buttons
.padding(.top, 14)

View File

@ -109,6 +109,7 @@ enum RoomScreenViewAction {
case itemDisappeared(itemID: TimelineItemIdentifier)
case itemTapped(itemID: TimelineItemIdentifier)
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
@ -241,6 +242,8 @@ struct ReadReceiptSummaryInfo: Identifiable {
enum RoomScreenAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
case encryptionAuthenticity(String)
}
struct RoomMemberState {

View File

@ -169,6 +169,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .itemTapped(let id):
Task { await handleItemTapped(with: id) }
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemId):
Task { await timelineController.toggleReaction(emoji, to: itemId) }
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
@ -607,6 +609,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
state.showLoading = false
}
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
MXLog.warning("Couldn't find timeline item.")
return
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
fatalError("Only events can have send info.")
}
if eventTimelineItem.properties.deliveryStatus == .sendingFailed {
displayAlert(.sendingFailed)
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
displayAlert(.encryptionAuthenticity(authenticityMessage))
}
}
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions) async {
guard !message.isEmpty else {
@ -851,6 +870,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
message: L10n.commonPollEndConfirmation,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk, action: { self.roomScreenInteractionHandler.endPoll(pollStartID: pollStartID) }))
case .sendingFailed:
state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed,
primaryButton: .init(title: L10n.actionOk, action: nil))
case .encryptionAuthenticity(let message):
state.bindings.alertInfo = .init(id: type,
title: message,
primaryButton: .init(title: L10n.actionOk, action: nil))
}
}

View File

@ -29,7 +29,7 @@ struct TimelineItemMenu: View {
var body: some View {
VStack(spacing: 8) {
header
messagePreview
.frame(idealWidth: 300.0)
Divider()
@ -63,34 +63,44 @@ struct TimelineItemMenu: View {
.presentationDragIndicator(.visible)
}
private var header: some View {
HStack(alignment: .top, spacing: 0.0) {
LoadableAvatarImage(url: item.sender.avatarURL,
name: item.sender.displayName,
contentID: item.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
Spacer(minLength: 8.0)
VStack(alignment: .leading, spacing: 0) {
Text(item.sender.displayName ?? item.sender.id)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.textSelection(.enabled)
private var messagePreview: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 0.0) {
LoadableAvatarImage(url: item.sender.avatarURL,
name: item.sender.displayName,
contentID: item.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
.accessibilityHidden(true)
Text(item.timelineMenuDescription)
.font(.compound.bodyMD)
Spacer(minLength: 8.0)
VStack(alignment: .leading, spacing: 0) {
Text(item.sender.displayName ?? item.sender.id)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.textSelection(.enabled)
Text(item.timelineMenuDescription)
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 16.0)
Text(item.timestamp)
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
Spacer(minLength: 16.0)
Text(item.timestamp)
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
if let authenticity = item.properties.encryptionAuthenticity {
Label(authenticity.message, icon: authenticity.icon, iconSize: .small, relativeTo: .compound.bodySMSemibold)
.font(.compound.bodySMSemibold)
.foregroundStyle(authenticity.foregroundStyle)
}
}
.padding(.horizontal)
.padding(.top, 32.0)
@ -169,23 +179,54 @@ struct TimelineItemMenu: View {
}
}
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
testView
.previewDisplayName("With button shapes off")
testView
.environment(\._accessibilityShowButtonShapes, true)
.previewDisplayName("With button shapes on")
}
@ViewBuilder
static var testView: some View {
if let item = RoomTimelineItemFixtures.singleMessageChunk.first as? EventBasedTimelineItemProtocol,
let actions = TimelineItemMenuActions(isReactable: true, actions: [.copy, .edit, .reply(isThread: false), .pin, .redact], debugActions: [.viewSource]) {
TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context)
private extension EncryptionAuthenticity {
var foregroundStyle: SwiftUI.Color {
switch color {
case .red: .compound.textCriticalPrimary
case .gray: .compound.textSecondary
}
}
}
// MARK: - Previews
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let (item, actions) = makeItem()
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray))
static let (unencryptedItem, _) = makeItem(authenticity: .sentInClear(color: .red))
static var previews: some View {
TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("With button shapes off")
TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context)
.environment(\._accessibilityShowButtonShapes, true)
.previewDisplayName("With button shapes on")
TimelineItemMenu(item: backupItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Authenticity not guaranteed")
TimelineItemMenu(item: unencryptedItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Unencrypted")
}
static func makeItem(authenticity: EncryptionAuthenticity? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! {
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem,
let actions = TimelineItemMenuActions(isReactable: true,
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact],
debugActions: [.viewSource]) else {
return nil
}
if let authenticity {
item.properties.encryptionAuthenticity = authenticity
}
return (item, actions)
}
}

View File

@ -157,7 +157,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
var messageBubble: some View {
contentWithReply
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus)
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, context: context)
.bubbleStyle(insets: timelineItem.bubbleInsets,
color: timelineItem.bubbleBackgroundColor,
corners: roundedCorners)

View File

@ -20,15 +20,18 @@ import SwiftUI
extension View {
/// Adds the send info (timestamp along indicators for edits and delivery/encryption issues) for the given timeline item to this view.
func timelineItemSendInfo(timelineItem: EventBasedTimelineItemProtocol,
adjustedDeliveryStatus: TimelineItemDeliveryStatus?) -> some View {
adjustedDeliveryStatus: TimelineItemDeliveryStatus?,
context: RoomScreenViewModel.Context) -> some View {
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
adjustedDeliveryStatus: adjustedDeliveryStatus)))
adjustedDeliveryStatus: adjustedDeliveryStatus),
context: context))
}
}
/// Adds the send info to a view with the correct layout.
private struct TimelineItemSendInfoModifier: ViewModifier {
let sendInfo: TimelineItemSendInfo
let context: RoomScreenViewModel.Context
var layout: AnyLayout {
switch sendInfo.layoutType {
@ -44,7 +47,15 @@ private struct TimelineItemSendInfoModifier: ViewModifier {
func body(content: Content) -> some View {
layout {
content
TimelineItemSendInfoLabel(sendInfo: sendInfo)
.contentShape(.rect)
// Tap gesture to avoid the message being detected as a button by VoiceOver
// (and the action shows a description that is already read to the user).
.onTapGesture {
guard sendInfo.status != nil else { return }
context.send(viewAction: .itemSendInfoTapped(itemID: sendInfo.itemID))
}
}
}
}
@ -55,26 +66,17 @@ private struct TimelineItemSendInfoLabel: View {
var statusIcon: KeyPath<CompoundIcons, Image>? {
switch sendInfo.status {
case .sendingFailed:
\.error
case .encryptionAuthenticity(.notGuaranteed):
\.infoSolid
case .encryptionAuthenticity(.unknownDevice),
.encryptionAuthenticity(.unsignedDevice),
.encryptionAuthenticity(.unverifiedIdentity),
.encryptionAuthenticity(.sentInClear):
\.lockOff
case .none:
nil
case .sendingFailed: \.error
case .encryptionAuthenticity(let authenticity): authenticity.icon
case .none: nil
}
}
var statusIconAccessibilityLabel: String? {
switch sendInfo.status {
case .sendingFailed: L10n.commonSendingFailed
case .none: nil
// Temporary testing strings.
case .encryptionAuthenticity(let authenticity): authenticity.message
case .none: nil
}
}
@ -104,9 +106,10 @@ private struct TimelineItemSendInfoLabel: View {
HStack(spacing: 4) {
Text(sendInfo.localizedString)
if let statusIcon, let statusIconAccessibilityLabel {
if let statusIcon {
CompoundIcon(statusIcon, size: .xSmall, relativeTo: .compound.bodyXS)
.accessibilityLabel(statusIconAccessibilityLabel)
.accessibilityLabel(statusIconAccessibilityLabel ?? "")
.accessibilityHidden(statusIconAccessibilityLabel == nil)
}
}
.font(.compound.bodyXS)
@ -125,6 +128,7 @@ private struct TimelineItemSendInfo {
case overlay(capsuleStyle: Bool)
}
let itemID: TimelineItemIdentifier
let localizedString: String
var status: Status?
let layoutType: LayoutType
@ -143,6 +147,7 @@ private struct TimelineItemSendInfo {
private extension TimelineItemSendInfo {
init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?) {
itemID = timelineItem.id
localizedString = timelineItem.localizedSendInfo
status = if adjustedDeliveryStatus == .sendingFailed {
@ -172,18 +177,9 @@ private extension TimelineItemSendInfo {
private extension EncryptionAuthenticity {
var foregroundStyle: SwiftUI.Color {
switch self {
case .notGuaranteed(let color),
.unknownDevice(let color),
.unsignedDevice(let color),
.unverifiedIdentity(let color),
.sentInClear(let color):
switch color {
case .red:
.compound.textCriticalPrimary
case .gray:
.compound.textSecondary
}
switch color {
case .red: .compound.textCriticalPrimary
case .gray: .compound.textSecondary
}
}
}
@ -193,20 +189,25 @@ private extension EncryptionAuthenticity {
struct TimelineItemSendInfoLabel_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16) {
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
localizedString: "09:47 AM",
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
localizedString: "09:47 AM",
status: .sendingFailed,
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
localizedString: "09:47 AM",
status: .encryptionAuthenticity(.unsignedDevice(color: .red)),
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
localizedString: "09:47 AM",
status: .encryptionAuthenticity(.notGuaranteed(color: .gray)),
layoutType: .horizontal()))
// TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
// status: .unencrypted,
// layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
localizedString: "09:47 AM",
status: .encryptionAuthenticity(.sentInClear(color: .red)),
layoutType: .horizontal()))
}
}
}

View File

@ -14,8 +14,9 @@
// limitations under the License.
//
import Foundation
import Compound
import MatrixRustSDK
import SwiftUI
/// Represents and issue with a timeline item's authenticity such as coming from an
/// unsigned session or being sent unencrypted in an encrypted room. See Rust's
@ -43,6 +44,25 @@ enum EncryptionAuthenticity: Hashable {
L10n.eventShieldReasonSentInClear
}
}
var color: Color {
switch self {
case .notGuaranteed(let color),
.unknownDevice(let color),
.unsignedDevice(let color),
.unverifiedIdentity(let color),
.sentInClear(let color):
color
}
}
var icon: KeyPath<CompoundIcons, Image> {
// TODO: Should sentInClear have a dedicated icon???
switch color {
case .red: \.error
case .gray: \.info
}
}
}
extension EncryptionAuthenticity {