Skip to content

Commit 51b7298

Browse files
committed
feat: add hover actions panel for timeline messages
Floating panel with quick reactions (πŸ‘πŸŽ‰β€οΈ), reply, reply in thread, and pin actions. Panel is dismissed when scrolling or resizing, and hidden when the row top is outside the visible timeline area.
1 parent fc8273b commit 51b7298

6 files changed

Lines changed: 272 additions & 81 deletions

File tree

β€ŽMactrix/Models/LiveTimeline.swiftβ€Ž

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,26 @@ public final class LiveTimeline {
2020
public private(set) var focusedTimelineEventId: EventOrTransactionId?
2121
// public private(set) var focusedTimelineGroupId: String?
2222

23-
public var sendReplyTo: MatrixRustSDK.EventTimelineItem?
23+
public var sendReplyTo: MatrixRustSDK.EventTimelineItem? {
24+
get { _sendReplyTo }
25+
set {
26+
let id = newValue?.eventOrTransactionId.id ?? "nil"
27+
let msg = "sendReplyTo changed to \(id)"
28+
if newValue == nil && _sendReplyTo != nil {
29+
let stack = Thread.callStackSymbols.prefix(15).joined(separator: "\n ")
30+
Self.debugLog("\(msg) β€” CLEARED\n \(stack)")
31+
} else {
32+
Self.debugLog(msg)
33+
}
34+
_sendReplyTo = newValue
35+
}
36+
}
37+
private var _sendReplyTo: MatrixRustSDK.EventTimelineItem?
38+
39+
private static func debugLog(_ msg: String) {
40+
fputs("HOVER: \(msg)\n", stderr)
41+
}
42+
public var hoveredEventId: EventOrTransactionId?
2443

2544
public private(set) var timelineItems: [TimelineItem] = []
2645
public private(set) var loadedReplyDetails: [String: InReplyToDetails] = [:]

β€ŽMactrix/Views/ChatView/ChatInputView.swiftβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ struct ChatInputView: View {
2929
}
3030

3131
chatInput = ""
32+
NSLog("HOVER: replyTo cleared by sendMessage")
3233
replyTo = nil
3334
timeline.scrollPosition.scrollTo(edge: .bottom)
3435
}
@@ -120,6 +121,7 @@ struct ChatInputView: View {
120121
VStack(alignment: .leading) {
121122
if let replyEmbeddedDetails {
122123
EmbeddedMessageView(embeddedEvent: replyEmbeddedDetails) {
124+
NSLog("HOVER: replyTo cleared by EmbeddedMessageView tap")
123125
replyTo = nil
124126
}
125127
}

β€ŽMactrix/Views/ChatView/ChatMessageView.swiftβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ struct ChatMessageView: View, UI.MessageEventActions {
128128
UI.MessageEventProfileView(event: event, actions: self, imageLoader: appState.matrixClient)
129129
.font(.system(size: .init(fontSize)))
130130
}
131-
UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) {
131+
UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? [], isExternallyHovered: timeline?.hoveredEventId == event.eventOrTransactionId) {
132132
VStack(alignment: .leading, spacing: 10) {
133133
if let replyTo = msg.inReplyTo {
134134
let eventId = replyTo.eventId()
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import AppKit
2+
import SwiftUI
3+
import UI
4+
5+
/// A floating panel that displays message hover actions (react, reply, pin)
6+
/// positioned above the hovered timeline row, outside the NSTableView's row clipping.
7+
class HoverActionsPanel: NSPanel {
8+
private let hostingView: NSHostingView<AnyView>
9+
10+
init() {
11+
hostingView = NSHostingView(rootView: AnyView(EmptyView()))
12+
13+
super.init(
14+
contentRect: .zero,
15+
styleMask: [.borderless, .nonactivatingPanel],
16+
backing: .buffered,
17+
defer: true
18+
)
19+
20+
isOpaque = false
21+
backgroundColor = .clear
22+
hasShadow = false
23+
level = .floating
24+
hidesOnDeactivate = true
25+
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
26+
ignoresMouseEvents = false
27+
contentView = hostingView
28+
}
29+
30+
override var canBecomeKey: Bool { true }
31+
32+
var isMouseInside: Bool {
33+
NSMouseInRect(NSEvent.mouseLocation, frame, false)
34+
}
35+
36+
func update(eventId: String, onReaction: @escaping (String) -> Void, onReply: @escaping () -> Void, onReplyInThread: @escaping () -> Void, onPin: @escaping () -> Void) {
37+
hostingView.rootView = AnyView(
38+
HoverActionsView(onReaction: onReaction, onReply: onReply, onReplyInThread: onReplyInThread, onPin: onPin)
39+
.id(eventId)
40+
)
41+
hostingView.layoutSubtreeIfNeeded()
42+
let size = hostingView.fittingSize
43+
setContentSize(size)
44+
}
45+
46+
func position(relativeTo rowRect: NSRect, in window: NSWindow, topOffset: CGFloat = 0) {
47+
let screenRect = window.convertToScreen(rowRect)
48+
let size = hostingView.fittingSize
49+
let origin = NSPoint(
50+
x: screenRect.maxX - size.width - 20,
51+
y: screenRect.maxY - 6 - topOffset
52+
)
53+
setFrameOrigin(origin)
54+
}
55+
}
56+
57+
private struct HoverActionsView: View {
58+
let onReaction: (String) -> Void
59+
let onReply: () -> Void
60+
let onReplyInThread: () -> Void
61+
let onPin: () -> Void
62+
63+
var body: some View {
64+
HStack(spacing: 0) {
65+
HoverButton(icon: { Text("πŸ‘") }, tooltip: "React") { onReaction("πŸ‘") }
66+
HoverButton(icon: { Text("πŸŽ‰") }, tooltip: "React") { onReaction("πŸŽ‰") }
67+
HoverButton(icon: { Text("❀️") }, tooltip: "React") { onReaction("❀️") }
68+
Divider().frame(height: 18)
69+
HoverButton(icon: { Image(systemName: "face.smiling") }, tooltip: "React") {}
70+
HoverButton(icon: { Image(systemName: "arrowshape.turn.up.left") }, tooltip: "Reply") { onReply() }
71+
HoverButton(icon: { Image(systemName: "ellipsis.message") }, tooltip: "Reply in thread") { onReplyInThread() }
72+
73+
HoverButton(icon: { Image(systemName: "pin") }, tooltip: "Pin") { onPin() }
74+
}
75+
.padding(2)
76+
.background(
77+
RoundedRectangle(cornerRadius: 4)
78+
.fill(Color(NSColor.controlBackgroundColor))
79+
.stroke(Color(NSColor.separatorColor), lineWidth: 1)
80+
.shadow(color: .black.opacity(0.1), radius: 4)
81+
)
82+
}
83+
}

β€ŽMactrix/Views/ChatView/TimelineView/TimelineTableView.swiftβ€Ž

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ class TimelineViewController: NSViewController {
9292
var rowHeights: [Int: CGFloat] = [:]
9393
private var pendingHeightUpdate = false
9494

95+
private let hoverPanel = HoverActionsPanel()
96+
private var hideTimer: Timer?
97+
9598
init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) {
9699
self.coordinator = coordinator
97100
self.timeline = timeline
@@ -154,6 +157,11 @@ class TimelineViewController: NSViewController {
154157
tableView.backgroundColor = .clear
155158
view = scrollView
156159

160+
// Hover actions panel
161+
tableView.onHoveredRowChanged = { [weak self] row in
162+
self?.handleHoveredRowChanged(row)
163+
}
164+
157165
// Subscribe to view resize notifications
158166
scrollView.contentView.postsBoundsChangedNotifications = true
159167
NotificationCenter.default.addObserver(
@@ -163,12 +171,30 @@ class TimelineViewController: NSViewController {
163171
object: scrollView.contentView
164172
)
165173

174+
NotificationCenter.default.addObserver(
175+
self,
176+
selector: #selector(windowDidResignKey),
177+
name: NSWindow.didResignKeyNotification,
178+
object: nil
179+
)
180+
166181
listenForFocusTimelineItem()
167182
}
168183

184+
@objc func windowDidResignKey(_ notification: Notification) {
185+
dismissHoverPanel()
186+
}
187+
188+
func dismissHoverPanel() {
189+
hoverPanel.orderOut(nil)
190+
timeline.hoveredEventId = nil
191+
}
192+
169193
var timelineFetchTask: Task<Void, Never>?
170194

171195
@objc func viewDidScroll(_ notification: Notification) {
196+
dismissHoverPanel()
197+
172198
let currentOffset = scrollView.contentView.bounds.origin.y
173199
let timelineHeight = scrollView.contentView.documentRect.height
174200
let viewHeight = scrollView.contentView.documentVisibleRect.height
@@ -212,6 +238,71 @@ class TimelineViewController: NSViewController {
212238
fatalError("init(coder:) is not available")
213239
}
214240

241+
// MARK: - Hover actions panel
242+
243+
private func handleHoveredRowChanged(_ row: Int?) {
244+
hideTimer?.invalidate()
245+
hideTimer = nil
246+
247+
guard let row,
248+
case .message(let event, _) = timelineItems[row].rowInfo else {
249+
// Delay hiding so mouse can move from row to panel
250+
hideTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
251+
guard let self else { return }
252+
if !self.hoverPanel.isMouseInside {
253+
self.dismissHoverPanel()
254+
}
255+
}
256+
return
257+
}
258+
259+
let timeline = self.timeline
260+
let windowState = self.coordinator.windowState
261+
262+
timeline.hoveredEventId = event.eventOrTransactionId
263+
hoverPanel.update(
264+
eventId: event.eventOrTransactionId.id,
265+
onReaction: { key in
266+
Task {
267+
guard let inner = timeline.timeline else { return }
268+
do {
269+
let _ = try await inner.toggleReaction(itemId: event.eventOrTransactionId, key: key)
270+
} catch {
271+
Logger.timelineTableView.error("Failed to toggle reaction: \(error)")
272+
}
273+
}
274+
},
275+
onReply: {
276+
NSLog("HOVER: Reply tapped for event \(event.eventOrTransactionId.id)")
277+
timeline.sendReplyTo = event
278+
},
279+
onReplyInThread: { windowState.focusThread(rootEventId: event.eventOrTransactionId.id) },
280+
onPin: {
281+
guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return }
282+
Task {
283+
do {
284+
let _ = try await timeline.timeline?.pinEvent(eventId: eventId)
285+
} catch {
286+
Logger.timelineTableView.error("Failed to pin message: \(error)")
287+
}
288+
}
289+
}
290+
)
291+
292+
// Position relative to the row, offset past profile header if present
293+
let rowRect = tableView.rect(ofRow: row)
294+
guard tableView.visibleRect.contains(CGPoint(x: rowRect.midX, y: rowRect.maxY)) else {
295+
return dismissHoverPanel()
296+
}
297+
let rowRectInWindow = tableView.convert(rowRect, to: nil)
298+
let profileHeaderOffset: CGFloat = shouldIncludeProfileHeader(at: row) ? 32 : 0
299+
if let window = tableView.window {
300+
hoverPanel.position(relativeTo: rowRectInWindow, in: window, topOffset: profileHeaderOffset)
301+
window.addChildWindow(hoverPanel, ordered: .above)
302+
hoverPanel.orderFront(nil)
303+
}
304+
}
305+
215306
enum TimelineSection {
216307
case main
217308
case typingIndicator
@@ -241,6 +332,13 @@ class TimelineViewController: NSViewController {
241332

242333
dataSource?.apply(snapshot, animatingDifferences: false)
243334

335+
// Refresh or dismiss the hover panel with updated event data
336+
if let hoveredRow = tableView.hoveredRow {
337+
handleHoveredRowChanged(hoveredRow)
338+
} else {
339+
dismissHoverPanel()
340+
}
341+
244342
// Re-measure visible rows after hosting views settle
245343
DispatchQueue.main.async { [weak self] in
246344
guard let self else { return }
@@ -298,6 +396,37 @@ class BottomStickyTableView: NSTableView {
298396
override var isFlipped: Bool {
299397
return false
300398
}
399+
400+
var onHoveredRowChanged: ((Int?) -> Void)?
401+
private var trackingArea: NSTrackingArea?
402+
private(set) var hoveredRow: Int? = nil
403+
404+
override func updateTrackingAreas() {
405+
super.updateTrackingAreas()
406+
if let existing = trackingArea { removeTrackingArea(existing) }
407+
let area = NSTrackingArea(
408+
rect: bounds,
409+
options: [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp],
410+
owner: self
411+
)
412+
addTrackingArea(area)
413+
trackingArea = area
414+
}
415+
416+
override func mouseMoved(with event: NSEvent) {
417+
let point = convert(event.locationInWindow, from: nil)
418+
let row = self.row(at: point)
419+
let newRow = row >= 0 ? row : nil
420+
if newRow != hoveredRow {
421+
hoveredRow = newRow
422+
onHoveredRowChanged?(newRow)
423+
}
424+
}
425+
426+
override func mouseExited(with event: NSEvent) {
427+
hoveredRow = nil
428+
onHoveredRowChanged?(nil)
429+
}
301430
}
302431

303432
class SelfSizingHostingView<Content: View>: NSHostingView<Content> {

0 commit comments

Comments
Β (0)