mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Fixes element-hq/element-meta/issues/2525 - Display a warning when a user's pinned identity changes
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user