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
@@ -124,6 +124,8 @@ final class AppSettings {
let encryptionURL: URL = "https://element.io/help#encryption"
/// A URL where users can go read more about the chat backup.
let chatBackupDetailsURL: URL = "https://element.io/help#encryption5"
/// A URL where users can go read more about identity pinning violations
let identityPinningViolationDetailsURL: URL = "https://element.io/help#18"
/// Any domains that Element web may be hosted on - used for handling links.
let elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
@@ -590,7 +590,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
roomProxy: roomProxy,
focussedEvent: focussedEvent,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
@@ -4426,6 +4426,76 @@ class ClientProxyMock: ClientProxyProtocol {
return curve25519Base64ReturnValue
}
}
//MARK: - pinUserIdentity
var pinUserIdentityUnderlyingCallsCount = 0
var pinUserIdentityCallsCount: Int {
get {
if Thread.isMainThread {
return pinUserIdentityUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = pinUserIdentityUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
pinUserIdentityUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
pinUserIdentityUnderlyingCallsCount = newValue
}
}
}
}
var pinUserIdentityCalled: Bool {
return pinUserIdentityCallsCount > 0
}
var pinUserIdentityReceivedUserID: String?
var pinUserIdentityReceivedInvocations: [String] = []
var pinUserIdentityUnderlyingReturnValue: Result<Void, ClientProxyError>!
var pinUserIdentityReturnValue: Result<Void, ClientProxyError>! {
get {
if Thread.isMainThread {
return pinUserIdentityUnderlyingReturnValue
} else {
var returnValue: Result<Void, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = pinUserIdentityUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
pinUserIdentityUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
pinUserIdentityUnderlyingReturnValue = newValue
}
}
}
}
var pinUserIdentityClosure: ((String) async -> Result<Void, ClientProxyError>)?
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError> {
pinUserIdentityCallsCount += 1
pinUserIdentityReceivedUserID = userID
DispatchQueue.main.async {
self.pinUserIdentityReceivedInvocations.append(userID)
}
if let pinUserIdentityClosure = pinUserIdentityClosure {
return await pinUserIdentityClosure(userID)
} else {
return pinUserIdentityReturnValue
}
}
//MARK: - resetIdentity
var resetIdentityUnderlyingCallsCount = 0
@@ -5791,6 +5861,11 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol {
set(value) { underlyingTypingMembersPublisher = value }
}
var underlyingTypingMembersPublisher: CurrentValuePublisher<[String], Never>!
var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> {
get { return underlyingIdentityStatusChangesPublisher }
set(value) { underlyingIdentityStatusChangesPublisher = value }
}
var underlyingIdentityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never>!
var actionsPublisher: AnyPublisher<JoinedRoomProxyAction, Never> {
get { return underlyingActionsPublisher }
set(value) { underlyingActionsPublisher = value }
@@ -12495,6 +12570,7 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol {
}
var underlyingUserID: String!
var displayName: String?
var disambiguatedDisplayName: String?
var avatarURL: URL?
var membership: MembershipState {
get { return underlyingMembership }
@@ -625,6 +625,52 @@ open class ClientSDKMock: MatrixRustSDK.Client {
}
}
//MARK: - customLoginWithJwt
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdThrowableError: Error?
var customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = 0
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount: Int {
get {
if Thread.isMainThread {
return customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = newValue
}
}
}
}
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdCalled: Bool {
return customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount > 0
}
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedArguments: (jwt: String, initialDeviceName: String?, deviceId: String?)?
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedInvocations: [(jwt: String, initialDeviceName: String?, deviceId: String?)] = []
open var customLoginWithJwtJwtInitialDeviceNameDeviceIdClosure: ((String, String?, String?) async throws -> Void)?
open override func customLoginWithJwt(jwt: String, initialDeviceName: String?, deviceId: String?) async throws {
if let error = customLoginWithJwtJwtInitialDeviceNameDeviceIdThrowableError {
throw error
}
customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount += 1
customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedArguments = (jwt: jwt, initialDeviceName: initialDeviceName, deviceId: deviceId)
DispatchQueue.main.async {
self.customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedInvocations.append((jwt: jwt, initialDeviceName: initialDeviceName, deviceId: deviceId))
}
try await customLoginWithJwtJwtInitialDeviceNameDeviceIdClosure?(jwt, initialDeviceName, deviceId)
}
//MARK: - deactivateAccount
open var deactivateAccountAuthDataEraseDataThrowableError: Error?
@@ -4953,6 +4999,77 @@ open class ClientBuilderSDKMock: MatrixRustSDK.ClientBuilder {
}
}
//MARK: - roomDecryptionTrustRequirement
var roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = 0
open var roomDecryptionTrustRequirementTrustRequirementCallsCount: Int {
get {
if Thread.isMainThread {
return roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = newValue
}
}
}
}
open var roomDecryptionTrustRequirementTrustRequirementCalled: Bool {
return roomDecryptionTrustRequirementTrustRequirementCallsCount > 0
}
open var roomDecryptionTrustRequirementTrustRequirementReceivedTrustRequirement: TrustRequirement?
open var roomDecryptionTrustRequirementTrustRequirementReceivedInvocations: [TrustRequirement] = []
var roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue: ClientBuilder!
open var roomDecryptionTrustRequirementTrustRequirementReturnValue: ClientBuilder! {
get {
if Thread.isMainThread {
return roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue
} else {
var returnValue: ClientBuilder? = nil
DispatchQueue.main.sync {
returnValue = roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue = newValue
}
}
}
}
open var roomDecryptionTrustRequirementTrustRequirementClosure: ((TrustRequirement) -> ClientBuilder)?
open override func roomDecryptionTrustRequirement(trustRequirement: TrustRequirement) -> ClientBuilder {
roomDecryptionTrustRequirementTrustRequirementCallsCount += 1
roomDecryptionTrustRequirementTrustRequirementReceivedTrustRequirement = trustRequirement
DispatchQueue.main.async {
self.roomDecryptionTrustRequirementTrustRequirementReceivedInvocations.append(trustRequirement)
}
if let roomDecryptionTrustRequirementTrustRequirementClosure = roomDecryptionTrustRequirementTrustRequirementClosure {
return roomDecryptionTrustRequirementTrustRequirementClosure(trustRequirement)
} else {
return roomDecryptionTrustRequirementTrustRequirementReturnValue
}
}
//MARK: - roomKeyRecipientStrategy
var roomKeyRecipientStrategyStrategyUnderlyingCallsCount = 0
@@ -12296,6 +12413,34 @@ open class RoomSDKMock: MatrixRustSDK.Room {
}
}
//MARK: - pinUserIdentity
open var pinUserIdentityUserIdThrowableError: Error?
var pinUserIdentityUserIdUnderlyingCallsCount = 0
open var pinUserIdentityUserIdCallsCount: Int {
get {
if Thread.isMainThread {
return pinUserIdentityUserIdUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = pinUserIdentityUserIdUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
pinUserIdentityUserIdUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
pinUserIdentityUserIdUnderlyingCallsCount = newValue
}
}
}
}
//MARK: - pinnedEventsTimeline
open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadMaxConcurrentRequestsThrowableError: Error?
@@ -13068,6 +13213,77 @@ open class RoomSDKMock: MatrixRustSDK.Room {
try await setUnreadFlagNewValueClosure?(newValue)
}
//MARK: - subscribeToIdentityStatusChanges
var subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = 0
open var subscribeToIdentityStatusChangesListenerCallsCount: Int {
get {
if Thread.isMainThread {
return subscribeToIdentityStatusChangesListenerUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = subscribeToIdentityStatusChangesListenerUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = newValue
}
}
}
}
open var subscribeToIdentityStatusChangesListenerCalled: Bool {
return subscribeToIdentityStatusChangesListenerCallsCount > 0
}
open var subscribeToIdentityStatusChangesListenerReceivedListener: IdentityStatusChangeListener?
open var subscribeToIdentityStatusChangesListenerReceivedInvocations: [IdentityStatusChangeListener] = []
var subscribeToIdentityStatusChangesListenerUnderlyingReturnValue: TaskHandle!
open var subscribeToIdentityStatusChangesListenerReturnValue: TaskHandle! {
get {
if Thread.isMainThread {
return subscribeToIdentityStatusChangesListenerUnderlyingReturnValue
} else {
var returnValue: TaskHandle? = nil
DispatchQueue.main.sync {
returnValue = subscribeToIdentityStatusChangesListenerUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToIdentityStatusChangesListenerUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
subscribeToIdentityStatusChangesListenerUnderlyingReturnValue = newValue
}
}
}
}
open var subscribeToIdentityStatusChangesListenerClosure: ((IdentityStatusChangeListener) -> TaskHandle)?
open override func subscribeToIdentityStatusChanges(listener: IdentityStatusChangeListener) -> TaskHandle {
subscribeToIdentityStatusChangesListenerCallsCount += 1
subscribeToIdentityStatusChangesListenerReceivedListener = listener
DispatchQueue.main.async {
self.subscribeToIdentityStatusChangesListenerReceivedInvocations.append(listener)
}
if let subscribeToIdentityStatusChangesListenerClosure = subscribeToIdentityStatusChangesListenerClosure {
return subscribeToIdentityStatusChangesListenerClosure(listener)
} else {
return subscribeToIdentityStatusChangesListenerReturnValue
}
}
//MARK: - subscribeToRoomInfoUpdates
var subscribeToRoomInfoUpdatesListenerUnderlyingCallsCount = 0
@@ -14087,77 +14303,6 @@ open class RoomListSDKMock: MatrixRustSDK.RoomList {
fileprivate var pointer: UnsafeMutableRawPointer!
//MARK: - entries
var entriesListenerUnderlyingCallsCount = 0
open var entriesListenerCallsCount: Int {
get {
if Thread.isMainThread {
return entriesListenerUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = entriesListenerUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
entriesListenerUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
entriesListenerUnderlyingCallsCount = newValue
}
}
}
}
open var entriesListenerCalled: Bool {
return entriesListenerCallsCount > 0
}
open var entriesListenerReceivedListener: RoomListEntriesListener?
open var entriesListenerReceivedInvocations: [RoomListEntriesListener] = []
var entriesListenerUnderlyingReturnValue: TaskHandle!
open var entriesListenerReturnValue: TaskHandle! {
get {
if Thread.isMainThread {
return entriesListenerUnderlyingReturnValue
} else {
var returnValue: TaskHandle? = nil
DispatchQueue.main.sync {
returnValue = entriesListenerUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
entriesListenerUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
entriesListenerUnderlyingReturnValue = newValue
}
}
}
}
open var entriesListenerClosure: ((RoomListEntriesListener) -> TaskHandle)?
open override func entries(listener: RoomListEntriesListener) -> TaskHandle {
entriesListenerCallsCount += 1
entriesListenerReceivedListener = listener
DispatchQueue.main.async {
self.entriesListenerReceivedInvocations.append(listener)
}
if let entriesListenerClosure = entriesListenerClosure {
return entriesListenerClosure(listener)
} else {
return entriesListenerReturnValue
}
}
//MARK: - entriesWithDynamicAdapters
var entriesWithDynamicAdaptersPageSizeListenerUnderlyingCallsCount = 0
@@ -80,6 +80,7 @@ extension JoinedRoomProxyMock {
membersPublisher = CurrentValueSubject(configuration.members).asCurrentValuePublisher()
typingMembersPublisher = CurrentValueSubject([]).asCurrentValuePublisher()
identityStatusChangesPublisher = CurrentValueSubject([]).asCurrentValuePublisher()
joinedMembersCount = configuration.members.filter { $0.membership == .join }.count
activeMembersCount = configuration.members.filter { $0.membership == .join || $0.membership == .invite }.count
@@ -25,6 +25,11 @@ extension RoomMemberProxyMock {
self.init()
userID = configuration.userID
displayName = configuration.displayName
if let displayName = configuration.displayName {
disambiguatedDisplayName = "\(displayName) (\(userID))"
}
avatarURL = configuration.avatarURL
membership = configuration.membership
@@ -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 }
}
}
@@ -895,6 +895,22 @@ class ClientProxy: ClientProxyProtocol {
await client.encryption().curve25519Key()
}
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError> {
MXLog.info("Pinning current identity for user: \(userID)")
do {
guard let userIdentity = try await client.encryption().getUserIdentity(userId: userID) else {
MXLog.error("Failed retrieving identity for user: \(userID)")
return .failure(.failedRetrievingUserIdentity)
}
return try await .success(userIdentity.pin())
} catch {
MXLog.error("Failed pinning current identity for user: \(error)")
return .failure(.sdkError(error))
}
}
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError> {
do {
return try await .success(client.encryption().resetIdentity())
@@ -35,6 +35,7 @@ enum ClientProxyError: Error {
case invalidServerName
case failedUploadingMedia(Error, MatrixErrorCode)
case roomPreviewIsPrivate
case failedRetrievingUserIdentity
}
enum SlidingSyncConstants {
@@ -196,5 +197,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func ed25519Base64() async -> String?
func curve25519Base64() async -> String?
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
}
@@ -295,10 +295,12 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
roomProxy
.actionsPublisher
.map { action -> (Bool, [String]) in
.compactMap { action -> (Bool, [String])? in
switch action {
case .roomInfoUpdate:
return (roomProxy.hasOngoingCall, roomProxy.activeRoomCallParticipants)
default:
return nil
}
}
.removeDuplicates { $0 == $1 }
@@ -58,6 +58,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
private var roomInfoObservationToken: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var typingNotificationObservationToken: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var identityStatusChangesObservationToken: TaskHandle?
private var subscribedForUpdates = false
@@ -70,6 +72,11 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
var typingMembersPublisher: CurrentValuePublisher<[String], Never> {
typingMembersSubject.asCurrentValuePublisher()
}
private let identityStatusChangesSubject = CurrentValueSubject<[IdentityStatusChange], Never>([])
var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> {
identityStatusChangesSubject.asCurrentValuePublisher()
}
private let actionsSubject = PassthroughSubject<JoinedRoomProxyAction, Never>()
var actionsPublisher: AnyPublisher<JoinedRoomProxyAction, Never> {
@@ -193,6 +200,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
subscribeToRoomInfoUpdates()
subscribeToIdentityStatusChanges()
subscribeToTypingNotifications()
}
@@ -708,6 +717,16 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
typingMembersSubject.send(typingMembers)
})
}
private func subscribeToIdentityStatusChanges() {
identityStatusChangesObservationToken = room.subscribeToIdentityStatusChanges(listener: RoomIdentityStatusChangeListener { [weak self] changes in
guard let self else { return }
MXLog.info("Received identity status changes: \(changes)")
identityStatusChangesSubject.send(changes)
})
}
}
private final class RoomInfoUpdateListener: RoomInfoListener {
@@ -733,3 +752,15 @@ private final class RoomTypingNotificationUpdateListener: TypingNotificationsLis
onUpdateClosure(typingUserIds)
}
}
private final class RoomIdentityStatusChangeListener: IdentityStatusChangeListener {
private let onUpdateClosure: ([IdentityStatusChange]) -> Void
init(_ onUpdateClosure: @escaping ([IdentityStatusChange]) -> Void) {
self.onUpdateClosure = onUpdateClosure
}
func call(identityStatusChange: [IdentityStatusChange]) {
onUpdateClosure(identityStatusChange)
}
}
@@ -16,12 +16,24 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
}
var userID: String { member.userId }
var displayName: String? { member.displayName }
var disambiguatedDisplayName: String? {
guard let displayName else {
return nil
}
return member.isNameAmbiguous ? "\(displayName) (\(userID))" : displayName
}
var avatarURL: URL? { member.avatarUrl.flatMap(URL.init(string:)) }
var membership: MembershipState { member.membership }
var isIgnored: Bool { member.isIgnored }
var powerLevel: Int { Int(member.powerLevel) }
var role: RoomMemberRole { member.suggestedRoleForPowerLevel }
}
@@ -11,13 +11,18 @@ import MatrixRustSDK
// sourcery: AutoMockable
protocol RoomMemberProxyProtocol: AnyObject {
var userID: String { get }
var displayName: String? { get }
var disambiguatedDisplayName: String? { get }
var avatarURL: URL? { get }
var membership: MembershipState { get }
var isIgnored: Bool { get }
var powerLevel: Int { get }
var role: RoomMemberRole { get }
}
@@ -56,7 +56,7 @@ protocol InvitedRoomProxyProtocol: RoomProxyProtocol {
func acceptInvitation() async -> Result<Void, RoomProxyError>
}
enum JoinedRoomProxyAction {
enum JoinedRoomProxyAction: Equatable {
case roomInfoUpdate
}
@@ -73,6 +73,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
var typingMembersPublisher: CurrentValuePublisher<[String], Never> { get }
var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> { get }
var actionsPublisher: AnyPublisher<JoinedRoomProxyAction, Never> { get }
var timeline: TimelineProxyProtocol { get }
@@ -19,7 +19,7 @@ enum EncryptionAuthenticity: Hashable {
case unknownDevice(color: Color)
case unsignedDevice(color: Color)
case unverifiedIdentity(color: Color)
case previouslyVerified(color: Color)
case verificationViolation(color: Color)
case sentInClear(color: Color)
var message: String {
@@ -32,7 +32,7 @@ enum EncryptionAuthenticity: Hashable {
L10n.eventShieldReasonUnsignedDevice
case .unverifiedIdentity:
L10n.eventShieldReasonUnverifiedIdentity
case .previouslyVerified:
case .verificationViolation:
L10n.eventShieldReasonPreviouslyVerified
case .sentInClear:
L10n.eventShieldReasonSentInClear
@@ -45,7 +45,7 @@ enum EncryptionAuthenticity: Hashable {
.unknownDevice(let color),
.unsignedDevice(let color),
.unverifiedIdentity(let color),
.previouslyVerified(let color),
.verificationViolation(let color),
.sentInClear(let color):
color
}
@@ -54,7 +54,7 @@ enum EncryptionAuthenticity: Hashable {
var icon: KeyPath<CompoundIcons, Image> {
switch self {
case .notGuaranteed: \.info
case .unknownDevice, .unsignedDevice, .unverifiedIdentity, .previouslyVerified: \.helpSolid
case .unknownDevice, .unsignedDevice, .unverifiedIdentity, .verificationViolation: \.helpSolid
case .sentInClear: \.lockOff
}
}
@@ -82,8 +82,8 @@ extension EncryptionAuthenticity {
self = .unsignedDevice(color: color)
case .unverifiedIdentity:
self = .unverifiedIdentity(color: color)
case .previouslyVerified:
self = .previouslyVerified(color: color)
case .verificationViolation:
self = .verificationViolation(color: color)
case .sentInClear:
self = .sentInClear(color: color)
}
@@ -233,7 +233,8 @@ class MockScreen: Identifiable {
return navigationStackCoordinator
case .roomPlainNoAvatar:
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Some room name", avatarURL: nil)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Some room name", avatarURL: nil)),
timelineController: MockRoomTimelineController(),
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -251,7 +252,8 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -269,7 +271,8 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.default
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -287,7 +290,8 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunkWithReadReceipts
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "New room", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -308,7 +312,8 @@ class MockScreen: Identifiable {
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -329,7 +334,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -350,7 +356,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -372,7 +379,8 @@ class MockScreen: Identifiable {
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -393,7 +401,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -413,7 +422,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.permalinkChunk
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Timeline highlight", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Timeline highlight", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -447,7 +457,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.disclosedPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -468,7 +479,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.undisclosedPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -489,7 +501,8 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.outgoingPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(clientProxy: ClientProxyMock(),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),