@@ -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,38 @@ 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+
193+ private func repositionHoverPanel( ) {
194+ if let hoveredRow = tableView. hoveredRow {
195+ handleHoveredRowChanged ( hoveredRow)
196+ } else {
197+ dismissHoverPanel ( )
198+ }
199+ }
200+
169201 var timelineFetchTask : Task < Void , Never > ?
170202
171203 @objc func viewDidScroll( _ notification: Notification ) {
204+ dismissHoverPanel ( )
205+
172206 let currentOffset = scrollView. contentView. bounds. origin. y
173207 let timelineHeight = scrollView. contentView. documentRect. height
174208 let viewHeight = scrollView. contentView. documentVisibleRect. height
@@ -212,6 +246,72 @@ class TimelineViewController: NSViewController {
212246 fatalError ( " init(coder:) is not available " )
213247 }
214248
249+ // MARK: - Hover actions panel
250+
251+ private func handleHoveredRowChanged( _ row: Int ? ) {
252+ hideTimer? . invalidate ( )
253+ hideTimer = nil
254+
255+ guard let row,
256+ case . message( let event, _) = timelineItems [ row] . rowInfo else {
257+ // Delay hiding so mouse can move from row to panel
258+ hideTimer = Timer . scheduledTimer ( withTimeInterval: 0.15 , repeats: false ) { [ weak self] _ in
259+ guard let self else { return }
260+ if !self . hoverPanel. isMouseInside {
261+ self . dismissHoverPanel ( )
262+ }
263+ }
264+ return
265+ }
266+
267+ let timeline = self . timeline
268+ let windowState = self . coordinator. windowState
269+
270+ timeline. hoveredEventId = event. eventOrTransactionId
271+ hoverPanel. update (
272+ eventId: event. eventOrTransactionId. id,
273+ onReaction: { key in
274+ Task {
275+ guard let inner = timeline. timeline else { return }
276+ do {
277+ let _ = try await inner. toggleReaction ( itemId: event. eventOrTransactionId, key: key)
278+ } catch {
279+ Logger . timelineTableView. error ( " Failed to toggle reaction: \( error) " )
280+ }
281+ }
282+ } ,
283+ onReply: {
284+ timeline. sendReplyTo = event
285+ } ,
286+ onReplyInThread: { windowState. focusThread ( rootEventId: event. eventOrTransactionId. id) } ,
287+ onPin: {
288+ guard case let . eventId( eventId: eventId) = event. eventOrTransactionId else { return }
289+ Task {
290+ do {
291+ let _ = try await timeline. timeline? . pinEvent ( eventId: eventId)
292+ } catch {
293+ Logger . timelineTableView. error ( " Failed to pin message: \( error) " )
294+ }
295+ }
296+ }
297+ )
298+
299+ // Position relative to the row, offset past profile header if present
300+ let rowRect = tableView. rect ( ofRow: row)
301+ guard tableView. visibleRect. contains ( CGPoint ( x: rowRect. midX, y: rowRect. maxY) ) else {
302+ return dismissHoverPanel ( )
303+ }
304+ let rowRectInWindow = tableView. convert ( rowRect, to: nil )
305+ let profileHeaderOffset : CGFloat = shouldIncludeProfileHeader ( at: row) ? 32 : 0
306+ if let window = tableView. window {
307+ hoverPanel. position ( relativeTo: rowRectInWindow, in: window, topOffset: profileHeaderOffset)
308+ if hoverPanel. parent != window {
309+ window. addChildWindow ( hoverPanel, ordered: . above)
310+ }
311+ hoverPanel. orderFront ( nil )
312+ }
313+ }
314+
215315 enum TimelineSection {
216316 case main
217317 case typingIndicator
@@ -229,6 +329,7 @@ class TimelineViewController: NSViewController {
229329 if oldIds == newIds {
230330 tableView. reloadData ( forRowIndexes: IndexSet ( integersIn: 0 ..< self . timelineItems. count) ,
231331 columnIndexes: IndexSet ( integer: 0 ) )
332+ repositionHoverPanel ( )
232333 return
233334 }
234335
@@ -241,6 +342,8 @@ class TimelineViewController: NSViewController {
241342
242343 dataSource? . apply ( snapshot, animatingDifferences: false )
243344
345+ repositionHoverPanel ( )
346+
244347 // Re-measure visible rows after hosting views settle
245348 DispatchQueue . main. async { [ weak self] in
246349 guard let self else { return }
@@ -298,6 +401,37 @@ class BottomStickyTableView: NSTableView {
298401 override var isFlipped : Bool {
299402 return false
300403 }
404+
405+ var onHoveredRowChanged : ( ( Int ? ) -> Void ) ?
406+ private var trackingArea : NSTrackingArea ?
407+ private( set) var hoveredRow : Int ? = nil
408+
409+ override func updateTrackingAreas( ) {
410+ super. updateTrackingAreas ( )
411+ if let existing = trackingArea { removeTrackingArea ( existing) }
412+ let area = NSTrackingArea (
413+ rect: bounds,
414+ options: [ . mouseMoved, . mouseEnteredAndExited, . activeInActiveApp] ,
415+ owner: self
416+ )
417+ addTrackingArea ( area)
418+ trackingArea = area
419+ }
420+
421+ override func mouseMoved( with event: NSEvent ) {
422+ let point = convert ( event. locationInWindow, from: nil )
423+ let row = self . row ( at: point)
424+ let newRow = row >= 0 ? row : nil
425+ if newRow != hoveredRow {
426+ hoveredRow = newRow
427+ onHoveredRowChanged ? ( newRow)
428+ }
429+ }
430+
431+ override func mouseExited( with event: NSEvent ) {
432+ hoveredRow = nil
433+ onHoveredRowChanged ? ( nil )
434+ }
301435}
302436
303437class SelfSizingHostingView < Content: View > : NSHostingView < Content > {
0 commit comments