Fixes element-hq/element-meta/issues/2525 - Display a warning when a user's pinned identity changes

This commit is contained in:
Stefan Ceriu
2024-10-01 08:41:15 +03:00
parent 70652ed7be
commit 794d0eead1
30 changed files with 678 additions and 131 deletions
@@ -12,6 +12,7 @@ import SwiftUI
import WysiwygComposer
struct RoomScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let roomProxy: JoinedRoomProxyProtocol
var focussedEvent: FocusEvent?
let timelineController: RoomTimelineControllerProtocol
@@ -61,13 +62,15 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
selectedPinnedEventID = focussedEvent.shouldSetPin ? focussedEvent.eventID : nil
}
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
roomViewModel = RoomScreenViewModel(clientProxy: parameters.clientProxy,
roomProxy: parameters.roomProxy,
initialSelectedPinnedEventID: selectedPinnedEventID,
mediaProvider: parameters.mediaProvider,
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEvent?.eventID,
@@ -149,10 +152,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables)
roomViewModel.actions
.sink { [weak self] actions in
.sink { [weak self] action in
guard let self else { return }
switch actions {
switch action {
case .focusEvent(eventID: let eventID):
focusOnEvent(FocusEvent(eventID: eventID, shouldSetPin: false))
case .displayPinnedEventsTimeline:
@@ -21,6 +21,7 @@ enum RoomScreenViewAction {
case viewAllPins
case displayRoomDetails
case displayCall
case footerViewAction(RoomScreenFooterViewAction)
}
struct RoomScreenViewState: BindableState {
@@ -38,11 +39,21 @@ struct RoomScreenViewState: BindableState {
var hasOngoingCall: Bool
var shouldShowCallButton = true
var footerDetails: RoomScreenFooterViewDetails?
var bindings: RoomScreenViewStateBindings
}
struct RoomScreenViewStateBindings { }
enum RoomScreenFooterViewAction {
case resolvePinViolation(userID: String)
}
enum RoomScreenFooterViewDetails {
case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
}
enum PinnedEventsBannerState: Equatable {
case loading(numbersOfEvents: Int)
case loaded(state: PinnedEventsState)
@@ -7,18 +7,24 @@
import Combine
import Foundation
import MatrixRustSDK
import OrderedCollections
import SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder
private let userIndicatorController: UserIndicatorControllerProtocol
private var initialSelectedPinnedEventID: String?
private let pinnedEventStringBuilder: RoomEventStringBuilder
private var identityPinningViolations = [String: RoomMemberProxyProtocol]()
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
@@ -43,17 +49,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
init(roomProxy: JoinedRoomProxyProtocol,
init(clientProxy: ClientProxyProtocol,
roomProxy: JoinedRoomProxyProtocol,
initialSelectedPinnedEventID: String?,
mediaProvider: MediaProviderProtocol,
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) {
analyticsService: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
self.clientProxy = clientProxy
self.roomProxy = roomProxy
self.appMediator = appMediator
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.initialSelectedPinnedEventID = initialSelectedPinnedEventID
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
@@ -87,6 +98,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.displayCall)
actionsSubject.send(.removeComposerFocus)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
case .footerViewAction(let action):
switch action {
case .resolvePinViolation(let userID):
Task { await resolveIdentityPinningViolation(userID) }
}
}
}
@@ -98,6 +114,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
}
// MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
let roomInfoSubscription = roomProxy
.actionsPublisher
@@ -124,6 +142,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
.store(in: &cancellables)
let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main)
Task { [weak self] in
for await changes in identityStatusChangesPublisher.values {
guard !Task.isCancelled else {
return
}
await self?.processIdentityStatusChanges(changes)
}
}
.store(in: &cancellables)
appMediator.networkMonitor.reachabilityPublisher
.filter { $0 == .reachable }
.receive(on: DispatchQueue.main)
@@ -141,6 +172,43 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.store(in: &cancellables)
}
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
for change in changes {
switch change.changedTo {
case .pinned:
identityPinningViolations[change.userId] = nil
case .pinViolation:
guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else {
MXLog.error("Failed retrieving room member for identity status change: \(change)")
continue
}
identityPinningViolations[change.userId] = member
default:
break
}
}
if let member = identityPinningViolations.values.first {
state.footerDetails = .pinViolation(member: member,
learnMoreURL: appSettings.identityPinningViolationDetailsURL)
} else {
state.footerDetails = nil
}
}
private func resolveIdentityPinningViolation(_ userID: String) async {
defer {
hideLoadingIndicator()
}
showLoadingIndicator()
if case .failure = await clientProxy.pinUserIdentity(userID) {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
}
}
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
@@ -190,16 +258,30 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
}
// MARK: Loading indicators
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, type: .toast, title: L10n.commonLoading))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
extension RoomScreenViewModel {
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: roomProxyMock,
RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@@ -29,21 +29,28 @@ struct RoomScreen: View {
timeline
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.safeAreaInset(edge: .bottom, spacing: 0) {
composerToolbar
.padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12)
.background {
if composerToolbarContext.composerFormattingEnabled {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
.ignoresSafeArea()
}
VStack(spacing: 0) {
RoomScreenFooterView(details: roomContext.viewState.footerDetails,
mediaProvider: roomContext.mediaProvider) { action in
roomContext.send(viewAction: .footerViewAction(action))
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
// Make sure the reply header honours the hideTimelineMedia setting too.
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
composerToolbar
.padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12)
.background {
if composerToolbarContext.composerFormattingEnabled {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
.ignoresSafeArea()
}
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
// Make sure the reply header honours the hideTimelineMedia setting too.
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
}
}
.overlay(alignment: .top) {
Group {
@@ -0,0 +1,78 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import SwiftUI
struct RoomScreenFooterView: View {
let details: RoomScreenFooterViewDetails?
let mediaProvider: MediaProviderProtocol?
let callback: (RoomScreenFooterViewAction) -> Void
var body: some View {
if let details {
ZStack(alignment: .top) {
VStack(spacing: 0) {
Color.compound.borderInfoSubtle
.frame(height: 1)
LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault],
startPoint: .top,
endPoint: .bottom)
}
switch details {
case .pinViolation(let member, let learnMoreURL):
pinViolation(member: member, learnMoreURL: learnMoreURL)
}
}
.padding(.top, 8)
.fixedSize(horizontal: false, vertical: true)
}
}
private func pinViolation(member: RoomMemberProxyProtocol,
learnMoreURL: URL) -> some View {
VStack(spacing: 16) {
HStack(spacing: 16) {
LoadableAvatarImage(url: member.avatarURL,
name: member.disambiguatedDisplayName,
contentID: member.userID,
avatarSize: .user(on: .timeline),
mediaProvider: mediaProvider)
Text(pinViolationDescriptionWithLearnMoreLink(displayName: member.disambiguatedDisplayName ?? member.userID,
url: learnMoreURL))
.font(.compound.bodyMD)
.foregroundColor(.compound.textPrimary)
}
Button(L10n.actionOk) {
callback(.resolvePinViolation(userID: member.userID))
}
.buttonStyle(.compound(.primary, size: .medium))
}
.padding(.top, 16)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func pinViolationDescriptionWithLearnMoreLink(displayName: String, url: URL) -> AttributedString {
let linkPlaceholder = "{link}"
var description = AttributedString(L10n.cryptoIdentityChangePinViolation(displayName, linkPlaceholder))
var linkString = AttributedString(L10n.actionLearnMore)
linkString.link = url
linkString.bold()
description.replace(linkPlaceholder, with: linkString)
return description
}
}
struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomScreenFooterView(details: .pinViolation(member: RoomMemberProxyMock.mockBob, learnMoreURL: "https://element.io/"),
mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
}
}