Require acknowledgement to send to verified users who have unsigned devices or have changed their identity. (#3215)

* Refactor HeroImage style.

* Add a screen to resolve (crypto-related) timeline item send failures.

* Refactor send failures.

* Update the SDK.
This commit is contained in:
Doug
2024-09-10 09:56:11 +01:00
committed by GitHub
parent f6c8aae09e
commit cc4942fb78
86 changed files with 1422 additions and 158 deletions
@@ -39,7 +39,7 @@ struct EncryptionResetScreen: View {
private var header: some View {
VStack(spacing: 8) {
HeroImage(icon: \.error, style: .critical)
HeroImage(icon: \.error, style: .criticalOnSecondary)
.padding(.bottom, 8)
Text(L10n.screenEncryptionResetTitle)
@@ -29,7 +29,7 @@ struct IdentityConfirmedScreen: View {
@ViewBuilder
private var screenHeader: some View {
VStack(spacing: 0) {
HeroImage(icon: \.checkCircle, style: .positive)
HeroImage(icon: \.checkCircle, style: .success)
.padding(.bottom, 16)
Text(L10n.screenIdentityConfirmedTitle)
@@ -78,7 +78,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
// These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .composer, .hasScrolled:
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.displayResolveSendFailure, .composer, .hasScrolled:
// These actions are not handled in this coordinator
break
}
@@ -266,7 +266,7 @@ struct QRCodeLoginScreen: View {
case .connectionNotSecure:
VStack(spacing: 40) {
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
HeroImage(icon: \.error, style: .criticalOnSecondary)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginConnectionNoteSecureStateTitle)
@@ -332,7 +332,7 @@ struct QRCodeLoginScreen: View {
}
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
HeroImage(icon: \.error, style: .criticalOnSecondary)
VStack(spacing: 8) {
Text(title)
@@ -0,0 +1,65 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct ResolveVerifiedUserSendFailureScreenCoordinatorParameters {
let failure: TimelineItemSendFailure.VerifiedUser
let itemID: TimelineItemIdentifier
let roomProxy: JoinedRoomProxyProtocol
}
enum ResolveVerifiedUserSendFailureScreenCoordinatorAction {
case dismiss
}
final class ResolveVerifiedUserSendFailureScreenCoordinator: CoordinatorProtocol {
private let parameters: ResolveVerifiedUserSendFailureScreenCoordinatorParameters
private let viewModel: ResolveVerifiedUserSendFailureScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<ResolveVerifiedUserSendFailureScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<ResolveVerifiedUserSendFailureScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: ResolveVerifiedUserSendFailureScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ResolveVerifiedUserSendFailureScreenViewModel(failure: parameters.failure,
itemID: parameters.itemID,
roomProxy: parameters.roomProxy)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(ResolveVerifiedUserSendFailureScreen(context: viewModel.context))
}
}
@@ -0,0 +1,53 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum ResolveVerifiedUserSendFailureScreenViewModelAction {
case dismiss
}
struct ResolveVerifiedUserSendFailureScreenViewState: BindableState {
var currentFailure: TimelineItemSendFailure.VerifiedUser
var currentMemberDisplayName: String
var title: String {
switch currentFailure {
case .hasUnsignedDevice: L10n.screenResolveSendFailureUnsignedDeviceTitle(currentMemberDisplayName)
case .changedIdentity: L10n.screenResolveSendFailureChangedIdentityTitle(currentMemberDisplayName)
}
}
var subtitle: String {
switch currentFailure {
case .hasUnsignedDevice: L10n.screenResolveSendFailureUnsignedDeviceSubtitle(currentMemberDisplayName, currentMemberDisplayName)
case .changedIdentity: L10n.screenResolveSendFailureChangedIdentitySubtitle(currentMemberDisplayName)
}
}
var primaryButtonTitle: String {
switch currentFailure {
case .hasUnsignedDevice: L10n.screenResolveSendFailureUnsignedDevicePrimaryButtonTitle
case .changedIdentity: L10n.screenResolveSendFailureChangedIdentityPrimaryButtonTitle
}
}
}
enum ResolveVerifiedUserSendFailureScreenViewAction {
case resolveAndResend
case resend
case cancel
}
@@ -0,0 +1,132 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
typealias ResolveVerifiedUserSendFailureScreenViewModelType = StateStoreViewModel<ResolveVerifiedUserSendFailureScreenViewState, ResolveVerifiedUserSendFailureScreenViewAction>
class ResolveVerifiedUserSendFailureScreenViewModel: ResolveVerifiedUserSendFailureScreenViewModelType, ResolveVerifiedUserSendFailureScreenViewModelProtocol {
private let iterator: VerifiedUserSendFailureIterator
private let failure: TimelineItemSendFailure.VerifiedUser
private let itemID: TimelineItemIdentifier
private let roomProxy: JoinedRoomProxyProtocol
private var members: [String: RoomMemberProxyProtocol]
private let actionsSubject: PassthroughSubject<ResolveVerifiedUserSendFailureScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ResolveVerifiedUserSendFailureScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(failure: TimelineItemSendFailure.VerifiedUser, itemID: TimelineItemIdentifier, roomProxy: JoinedRoomProxyProtocol) {
iterator = switch failure {
case .hasUnsignedDevice(let devices): UnsignedDeviceFailureIterator(devices: devices)
case .changedIdentity(let users): ChangedIdentityFailureIterator(users: users)
}
self.failure = failure
self.itemID = itemID
self.roomProxy = roomProxy
members = Dictionary(uniqueKeysWithValues: roomProxy.membersPublisher.value.map { ($0.userID, $0) })
guard let (userID, failure) = iterator.next() else {
MXLog.error("There aren't any known users/devices to resolve the failure with.")
fatalError("There aren't any known users/devices to resolve the failure with.")
}
super.init(initialViewState: ResolveVerifiedUserSendFailureScreenViewState(currentFailure: failure,
currentMemberDisplayName: members[userID]?.displayName ?? userID))
}
// MARK: Public
override func process(viewAction: ResolveVerifiedUserSendFailureScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .resolveAndResend:
Task { await resolveAndResend() }
case .resend:
Task { await resend() }
case .cancel:
actionsSubject.send(.dismiss)
}
}
private func resolveAndResend() async {
let result = switch failure {
case .hasUnsignedDevice(let devices):
await roomProxy.ignoreDeviceTrustAndResend(devices: devices, itemID: itemID)
case .changedIdentity(let users):
await roomProxy.withdrawVerificationAndResend(userIDs: users, itemID: itemID)
}
if case let .failure(error) = result {
#warning("Show the error?")
return
}
if let (userID, failure) = iterator.next() {
state.currentMemberDisplayName = members[userID]?.displayName ?? userID
state.currentFailure = failure
} else {
actionsSubject.send(.dismiss)
}
}
private func resend() async {
switch await roomProxy.resend(itemID: itemID) {
case .success:
actionsSubject.send(.dismiss)
case .failure(let failure):
#warning("Show the error?")
}
}
}
// MARK: - Iterators
@MainActor
private protocol VerifiedUserSendFailureIterator {
func next() -> (userID: String, failure: TimelineItemSendFailure.VerifiedUser)?
}
private class UnsignedDeviceFailureIterator: VerifiedUserSendFailureIterator {
private var iterator: [String: [String]].Iterator
init(devices: [String: [String]]) {
iterator = devices.makeIterator()
}
func next() -> (userID: String, failure: TimelineItemSendFailure.VerifiedUser)? {
guard let nextUserDevices = iterator.next() else { return nil }
return (nextUserDevices.key, .hasUnsignedDevice(devices: [nextUserDevices.key: nextUserDevices.value]))
}
}
private class ChangedIdentityFailureIterator: VerifiedUserSendFailureIterator {
private var iterator: [String].Iterator
init(users: [String]) {
iterator = users.makeIterator()
}
func next() -> (userID: String, failure: TimelineItemSendFailure.VerifiedUser)? {
guard let nextUserID = iterator.next() else { return nil }
return (nextUserID, .changedIdentity(users: [nextUserID]))
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol ResolveVerifiedUserSendFailureScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<ResolveVerifiedUserSendFailureScreenViewModelAction, Never> { get }
var context: ResolveVerifiedUserSendFailureScreenViewModelType.Context { get }
}
@@ -0,0 +1,110 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
struct ResolveVerifiedUserSendFailureScreen: View {
@ObservedObject var context: ResolveVerifiedUserSendFailureScreenViewModel.Context
@State private var sheetFrame: CGRect = .zero
var body: some View {
ScrollView {
VStack(spacing: 40) {
header
buttons
}
.padding(.top, 24)
.padding(.bottom, 16)
.padding(.horizontal, 16)
.readFrame($sheetFrame)
}
.scrollBounceBehavior(.basedOnSize)
.presentationDetents([.height(sheetFrame.height)])
}
var header: some View {
VStack(spacing: 8) {
HeroImage(icon: \.error, style: .critical)
.padding(.bottom, 8)
Text(context.viewState.title)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
Text(context.viewState.subtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
}
var buttons: some View {
VStack(spacing: 16) {
Button(context.viewState.primaryButtonTitle) {
context.send(viewAction: .resolveAndResend)
}
.buttonStyle(.compound(.primary))
Button(L10n.actionRetry) {
context.send(viewAction: .resend)
}
.buttonStyle(.compound(.secondary))
Button { context.send(viewAction: .cancel) } label: {
Text(L10n.actionCancelForNow)
.padding(.vertical, 14)
}
.buttonStyle(.compound(.plain))
}
}
}
// MARK: - Previews
struct ResolveVerifiedUserSendFailureScreen_Previews: PreviewProvider, TestablePreview {
static let unsignedDeviceViewModel = makeViewModel(failure: .hasUnsignedDevice(devices: ["@alice:matrix.org": []]))
static let changedIdentityViewModel = makeViewModel(failure: .changedIdentity(users: ["@alice:matrix.org"]))
static var previews: some View {
ResolveVerifiedUserSendFailureScreen(context: unsignedDeviceViewModel.context)
.previewDisplayName("Unsigned Device")
ResolveVerifiedUserSendFailureScreen(context: changedIdentityViewModel.context)
.previewDisplayName("Identity Changed")
}
static func makeViewModel(failure: TimelineItemSendFailure.VerifiedUser) -> ResolveVerifiedUserSendFailureScreenViewModel {
ResolveVerifiedUserSendFailureScreenViewModel(failure: failure,
itemID: .random,
roomProxy: JoinedRoomProxyMock(.init()))
}
}
struct ResolveVerifiedUserSendFailureScreenSheet_Previews: PreviewProvider {
static let viewModel = ResolveVerifiedUserSendFailureScreenViewModel(failure: .changedIdentity(users: ["@alice:matrix.org"]),
itemID: .random,
roomProxy: JoinedRoomProxyMock(.init()))
static var previews: some View {
Text("Hello")
.sheet(isPresented: .constant(true)) {
ResolveVerifiedUserSendFailureScreen(context: viewModel.context)
}
.previewDisplayName("Sheet")
}
}
@@ -38,6 +38,7 @@ enum RoomScreenCoordinatorAction {
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
case presentCallScreen
case presentPinnedEventsTimeline
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, itemID: TimelineItemIdentifier)
}
final class RoomScreenCoordinator: CoordinatorProtocol {
@@ -126,6 +127,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .displayResolveSendFailure(let failure, let itemID):
actionsSubject.send(.presentResolveSendFailure(failure: failure, itemID: itemID))
case .composer(let action):
composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction):
@@ -75,7 +75,10 @@ struct RoomScreen: View {
}
}
.sheet(item: $timelineContext.reactionSummaryInfo) {
ReactionsSummaryView(reactions: $0.reactions, members: timelineContext.viewState.members, mediaProvider: timelineContext.mediaProvider, selectedReactionKey: $0.selectedKey)
ReactionsSummaryView(reactions: $0.reactions,
members: timelineContext.viewState.members,
mediaProvider: timelineContext.mediaProvider,
selectedReactionKey: $0.selectedKey)
.edgesIgnoringSafeArea([.bottom])
}
.sheet(item: $timelineContext.readReceiptsSummaryInfo) {
@@ -21,6 +21,7 @@ enum TimelineViewModelAction {
case tappedOnSenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, itemID: TimelineItemIdentifier)
case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String)
@@ -550,9 +550,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
fatalError("Only events can have send info.")
}
if case .sendingFailed = eventTimelineItem.properties.deliveryStatus {
// In the future we will show different errors for the various failure reasons.
if case .sendingFailed(.unknown) = eventTimelineItem.properties.deliveryStatus {
displayAlert(.sendingFailed)
} else if case let .sendingFailed(.verifiedUser(failure)) = eventTimelineItem.properties.deliveryStatus {
actionsSubject.send(.displayResolveSendFailure(failure: failure, itemID: itemID))
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
displayAlert(.encryptionAuthenticity(authenticityMessage))
}
@@ -21,6 +21,9 @@ struct TimelineItemMenu: View {
var body: some View {
VStack(spacing: 8) {
messagePreview
.padding(.horizontal, 16)
.padding(.top, 32.0)
.padding(.bottom, 4.0)
.frame(idealWidth: 300.0)
Divider()
@@ -30,7 +33,6 @@ struct TimelineItemMenu: View {
VStack(alignment: .leading, spacing: 0.0) {
if !actions.reactions.isEmpty {
reactionsSection
.padding(.top, 4.0)
.padding(.bottom, 8.0)
Divider()
@@ -87,15 +89,20 @@ struct TimelineItemMenu: View {
}
.accessibilityElement(children: .combine)
if let authenticity = item.properties.encryptionAuthenticity {
if case let .sendingFailed(.verifiedUser(failure)) = item.properties.deliveryStatus {
Divider()
.padding(.horizontal, -16)
VerifiedUserSendFailureView(failure: failure, members: context.viewState.members) {
send(.itemSendInfoTapped(itemID: item.id))
}
.padding(.bottom, 8)
} else 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)
.padding(.bottom, 4.0)
}
private var reactionsSection: some View {
@@ -162,10 +169,50 @@ struct TimelineItemMenu: View {
}
private func send(_ action: TimelineItemMenuAction) {
send(.handleTimelineItemMenuAction(itemID: item.id, action: action))
}
private func send(_ action: TimelineViewAction) {
dismiss()
// Otherwise we might get errors that a sheet is already presented
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: action))
context.send(viewAction: action)
}
}
}
private struct VerifiedUserSendFailureView: View {
let failure: TimelineItemSendFailure.VerifiedUser
let action: () -> Void
private let memberDisplayName: String
init(failure: TimelineItemSendFailure.VerifiedUser,
members: [String: RoomMemberState],
action: @escaping () -> Void) {
self.failure = failure
self.action = action
let userIDs = failure.affectedUserIDs
memberDisplayName = userIDs.first.map { members[$0]?.displayName ?? $0 } ?? ""
}
var title: String {
switch failure {
case .hasUnsignedDevice: L10n.screenTimelineItemMenuSendFailureUnsignedDevice(memberDisplayName)
case .changedIdentity: L10n.screenTimelineItemMenuSendFailureChangedIdentity(memberDisplayName)
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Label(title, icon: \.error, iconSize: .small, relativeTo: .compound.bodySMSemibold)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textCriticalPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
ListRowAccessory.navigationLink
}
}
}
}
@@ -187,20 +234,27 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray))
static let (unsignedItem, _) = makeItem(authenticity: .unsignedDevice(color: .red))
static let (unencryptedItem, _) = makeItem(authenticity: .sentInClear(color: .red))
static let (unknownFailureItem, _) = makeItem(deliveryStatus: .sendingFailed(.unknown))
static let (identityChangedItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [
"@alice:matrix.org"
]))))
static let (unsignedDevicesItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [
"@alice:matrix.org": ["DEVICE1", "DEVICE2"]
]))))
static var previews: some View {
TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("With button shapes off")
.previewDisplayName("Normal")
TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context)
.environment(\._accessibilityShowButtonShapes, true)
.previewDisplayName("With button shapes on")
.previewDisplayName("Button shapes")
TimelineItemMenu(item: backupItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Authenticity not guaranteed")
.previewDisplayName("Authenticity")
TimelineItemMenu(item: unsignedItem, actions: actions)
.environmentObject(viewModel.context)
@@ -209,9 +263,22 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
TimelineItemMenu(item: unencryptedItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Unencrypted")
TimelineItemMenu(item: unknownFailureItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Unknown failure")
TimelineItemMenu(item: unsignedDevicesItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Unsigned Devices")
TimelineItemMenu(item: identityChangedItem, actions: actions)
.environmentObject(viewModel.context)
.previewDisplayName("Identity Changed")
}
static func makeItem(authenticity: EncryptionAuthenticity? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! {
static func makeItem(authenticity: EncryptionAuthenticity? = nil,
deliveryStatus: TimelineItemDeliveryStatus? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! {
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem,
let actions = TimelineItemMenuActions(isReactable: true,
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact],
@@ -223,6 +290,10 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
item.properties.encryptionAuthenticity = authenticity
}
if let deliveryStatus {
item.properties.deliveryStatus = deliveryStatus
}
return (item, actions)
}
}