eleven / ocho Public

mac: ⌘R replies to the latest message, ⌘T reacts to it (#392) (#1421)

* mac: ⌘R replies to the latest message, ⌘T reacts to it (#392)

The web feed has had two keyboard message verbs since chat.js landed —
cmd/ctrl-R stages a reply on the newest message, cmd/ctrl-T opens the
reaction picker on the newest one that isn't yours, where 1–6 then fire
the six reactions. Neither ever existed on the Mac.

Both arrive as MENU items (Message ▸ Reply to / React to Latest Message)
for the same reason ⌘F is one: the composer is a real NSTextView and a
mini-app pane is a WKWebView, so a view-level key equivalent gets
swallowed, while a menu item wins the way ⌘C does.

⌘T's picker is a new view, not the existing right-click NSMenu — even
though that menu already carries the same Tapback row. NSMenu tracking
runs its own event loop and never routes through NSApplication.sendEvent,
so an NSEvent local monitor (the only way to claim bare digits ahead of
the composer's text view) is never called while a menu is up. The bar
captions each emoji with the digit that fires it, dismisses on Escape or
a click elsewhere, passes ⌘/⌃/⌥ combos straight through, and steps aside
on any other key WITHOUT eating it — so a member who meant to keep
typing loses neither the keystroke nor a beat.

Both verbs act on whatever is in front of the reader: the reply-thread
view's messages while it's open, the feed's otherwise. Their targets are
pure statics in LChatCore (latestReplyTarget / latestReactionTarget) so
iOS can share them and they're unit-tested — action lines are skipped
(no bubble to quote) and so are optimistic copies, whose synthetic ids
the server has never seen.

Mac-scoped by nature: these are ⌘-key verbs on the desktop surface. The
shared targets are in LChatCore ready for an iPad hardware-keyboard pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: the ⌘T bar's own clicks survive its click-outside dismissal

The monitor runs ahead of sendEvent, so dismissing on any mouse event
pulled the emoji buttons out from under the very click meant to press
one. Hit-test the bar's window frame (LiveFrame, read at event time)
and dismiss only for clicks ELSEWHERE — which also lets it go back to
mouse-DOWN, the web's pointerdown semantics.

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: ⌘T pops the real message menu, and 1–6 work inside it

Paul's call, and the simpler shape: ⌘T should show exactly what a
right-click shows — Tapback row, Reply, Copy, the lot — rather than a
bespoke reaction bar with digit captions under each emoji. The numbers
then belong to the MENU, however it was opened, so they live with it
(ReactionMenuKeys) instead of with the shortcut.

The bespoke bar existed because a local NSEvent monitor is widely held
not to fire during menu tracking (its own event loop, never through
sendEvent). Rather than take that on faith, the arming tries BOTH that
monitor and NSMenuDelegate's key-equivalent hook; whichever AppKit
honours first wins, the other no-ops, and each logs which it was — so
the loser can be deleted once a real keypress has settled it.

⌘T reaches the right bubble's catcher through a MessageMenuScope handed
down the Environment — one per surface, so a message showing in both the
feed and an open reply-thread view can't hand ⌘T whichever mounted last.
MessageBubble's call sites are untouched but for the message id.

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: the Tapback row becomes a palette submenu, so keys reach it

James asked what it would take for the arrow keys to walk the reaction
row like they walk every other menu item. The answer turned out to
dissolve the whole problem.

The row was one NSMenuItem wearing an NSHostingView, and AppKit skips
view-bearing items entirely when it matches key equivalents — proven
with three probe items (hidden: no; zero-height: no; plainly visible:
YES). So no key could ever reach the strip, by any route: not a local
event monitor, not the NSMenuDelegate hook, neither of which fires
inside a tracking menu at all.

macOS 14's palette presentation gives a horizontal row built from
ORDINARY menu items. Arrows walk it, Return picks, and bare 1…6 fire as
key equivalents — with no number shown anywhere, since a palette has no
shortcut column. Same menu from ⌘T and from a right-click, which is
what was asked for.

It also settles an inconsistency: MessageBubble already builds this row
as a ControlGroup(.palette) for the iOS contextMenu path, emoji
rasterised to image labels and all. The Mac was the odd one out. Both
paths now use the one construct and the one emojiImage rasteriser.

Deleted along the way: the bespoke reaction bar, the two dead key
routes, the probes, and the hosted SwiftUI ReactionRow.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam pushed by codex agent, operated by paul@keymail.dev 06d2ef85cd24f8c46799b5cfc0dd4db8ec6da871 parent a0ee4ca
6 files changed, +242 −52
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +18 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +5 −2
  • ios/LChatCore/Sources/LChatCore/MessageRightClick.swift +85 −50
  • ios/LChatCore/Tests/LChatCoreTests/LatestMessageHotkeyTests.swift +61 −0
  • ios/LChatMac/LChatMacApp.swift +16 −0
  • ios/LChatMac/NativeFeedView.swift +57 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index b13f706..2e1f8f2 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1746,6 +1746,24 @@ public final class ChatFeedModel: ObservableObject {
Self.replyTarget(in: messages, for: m)
}
+ /// What ⌘R replies to: the newest real message, yours or anyone else's
+ /// (chat.js's `latest(false)`). Action lines are skipped — a /me-style
+ /// line has no bubble to quote — and so are optimistic copies: their id is
+ /// a synthetic pending one, so a reply staged on it would carry a sid the
+ /// server has never heard of. (The web has that wart; there's no reason to
+ /// port it.)
+ nonisolated public static func latestReplyTarget(in list: [Message]) -> Message? {
+ list.last { !$0.action && !$0.pending }
+ }
+
+ /// What ⌘T reacts to: the newest message that ISN'T yours (chat.js's
+ /// `latest(true)`) — reacting to your own message is what the right-click
+ /// menu is for. Attribution falls back to `mine`, which already resolves
+ /// uid-less legacy messages by name.
+ nonisolated public static func latestReactionTarget(in list: [Message]) -> Message? {
+ list.last { !$0.action && !$0.pending && !$0.mine }
+ }
+
/// The reply thread rooted at `rootID`: the root message (when loaded)
/// first, then every message whose RESOLVED thread root is that id, in
/// server order. Legacy chains (a reply quoting another reply) resolve
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index acbc0c0..ac6b6c0 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -331,7 +331,8 @@ public struct MessageBubble: View {
if let actions = owner.actions, !owner.message.action {
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
content.overlay {
- MessageRightClick(actions: actions, mine: owner.message.mine,
+ MessageRightClick(id: owner.message.id, actions: actions,
+ mine: owner.message.mine,
timestamp: owner.hoverTime)
}
#else
@@ -384,8 +385,10 @@ public struct MessageBubble: View {
}
// Rasterise an emoji for the palette row (image labels only), cached.
+ // Internal, not private: the Mac's NSMenu builds the same palette row out
+ // of real menu items (MessageRightClick) and needs the same glyphs.
private static var emojiImages: [String: PlatformImage] = [:]
- private static func emojiImage(_ emoji: String) -> PlatformImage {
+ static func emojiImage(_ emoji: String) -> PlatformImage {
if let hit = emojiImages[emoji] { return hit }
let side = 36.0 // @2x for a crisp ~18pt menu glyph
let attrs: [NSAttributedString.Key: Any] = [.font: PlatformFont.systemFont(ofSize: 28)]
diff --git a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
index 599c604..a2fe186 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
@@ -2,25 +2,34 @@
import AppKit
import SwiftUI
-// The Mac bubble menu, Messages-style: a real NSMenu with the Tapback emoji
-// row hosted as a custom view item (NSMenuItem.view — the same trick Messages
-// uses). It exists because SwiftUI's .contextMenu and selectable text can't
+// The Mac bubble menu, Messages-style: a real NSMenu whose Tapback emoji row
+// is a macOS 14 palette submenu (see buildMenu). It exists because SwiftUI's
+// .contextMenu and selectable text can't
// coexist — the text system claims right-click for Look Up/Translate — so a
// transparent catcher overlays the bubble, passes every LEFT event through
// (drag-to-select keeps working underneath) and claims right/ctrl-click only.
struct MessageRightClick: NSViewRepresentable {
+ let id: Int64
let actions: MessageActions
let mine: Bool
let timestamp: String
func makeNSView(context: Context) -> RightClickCatcher {
let view = RightClickCatcher()
- view.makeMenu = { Self.buildMenu(actions: actions, mine: mine, timestamp: timestamp) }
+ wire(view, context: context)
return view
}
func updateNSView(_ view: RightClickCatcher, context: Context) {
+ wire(view, context: context)
+ }
+
+ // The catcher carries what it needs to pop the menu on its own — from a
+ // right-click, or from ⌘T reaching it through the surrounding surface's
+ // MessageMenuScope (issue #392).
+ private func wire(_ view: RightClickCatcher, context: Context) {
view.makeMenu = { Self.buildMenu(actions: actions, mine: mine, timestamp: timestamp) }
+ context.environment.messageMenuScope?.register(id, view)
}
static func buildMenu(actions: MessageActions, mine: Bool, timestamp: String) -> NSMenu {
@@ -28,18 +37,29 @@ struct MessageRightClick: NSViewRepresentable {
menu.autoenablesItems = false
if let react = actions.onReact {
- let item = NSMenuItem()
- // [weak menu]: menu → item → hosting view → row → closure → menu
- // is otherwise a closed strong cycle, leaking one menu (and its
- // captured actions) per right-click.
- let row = ReactionRow(onReact: { [weak menu] emoji in
- menu?.cancelTracking()
- react(emoji)
- })
- let host = NSHostingView(rootView: row)
- host.frame = NSRect(x: 0, y: 0, width: 6 * 38 + 16, height: 42)
- item.view = host
- menu.addItem(item)
+ // The Tapback row, as a macOS 14 PALETTE submenu: presentation
+ // style .palette lays its items out in a horizontal strip, and —
+ // the whole point — they are ORDINARY menu items rather than a
+ // hosted custom view. So AppKit treats them like any other item:
+ // ← → walk along the row, Return picks, and 1…6 fire as bare key
+ // equivalents. A custom-view row could never take a key at all
+ // (proven: AppKit skips view-bearing items when matching), which
+ // is why the emoji are rasterised into image labels here exactly
+ // as the SwiftUI ControlGroup palette does on iOS.
+ let palette = NSMenu()
+ palette.presentationStyle = .palette
+ for (index, emoji) in ChatFeedModel.allowedReactions.enumerated() {
+ let item = ClosureMenuItem(title: emoji, handler: { react(emoji) })
+ item.image = MessageBubble.emojiImage(emoji)
+ item.keyEquivalent = "\(index + 1)"
+ // A BARE digit: ⌘1…⌘9 already switch spaces. A palette has no
+ // shortcut column, so the number never shows — it just works.
+ item.keyEquivalentModifierMask = []
+ palette.addItem(item)
+ }
+ let row = NSMenuItem()
+ row.submenu = palette
+ menu.addItem(row)
menu.addItem(.separator())
}
if let reply = actions.onReply {
@@ -102,6 +122,55 @@ final class RightClickCatcher: NSView {
guard let menu = makeMenu?() else { return }
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
+
+ // ⌘T's entry (issue #392): the SAME menu a right-click gives, hung off the
+ // bubble's bottom-left instead of the pointer, so the Tapback row lands
+ // right under the message.
+ func popFromKeyboard() {
+ guard let menu = makeMenu?() else { return }
+ menu.popUp(positioning: nil, at: .zero, in: self)
+ }
+}
+
+// 🤖 One feed surface's message menus, addressed by message id — how ⌘T
+// reaches the right bubble's catcher without a global registry that two
+// mounted panes (or a feed and its open reply-thread view) could collide in.
+// Handed down the Environment, so MessageBubble's call sites are untouched.
+@MainActor
+public final class MessageMenuScope {
+ private struct Weak { weak var view: RightClickCatcher? }
+ private var catchers: [Int64: Weak] = [:]
+
+ public init() {}
+
+ func register(_ id: Int64, _ view: RightClickCatcher) {
+ catchers[id] = Weak(view: view)
+ }
+
+ /// Pop the message menu for `id` exactly as a right-click would.
+ /// False when that message has no catcher on screen — it scrolled out of
+ /// the lazy stack, or it's an action line, which has no menu at all.
+ @discardableResult
+ public func popUpMenu(for id: Int64) -> Bool {
+ // Rows are recycled constantly; prune on the way past rather than
+ // asking every torn-down bubble to unregister itself.
+ catchers = catchers.filter { $0.value.view?.window != nil }
+ guard let view = catchers[id]?.view else { return false }
+ view.popFromKeyboard()
+ return true
+ }
+}
+
+private struct MessageMenuScopeKey: EnvironmentKey {
+ static let defaultValue: MessageMenuScope? = nil
+}
+
+public extension EnvironmentValues {
+ /// The surface whose message menus ⌘T can open (nil = no keyboard route).
+ var messageMenuScope: MessageMenuScope? {
+ get { self[MessageMenuScopeKey.self] }
+ set { self[MessageMenuScopeKey.self] = newValue }
+ }
}
private final class ClosureMenuItem: NSMenuItem {
@@ -118,38 +187,4 @@ private final class ClosureMenuItem: NSMenuItem {
@objc private func fire() { handler() }
}
-// The Tapback-style row: the six-emoji allowlist, hover-highlighted circles.
-private struct ReactionRow: View {
- let onReact: (String) -> Void
-
- var body: some View {
- HStack(spacing: 4) {
- ForEach(ChatFeedModel.allowedReactions, id: \.self) { emoji in
- ReactionButton(emoji: emoji) { onReact(emoji) }
- }
- }
- .padding(.horizontal, 8)
- .frame(maxHeight: .infinity)
- }
-
- private struct ReactionButton: View {
- let emoji: String
- let action: () -> Void
- @State private var hovering = false
-
- var body: some View {
- Button(action: action) {
- Text(emoji)
- .font(.system(size: 22))
- .frame(width: 34, height: 34)
- .background(Circle().fill(hovering ? Color.primary.opacity(0.12) : .clear))
- .contentShape(Circle())
- }
- .buttonStyle(.plain)
- .onHover { hovering = $0 }
- .scaleEffect(hovering ? 1.15 : 1)
- .animation(.spring(response: 0.2, dampingFraction: 0.7), value: hovering)
- }
- }
-}
#endif
diff --git a/ios/LChatCore/Tests/LChatCoreTests/LatestMessageHotkeyTests.swift b/ios/LChatCore/Tests/LChatCoreTests/LatestMessageHotkeyTests.swift
new file mode 100644
index 0000000..a6f486f
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/LatestMessageHotkeyTests.swift
@@ -0,0 +1,61 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The two keyboard message verbs' targets (issue #392): ⌘R replies to the
+// latest message, ⌘T reacts to the latest one that ISN'T yours. Web twin:
+// chat.js's `latest(othersOnly)` behind its cmd/ctrl-R and -T handlers.
+final class LatestMessageHotkeyTests: XCTestCase {
+ typealias Message = ChatFeedModel.Message
+
+ private func message(_ id: Int64, _ text: String, mine: Bool,
+ action: Bool = false, pending: Bool = false) -> Message {
+ var m = Message(id: id, name: mine ? "Me" : "Alice", text: text, mine: mine,
+ at: 1_700_000_000 + id, action: action, edited: false)
+ m.pending = pending
+ return m
+ }
+
+ func testReplyTargetsTheNewestMessageEvenWhenItsYours() {
+ let list = [
+ message(1, "Morning", mine: false),
+ message(2, "Morning!", mine: true),
+ ]
+ // The issue's wording: "even if it's from you".
+ XCTAssertEqual(ChatFeedModel.latestReplyTarget(in: list)?.id, 2)
+ }
+
+ func testReplySkipsActionLinesAndOptimisticCopies() {
+ let list = [
+ message(1, "Real message", mine: false),
+ message(2, "added the To-do app", mine: true, action: true),
+ message(3, "not acknowledged yet", mine: true, pending: true),
+ ]
+ // An action line has no bubble to quote, and a pending copy's id is a
+ // synthetic one the server has never seen.
+ XCTAssertEqual(ChatFeedModel.latestReplyTarget(in: list)?.id, 1)
+ }
+
+ func testReplyTargetIsNilWithNothingToReplyTo() {
+ XCTAssertNil(ChatFeedModel.latestReplyTarget(in: []))
+ XCTAssertNil(ChatFeedModel.latestReplyTarget(in: [
+ message(1, "set the group photo", mine: false, action: true),
+ ]))
+ }
+
+ func testReactionTargetsTheNewestMessageThatIsntYours() {
+ let list = [
+ message(1, "Morning", mine: false),
+ message(2, "Standup in five", mine: false),
+ message(3, "On my way", mine: true),
+ ]
+ XCTAssertEqual(ChatFeedModel.latestReactionTarget(in: list)?.id, 2)
+ }
+
+ func testReactionTargetIsNilWhenEveryMessageIsYours() {
+ let list = [
+ message(1, "Anyone about?", mine: true),
+ message(2, "Never mind", mine: true),
+ ]
+ XCTAssertNil(ChatFeedModel.latestReactionTarget(in: list))
+ }
+}
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 7f84ce5..4121aee 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -122,6 +122,17 @@ struct LChatMacApp: App {
.keyboardShortcut("0", modifiers: .command)
Divider()
}
+ // The keyboard's two message verbs (issue #392), the web feed's
+ // ⌘R / ⌘T. Menu items for the same reason ⌘F is one: the composer
+ // is a real NSTextView and a mini-app pane is a WKWebView, and
+ // either would swallow a view-level key equivalent — a menu item
+ // wins first, the way ⌘C does.
+ CommandMenu("Message") {
+ Button("Reply to Latest Message") { app.replyLatestTick += 1 }
+ .keyboardShortcut("r", modifiers: .command)
+ Button("React to Latest Message") { app.reactLatestTick += 1 }
+ .keyboardShortcut("t", modifiers: .command)
+ }
// ⌘1…⌘9 jump straight to a space, like browser/Slack tabs. The
// menu mirrors the rail: favourites (else the first nine active),
// the rest under More, and the two ways a space arrives.
@@ -278,6 +289,11 @@ final class MacAppState: ObservableObject {
// counter moves ±1), and any change is one step — the active NativeChatView
// observes it.
@Published var cycleChatTick = 0
+ // Message ▸ Reply to / React to Latest Message (⌘R / ⌘T, issue #392).
+ // Ticks like the two above — pressing the same shortcut twice must fire
+ // twice — consumed by whichever native feed is on screen.
+ @Published var replyLatestTick = 0
+ @Published var reactLatestTick = 0
// File → New Space (and anything else app-level) asks for the create flow
// by bumping this; RootView owns the takeover and opens on the tick.
@Published var createSpaceTick = 0
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index bf274df..c1a67c3 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -262,6 +262,15 @@ struct NativeFeedView: View {
// threadFallback (chat.js openThread's ref argument).
@State private var threadRootID: Int64?
@State private var threadFallback: ReplyRef?
+ // This surface's message menus, keyed by message id, so ⌘T can pop the one
+ // belonging to the right bubble (issue #392). A plain reference handed down
+ // the Environment: the catchers register themselves as they mount, and
+ // nothing here ever observes it.
+ // One per surface, never shared: a message showing in BOTH the feed and an
+ // open reply-thread view has a catcher in each, and a single scope would
+ // hand ⌘T whichever mounted last.
+ @State private var menuScope = MessageMenuScope()
+ @State private var threadMenuScope = MessageMenuScope()
// Messages' "Show Times": how far the time gutter is revealed (0…44).
// Driven continuously by two-finger horizontal swipes over the feed and
// snapped open/closed on release, or toggled whole from the context menu.
@@ -467,6 +476,9 @@ struct NativeFeedView: View {
})
}
.coordinateSpace(name: "feed")
+ // Every bubble's menu catcher registers itself here as it mounts,
+ // so ⌘T can pop the right one (issue #392).
+ .environment(\.messageMenuScope, menuScope)
.background(GeometryReader { g in
Color.clear
.onAppear {
@@ -539,6 +551,36 @@ struct NativeFeedView: View {
// Search hits land on their message: page history until the
// target is loaded, then jump-and-wave.
.onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) }
+ // ⌘R / ⌘T (Message ▸ Reply to / React to Latest Message, issue
+ // #392) — the web feed's two keyboard message verbs, which the Mac
+ // never had. Both act on the surface in FRONT of the reader: the
+ // reply-thread view's messages while it's open, the feed's
+ // otherwise.
+ .onChange(of: app.replyLatestTick) { _, _ in
+ guard isActive, !model.dmClosed,
+ let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return }
+ startReply(m)
+ }
+ .onChange(of: app.reactLatestTick) { _, _ in
+ guard isActive,
+ let m = ChatFeedModel.latestReactionTarget(in: hotkeyMessages) else { return }
+ // The menu hangs off the bubble, so the bubble has to be on
+ // screen — and materialised at all, or there's no catcher to
+ // pop it from. A feed scrolled back into history scrolls first
+ // and pops once the row has landed. (The thread view scrolls
+ // itself and opens at the bottom, so it's exempt.)
+ let scope = threadRootID == nil ? menuScope : threadMenuScope
+ if threadRootID == nil, !rowOnScreen(m.id) {
+ withAnimation { proxy.scrollTo(m.id, anchor: .bottom) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ scope.popUpMenu(for: m.id)
+ }
+ return
+ }
+ // Never straight from the onChange: popping runs a modal
+ // tracking loop, and this one is still inside a view update.
+ DispatchQueue.main.async { scope.popUpMenu(for: m.id) }
+ }
// A Text Size change (⌘+/⌘−, Settings slider) relayouts every row
// at once; a shrink can leave the scroll offset clamped past the
// new content end — a fully blank feed, the same wedge the
@@ -645,6 +687,7 @@ struct NativeFeedView: View {
.overlay {
if let rootID = threadRootID {
threadOverlay(rootID)
+ .environment(\.messageMenuScope, threadMenuScope)
.transition(.opacity.combined(with: .offset(y: 12)))
}
}
@@ -1206,6 +1249,20 @@ struct NativeFeedView: View {
.jumpWave(on: m.id == danceID, tick: danceTick)
}
+ // The list ⌘R / ⌘T read — see the tick handlers above.
+ private var hotkeyMessages: [ChatFeedModel.Message] {
+ guard let rootID = threadRootID else { return model.messages }
+ return ChatFeedModel.threadItems(in: model.messages, root: rootID)
+ }
+
+ // Materialised (an unrealised row has no catcher to pop a menu from) and
+ // wholly inside the viewport, so the menu hangs off a bubble the reader
+ // can see.
+ private func rowOnScreen(_ id: Int64) -> Bool {
+ guard let f = frames.rows[id] else { return false }
+ return f.minY >= 0 && f.maxY <= frames.viewport.height
+ }
+
// The native composer — the first native SEND path: the message is
// encrypted in Swift (ChatFeedModel.send) and framed straight onto the
// native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines.