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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user