mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 00:49:44 +00:00
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 {
|
struct RoomHeaderView: View {
|
||||||
let roomName: String
|
let roomName: String
|
||||||
|
var roomSubtitle: String?
|
||||||
let roomAvatar: RoomAvatar
|
let roomAvatar: RoomAvatar
|
||||||
var dmRecipientVerificationState: UserIdentityVerificationState?
|
var dmRecipientVerificationState: UserIdentityVerificationState?
|
||||||
|
|
||||||
let mediaProvider: MediaProviderProtocol?
|
let mediaProvider: MediaProviderProtocol?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 19, *) {
|
if ProcessInfo.isRunningAccessibilityTests {
|
||||||
// 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 {
|
|
||||||
// Accessibility tests scale up the dynamic size in real time which may break the view
|
// Accessibility tests scale up the dynamic size in real time which may break the view
|
||||||
content
|
content
|
||||||
} else {
|
} else {
|
||||||
@@ -37,10 +34,18 @@ struct RoomHeaderView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(roomName)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
.lineLimit(1)
|
Text(roomName)
|
||||||
.font(.compound.bodyLGSemibold)
|
.lineLimit(1)
|
||||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
|
.font(.compound.bodyLGSemibold)
|
||||||
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
|
||||||
|
if let roomSubtitle {
|
||||||
|
Text(roomSubtitle)
|
||||||
|
.lineLimit(1)
|
||||||
|
.font(.compound.bodyXS)
|
||||||
|
.foregroundStyle(.compound.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let dmRecipientVerificationState {
|
if let dmRecipientVerificationState {
|
||||||
VerificationBadge(verificationState: dmRecipientVerificationState)
|
VerificationBadge(verificationState: dmRecipientVerificationState)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
|
|||||||
init(parameters: ThreadTimelineScreenCoordinatorParameters) {
|
init(parameters: ThreadTimelineScreenCoordinatorParameters) {
|
||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
|
|
||||||
viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy)
|
viewModel = ThreadTimelineScreenViewModel(roomProxy: parameters.roomProxy, userSession: parameters.userSession)
|
||||||
|
|
||||||
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||||
timelineController: parameters.timelineController,
|
timelineController: parameters.timelineController,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import Foundation
|
|||||||
enum ThreadTimelineScreenViewModelAction { }
|
enum ThreadTimelineScreenViewModelAction { }
|
||||||
|
|
||||||
struct ThreadTimelineScreenViewState: BindableState {
|
struct ThreadTimelineScreenViewState: BindableState {
|
||||||
|
var roomTitle: String
|
||||||
|
var roomAvatar: RoomAvatar
|
||||||
var canSendMessage = true
|
var canSendMessage = true
|
||||||
|
var dmRecipientVerificationState: UserIdentityVerificationState?
|
||||||
|
|
||||||
var bindings = ThreadTimelineScreenViewStateBindings()
|
var bindings = ThreadTimelineScreenViewStateBindings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ typealias ThreadTimelineScreenViewModelType = StateStoreViewModel<ThreadTimeline
|
|||||||
|
|
||||||
class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTimelineScreenViewModelProtocol {
|
class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTimelineScreenViewModelProtocol {
|
||||||
private let roomProxy: JoinedRoomProxyProtocol
|
private let roomProxy: JoinedRoomProxyProtocol
|
||||||
|
private let userSession: UserSessionProtocol
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<ThreadTimelineScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<ThreadTimelineScreenViewModelAction, Never> = .init()
|
||||||
var actionsPublisher: AnyPublisher<ThreadTimelineScreenViewModelAction, Never> {
|
var actionsPublisher: AnyPublisher<ThreadTimelineScreenViewModelAction, Never> {
|
||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(roomProxy: JoinedRoomProxyProtocol) {
|
init(roomProxy: JoinedRoomProxyProtocol,
|
||||||
|
userSession: UserSessionProtocol) {
|
||||||
self.roomProxy = roomProxy
|
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
|
roomProxy.infoPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@@ -30,7 +34,20 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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)
|
updateRoomInfo(roomProxy.infoPublisher.value)
|
||||||
|
Task { await updateVerificationBadge() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -58,7 +75,26 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
|
|||||||
|
|
||||||
// MARK: - Private
|
// 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) {
|
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
|
||||||
|
state.roomTitle = roomInfo.displayName ?? roomProxy.id
|
||||||
|
state.roomAvatar = roomInfo.avatar
|
||||||
if let powerLevels = roomInfo.powerLevels {
|
if let powerLevels = roomInfo.powerLevels {
|
||||||
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
|
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ struct ThreadTimelineScreen: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView(timelineContext: timelineContext)
|
TimelineView(timelineContext: timelineContext)
|
||||||
.navigationTitle("Thread")
|
.navigationTitle(L10n.commonThread)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { toolbar }
|
||||||
.background(.compound.bgCanvasDefault)
|
.background(.compound.bgCanvasDefault)
|
||||||
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
|
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
|
||||||
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
|
.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 {
|
private var isAtBottomAndLive: Bool {
|
||||||
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
|
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -50,7 +50,9 @@ struct TimelineItemMenuActionProvider {
|
|||||||
|
|
||||||
if item.canBeRepliedTo, canCurrentUserSendMessage {
|
if item.canBeRepliedTo, canCurrentUserSendMessage {
|
||||||
if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
|
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 {
|
} else {
|
||||||
actions.append(.reply(isThread: false))
|
actions.append(.reply(isThread: false))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user