added the avatar and the room title as subtitle in the thread timeline as a principal action

also added a fix for the erroneous reply in thread action while in a thread, and also discovered that we can lead align the principal action again on iOS 26 (so it was a beta issue)
This commit is contained in:
Mauro Romito
2025-09-16 17:19:07 +02:00
committed by Mauro
parent 7236c491d5
commit 1d5c252a42
6 changed files with 79 additions and 14 deletions

View File

@@ -11,17 +11,14 @@ import SwiftUI
struct RoomHeaderView: View {
let roomName: String
var roomSubtitle: String?
let roomAvatar: RoomAvatar
var dmRecipientVerificationState: UserIdentityVerificationState?
let mediaProvider: MediaProviderProtocol?
var body: some View {
if #available(iOS 19, *) {
// https://github.com/element-hq/element-x-ios/issues/4180
// Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSLayoutConstraint constant is not finite!
content
} else if ProcessInfo.isRunningAccessibilityTests {
if ProcessInfo.isRunningAccessibilityTests {
// Accessibility tests scale up the dynamic size in real time which may break the view
content
} else {
@@ -37,10 +34,18 @@ struct RoomHeaderView: View {
.accessibilityHidden(true)
HStack(spacing: 4) {
Text(roomName)
.lineLimit(1)
.font(.compound.bodyLGSemibold)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
VStack(alignment: .leading, spacing: 0) {
Text(roomName)
.lineLimit(1)
.font(.compound.bodyLGSemibold)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let roomSubtitle {
Text(roomSubtitle)
.lineLimit(1)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
}
}
if let dmRecipientVerificationState {
VerificationBadge(verificationState: dmRecipientVerificationState)

View File

@@ -54,7 +54,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
init(parameters: ThreadTimelineScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy)
viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy, userSession: parameters.userSession)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController,

View File

@@ -10,7 +10,10 @@ import Foundation
enum ThreadTimelineScreenViewModelAction { }
struct ThreadTimelineScreenViewState: BindableState {
var roomTitle: String
var roomAvatar: RoomAvatar
var canSendMessage = true
var dmRecipientVerificationState: UserIdentityVerificationState?
var bindings = ThreadTimelineScreenViewStateBindings()
}

View File

@@ -12,16 +12,20 @@ typealias ThreadTimelineScreenViewModelType = StateStoreViewModel<ThreadTimeline
class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTimelineScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userSession: UserSessionProtocol
private let actionsSubject: PassthroughSubject<ThreadTimelineScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ThreadTimelineScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol) {
init(roomProxy: JoinedRoomProxyProtocol,
userSession: UserSessionProtocol) {
self.roomProxy = roomProxy
self.userSession = userSession
super.init(initialViewState: ThreadTimelineScreenViewState())
super.init(initialViewState: ThreadTimelineScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id,
roomAvatar: roomProxy.infoPublisher.value.avatar), mediaProvider: userSession.mediaProvider)
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
@@ -30,7 +34,20 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
}
.store(in: &cancellables)
let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main)
Task { [weak self] in
for await _ in identityStatusChangesPublisher.values {
guard !Task.isCancelled else {
return
}
await self?.updateVerificationBadge()
}
}
.store(in: &cancellables)
updateRoomInfo(roomProxy.infoPublisher.value)
Task { await updateVerificationBadge() }
}
// MARK: - Public
@@ -58,7 +75,26 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
// MARK: - Private
private func updateVerificationBadge() async {
guard roomProxy.isDirectOneToOneRoom,
let dmRecipient = roomProxy.membersPublisher.value.first(where: { $0.userID != roomProxy.ownUserID }),
case let .success(userIdentity) = await userSession.clientProxy.userIdentity(for: dmRecipient.userID) else {
state.dmRecipientVerificationState = .notVerified
return
}
guard let userIdentity else {
MXLog.failure("User identity should be known at this point")
state.dmRecipientVerificationState = .notVerified
return
}
state.dmRecipientVerificationState = userIdentity.verificationState
}
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.roomTitle = roomInfo.displayName ?? roomProxy.id
state.roomAvatar = roomInfo.avatar
if let powerLevels = roomInfo.powerLevels {
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
}

View File

@@ -23,8 +23,9 @@ struct ThreadTimelineScreen: View {
var body: some View {
TimelineView(timelineContext: timelineContext)
.navigationTitle("Thread")
.navigationTitle(L10n.commonThread)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.background(.compound.bgCanvasDefault)
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
@@ -53,6 +54,24 @@ struct ThreadTimelineScreen: View {
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: L10n.commonThread,
roomSubtitle: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar,
dmRecipientVerificationState: context.viewState.dmRecipientVerificationState,
mediaProvider: context.mediaProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)
}
if #available(iOS 26, *) {
ToolbarSpacer()
}
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
}

View File

@@ -50,7 +50,9 @@ struct TimelineItemMenuActionProvider {
if item.canBeRepliedTo, canCurrentUserSendMessage {
if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
actions.append(.reply(isThread: messageItem.properties.isThreaded))
// If threads are enabled we will have the dedicated `replyInThread` action
// so there is no need to make the normal reply use the thread.
actions.append(.reply(isThread: areThreadsEnabled ? false : messageItem.properties.isThreaded))
} else {
actions.append(.reply(isThread: false))
}