Setup composer actions within threaded timelines.

This commit is contained in:
Stefan Ceriu
2025-06-03 17:53:43 +03:00
committed by Stefan Ceriu
parent c3a9e5deaa
commit 21e07e164c
9 changed files with 66 additions and 37 deletions
@@ -967,6 +967,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
switch await roomProxy.timeline.sendMessage(replyText, switch await roomProxy.timeline.sendMessage(replyText,
html: nil, html: nil,
threadRootEventID: nil,
inReplyToEventID: nil, inReplyToEventID: nil,
intentionalMentions: .empty) { intentionalMentions: .empty) {
case .success: case .success:
@@ -16683,15 +16683,15 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable {
} }
//MARK: - sendMessage //MARK: - sendMessage
var sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = 0 var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = 0
var sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount: Int { var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount returnValue = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount
} }
return returnValue! return returnValue!
@@ -16699,29 +16699,29 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendMessageHtmlInReplyToEventIDIntentionalMentionsCalled: Bool { var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCalled: Bool {
return sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount > 0 return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount > 0
} }
var sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedArguments: (message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)? var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedArguments: (message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)?
var sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedInvocations: [(message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)] = [] var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedInvocations: [(message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)] = []
var sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue: Result<Void, TimelineProxyError>! var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue: Result<Void, TimelineProxyError>! { var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReturnValue: Result<Void, TimelineProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, TimelineProxyError>? = nil var returnValue: Result<Void, TimelineProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue returnValue = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue
} }
return returnValue! return returnValue!
@@ -16729,26 +16729,26 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure: ((String, String?, String?, IntentionalMentions) async -> Result<Void, TimelineProxyError>)? var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure: ((String, String?, String?, String?, IntentionalMentions) async -> Result<Void, TimelineProxyError>)?
func sendMessage(_ message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError> { func sendMessage(_ message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError> {
sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount += 1 sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount += 1
sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedArguments = (message: message, html: html, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions) sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedArguments = (message: message, html: html, threadRootEventID: threadRootEventID, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions)
DispatchQueue.main.async { DispatchQueue.main.async {
self.sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedInvocations.append((message: message, html: html, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions)) self.sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedInvocations.append((message: message, html: html, threadRootEventID: threadRootEventID, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions))
} }
if let sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure = sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure { if let sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure {
return await sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure(message, html, inReplyToEventID, intentionalMentions) return await sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure(message, html, threadRootEventID, inReplyToEventID, intentionalMentions)
} else { } else {
return sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReturnValue
} }
} }
//MARK: - toggleReaction //MARK: - toggleReaction
@@ -120,9 +120,11 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description)) actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .displayResolveSendFailure(let failure, let sendHandle): case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
case .displayThread, .composer, .hasScrolled, .displayRoom: case .hasScrolled, .displayRoom:
break break
case .viewInRoomTimeline: case .composer(let action):
composerViewModel.process(timelineAction: action)
case .viewInRoomTimeline, .displayThread:
fatalError("The action: \(action) should not be sent to this coordinator") fatalError("The action: \(action) should not be sent to this coordinator")
} }
} }
@@ -125,7 +125,7 @@ struct TimelineItemMenuActionProvider {
secondaryActions = secondaryActions.filter(\.canAppearInRedacted) secondaryActions = secondaryActions.filter(\.canAppearInRedacted)
} }
let isReactable = timelineKind == .live || timelineKind == .detached || timelineKind == .thread ? item.isReactable : false let isReactable = timelineKind == .live || timelineKind == .detached || timelineKind.isThread ? item.isReactable : false
return .init(isReactable: isReactable, actions: actions, secondaryActions: secondaryActions, emojiProvider: emojiProvider) return .init(isReactable: isReactable, actions: actions, secondaryActions: secondaryActions, emojiProvider: emojiProvider)
} }
@@ -120,7 +120,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.onTapGesture { } .onTapGesture { }
} }
if context.viewState.timelineKind != .thread, let threadSummary = timelineItem.properties.threadSummary { if !context.viewState.timelineKind.isThread, let threadSummary = timelineItem.properties.threadSummary {
TimelineThreadSummaryView(threadSummary: threadSummary) { TimelineThreadSummaryView(threadSummary: threadSummary) {
context.send(viewAction: .displayThread(itemID: timelineItem.id)) context.send(viewAction: .displayThread(itemID: timelineItem.id))
} }
@@ -177,7 +177,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder @ViewBuilder
var contentWithReply: some View { var contentWithReply: some View {
TimelineBubbleLayout(spacing: 8) { TimelineBubbleLayout(spacing: 8) {
if context.viewState.timelineKind != .thread, timelineItem.properties.isThreaded { if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded {
ThreadDecorator() ThreadDecorator()
.padding(.leading, 4) .padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.regularText) .layoutPriority(TimelineBubbleLayout.Priority.regularText)
@@ -213,7 +213,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
} }
private var shouldShowReplyDetails: Bool { private var shouldShowReplyDetails: Bool {
!timelineItem.properties.isThreaded || (timelineItem.properties.isThreaded && context.viewState.timelineKind != .thread) !timelineItem.properties.isThreaded || (timelineItem.properties.isThreaded && !context.viewState.timelineKind.isThread)
} }
private var messageBubbleTopPadding: CGFloat { private var messageBubbleTopPadding: CGFloat {
@@ -175,7 +175,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
trackReadReceipts: true, trackReadReceipts: true,
reportUtds: true)) reportUtds: true))
let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread) let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread(rootEventID: eventID))
await timeline.subscribeForUpdates() await timeline.subscribeForUpdates()
return .success(timeline) return .success(timeline)
@@ -158,6 +158,7 @@ class TimelineController: TimelineControllerProtocol {
switch await activeTimeline.sendMessage(message, switch await activeTimeline.sendMessage(message,
html: html, html: html,
threadRootEventID: timelineKind.threadRootEventID,
inReplyToEventID: inReplyToEventID, inReplyToEventID: inReplyToEventID,
intentionalMentions: intentionalMentions) { intentionalMentions: intentionalMentions) {
case .success: case .success:
@@ -389,8 +389,15 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(()) return .success(())
} }
/// Send a message within a room. If `inReplyToEventID` is specified then it will be sent as a reply
/// to that particular message. If the `threadRootEventID` is also specified then it will be sent
/// as a reply to the given `inReplyToEventID` within the thread rooted in `threadRootEventID`
///
/// Internally `enforceThread` is set to true whenever `threadRootEventID` is specified
/// and `replyWithinThread` when `inReplyToEventID` is.
func sendMessage(_ message: String, func sendMessage(_ message: String,
html: String?, html: String?,
threadRootEventID: String?,
inReplyToEventID: String? = nil, inReplyToEventID: String? = nil,
intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError> { intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError> {
if let inReplyToEventID { if let inReplyToEventID {
@@ -405,14 +412,18 @@ final class TimelineProxy: TimelineProxyProtocol {
do { do {
if let inReplyToEventID { if let inReplyToEventID {
// `enforceThread` will force send the message a thread with `inReplyToEventID` while
// `replyWithinThread` will create an in-reply-to associated field *within* that same thread
try await timeline.sendReply(msg: messageContent, replyParams: .init(eventId: inReplyToEventID, try await timeline.sendReply(msg: messageContent, replyParams: .init(eventId: inReplyToEventID,
enforceThread: false, enforceThread: threadRootEventID != nil,
replyWithinThread: false)) replyWithinThread: threadRootEventID != nil))
MXLog.info("Finished sending reply to eventID: \(inReplyToEventID)") MXLog.info("Finished sending reply to eventID: \(inReplyToEventID)")
} else { } else {
_ = try await timeline.send(msg: messageContent) if let threadRootEventID {
try await timeline.sendReply(msg: messageContent, replyParams: .init(eventId: threadRootEventID,
enforceThread: true,
replyWithinThread: false))
} else {
_ = try await timeline.send(msg: messageContent)
}
MXLog.info("Finished sending message") MXLog.info("Finished sending message")
} }
} catch { } catch {
@@ -13,10 +13,23 @@ enum TimelineKind: Equatable {
case live case live
case detached case detached
case pinned case pinned
case thread case thread(rootEventID: String)
enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen } enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen }
case media(MediaPresentation) case media(MediaPresentation)
var isThread: Bool {
threadRootEventID != nil
}
var threadRootEventID: String? {
switch self {
case .thread(let rootEventID):
rootEventID
default:
nil
}
}
} }
enum TimelineFocus { enum TimelineFocus {
@@ -105,6 +118,7 @@ protocol TimelineProxyProtocol {
func sendMessage(_ message: String, func sendMessage(_ message: String,
html: String?, html: String?,
threadRootEventID: String?,
inReplyToEventID: String?, inReplyToEventID: String?,
intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError> intentionalMentions: IntentionalMentions) async -> Result<Void, TimelineProxyError>