mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Show a verification badge on the Room Member/User Profile screens. (#3427)
* Add a badge for verified users/room members. * Reorder subviews. * Add a (disabled) button to verify other users. * PR comments. * Update the SDK. * Adopt the SDK changes introduced in matrix-rust-sdk/pull/4100 --------- Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com>
This commit is contained in:
@@ -93,5 +93,7 @@ extension ClientProxyMock {
|
||||
|
||||
return await .joined(JoinedRoomProxyMock(.init(id: room.id, name: room.name)))
|
||||
}
|
||||
|
||||
userIdentityForReturnValue = .success(UserIdentitySDKMock(configuration: .init()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4700,6 +4700,76 @@ class ClientProxyMock: ClientProxyProtocol {
|
||||
return resetIdentityReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - userIdentity
|
||||
|
||||
var userIdentityForUnderlyingCallsCount = 0
|
||||
var userIdentityForCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return userIdentityForUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = userIdentityForUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
userIdentityForUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
userIdentityForUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var userIdentityForCalled: Bool {
|
||||
return userIdentityForCallsCount > 0
|
||||
}
|
||||
var userIdentityForReceivedUserID: String?
|
||||
var userIdentityForReceivedInvocations: [String] = []
|
||||
|
||||
var userIdentityForUnderlyingReturnValue: Result<UserIdentity?, ClientProxyError>!
|
||||
var userIdentityForReturnValue: Result<UserIdentity?, ClientProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return userIdentityForUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<UserIdentity?, ClientProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = userIdentityForUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
userIdentityForUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
userIdentityForUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var userIdentityForClosure: ((String) async -> Result<UserIdentity?, ClientProxyError>)?
|
||||
|
||||
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError> {
|
||||
userIdentityForCallsCount += 1
|
||||
userIdentityForReceivedUserID = userID
|
||||
DispatchQueue.main.async {
|
||||
self.userIdentityForReceivedInvocations.append(userID)
|
||||
}
|
||||
if let userIdentityForClosure = userIdentityForClosure {
|
||||
return await userIdentityForClosure(userID)
|
||||
} else {
|
||||
return userIdentityForReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - loadMediaContentForSource
|
||||
|
||||
var loadMediaContentForSourceThrowableError: Error?
|
||||
|
||||
@@ -6214,81 +6214,6 @@ open class EncryptionSDKMock: MatrixRustSDK.Encryption {
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - getUserIdentity
|
||||
|
||||
open var getUserIdentityUserIdThrowableError: Error?
|
||||
var getUserIdentityUserIdUnderlyingCallsCount = 0
|
||||
open var getUserIdentityUserIdCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return getUserIdentityUserIdUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = getUserIdentityUserIdUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
getUserIdentityUserIdUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
getUserIdentityUserIdUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var getUserIdentityUserIdCalled: Bool {
|
||||
return getUserIdentityUserIdCallsCount > 0
|
||||
}
|
||||
open var getUserIdentityUserIdReceivedUserId: String?
|
||||
open var getUserIdentityUserIdReceivedInvocations: [String] = []
|
||||
|
||||
var getUserIdentityUserIdUnderlyingReturnValue: UserIdentity?
|
||||
open var getUserIdentityUserIdReturnValue: UserIdentity? {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return getUserIdentityUserIdUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: UserIdentity?? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = getUserIdentityUserIdUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
getUserIdentityUserIdUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
getUserIdentityUserIdUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var getUserIdentityUserIdClosure: ((String) async throws -> UserIdentity?)?
|
||||
|
||||
open override func getUserIdentity(userId: String) async throws -> UserIdentity? {
|
||||
if let error = getUserIdentityUserIdThrowableError {
|
||||
throw error
|
||||
}
|
||||
getUserIdentityUserIdCallsCount += 1
|
||||
getUserIdentityUserIdReceivedUserId = userId
|
||||
DispatchQueue.main.async {
|
||||
self.getUserIdentityUserIdReceivedInvocations.append(userId)
|
||||
}
|
||||
if let getUserIdentityUserIdClosure = getUserIdentityUserIdClosure {
|
||||
return try await getUserIdentityUserIdClosure(userId)
|
||||
} else {
|
||||
return getUserIdentityUserIdReturnValue
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - isLastDevice
|
||||
|
||||
open var isLastDeviceThrowableError: Error?
|
||||
@@ -6753,6 +6678,81 @@ open class EncryptionSDKMock: MatrixRustSDK.Encryption {
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - userIdentity
|
||||
|
||||
open var userIdentityUserIdThrowableError: Error?
|
||||
var userIdentityUserIdUnderlyingCallsCount = 0
|
||||
open var userIdentityUserIdCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return userIdentityUserIdUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = userIdentityUserIdUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
userIdentityUserIdUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
userIdentityUserIdUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var userIdentityUserIdCalled: Bool {
|
||||
return userIdentityUserIdCallsCount > 0
|
||||
}
|
||||
open var userIdentityUserIdReceivedUserId: String?
|
||||
open var userIdentityUserIdReceivedInvocations: [String] = []
|
||||
|
||||
var userIdentityUserIdUnderlyingReturnValue: UserIdentity?
|
||||
open var userIdentityUserIdReturnValue: UserIdentity? {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return userIdentityUserIdUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: UserIdentity?? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = userIdentityUserIdUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
userIdentityUserIdUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
userIdentityUserIdUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var userIdentityUserIdClosure: ((String) async throws -> UserIdentity?)?
|
||||
|
||||
open override func userIdentity(userId: String) async throws -> UserIdentity? {
|
||||
if let error = userIdentityUserIdThrowableError {
|
||||
throw error
|
||||
}
|
||||
userIdentityUserIdCallsCount += 1
|
||||
userIdentityUserIdReceivedUserId = userId
|
||||
DispatchQueue.main.async {
|
||||
self.userIdentityUserIdReceivedInvocations.append(userId)
|
||||
}
|
||||
if let userIdentityUserIdClosure = userIdentityUserIdClosure {
|
||||
return try await userIdentityUserIdClosure(userId)
|
||||
} else {
|
||||
return userIdentityUserIdReturnValue
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - verificationState
|
||||
|
||||
var verificationStateUnderlyingCallsCount = 0
|
||||
@@ -18042,34 +18042,9 @@ open class TimelineSDKMock: MatrixRustSDK.Timeline {
|
||||
}
|
||||
open var editEventOrTransactionIdNewContentReceivedArguments: (eventOrTransactionId: EventOrTransactionId, newContent: EditedContent)?
|
||||
open var editEventOrTransactionIdNewContentReceivedInvocations: [(eventOrTransactionId: EventOrTransactionId, newContent: EditedContent)] = []
|
||||
open var editEventOrTransactionIdNewContentClosure: ((EventOrTransactionId, EditedContent) async throws -> Void)?
|
||||
|
||||
var editEventOrTransactionIdNewContentUnderlyingReturnValue: Bool!
|
||||
open var editEventOrTransactionIdNewContentReturnValue: Bool! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return editEventOrTransactionIdNewContentUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Bool? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = editEventOrTransactionIdNewContentUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
editEventOrTransactionIdNewContentUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
editEventOrTransactionIdNewContentUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var editEventOrTransactionIdNewContentClosure: ((EventOrTransactionId, EditedContent) async throws -> Bool)?
|
||||
|
||||
open override func edit(eventOrTransactionId: EventOrTransactionId, newContent: EditedContent) async throws -> Bool {
|
||||
open override func edit(eventOrTransactionId: EventOrTransactionId, newContent: EditedContent) async throws {
|
||||
if let error = editEventOrTransactionIdNewContentThrowableError {
|
||||
throw error
|
||||
}
|
||||
@@ -18078,11 +18053,7 @@ open class TimelineSDKMock: MatrixRustSDK.Timeline {
|
||||
DispatchQueue.main.async {
|
||||
self.editEventOrTransactionIdNewContentReceivedInvocations.append((eventOrTransactionId: eventOrTransactionId, newContent: newContent))
|
||||
}
|
||||
if let editEventOrTransactionIdNewContentClosure = editEventOrTransactionIdNewContentClosure {
|
||||
return try await editEventOrTransactionIdNewContentClosure(eventOrTransactionId, newContent)
|
||||
} else {
|
||||
return editEventOrTransactionIdNewContentReturnValue
|
||||
}
|
||||
try await editEventOrTransactionIdNewContentClosure?(eventOrTransactionId, newContent)
|
||||
}
|
||||
|
||||
//MARK: - endPoll
|
||||
@@ -20976,6 +20947,71 @@ open class UserIdentitySDKMock: MatrixRustSDK.UserIdentity {
|
||||
|
||||
fileprivate var pointer: UnsafeMutableRawPointer!
|
||||
|
||||
//MARK: - isVerified
|
||||
|
||||
var isVerifiedUnderlyingCallsCount = 0
|
||||
open var isVerifiedCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return isVerifiedUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = isVerifiedUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
isVerifiedUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
isVerifiedUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var isVerifiedCalled: Bool {
|
||||
return isVerifiedCallsCount > 0
|
||||
}
|
||||
|
||||
var isVerifiedUnderlyingReturnValue: Bool!
|
||||
open var isVerifiedReturnValue: Bool! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return isVerifiedUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Bool? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = isVerifiedUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
isVerifiedUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
isVerifiedUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
open var isVerifiedClosure: (() -> Bool)?
|
||||
|
||||
open override func isVerified() -> Bool {
|
||||
isVerifiedCallsCount += 1
|
||||
if let isVerifiedClosure = isVerifiedClosure {
|
||||
return isVerifiedClosure()
|
||||
} else {
|
||||
return isVerifiedReturnValue
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - masterKey
|
||||
|
||||
var masterKeyUnderlyingCallsCount = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
extension UserIdentitySDKMock {
|
||||
struct Configuration {
|
||||
var isVerified = false
|
||||
}
|
||||
|
||||
convenience init(configuration: Configuration) {
|
||||
self.init()
|
||||
|
||||
isVerifiedReturnValue = configuration.isVerified
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
private enum Badge: Hashable {
|
||||
case encrypted(Bool)
|
||||
case `public`
|
||||
case verified
|
||||
}
|
||||
|
||||
private let avatarInfo: AvatarInfo
|
||||
@@ -70,6 +71,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
}
|
||||
|
||||
init(member: RoomMemberDetails,
|
||||
isVerified: Bool = false,
|
||||
avatarSize: AvatarSize,
|
||||
mediaProvider: MediaProviderProtocol? = nil,
|
||||
onAvatarTap: ((URL) -> Void)? = nil,
|
||||
@@ -77,6 +79,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
let profile = UserProfileProxy(member: member)
|
||||
|
||||
self.init(user: profile,
|
||||
isVerified: isVerified,
|
||||
avatarSize: avatarSize,
|
||||
mediaProvider: mediaProvider,
|
||||
onAvatarTap: onAvatarTap,
|
||||
@@ -84,6 +87,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
}
|
||||
|
||||
init(user: UserProfileProxy,
|
||||
isVerified: Bool,
|
||||
avatarSize: AvatarSize,
|
||||
mediaProvider: MediaProviderProtocol? = nil,
|
||||
onAvatarTap: ((URL) -> Void)? = nil,
|
||||
@@ -96,27 +100,29 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
self.mediaProvider = mediaProvider
|
||||
self.onAvatarTap = onAvatarTap
|
||||
self.footer = footer
|
||||
badges = []
|
||||
badges = isVerified ? [.verified] : []
|
||||
}
|
||||
|
||||
private var badgesStack: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(badges, id: \.self) { badge in
|
||||
switch badge {
|
||||
case .encrypted(let isEncrypted):
|
||||
if isEncrypted {
|
||||
BadgeLabel(title: L10n.screenRoomDetailsBadgeEncrypted,
|
||||
icon: \.lockSolid,
|
||||
isHighlighted: true)
|
||||
} else {
|
||||
BadgeLabel(title: L10n.screenRoomDetailsBadgeNotEncrypted,
|
||||
icon: \.lockOff,
|
||||
isHighlighted: false)
|
||||
}
|
||||
case .encrypted(true):
|
||||
BadgeLabel(title: L10n.screenRoomDetailsBadgeEncrypted,
|
||||
icon: \.lockSolid,
|
||||
isHighlighted: true)
|
||||
case .encrypted(false):
|
||||
BadgeLabel(title: L10n.screenRoomDetailsBadgeNotEncrypted,
|
||||
icon: \.lockOff,
|
||||
isHighlighted: false)
|
||||
case .public:
|
||||
BadgeLabel(title: L10n.screenRoomDetailsBadgePublic,
|
||||
icon: \.public,
|
||||
isHighlighted: false)
|
||||
case .verified:
|
||||
BadgeLabel(title: L10n.commonVerified,
|
||||
icon: \.verified,
|
||||
isHighlighted: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +226,11 @@ struct AvatarHeaderView_Previews: PreviewProvider, TestablePreview {
|
||||
avatarSize: .room(on: .details),
|
||||
mediaProvider: MediaProviderMock(configuration: .init())) { Text("") }
|
||||
|
||||
AvatarHeaderView(member: RoomMemberDetails(withProxy: RoomMemberProxyMock.mockBob),
|
||||
isVerified: true,
|
||||
avatarSize: .room(on: .details),
|
||||
mediaProvider: MediaProviderMock(configuration: .init())) { Text("") }
|
||||
|
||||
AvatarHeaderView(member: RoomMemberDetails(withProxy: RoomMemberProxyMock.mockBanned[3]),
|
||||
avatarSize: .room(on: .details),
|
||||
mediaProvider: MediaProviderMock(configuration: .init())) { Text("") }
|
||||
|
||||
@@ -16,11 +16,24 @@ enum RoomMemberDetailsScreenViewModelAction {
|
||||
struct RoomMemberDetailsScreenViewState: BindableState {
|
||||
let userID: String
|
||||
var memberDetails: RoomMemberDetails?
|
||||
var isVerified: Bool?
|
||||
var isOwnMemberDetails = false
|
||||
var isProcessingIgnoreRequest = false
|
||||
var dmRoomID: String?
|
||||
|
||||
var bindings: RoomMemberDetailsScreenViewStateBindings
|
||||
|
||||
var showVerifiedBadge: Bool {
|
||||
isVerified == true // We purposely show the badge on your own account for consistency with Web.
|
||||
}
|
||||
|
||||
var showVerificationSection: Bool {
|
||||
isVerified == false && !isOwnMemberDetails
|
||||
}
|
||||
|
||||
var verifyButtonTitle: String {
|
||||
L10n.screenRoomMemberDetailsVerifyButtonTitle(memberDetails?.name ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomMemberDetailsScreenViewStateBindings {
|
||||
|
||||
+33
-21
@@ -43,25 +43,8 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
|
||||
showMemberLoadingIndicator()
|
||||
Task {
|
||||
defer {
|
||||
hideMemberLoadingIndicator()
|
||||
}
|
||||
|
||||
switch await roomProxy.getMember(userID: userID) {
|
||||
case .success(let member):
|
||||
roomMemberProxy = member
|
||||
state.memberDetails = RoomMemberDetails(withProxy: member)
|
||||
state.isOwnMemberDetails = member.userID == roomProxy.ownUserID
|
||||
switch await clientProxy.directRoomForUserID(member.userID) {
|
||||
case .success(let roomID):
|
||||
state.dmRoomID = roomID
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.warning("Failed to find member: \(error)")
|
||||
actionsSubject.send(.openUserProfile)
|
||||
}
|
||||
await loadMember()
|
||||
hideMemberLoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +77,37 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@MainActor
|
||||
|
||||
private func loadMember() async {
|
||||
async let memberResult = roomProxy.getMember(userID: state.userID)
|
||||
async let identityResult = clientProxy.userIdentity(for: state.userID)
|
||||
|
||||
switch await memberResult {
|
||||
case .success(let member):
|
||||
roomMemberProxy = member
|
||||
state.memberDetails = RoomMemberDetails(withProxy: member)
|
||||
state.isOwnMemberDetails = member.userID == roomProxy.ownUserID
|
||||
switch await clientProxy.directRoomForUserID(member.userID) {
|
||||
case .success(let roomID):
|
||||
state.dmRoomID = roomID
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.warning("Failed to find member: \(error)")
|
||||
// As we didn't find a member with the specified user ID in this room we instead
|
||||
// fall back to showing a generic user profile screen as the source is likely
|
||||
// a message containing a permalink to someone who's not in this room.
|
||||
actionsSubject.send(.openUserProfile)
|
||||
}
|
||||
|
||||
if case let .success(.some(identity)) = await identityResult {
|
||||
state.isVerified = identity.isVerified()
|
||||
} else {
|
||||
MXLog.error("Failed to find the member's identity.")
|
||||
}
|
||||
}
|
||||
|
||||
private func ignoreUser() async {
|
||||
guard let roomMemberProxy else {
|
||||
fatalError()
|
||||
|
||||
+38
-15
@@ -15,6 +15,8 @@ struct RoomMemberDetailsScreen: View {
|
||||
Form {
|
||||
headerSection
|
||||
|
||||
verificationSection
|
||||
|
||||
if context.viewState.memberDetails != nil, !context.viewState.isOwnMemberDetails {
|
||||
blockUserSection
|
||||
}
|
||||
@@ -30,6 +32,25 @@ struct RoomMemberDetailsScreen: View {
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
if let memberDetails = context.viewState.memberDetails {
|
||||
AvatarHeaderView(member: memberDetails,
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider) { url in
|
||||
context.send(viewAction: .displayAvatar(url))
|
||||
} footer: {
|
||||
otherUserFooter
|
||||
}
|
||||
} else {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
}
|
||||
}
|
||||
|
||||
private var otherUserFooter: some View {
|
||||
HStack(spacing: 8) {
|
||||
if context.viewState.memberDetails != nil, !context.viewState.isOwnMemberDetails {
|
||||
@@ -62,20 +83,15 @@ struct RoomMemberDetailsScreen: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
if let memberDetails = context.viewState.memberDetails {
|
||||
AvatarHeaderView(member: memberDetails,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider) { url in
|
||||
context.send(viewAction: .displayAvatar(url))
|
||||
} footer: {
|
||||
otherUserFooter
|
||||
var verificationSection: some View {
|
||||
if context.viewState.showVerificationSection {
|
||||
Section {
|
||||
ListRow(label: .default(title: context.viewState.verifyButtonTitle,
|
||||
description: L10n.screenRoomMemberDetailsVerifyButtonSubtitle,
|
||||
icon: \.lock),
|
||||
kind: .button { })
|
||||
.disabled(true)
|
||||
}
|
||||
} else {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +133,15 @@ struct RoomMemberDetailsScreen: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let otherUserViewModel = makeViewModel(member: .mockDan)
|
||||
static let verifiedUserViewModel = makeViewModel(member: .mockDan)
|
||||
static let otherUserViewModel = makeViewModel(member: .mockAlice)
|
||||
static let accountOwnerViewModel = makeViewModel(member: .mockMe)
|
||||
static let ignoredUserViewModel = makeViewModel(member: .mockIgnored)
|
||||
|
||||
static var previews: some View {
|
||||
RoomMemberDetailsScreen(context: verifiedUserViewModel.context)
|
||||
.previewDisplayName("Verified User")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
RoomMemberDetailsScreen(context: otherUserViewModel.context)
|
||||
.previewDisplayName("Other User")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
@@ -136,9 +156,12 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static func makeViewModel(member: RoomMemberProxyMock) -> RoomMemberDetailsScreenViewModel {
|
||||
let roomProxyMock = JoinedRoomProxyMock(.init(name: ""))
|
||||
roomProxyMock.getMemberUserIDReturnValue = .success(member)
|
||||
|
||||
let clientProxyMock = ClientProxyMock(.init())
|
||||
|
||||
clientProxyMock.userIdentityForClosure = { userID in
|
||||
let isVerified = userID == RoomMemberProxyMock.mockDan.userID
|
||||
return .success(UserIdentitySDKMock(configuration: .init(isVerified: isVerified)))
|
||||
}
|
||||
// to avoid mock the call state for the account owner test case
|
||||
if member.userID != RoomMemberProxyMock.mockMe.userID {
|
||||
clientProxyMock.directRoomForUserIDReturnValue = .success("roomID")
|
||||
|
||||
@@ -284,14 +284,9 @@ extension FormatType {
|
||||
}
|
||||
|
||||
enum ComposerMode: Equatable {
|
||||
enum EditSource {
|
||||
case timeline
|
||||
case draftService
|
||||
}
|
||||
|
||||
case `default`
|
||||
case reply(eventID: String, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
||||
case edit(originalEventOrTransactionID: EventOrTransactionId, source: EditSource)
|
||||
case edit(originalEventOrTransactionID: EventOrTransactionId)
|
||||
case recordVoiceMessage(state: AudioRecorderState)
|
||||
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
case .newMessage:
|
||||
set(mode: .default)
|
||||
case .edit(let eventID):
|
||||
set(mode: .edit(originalEventOrTransactionID: .eventId(eventId: eventID), source: .draftService))
|
||||
set(mode: .edit(originalEventOrTransactionID: .eventId(eventId: eventID)))
|
||||
case .reply(let eventID):
|
||||
set(mode: .reply(eventID: eventID, replyDetails: .loading(eventID: eventID), isThread: false))
|
||||
replyLoadingTask = Task {
|
||||
@@ -314,7 +314,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
switch state.composerMode {
|
||||
case .default:
|
||||
type = .newMessage
|
||||
case .edit(.eventId(let originalEventID), _):
|
||||
case .edit(.eventId(let originalEventID)):
|
||||
type = .edit(eventID: originalEventID)
|
||||
case .reply(let eventID, _, _):
|
||||
type = .reply(eventID: eventID)
|
||||
|
||||
@@ -275,7 +275,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
|
||||
messageComposer()
|
||||
|
||||
messageComposer(.init(string: "Some message"),
|
||||
mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString), source: .timeline))
|
||||
mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString)))
|
||||
|
||||
messageComposer(mode: .reply(eventID: UUID().uuidString,
|
||||
replyDetails: .loaded(sender: .init(id: "Kirk"),
|
||||
|
||||
@@ -212,7 +212,7 @@ class TimelineInteractionHandler {
|
||||
}
|
||||
|
||||
// Always update the mode first and then the text so that the composer has time to save the text draft
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalEventOrTransactionID: eventOrTransactionID, source: .timeline))))
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalEventOrTransactionID: eventOrTransactionID))))
|
||||
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText)))
|
||||
}
|
||||
|
||||
|
||||
@@ -595,16 +595,15 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .clear))
|
||||
|
||||
|
||||
switch mode {
|
||||
case .reply(let eventID, _, _):
|
||||
await timelineController.sendMessage(message,
|
||||
html: html,
|
||||
inReplyToEventID: eventID,
|
||||
intentionalMentions: intentionalMentions)
|
||||
case .edit(let originalEventOrTransactionID, let source):
|
||||
case .edit(let originalEventOrTransactionID):
|
||||
await timelineController.edit(originalEventOrTransactionID,
|
||||
useTimeline: source == .timeline,
|
||||
message: message,
|
||||
html: html,
|
||||
intentionalMentions: intentionalMentions)
|
||||
|
||||
@@ -19,10 +19,23 @@ struct UserProfileScreenViewState: BindableState {
|
||||
let isPresentedModally: Bool
|
||||
|
||||
var userProfile: UserProfileProxy?
|
||||
var isVerified: Bool?
|
||||
var permalink: URL?
|
||||
var dmRoomID: String?
|
||||
|
||||
var bindings: UserProfileScreenViewStateBindings
|
||||
|
||||
var showVerifiedBadge: Bool {
|
||||
isVerified == true // We purposely show the badge on your own account for consistency with Web.
|
||||
}
|
||||
|
||||
var showVerificationSection: Bool {
|
||||
isVerified == false && !isOwnUser
|
||||
}
|
||||
|
||||
var verifyButtonTitle: String {
|
||||
L10n.screenRoomMemberDetailsVerifyButtonTitle(userProfile?.displayName ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
struct UserProfileScreenViewStateBindings {
|
||||
|
||||
@@ -42,24 +42,8 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
|
||||
showLoadingIndicator(allowsInteraction: true)
|
||||
Task {
|
||||
defer {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
switch await clientProxy.profile(for: userID) {
|
||||
case .success(let userProfile):
|
||||
state.userProfile = userProfile
|
||||
state.permalink = (try? matrixToUserPermalink(userId: userID)).flatMap(URL.init(string:))
|
||||
switch await clientProxy.directRoomForUserID(userProfile.userID) {
|
||||
case .success(let roomID):
|
||||
state.dmRoomID = roomID
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
case .failure(let error):
|
||||
state.bindings.alertInfo = .init(id: .unknown)
|
||||
MXLog.error("Failed to find user profile: \(error)")
|
||||
}
|
||||
await loadProfile()
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +71,32 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadProfile() async {
|
||||
async let profileResult = clientProxy.profile(for: state.userID)
|
||||
async let identityResult = clientProxy.userIdentity(for: state.userID)
|
||||
|
||||
switch await profileResult {
|
||||
case .success(let userProfile):
|
||||
state.userProfile = userProfile
|
||||
state.permalink = (try? matrixToUserPermalink(userId: state.userID)).flatMap(URL.init(string:))
|
||||
switch await clientProxy.directRoomForUserID(userProfile.userID) {
|
||||
case .success(let roomID):
|
||||
state.dmRoomID = roomID
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
case .failure(let error):
|
||||
state.bindings.alertInfo = .init(id: .unknown)
|
||||
MXLog.error("Failed to find user profile: \(error)")
|
||||
}
|
||||
|
||||
if case let .success(.some(identity)) = await identityResult {
|
||||
state.isVerified = identity.isVerified()
|
||||
} else {
|
||||
MXLog.error("Failed to find the user's identity.")
|
||||
}
|
||||
}
|
||||
|
||||
private func displayFullScreenAvatar(_ url: URL) async {
|
||||
guard let userProfile = state.userProfile else { fatalError() }
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ struct UserProfileScreen: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
headerSection
|
||||
|
||||
verificationSection
|
||||
}
|
||||
.compoundList()
|
||||
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
|
||||
@@ -27,6 +29,25 @@ struct UserProfileScreen: View {
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
if let userProfile = context.viewState.userProfile {
|
||||
AvatarHeaderView(user: userProfile,
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider) { url in
|
||||
context.send(viewAction: .displayAvatar(url))
|
||||
} footer: {
|
||||
otherUserFooter
|
||||
}
|
||||
} else {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
}
|
||||
}
|
||||
|
||||
private var otherUserFooter: some View {
|
||||
HStack(spacing: 8) {
|
||||
if context.viewState.userProfile != nil, !context.viewState.isOwnUser {
|
||||
@@ -59,20 +80,15 @@ struct UserProfileScreen: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
if let userProfile = context.viewState.userProfile {
|
||||
AvatarHeaderView(user: userProfile,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider) { url in
|
||||
context.send(viewAction: .displayAvatar(url))
|
||||
} footer: {
|
||||
otherUserFooter
|
||||
var verificationSection: some View {
|
||||
if context.viewState.showVerificationSection {
|
||||
Section {
|
||||
ListRow(label: .default(title: context.viewState.verifyButtonTitle,
|
||||
description: L10n.screenRoomMemberDetailsVerifyButtonSubtitle,
|
||||
icon: \.lock),
|
||||
kind: .button { })
|
||||
.disabled(true)
|
||||
}
|
||||
} else {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +107,14 @@ struct UserProfileScreen: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let otherUserViewModel = makeViewModel(userID: RoomMemberProxyMock.mockDan.userID)
|
||||
static let verifiedUserViewModel = makeViewModel(userID: RoomMemberProxyMock.mockDan.userID)
|
||||
static let otherUserViewModel = makeViewModel(userID: RoomMemberProxyMock.mockAlice.userID)
|
||||
static let accountOwnerViewModel = makeViewModel(userID: RoomMemberProxyMock.mockMe.userID)
|
||||
|
||||
static var previews: some View {
|
||||
UserProfileScreen(context: verifiedUserViewModel.context)
|
||||
.previewDisplayName("Verified User")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
UserProfileScreen(context: otherUserViewModel.context)
|
||||
.previewDisplayName("Other User")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
@@ -105,6 +125,10 @@ struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static func makeViewModel(userID: String) -> UserProfileScreenViewModel {
|
||||
let clientProxyMock = ClientProxyMock(.init())
|
||||
clientProxyMock.userIdentityForClosure = { userID in
|
||||
let isVerified = userID == RoomMemberProxyMock.mockDan.userID
|
||||
return .success(UserIdentitySDKMock(configuration: .init(isVerified: isVerified)))
|
||||
}
|
||||
if userID != RoomMemberProxyMock.mockMe.userID {
|
||||
clientProxyMock.directRoomForUserIDReturnValue = .success("roomID")
|
||||
}
|
||||
|
||||
@@ -933,7 +933,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
MXLog.info("Pinning current identity for user: \(userID)")
|
||||
|
||||
do {
|
||||
guard let userIdentity = try await client.encryption().getUserIdentity(userId: userID) else {
|
||||
guard let userIdentity = try await client.encryption().userIdentity(userId: userID) else {
|
||||
MXLog.error("Failed retrieving identity for user: \(userID)")
|
||||
return .failure(.failedRetrievingUserIdentity)
|
||||
}
|
||||
@@ -952,6 +952,15 @@ class ClientProxy: ClientProxyProtocol {
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError> {
|
||||
do {
|
||||
return try await .success(client.encryption().userIdentity(userId: userID))
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving user identity: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientProxy: MediaLoaderProtocol {
|
||||
|
||||
@@ -204,4 +204,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
||||
|
||||
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
|
||||
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
|
||||
|
||||
func userIdentity(for userID: String) async -> Result<UserIdentity?, ClientProxyError>
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
func toggleReaction(_ reaction: String, to eventID: EventOrTransactionId) async { }
|
||||
|
||||
func edit(_ eventOrTransactionID: EventOrTransactionId,
|
||||
useTimeline: Bool,
|
||||
message: String,
|
||||
html: String?,
|
||||
intentionalMentions: IntentionalMentions) async { }
|
||||
|
||||
@@ -170,7 +170,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
}
|
||||
|
||||
func edit(_ eventOrTransactionID: EventOrTransactionId,
|
||||
useTimeline: Bool,
|
||||
message: String,
|
||||
html: String?,
|
||||
intentionalMentions: IntentionalMentions) async {
|
||||
@@ -181,25 +180,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
html: html,
|
||||
intentionalMentions: intentionalMentions.toRustMentions())
|
||||
|
||||
if useTimeline {
|
||||
switch await activeTimeline.edit(eventOrTransactionID, newContent: messageContent) {
|
||||
case .success:
|
||||
MXLog.info("Finished editing message by event")
|
||||
case let .failure(error):
|
||||
MXLog.error("Failed editing message by event with error: \(error)")
|
||||
}
|
||||
} else {
|
||||
guard case let .eventId(eventID) = eventOrTransactionID else {
|
||||
MXLog.error("Failed editing message, missing eventID.")
|
||||
return
|
||||
}
|
||||
|
||||
switch await roomProxy.edit(eventID: eventID, newContent: messageContent) {
|
||||
case .success:
|
||||
MXLog.info("Finished editing message by event ID")
|
||||
case let .failure(error):
|
||||
MXLog.error("Failed editing message by event ID with error: \(error)")
|
||||
}
|
||||
switch await activeTimeline.edit(eventOrTransactionID, newContent: messageContent) {
|
||||
case .success:
|
||||
MXLog.info("Finished editing message by event")
|
||||
case let .failure(error):
|
||||
MXLog.error("Failed editing message by event with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -52,7 +52,6 @@ protocol RoomTimelineControllerProtocol {
|
||||
intentionalMentions: IntentionalMentions) async
|
||||
|
||||
func edit(_ eventOrTransactionID: EventOrTransactionId,
|
||||
useTimeline: Bool,
|
||||
message: String,
|
||||
html: String?,
|
||||
intentionalMentions: IntentionalMentions) async
|
||||
|
||||
@@ -166,12 +166,10 @@ final class TimelineProxy: TimelineProxyProtocol {
|
||||
|
||||
func edit(_ eventOrTransactionID: EventOrTransactionId, newContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, TimelineProxyError> {
|
||||
do {
|
||||
guard try await timeline.edit(eventOrTransactionId: eventOrTransactionID, newContent: .roomMessage(content: newContent)) == true else {
|
||||
return .failure(.failedEditing)
|
||||
}
|
||||
try await timeline.edit(eventOrTransactionId: eventOrTransactionID, newContent: .roomMessage(content: newContent))
|
||||
|
||||
MXLog.info("Finished editing timeline item: \(eventOrTransactionID)")
|
||||
|
||||
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Failed editing timeline item: \(eventOrTransactionID) with error: \(error)")
|
||||
@@ -460,13 +458,11 @@ final class TimelineProxy: TimelineProxyProtocol {
|
||||
do {
|
||||
let originalEvent = try await timeline.getEventTimelineItemByEventId(eventId: eventID)
|
||||
|
||||
guard try await timeline.edit(eventOrTransactionId: originalEvent.eventOrTransactionId,
|
||||
newContent: .pollStart(pollData: .init(question: question,
|
||||
answers: answers,
|
||||
maxSelections: 1,
|
||||
pollKind: .init(pollKind: pollKind)))) else {
|
||||
return .failure(.failedEditing)
|
||||
}
|
||||
try await timeline.edit(eventOrTransactionId: originalEvent.eventOrTransactionId,
|
||||
newContent: .pollStart(pollData: .init(question: question,
|
||||
answers: answers,
|
||||
maxSelections: 1,
|
||||
pollKind: .init(pollKind: pollKind))))
|
||||
|
||||
MXLog.info("Finished editing poll with eventID: \(eventID)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user