// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine import Foundation import MatrixRustSDK final class TimelineProxy: TimelineProxyProtocol { private let timeline: Timeline private var backPaginationStateObservationToken: TaskHandle? // The default values shouldn't matter here as they will be updated when calling subscribeToPagination // but empirically we randomly see the timeline start virtual item when we shouldn't. // We believe there's a race condition between the default values the status publisher // so we're going to default the backwards one to .idle. Worst case it's going to do // one extra back pagination. private let backPaginationStateSubject = CurrentValueSubject(.idle) private let forwardPaginationStateSubject = CurrentValueSubject(.endReached) private let kind: TimelineKind private var innerTimelineItemProvider: TimelineItemProviderProtocol! var timelineItemProvider: TimelineItemProviderProtocol { innerTimelineItemProvider } deinit { backPaginationStatusContinuation.finish() backPaginationStateObservationToken?.cancel() } /// Bridge from the SDK's synchronous callback into Swift Concurrency. Yielding is safe from any /// thread; a single long-lived `for await` consumer (set up in `init`) applies the status updates /// on the main actor in FIFO order. private let backPaginationStatusContinuation: AsyncStream.Continuation init(timeline: Timeline, kind: TimelineKind) { self.timeline = timeline self.kind = kind let (backPaginationStatusStream, backPaginationStatusContinuation) = AsyncStream.makeStream() self.backPaginationStatusContinuation = backPaginationStatusContinuation Task { [weak self] in for await status in backPaginationStatusStream { switch status { case .idle(let hitStartOfTimeline): self?.backPaginationStateSubject.send(hitStartOfTimeline ? .endReached : .idle) case .paginating: self?.backPaginationStateSubject.send(.paginating) } } } } func subscribeForUpdates() async { guard innerTimelineItemProvider == nil else { MXLog.warning("Timeline already subscribed for updates") return } let paginationStatePublisher = backPaginationStateSubject .combineLatest(forwardPaginationStateSubject) .map { TimelinePaginationState(backward: $0.0, forward: $0.1) } .eraseToAnyPublisher() await subscribeToPagination() let provider = TimelineItemProvider(timeline: timeline, kind: kind, paginationStatePublisher: paginationStatePublisher) innerTimelineItemProvider = provider Task { await timeline.fetchMembers() } } func fetchDetails(for eventID: String) { Task { do { MXLog.info("Fetching event details for \(eventID)") try await self.timeline.fetchDetailsForEvent(eventId: eventID) MXLog.info("Finished fetching event details for eventID: \(eventID)") } catch { MXLog.error("Failed fetching event details for eventID: \(eventID) with error: \(error)") } } } func messageEventContent(for timelineItemID: TimelineItemIdentifier) async -> RoomMessageEventContentWithoutRelation? { guard let content = timelineItemProvider.itemProxies.firstEventTimelineItemUsingStableID(timelineItemID)?.content, case let .msgLike(messageLikeContent) = content, case let .message(messageContent) = messageLikeContent.kind else { return nil } do { return try contentWithoutRelationFromMessage(message: messageContent) } catch { MXLog.error("Failed retrieving message event content for timelineItemID=\(timelineItemID)") return nil } } func paginateBackwards(requestSize: UInt16) async -> Result { // We can't subscribe to back pagination on detached timelines and as live timelines // can be shared between multiple instances of the same room on the stack, it is // safer to still use the subscription logic for back pagination when live. switch kind { case .live: return await paginateBackwardsOnLive(requestSize: requestSize) case .detached, .media, .thread: return await focussedPaginate(.backwards, requestSize: requestSize) case .pinned: return .success(()) } } func paginateForwards(requestSize: UInt16) async -> Result { guard kind != .pinned else { return .success(()) } return await focussedPaginate(.forwards, requestSize: requestSize) } /// Paginate backwards using the subscription from Rust to drive the pagination state. private func paginateBackwardsOnLive(requestSize: UInt16) async -> Result { MXLog.info("Paginating backwards") do { let _ = try await timeline.paginateBackwards(numEvents: requestSize) MXLog.info("Finished paginating backwards") return .success(()) } catch { MXLog.error("Failed paginating backwards with error: \(error)") return .failure(.sdkError(error)) } } /// Paginate forward or backwards using our own logic to drive the pagination state as the /// Rust subscription isn't allowed on focussed/detached timelines. private func focussedPaginate(_ direction: PaginationDirection, requestSize: UInt16) async -> Result { let subject = switch direction { case .backwards: backPaginationStateSubject case .forwards: forwardPaginationStateSubject } // This extra check is necessary as detached timelines don't support subscribing to pagination status. guard subject.value == .idle else { return .success(()) } MXLog.info("Paginating \(direction.rawValue)") subject.send(.paginating) do { let timelineEndReached = switch direction { case .backwards: try await timeline.paginateBackwards(numEvents: requestSize) case .forwards: try await timeline.paginateForwards(numEvents: requestSize) } MXLog.info("Finished paginating \(direction.rawValue)") subject.send(timelineEndReached ? .endReached : .idle) return .success(()) } catch { MXLog.error("Failed paginating \(direction.rawValue) with error: \(error)") subject.send(.idle) return .failure(.sdkError(error)) } } func retryDecryption(sessionIDs: [String]?) { let sessionIDs = sessionIDs ?? [] MXLog.info("Retrying decryption for sessionIDs: \(sessionIDs)") timeline.retryDecryption(sessionIds: sessionIDs) MXLog.info("Finished retrying decryption for sessionID: \(sessionIDs)") } func edit(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, newContent: EditedContent) async -> Result { do { try await timeline.edit(eventOrTransactionId: eventOrTransactionID.rustValue, newContent: newContent) MXLog.info("Finished editing timeline item: \(eventOrTransactionID)") return .success(()) } catch { MXLog.error("Failed editing timeline item: \(eventOrTransactionID) with error: \(error)") return .failure(.sdkError(error)) } } func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, reason: String?) async -> Result { MXLog.info("Redacting timeline item: \(eventOrTransactionID)") do { try await timeline.redactEvent(eventOrTransactionId: eventOrTransactionID.rustValue, reason: reason) MXLog.info("Redacted timeline item: \(eventOrTransactionID)") return .success(()) } catch { MXLog.error("Failed redacting timeline item: \(eventOrTransactionID) with error: \(error)") return .failure(.sdkError(error)) } } func getLoadedReplyDetails(eventID: String) async -> Result { do { return try await .success(timeline.loadReplyDetails(eventIdStr: eventID)) } catch { MXLog.error("Failed getting reply details for event \(eventID) with error: \(error)") return .failure(.sdkError(error)) } } func pin(eventID: String) async -> Result { do { return try await .success(timeline.pinEvent(eventId: eventID)) } catch { MXLog.error("Failed to pin the event \(eventID) with error: \(error)") return .failure(.sdkError(error)) } } func unpin(eventID: String) async -> Result { do { return try await .success(timeline.unpinEvent(eventId: eventID)) } catch { MXLog.error("Failed to unpin the event \(eventID) with error: \(error)") return .failure(.sdkError(error)) } } // MARK: - Sending func sendAudio(url: URL, audioInfo: AudioInfo, caption: String?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { MXLog.info("Sending audio") do { let handle = try timeline.sendAudio(params: .init(source: .file(filename: url.path(percentEncoded: false)), caption: caption, formattedCaption: nil, // Rust will build this from the caption's markdown. mentions: nil, inReplyTo: nil), audioInfo: audioInfo) requestHandle(handle) try await handle.join() MXLog.info("Finished sending audio") } catch { MXLog.error("Failed sending audio with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } func sendFile(url: URL, fileInfo: FileInfo, caption: String?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { MXLog.info("Sending file") do { let handle = try timeline.sendFile(params: .init(source: .file(filename: url.path(percentEncoded: false)), caption: caption, formattedCaption: nil, // Rust will build this from the caption's markdown. mentions: nil, inReplyTo: nil), fileInfo: fileInfo) requestHandle(handle) try await handle.join() MXLog.info("Finished sending file") } catch { MXLog.error("Failed sending file with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } func sendImage(url: URL, thumbnailURL: URL, imageInfo: ImageInfo, caption: String?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { MXLog.info("Sending image") do { let handle = try timeline.sendImage(params: .init(source: .file(filename: url.path(percentEncoded: false)), caption: caption, formattedCaption: nil, // Rust will build this from the caption's markdown. mentions: nil, inReplyTo: nil), thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)), imageInfo: imageInfo) requestHandle(handle) try await handle.join() MXLog.info("Finished sending image") } catch { MXLog.error("Failed sending image with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } func sendLocation(body: String, geoURI: GeoURI, description: String?, zoomLevel: UInt8?, assetType: AssetType?) async -> Result { MXLog.info("Sending location") do { try await timeline.sendLocation(body: body, geoUri: geoURI.string, description: description, zoomLevel: zoomLevel, assetType: assetType, repliedToEventId: nil) MXLog.info("Finished sending location") } catch { MXLog.error("Failed sending location with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo, caption: String?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { MXLog.info("Sending video") do { let handle = try timeline.sendVideo(params: .init(source: .file(filename: url.path(percentEncoded: false)), caption: caption, formattedCaption: nil, mentions: nil, inReplyTo: nil), thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)), videoInfo: videoInfo) requestHandle(handle) try await handle.join() MXLog.info("Finished sending video") } catch { MXLog.error("Failed sending video with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [Float], requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { MXLog.info("Sending voice message") do { let handle = try timeline.sendVoiceMessage(params: .init(source: .file(filename: url.path(percentEncoded: false)), caption: nil, formattedCaption: nil, mentions: nil, inReplyTo: nil), audioInfo: audioInfo, waveform: waveform) requestHandle(handle) try await handle.join() MXLog.info("Finished sending voice message") } catch { MXLog.error("Failed sending vocie message with error: \(error)") return .failure(.sdkError(error)) } return .success(()) } /// Send a message within a room. If `inReplyToEventID` is specified then it will be sent as a reply /// to that particular message. This works for both normal and threaded timelines with the relation and /// fallback logic being handled SDK side based on the timeline instance focus mode. func sendMessage(_ message: String, html: String?, inReplyToEventID: String? = nil, intentionalMentions: IntentionalMentions) async -> Result { if let inReplyToEventID { MXLog.info("Sending reply to eventID: \(inReplyToEventID)") } else { MXLog.info("Sending message") } let messageContent = buildMessageContentFor(message, html: html, intentionalMentions: intentionalMentions.toRustMentions()) do { if let inReplyToEventID { try await timeline.sendReply(msg: messageContent, eventId: inReplyToEventID) MXLog.info("Finished sending reply to eventID: \(inReplyToEventID)") } else { _ = try await timeline.send(msg: messageContent) MXLog.info("Finished sending message") } } catch { if let inReplyToEventID { MXLog.error("Failed sending reply to eventID: \(inReplyToEventID) with error: \(error)") } else { MXLog.error("Failed sending message with error: \(error)") } return .failure(.sdkError(error)) } return .success(()) } func sendMessageEventContent(_ messageContent: RoomMessageEventContentWithoutRelation) async -> Result { MXLog.info("Sending message content") do { _ = try await timeline.send(msg: messageContent) } catch { MXLog.error("Failed sending message with error: \(error)") } MXLog.info("Finished sending message content") return .success(()) } func sendReadReceipt(for eventID: String, type: ReceiptType) async -> Result { MXLog.info("Sending read receipt for eventID: \(eventID)") do { try await timeline.sendReadReceipt(receiptType: type, eventId: eventID) MXLog.info("Finished sending read receipt for eventID: \(eventID)") return .success(()) } catch { MXLog.error("Failed sending read receipt for eventID: \(eventID) with error: \(error)") return .failure(.sdkError(error)) } } func markAsRead(receiptType: ReceiptType) async -> Result { MXLog.info("Marking as \(receiptType)") do { try await timeline.markAsRead(receiptType: receiptType) MXLog.info("Finished marking as read") return .success(()) } catch { MXLog.error("Failed marking as \(receiptType) with error: \(error)") return .failure(.sdkError(error)) } } func toggleReaction(_ reaction: String, to eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async -> Result { MXLog.info("Toggling reaction \(reaction) for event: \(eventOrTransactionID)") do { _ = try await timeline.toggleReaction(itemId: eventOrTransactionID.rustValue, key: reaction) MXLog.info("Finished toggling reaction for event: \(eventOrTransactionID)") return .success(()) } catch { MXLog.error("Failed toggling reaction for event: \(eventOrTransactionID)") return .failure(.sdkError(error)) } } // MARK: - Polls func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result { MXLog.info("Creating poll") do { try await timeline.createPoll(question: question, answers: answers, maxSelections: UInt8(max(1, min(maxSelections, answers.count))), pollKind: .init(pollKind: pollKind)) MXLog.info("Finished creating poll") return .success(()) } catch { MXLog.error("Failed creating poll with error: \(error)") return .failure(.sdkError(error)) } } func editPoll(original eventID: String, question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result { MXLog.info("Editing poll with eventID: \(eventID)") do { let originalEvent = try await timeline.getEventTimelineItemByEventId(eventId: eventID) try await timeline.edit(eventOrTransactionId: originalEvent.eventOrTransactionId, newContent: .pollStart(pollData: .init(question: question, answers: answers, maxSelections: UInt8(max(1, min(maxSelections, answers.count))), pollKind: .init(pollKind: pollKind)))) MXLog.info("Finished editing poll with eventID: \(eventID)") return .success(()) } catch { MXLog.error("Failed editing poll with eventID: \(eventID) with error: \(error)") return .failure(.sdkError(error)) } } func endPoll(pollStartID: String, text: String) async -> Result { MXLog.info("Ending poll with eventID: \(pollStartID)") do { try await timeline.endPoll(pollStartEventId: pollStartID, text: text) MXLog.info("Finished ending poll with eventID: \(pollStartID)") return .success(()) } catch { MXLog.error("Failed ending poll with eventID: \(pollStartID) with error: \(error)") return .failure(.sdkError(error)) } } func sendPollResponse(pollStartID: String, answers: [String]) async -> Result { MXLog.info("Sending response for poll with eventID: \(pollStartID)") do { try await timeline.sendPollResponse(pollStartEventId: pollStartID, answers: answers) MXLog.info("Finished sending response for poll with eventID: \(pollStartID)") return .success(()) } catch { MXLog.error("Failed sending response for poll with eventID: \(pollStartID) with error: \(error)") return .failure(.sdkError(error)) } } func buildMessageContentFor(_ message: String, html: String?, intentionalMentions: Mentions) -> RoomMessageEventContentWithoutRelation { let emoteSlashCommand = "/me " let isEmote: Bool = message.starts(with: emoteSlashCommand) let content: RoomMessageEventContentWithoutRelation if isEmote { let emoteMessage = String(message.dropFirst(emoteSlashCommand.count)) var emoteHtml: String? if let html { emoteHtml = String(html.dropFirst(emoteSlashCommand.count)) } content = buildEmoteMessageContentFor(emoteMessage, html: emoteHtml) } else { if let html { content = messageEventContentFromHtml(body: message, htmlBody: html) } else { content = messageEventContentFromMarkdown(md: message) } } return content.withMentions(mentions: intentionalMentions) } // MARK: - Private private func buildEmoteMessageContentFor(_ message: String, html: String?) -> RoomMessageEventContentWithoutRelation { if let html { return messageEventContentFromHtmlAsEmote(body: message, htmlBody: html) } else { return messageEventContentFromMarkdownAsEmote(md: message) } } private func subscribeToPagination() async { switch kind { case .live: let backPaginationListener = SDKListener { [backPaginationStatusContinuation] status in backPaginationStatusContinuation.yield(status) } do { backPaginationStateObservationToken = try await timeline.subscribeToBackPaginationStatus(listener: backPaginationListener) } catch { MXLog.error("Failed to subscribe to back pagination status with error: \(error)") } forwardPaginationStateSubject.send(.endReached) case .detached, .thread: // Detached timelines don't support observation, set the initial state ourself. backPaginationStateSubject.send(.idle) forwardPaginationStateSubject.send(.idle) case .media(let presentation): backPaginationStateSubject.send(presentation == .pinnedEventsScreen ? .endReached : .idle) forwardPaginationStateSubject.send(presentation == .roomScreenDetached ? .idle : .endReached) case .pinned: backPaginationStateSubject.send(.endReached) forwardPaginationStateSubject.send(.endReached) } } } private extension MatrixRustSDK.PollKind { init(pollKind: Poll.Kind) { switch pollKind { case .disclosed: self = .disclosed case .undisclosed: self = .undisclosed } } } extension Array where Element == TimelineItemProxy { func firstEventTimelineItemUsingStableID(_ id: TimelineItemIdentifier) -> EventTimelineItem? { for item in self { if case let .event(eventTimelineItem) = item { if eventTimelineItem.id.uniqueID == id.uniqueID { return eventTimelineItem.item } } } return nil } func firstEventTimelineItemUsingEventOrTransactionID(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) -> EventTimelineItem? { for item in self { if case let .event(eventTimelineItem) = item, case let .event(_, identifier) = eventTimelineItem.id, identifier == eventOrTransactionID { return eventTimelineItem.item } } return nil } }