@@ -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
303432class SelfSizingHostingView < Content: View > : NSHostingView < Content > {
0 commit comments