Skip to content
4 changes: 1 addition & 3 deletions Mactrix/Extensions/Data+Mime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Mactrix/Extensions/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
35 changes: 35 additions & 0 deletions Mactrix/Extensions/NSTableView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
54 changes: 9 additions & 45 deletions Mactrix/Models/LiveTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
}
Expand All @@ -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<String>()

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)
}
}
}

Expand Down
19 changes: 11 additions & 8 deletions Mactrix/Views/ChatView/ChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)")
Expand All @@ -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() {
Expand All @@ -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)")
}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
104 changes: 13 additions & 91 deletions Mactrix/Views/ChatView/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 }
Expand Down
Loading