Show user statuses in the DM room header.

This commit is contained in:
Doug
2026-07-10 13:07:17 +01:00
committed by Doug
parent 64908ae642
commit 01b946926f
7 changed files with 75 additions and 34 deletions
@@ -11,10 +11,15 @@ import Compound
import SwiftUI import SwiftUI
struct RoomHeaderView: View { struct RoomHeaderView: View {
struct DMRecipientDetails {
var status: UserStatus?
var verification: UserIdentityVerificationState?
}
let roomName: String let roomName: String
var roomSubtitle: String? var roomSubtitle: String?
let roomAvatar: RoomAvatar let roomAvatar: RoomAvatar
var dmRecipientVerificationState: UserIdentityVerificationState? var dmRecipientDetails = DMRecipientDetails()
var roomHistorySharingState: RoomHistorySharingState? var roomHistorySharingState: RoomHistorySharingState?
let mediaProvider: MediaProviderProtocol? let mediaProvider: MediaProviderProtocol?
@@ -49,10 +54,20 @@ struct RoomHeaderView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(roomName) HStack(spacing: 8) {
.lineLimit(1) Text(roomName)
.font(.compound.bodyMDSemibold) .lineLimit(1)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name) .font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let statusEmoji = dmRecipientDetails.status?.displayed?.emoji {
Text(String(statusEmoji))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
}
}
if let roomSubtitle { if let roomSubtitle {
Text(roomSubtitle) Text(roomSubtitle)
.lineLimit(1) .lineLimit(1)
@@ -61,8 +76,8 @@ struct RoomHeaderView: View {
} }
} }
if let dmRecipientVerificationState { if let verificationState = dmRecipientDetails.verification {
VerificationBadge(verificationState: dmRecipientVerificationState, size: .xSmall, relativeTo: .compound.bodyMDSemibold) VerificationBadge(verificationState: verificationState, size: .xSmall, relativeTo: .compound.bodyMDSemibold)
} }
if let historySharingIcon { if let historySharingIcon {
@@ -112,35 +127,61 @@ private extension View {
struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 32) {
makeHeader(avatarURL: nil, verificationState: .notVerified) VStack(alignment: .leading, spacing: 16) {
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified) makeHeader(avatarURL: nil)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verified) makeHeader(avatarURL: .mockMXCAvatar)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verificationViolation)
makeHeader(avatarURL: .mockMXCAvatar, makeHeader(avatarURL: .mockMXCAvatar, historySharingState: .shared)
roomSubtitle: "Subtitle", makeHeader(avatarURL: .mockMXCAvatar, historySharingState: .worldReadable)
verificationState: .verified) }
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified, historySharingState: .shared)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified, historySharingState: .worldReadable) VStack(alignment: .leading, spacing: 16) {
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verified, historySharingState: .shared) makeHeader(avatarURL: .mockMXCUserAvatar, verificationState: .verified)
makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verificationViolation, historySharingState: .worldReadable) makeHeader(avatarURL: .mockMXCUserAvatar, verificationState: .verificationViolation)
makeHeader(avatarURL: .mockMXCUserAvatar,
userStatus: .mockHoliday,
verificationState: .notVerified)
makeHeader(avatarURL: .mockMXCUserAvatar,
userStatus: .mockCall,
verificationState: .verificationViolation)
makeHeader(avatarURL: .mockMXCUserAvatar,
roomSubtitle: "Subtitle",
userStatus: .mockFocussing,
verificationState: .verified)
makeHeader(avatarURL: .mockMXCUserAvatar,
roomSubtitle: "Subtitle",
verificationState: .verified)
makeHeader(avatarURL: .mockMXCUserAvatar,
userStatus: .mockHoliday,
verificationState: .verified,
historySharingState: .shared)
makeHeader(avatarURL: .mockMXCUserAvatar,
verificationState: .verificationViolation,
historySharingState: .worldReadable)
}
} }
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
} }
@ViewBuilder
static func makeHeader(avatarURL: URL?, static func makeHeader(avatarURL: URL?,
roomSubtitle: String? = nil, roomSubtitle: String? = nil,
verificationState: UserIdentityVerificationState, userStatus: UserStatus? = nil,
verificationState: UserIdentityVerificationState? = nil,
historySharingState: RoomHistorySharingState? = nil) -> some View { historySharingState: RoomHistorySharingState? = nil) -> some View {
RoomHeaderView(roomName: "Some Room name", let roomName = verificationState == nil ? "Some Room Name" : "Some User Name"
RoomHeaderView(roomName: roomName,
roomSubtitle: roomSubtitle, roomSubtitle: roomSubtitle,
roomAvatar: .room(id: "1", roomAvatar: .room(id: "1",
name: "Some Room Name", name: roomName,
avatarURL: avatarURL), avatarURL: avatarURL),
dmRecipientVerificationState: verificationState, dmRecipientDetails: .init(status: userStatus, verification: verificationState),
roomHistorySharingState: historySharingState, roomHistorySharingState: historySharingState,
mediaProvider: MediaProviderMock(.init())) { } mediaProvider: MediaProviderMock(.init())) { }
.padding()
} }
} }
@@ -42,7 +42,7 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState { struct RoomScreenViewState: BindableState {
var roomTitle = "" var roomTitle = ""
var roomAvatar: RoomAvatar var roomAvatar: RoomAvatar
var dmRecipientVerificationState: UserIdentityVerificationState? var dmRecipientDetails = RoomHeaderView.DMRecipientDetails()
var lastScrollDirection: ScrollDirection? var lastScrollDirection: ScrollDirection?
// This is used to control the banner // This is used to control the banner
@@ -274,17 +274,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
guard roomProxy.infoPublisher.value.isDM, guard roomProxy.infoPublisher.value.isDM,
let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }), let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }),
case let .success(userIdentity) = await clientProxy.userIdentity(for: dmRecipient.userID, fallBackToServer: true) else { case let .success(userIdentity) = await clientProxy.userIdentity(for: dmRecipient.userID, fallBackToServer: true) else {
state.dmRecipientVerificationState = .notVerified state.dmRecipientDetails.verification = .notVerified
return return
} }
guard let userIdentity else { guard let userIdentity else {
MXLog.failure("User identity should be known at this point") MXLog.failure("User identity should be known at this point")
state.dmRecipientVerificationState = .notVerified state.dmRecipientDetails.verification = .notVerified
return return
} }
state.dmRecipientVerificationState = userIdentity.verificationState state.dmRecipientDetails.verification = userIdentity.verificationState
} }
private func resolveIdentityPinningViolation(_ userID: String) async { private func resolveIdentityPinningViolation(_ userID: String) async {
@@ -292,7 +292,7 @@ struct RoomScreen: View {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: context.viewState.roomTitle, RoomHeaderView(roomName: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar, roomAvatar: context.viewState.roomAvatar,
dmRecipientVerificationState: context.viewState.dmRecipientVerificationState, dmRecipientDetails: context.viewState.dmRecipientDetails,
roomHistorySharingState: context.viewState.roomHistorySharingState, roomHistorySharingState: context.viewState.roomHistorySharingState,
mediaProvider: context.mediaProvider) { mediaProvider: context.mediaProvider) {
context.send(viewAction: .displayRoomDetails) context.send(viewAction: .displayRoomDetails)
@@ -16,7 +16,7 @@ struct ThreadTimelineScreenViewState: BindableState {
var roomTitle: String var roomTitle: String
var roomAvatar: RoomAvatar var roomAvatar: RoomAvatar
var canSendMessage = true var canSendMessage = true
var dmRecipientVerificationState: UserIdentityVerificationState? var dmRecipientDetails = RoomHeaderView.DMRecipientDetails()
var roomHistorySharingState: RoomHistorySharingState? var roomHistorySharingState: RoomHistorySharingState?
var bindings = ThreadTimelineScreenViewStateBindings() var bindings = ThreadTimelineScreenViewStateBindings()
@@ -87,17 +87,17 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
guard roomProxy.infoPublisher.value.isDM, guard roomProxy.infoPublisher.value.isDM,
let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }), let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }),
case let .success(userIdentity) = await userSession.clientProxy.userIdentity(for: dmRecipient.userID, fallBackToServer: true) else { case let .success(userIdentity) = await userSession.clientProxy.userIdentity(for: dmRecipient.userID, fallBackToServer: true) else {
state.dmRecipientVerificationState = .notVerified state.dmRecipientDetails.verification = .notVerified
return return
} }
guard let userIdentity else { guard let userIdentity else {
MXLog.failure("User identity should be known at this point") MXLog.failure("User identity should be known at this point")
state.dmRecipientVerificationState = .notVerified state.dmRecipientDetails.verification = .notVerified
return return
} }
state.dmRecipientVerificationState = userIdentity.verificationState state.dmRecipientDetails.verification = userIdentity.verificationState
} }
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
@@ -70,7 +70,7 @@ struct ThreadTimelineScreen: View {
RoomHeaderView(roomName: L10n.commonThread, RoomHeaderView(roomName: L10n.commonThread,
roomSubtitle: context.viewState.roomTitle, roomSubtitle: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar, roomAvatar: context.viewState.roomAvatar,
dmRecipientVerificationState: context.viewState.dmRecipientVerificationState, dmRecipientDetails: context.viewState.dmRecipientDetails,
roomHistorySharingState: context.viewState.roomHistorySharingState, roomHistorySharingState: context.viewState.roomHistorySharingState,
mediaProvider: context.mediaProvider) { mediaProvider: context.mediaProvider) {
// There is no action but the iOS 26 designs have it looking like a button. // There is no action but the iOS 26 designs have it looking like a button.