diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 6f3d5cc76..d1dfb9be7 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -967,6 +967,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg switch await roomProxy.timeline.sendMessage(replyText, html: nil, + threadRootEventID: nil, inReplyToEventID: nil, intentionalMentions: .empty) { case .success: diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9ede20243..42c23f9a6 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -16683,15 +16683,15 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable { } //MARK: - sendMessage - var sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = 0 - var sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount: Int { + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = 0 + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount: Int { get { if Thread.isMainThread { - return sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount + return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount + returnValue = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount } return returnValue! @@ -16699,29 +16699,29 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingCallsCount = newValue } } } } - var sendMessageHtmlInReplyToEventIDIntentionalMentionsCalled: Bool { - return sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount > 0 + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCalled: Bool { + return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount > 0 } - var sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedArguments: (message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)? - var sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedInvocations: [(message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)] = [] + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedArguments: (message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)? + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedInvocations: [(message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions)] = [] - var sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue: Result! - var sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue: Result! { + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue: Result! + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReturnValue: Result! { get { if Thread.isMainThread { - return sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue + return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue } else { var returnValue: Result? = nil DispatchQueue.main.sync { - returnValue = sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue + returnValue = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue } return returnValue! @@ -16729,26 +16729,26 @@ class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue } else { DispatchQueue.main.sync { - sendMessageHtmlInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsUnderlyingReturnValue = newValue } } } } - var sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure: ((String, String?, String?, IntentionalMentions) async -> Result)? + var sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure: ((String, String?, String?, String?, IntentionalMentions) async -> Result)? - func sendMessage(_ message: String, html: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions) async -> Result { - sendMessageHtmlInReplyToEventIDIntentionalMentionsCallsCount += 1 - sendMessageHtmlInReplyToEventIDIntentionalMentionsReceivedArguments = (message: message, html: html, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions) + func sendMessage(_ message: String, html: String?, threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions) async -> Result { + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsCallsCount += 1 + sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReceivedArguments = (message: message, html: html, threadRootEventID: threadRootEventID, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions) 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 { - return await sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure(message, html, inReplyToEventID, intentionalMentions) + if let sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure = sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure { + return await sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsClosure(message, html, threadRootEventID, inReplyToEventID, intentionalMentions) } else { - return sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue + return sendMessageHtmlThreadRootEventIDInReplyToEventIDIntentionalMentionsReturnValue } } //MARK: - toggleReaction diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 4fd35109a..8a3fa85d2 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -120,9 +120,11 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description)) case .displayResolveSendFailure(let failure, let sendHandle): actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) - case .displayThread, .composer, .hasScrolled, .displayRoom: + case .hasScrolled, .displayRoom: 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") } } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index dab66d5dc..0ead31e72 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -125,7 +125,7 @@ struct TimelineItemMenuActionProvider { 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) } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index e8f648242..6c058d170 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -120,7 +120,7 @@ struct TimelineItemBubbledStylerView: View { .onTapGesture { } } - if context.viewState.timelineKind != .thread, let threadSummary = timelineItem.properties.threadSummary { + if !context.viewState.timelineKind.isThread, let threadSummary = timelineItem.properties.threadSummary { TimelineThreadSummaryView(threadSummary: threadSummary) { context.send(viewAction: .displayThread(itemID: timelineItem.id)) } @@ -177,7 +177,7 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var contentWithReply: some View { TimelineBubbleLayout(spacing: 8) { - if context.viewState.timelineKind != .thread, timelineItem.properties.isThreaded { + if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded { ThreadDecorator() .padding(.leading, 4) .layoutPriority(TimelineBubbleLayout.Priority.regularText) @@ -213,7 +213,7 @@ struct TimelineItemBubbledStylerView: View { } 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 { diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 70ec51a5a..a0b4516c4 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -175,7 +175,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { trackReadReceipts: true, reportUtds: true)) - let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread) + let timeline = TimelineProxy(timeline: sdkTimeline, kind: .thread(rootEventID: eventID)) await timeline.subscribeForUpdates() return .success(timeline) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index 4cc196633..fa975d7eb 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -158,6 +158,7 @@ class TimelineController: TimelineControllerProtocol { switch await activeTimeline.sendMessage(message, html: html, + threadRootEventID: timelineKind.threadRootEventID, inReplyToEventID: inReplyToEventID, intentionalMentions: intentionalMentions) { case .success: diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index ab0d26a25..1935cd1e2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -389,8 +389,15 @@ final class TimelineProxy: TimelineProxyProtocol { 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, html: String?, + threadRootEventID: String?, inReplyToEventID: String? = nil, intentionalMentions: IntentionalMentions) async -> Result { if let inReplyToEventID { @@ -405,14 +412,18 @@ final class TimelineProxy: TimelineProxyProtocol { do { 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, - enforceThread: false, - replyWithinThread: false)) + enforceThread: threadRootEventID != nil, + replyWithinThread: threadRootEventID != nil)) MXLog.info("Finished sending reply to eventID: \(inReplyToEventID)") } 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") } } catch { diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index d6dd4f1ba..6843a6584 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -13,10 +13,23 @@ enum TimelineKind: Equatable { case live case detached case pinned - case thread + case thread(rootEventID: String) enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen } case media(MediaPresentation) + + var isThread: Bool { + threadRootEventID != nil + } + + var threadRootEventID: String? { + switch self { + case .thread(let rootEventID): + rootEventID + default: + nil + } + } } enum TimelineFocus { @@ -105,6 +118,7 @@ protocol TimelineProxyProtocol { func sendMessage(_ message: String, html: String?, + threadRootEventID: String?, inReplyToEventID: String?, intentionalMentions: IntentionalMentions) async -> Result