diff --git a/Mactrix/Extensions/Data+Mime.swift b/Mactrix/Extensions/Data+Mime.swift index c5d0a21..4215c46 100644 --- a/Mactrix/Extensions/Data+Mime.swift +++ b/Mactrix/Extensions/Data+Mime.swift @@ -3,9 +3,7 @@ import UniformTypeIdentifiers extension Data { func computeMimeType() -> UTType? { - guard !self.isEmpty else { return nil } - var b: UInt8 = 0 - self.copyBytes(to: &b, count: 1) + guard let b: UInt8 = first else { return nil } switch b { case 0xff: diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift index 01d869f..4e7ee35 100644 --- a/Mactrix/Extensions/Logger.swift +++ b/Mactrix/Extensions/Logger.swift @@ -10,6 +10,7 @@ extension Logger { static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") + static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") diff --git a/Mactrix/Extensions/NSTableView.swift b/Mactrix/Extensions/NSTableView.swift new file mode 100644 index 0000000..1165651 --- /dev/null +++ b/Mactrix/Extensions/NSTableView.swift @@ -0,0 +1,35 @@ +import AppKit + +extension NSTableView { + /// Animates scrolling to row with a given index. + func animateRowToVisible(_ index: Int) { + guard index >= 0, index < numberOfRows else { return } + guard let scrollView = enclosingScrollView else { return } + + let rowRect = rect(ofRow: index) + let clipView = scrollView.contentView + let visibleRect = clipView.bounds + + var targetY = visibleRect.origin.y + if rowRect.origin.y < visibleRect.origin.y { + // Row is above: Scroll up until the top of the row is at the top + targetY = rowRect.origin.y + } else if rowRect.maxY > visibleRect.maxY { + // Row is below: Scroll down until the bottom of the row is at the bottom + targetY = rowRect.maxY - visibleRect.height + } else { + // Row is already fully visible: Exit + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.allowsImplicitAnimation = true + + var newBounds = clipView.bounds + newBounds.origin.y = targetY + clipView.animator().bounds = newBounds + } + } +} diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 7661c7c..17c1b28 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -18,12 +18,12 @@ public final class LiveTimeline { public var errorMessage: String? public private(set) var focusedTimelineEventId: EventOrTransactionId? - public private(set) var focusedTimelineGroupId: String? + // public private(set) var focusedTimelineGroupId: String? public var sendReplyTo: MatrixRustSDK.EventTimelineItem? - @ObservationIgnored private var timelineItems: [TimelineItem] = [] - public private(set) var timelineGroups: TimelineGroups = .init() + public private(set) var timelineItems: [TimelineItem] = [] + // public private(set) var timelineGroups: TimelineGroups = .init() public private(set) var paginating: RoomPaginationStatus = .idle(hitTimelineStart: false) public private(set) var hitTimelineStart: Bool = false @@ -120,6 +120,11 @@ public final class LiveTimeline { Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") paginating = status + + if paginating == .idle(hitTimelineStart: false) && timelineItems.count < 20 { + try await Task.sleep(for: .milliseconds(500)) + try await fetchOlderMessages() + } } } } @@ -131,85 +136,44 @@ public final class LiveTimeline { return } + Logger.liveTimeline.info("fetch more messages") _ = try await timeline?.paginateBackwards(numEvents: 100) } public func focusEvent(id eventId: EventOrTransactionId) { Logger.liveTimeline.info("focus event: \(eventId.id)") focusedTimelineEventId = eventId - - let group = timelineGroups.groups.first { group in - switch group { - case let .messages(messages, _, _): - return messages.contains(where: { $0.event.eventOrTransactionId == eventId }) - case .stateChanges: - return false - case .virtual: - return false - } - } - focusedTimelineGroupId = group?.id - - if let focusedTimelineGroupId { - withAnimation { - scrollPosition.scrollTo(id: focusedTimelineGroupId) - } - } } } extension LiveTimeline { private func updateTimeline(diff: [TimelineDiff]) { - let oldView = scrollPosition.viewID - let oldEdge = scrollPosition.edge - Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") - - var updatedIds = Set() - for update in diff { switch update { case let .append(values): timelineItems.append(contentsOf: values) - for value in values { - updatedIds.insert(value.uniqueId().id) - } case .clear: timelineItems.removeAll() case let .pushFront(room): timelineItems.insert(room, at: 0) - updatedIds.insert(room.uniqueId().id) case let .pushBack(room): timelineItems.append(room) - updatedIds.insert(room.uniqueId().id) case .popFront: timelineItems.removeFirst() case .popBack: timelineItems.removeLast() case let .insert(index, room): timelineItems.insert(room, at: Int(index)) - updatedIds.insert(room.uniqueId().id) case let .set(index, room): timelineItems[Int(index)] = room - updatedIds.insert(room.uniqueId().id) case let .remove(index): timelineItems.remove(at: Int(index)) case let .truncate(length): timelineItems.removeSubrange(Int(length) ..< timelineItems.count) case let .reset(values: values): timelineItems = values - for value in values { - updatedIds.insert(value.uniqueId().id) - } } } - - timelineGroups.updateItems(items: timelineItems, updatedIds: updatedIds) - - if let oldEdge { - scrollPosition.scrollTo(edge: oldEdge) - } else if let oldView { - scrollPosition.scrollTo(id: oldView, anchor: .top) - } } } diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index 7828499..8a4baf6 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -9,7 +9,7 @@ struct ChatMessageView: View, UI.MessageEventActions { @Environment(WindowState.self) private var windowState @AppStorage("fontSize") private var fontSize = 13 - let timeline: LiveTimeline + let timeline: LiveTimeline? let event: MatrixRustSDK.EventTimelineItem let msg: MatrixRustSDK.MsgLikeContent let includeProfileHeader: Bool @@ -23,7 +23,7 @@ struct ChatMessageView: View, UI.MessageEventActions { func toggleReaction(key: String) { Task { - guard let innerTimeline = timeline.timeline else { return } + guard let innerTimeline = timeline?.timeline else { return } do { let reactionWasAdded = try await innerTimeline.toggleReaction(itemId: event.eventOrTransactionId, key: key) Logger.viewCycle.debug("reaction \(reactionWasAdded ? "added" : "removed"): \(key)") @@ -35,7 +35,7 @@ struct ChatMessageView: View, UI.MessageEventActions { func reply() { Logger.viewCycle.info("Reply to event: \(event.eventOrTransactionId.id)") - timeline.sendReplyTo = event + timeline?.sendReplyTo = event } func replyInThread() { @@ -47,7 +47,7 @@ struct ChatMessageView: View, UI.MessageEventActions { guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return } Task { do { - let _ = try await timeline.timeline?.pinEvent(eventId: eventId) + let _ = try await timeline?.timeline?.pinEvent(eventId: eventId) } catch { Logger.viewCycle.error("Failed to ping message: \(error)") } @@ -80,8 +80,11 @@ struct ChatMessageView: View, UI.MessageEventActions { Text(content.body.formatAsMarkdown) .textSelection(.enabled) .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) case let .text(content: content): - Text(content.body.formatAsMarkdown).textSelection(.enabled) + Text(content.body.formatAsMarkdown) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) case let .location(content: content): Text("Location: \(content.body) \(content.geoUri)").textSelection(.enabled) case let .other(msgtype: msgtype, body: body): @@ -109,7 +112,7 @@ struct ChatMessageView: View, UI.MessageEventActions { } var isEventFocused: Bool { - return timeline.focusedTimelineEventId == event.eventOrTransactionId + return timeline?.focusedTimelineEventId == event.eventOrTransactionId } var ownUserId: String { @@ -126,11 +129,11 @@ struct ChatMessageView: View, UI.MessageEventActions { UI.MessageEventProfileView(event: event, actions: self, imageLoader: appState.matrixClient) .font(.system(size: .init(fontSize))) } - UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline.room.members) { + UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { VStack(alignment: .leading, spacing: 10) { if let replyTo = msg.inReplyTo { EmbeddedMessageView(embeddedEvent: replyTo.event()) { - timeline.focusEvent(id: .eventId(eventId: replyTo.eventId())) + timeline?.focusEvent(id: .eventId(eventId: replyTo.eventId())) } .padding(.bottom, 10) } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 919be2b..6b1420d 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -22,96 +22,6 @@ struct TimelineGroupView: View { } } -struct TimelineItemsView: View { - let timeline: LiveTimeline - - var body: some View { - if !timeline.timelineGroups.groups.isEmpty { - LazyVStack { - ForEach(timeline.timelineGroups.groups) { item in - TimelineGroupView(timeline: timeline, timelineGroup: item) - } - } - .scrollTargetLayout() - } else { - ProgressView() - } - } -} - -struct ChatTimelineScrollView: View { - @Bindable var timeline: LiveTimeline - - @State private var scrollNearTop: Bool = false - - func loadMoreMessages() { - guard scrollNearTop else { return } - guard timeline.paginating == .idle(hitTimelineStart: false) else { - let p = timeline.paginating.debugDescription - Logger.viewCycle.info("Fetching messages cancelled, already: paginating \(p)") - return - } - Logger.viewCycle.info("Reached top, fetching more messages...") - - Task { - do { - try await self.timeline.fetchOlderMessages() - -// if scrollNearTop { -// try await Task.sleep(for: .seconds(1)) -// loadMoreMessages() -// } - } catch { - Logger.viewCycle.error("failed to fetch more message for timeline: \(error)") - } - } - } - - var body: some View { - ScrollView { - ProgressView("Loading more messages") - .opacity(timeline.paginating == .paginating ? 1 : 0) - - TimelineItemsView(timeline: timeline) - - if let errorMessage = timeline.errorMessage { - Text(errorMessage) - .foregroundStyle(Color.red) - .frame(maxWidth: .infinity) - } - - HStack { - UI.UserTypingIndicator(names: timeline.room.typingUserIds) - Spacer() - } - .padding(.horizontal, 10) - } - .scrollPosition($timeline.scrollPosition) - .defaultScrollAnchor(.bottom) - .onScrollGeometryChange(for: Bool.self) { geo in - geo.visibleRect.maxY - geo.containerSize.height < 400.0 - } action: { _, nearTop in - Logger.viewCycle.info("scroll near top: \(nearTop)") - scrollNearTop = nearTop - if nearTop { - loadMoreMessages() - } - } - .task(id: timeline.timelineGroups, priority: .background) { - do { - try await Task.sleep(for: .seconds(1)) - - Logger.viewCycle.debug("Mark room as read") - try await timeline.timeline?.markAsRead(receiptType: .read) - } catch is CancellationError { - /* sleep cancelled */ - } catch { - Logger.viewCycle.error("failed to send timeline read receipt: \(error)") - } - } - } -} - struct ChatJoinedRoom: View { @Environment(AppState.self) private var appState @Bindable var timeline: LiveTimeline @@ -129,7 +39,7 @@ struct ChatJoinedRoom: View { } var body: some View { - ChatTimelineScrollView(timeline: timeline) + TimelineViewRepresentable(timeline: timeline, items: timeline.timelineItems) .safeAreaPadding(.bottom, inputHeight ?? 60) // chat input overlay .overlay(alignment: .bottom) { ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo, height: $inputHeight) @@ -149,6 +59,18 @@ struct ChatJoinedRoom: View { Logger.viewCycle.error("failed to mark room as recently visited: \(error)") } } + .task(id: timeline.timelineItems.count, priority: .background) { + do { + try await Task.sleep(for: .seconds(1)) + + Logger.viewCycle.debug("Mark room as read") + try await timeline.timeline?.markAsRead(receiptType: .read) + } catch is CancellationError { + /* sleep cancelled */ + } catch { + Logger.viewCycle.error("failed to send timeline read receipt: \(error)") + } + } .onDisappear { Task { guard let timeline = timeline.timeline else { return } diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift new file mode 100644 index 0000000..4e1c5b7 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -0,0 +1,276 @@ +import AppKit +import MatrixRustSDK +import OSLog +import SwiftUI +import UI + +enum TimelineItemRowInfo { + case message(event: EventTimelineItem, content: MsgLikeContent) + case state(event: EventTimelineItem) + case virtual(virtual: VirtualTimelineItem) + + var reuseIdentifier: NSUserInterfaceItemIdentifier { + switch self { + case .message: + return NSUserInterfaceItemIdentifier("message") + case .state: + return NSUserInterfaceItemIdentifier("state") + case .virtual: + return NSUserInterfaceItemIdentifier("virtual") + } + } +} + +struct TimelineItemRowView: View { + let rowInfo: TimelineItemRowInfo + let timeline: LiveTimeline? + + let appState: AppState + let windowState: WindowState + + init(rowInfo: TimelineItemRowInfo, timeline: LiveTimeline?, coordinator: TimelineViewRepresentable.Coordinator) { + self.rowInfo = rowInfo + self.timeline = timeline + self.appState = coordinator.appState + self.windowState = coordinator.windowState + } + + @ViewBuilder + var contentView: some View { + switch rowInfo { + case .message(let event, let content): + ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: true) + case .state(let event): + UI.GenericEventView(event: event, name: event.content.description) + case .virtual(let virtual): + UI.VirtualItemView(item: virtual.asModel) + } + } + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + VStack(spacing: 0) { + contentView + .environment(appState) + .environment(windowState) + } + } + } +} + +extension TimelineItem { + var rowInfo: TimelineItemRowInfo { + if let virtual = asVirtual() { + return .virtual(virtual: virtual) + } + + if let event = asEvent() { + switch event.content { + case .msgLike(content: let content): + return .message(event: event, content: content) + default: + return .state(event: event) + } + } + + fatalError("unreachable state: item must be either virtual or event") + } +} + +class TimelineViewController: NSViewController { + let coordinator: TimelineViewRepresentable.Coordinator + + private var dataSource: NSTableViewDiffableDataSource? + + let scrollView = NSScrollView() + let tableView = BottomStickyTableView() + + let timeline: LiveTimeline + var timelineItems: [TimelineItem] + + init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) { + self.coordinator = coordinator + self.timeline = timeline + self.timelineItems = timelineItems + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.addTableColumn(NSTableColumn()) + tableView.headerView = nil + tableView.style = .plain + tableView.allowsColumnSelection = false + + tableView.rowHeight = -1 + tableView.usesAutomaticRowHeights = true + + oldWidth = tableView.frame.width + + dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in + guard let self else { return NSView() } + + let item = timelineItems[row] + let view = TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator) + + let hostView: NSHostingView + if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) + as? NSHostingView + { + recycledView.rootView = view + hostView = recycledView + } else { + hostView = NSHostingView(rootView: view) + hostView.identifier = item.rowInfo.reuseIdentifier + hostView.autoresizingMask = [.width, .height] + hostView.sizingOptions = [.preferredContentSize] + hostView.setContentHuggingPriority(.required, for: .vertical) + } + + return hostView + } + + tableView.delegate = self + + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + view = scrollView + + // Subscribe to view resize notifications + scrollView.contentView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTableResize), + name: NSView.frameDidChangeNotification, + object: scrollView.contentView + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(viewDidScroll(_:)), + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView + ) + + listenForFocusTimelineItem() + } + + @objc func handleTableResize(_ notification: Notification) { + if oldWidth != tableView.frame.width { + oldWidth = tableView.frame.width + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + + // Update only the height of visible rows + let visibleRect = tableView.visibleRect + let visibleRows = tableView.rows(in: visibleRect) + tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound)) + } + } + } + + var timelineFetchTask: Task? + + @objc func viewDidScroll(_ notification: Notification) { + let currentOffset = scrollView.contentView.bounds.origin.y + let timelineHeight = scrollView.contentView.documentRect.height + let viewHeight = scrollView.contentView.documentVisibleRect.height + + let distanceFromTop = timelineHeight - viewHeight - currentOffset + let threshold: CGFloat = 200.0 // Pixels from the top to trigger load + + if distanceFromTop <= threshold, timelineFetchTask == nil { + Logger.timelineTableView.info("Fetching older messages (scroll near top)") + timelineFetchTask = Task { + do { + try await timeline.fetchOlderMessages() + } catch { + Logger.timelineTableView.error("Failed to fetch older messages: \(error)") + } + + timelineFetchTask = nil + } + } + } + + func listenForFocusTimelineItem() { + Logger.timelineTableView.debug("Listen for focus timeline item") + + let focusedTimelineEventId = withObservationTracking { + timeline.focusedTimelineEventId + } onChange: { + Task { @MainActor in self.listenForFocusTimelineItem() } + } + + guard let focusedTimelineEventId, + let rowIndex = timelineItems.firstIndex(where: { + $0.asEvent()?.eventOrTransactionId == focusedTimelineEventId + }) else { return } + + tableView.animateRowToVisible(rowIndex) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not available") + } + + enum TimelineSection { + case main + case typingIndicator + } + + func updateTimelineItems(_ timelineItems: [TimelineItem]) { + Logger.timelineTableView.info("update timeline items") + self.timelineItems = timelineItems.reversed() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + for item in self.timelineItems { + snapshot.appendItems([.init(id: item.uniqueId().id)], toSection: .main) + } + + dataSource?.apply(snapshot, animatingDifferences: false) + } + + // values used to calculate height of a row + var oldWidth: CGFloat? + let measurementHostingView = { + let hostView = NSHostingController(rootView: AnyView(EmptyView())) + hostView.sizingOptions = [.preferredContentSize] + return hostView + }() +} + +extension TimelineViewController: NSTableViewDelegate { + func selectionShouldChange(in tableView: NSTableView) -> Bool { + return false + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + return false + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + let item = timelineItems[row] + + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, timeline: nil, coordinator: coordinator)) + + let targetWidth = tableView.tableColumns[0].width + let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) + + let size = measurementHostingView.sizeThatFits(in: proposedSize) + return size.height + } +} + +class BottomStickyTableView: NSTableView { + // By returning false, the table starts drawing from the bottom up + override var isFlipped: Bool { + return false + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift new file mode 100644 index 0000000..2777ed1 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -0,0 +1,37 @@ +import MatrixRustSDK +import SwiftUI + +struct TimelineViewRepresentable: NSViewControllerRepresentable { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + let timeline: LiveTimeline + let items: [TimelineItem] + + init(timeline: LiveTimeline, items: [TimelineItem]) { + self.timeline = timeline + self.items = items + } + + func makeCoordinator() -> Coordinator { + return Coordinator(appState: appState, windowState: windowState) + } + + class Coordinator { + let appState: AppState + let windowState: WindowState + + init(appState: AppState, windowState: WindowState) { + self.appState = appState + self.windowState = windowState + } + } + + func makeNSViewController(context: Context) -> TimelineViewController { + return TimelineViewController(coordinator: context.coordinator, timeline: timeline, timelineItems: items) + } + + func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { + timelineViewController.updateTimelineItems(items) + } +}