diff --git a/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift b/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift index 68b27e4a9..e1296bc60 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift @@ -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) diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 572c638fa..9a0525e5e 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -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, diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift index 24c01a6f3..8006f786f 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift @@ -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() } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift index 128fb6c16..96bfea584 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift @@ -12,16 +12,20 @@ typealias ThreadTimelineScreenViewModelType = StateStoreViewModel = .init() var actionsPublisher: AnyPublisher { 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) } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 841d1f120..dd5e246bc 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -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 } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index 352985034..595095c1e 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -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)) }