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:
Doug
2024-10-21 16:07:19 +01:00
committed by GitHub
parent a0c81cf393
commit a4ea552a83
50 changed files with 522 additions and 275 deletions
@@ -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 {
@@ -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()
@@ -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")
}