eleven / messenger Public

ios+mac: native reply thread view — tap a quote or tally to lift the thread (#461)

core: ChatFeedModel.threadItems(in:root:) + threadReplyRef(in:root:) mirror
chat.js's threadItems/threadFallback (root first when loaded, resolved-root
membership, server order; the staged reply / header stand-in for an unloaded
root), pinned by new ReplyThreadVectorTests mirroring the web's
threadItems test cases.

core: MessageBubble grows onOpenThread — quote-card and "N Replies" taps open
the thread on its resolved ROOT (legacy chains already chased via
threadRootID), with onJump kept as the belt when no thread view exists; a new
inReplyThread environment flag suppresses per-row quotes/tallies inside the
view (the web's `.chatapp-thread .reply-quote { display:none }`).

core: ReplyThreadView — the shared panel both platforms mount: frosted
material over a dimmed transcript (tap closes), "Thread" header with reply
count and ✕, the root pinned on top (height-capped ~40%, scrolling within
itself, quoted-snapshot fallback while unloaded), replies chronological below
via the container's own MessageBubble wiring (reactions/attachments/context
menus keep working), auto-follow only when already at the bottom, bounded
page-back hunting for an unloaded root with the inline loading note, and
close-on-whole-thread-deleted. Renders live from the model's published
messages through the pure selector — never a cached list.

ios: NativeChatScreen overlays the view over the feed box (header pill above,
real composer below staged to "Replying to <root author>"); swipe-down on the
header, ✕, or the dim closes; the keyboard is not popped on open.

mac: NativeFeedView overlays the feed pane; Escape closes via the reply
banner's existing .cancelAction. Both platforms: sending keeps the view open
and re-stages the root; cancelling the reply banner leaves the thread; an
edit borrows the composer and hands it back on completion — all web parity.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4c3046738027d23da721fd5140602f13cfe6a753 parent 0dae9d1
6 files changed, +676 −12
  • ios/LChat/Views/NativeChatScreen.swift +98 −2
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +40 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +41 −4
  • ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift +321 −0
  • ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift +63 −0
  • ios/LChatMac/NativeFeedView.swift +113 −6
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 5da2d14..d73758c 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -69,6 +69,11 @@ struct NativeChatScreen: View {
@State private var dcRouting = false
@State private var replyTo: ReplyRef?
@State private var editingID: Int64?
+ // The open reply-thread view's root id (nil = closed) and the header
+ // stand-in while that root isn't loaded — frozen at open, like the web's
+ // threadFallback (chat.js openThread's ref argument).
+ @State private var threadRootID: Int64?
+ @State private var threadFallback: ReplyRef?
@State private var pickerItem: PhotosPickerItem?
@State private var showFilePicker = false
@State private var staged: [StagedUpload] = []
@@ -187,6 +192,16 @@ struct NativeChatScreen: View {
var body: some View {
feed
+ // The thread view lifts over the FEED only (the web's
+ // .chatapp-feedwrap overlay) — applied before the safe-area
+ // insets, so the header pill stays above it and the real composer
+ // below stays live, staged to the thread's root.
+ .overlay {
+ if let rootID = threadRootID {
+ threadOverlay(rootID)
+ .transition(.opacity.combined(with: .offset(y: 12)))
+ }
+ }
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
@@ -520,6 +535,7 @@ struct NativeChatScreen: View {
MessageBubble(message: m, showSender: isGroup && startsRun,
theme: .named(themeName),
onJump: { target in jumpAndWave(target, proxy: proxy) },
+ onOpenThread: { root in openThread(root) },
blobSource: { model.blobSource($0) },
onImageTap: { att, image, _ in
viewerImage = IdentifiedImage(id: att.id, image: image)
@@ -599,6 +615,73 @@ struct NativeChatScreen: View {
}
}
+ // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
+
+ // The lifted thread panel over the dimmed feed. Rows reuse the exact
+ // MessageBubble wiring the feed uses, so reactions, attachments and the
+ // long-press menu keep working inside the view; reply actions in here
+ // resolve to the thread's ROOT via the same startReply path.
+ private func threadOverlay(_ rootID: Int64) -> some View {
+ ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback,
+ theme: .named(themeName), isGroup: isGroup,
+ swipeDownCloses: true,
+ onClose: closeThread,
+ onRootPaged: {
+ // The root just paged in — refresh the banner to its
+ // live snapshot, unless an edit borrowed the composer.
+ if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
+ },
+ row: { m, showSender in threadBubble(m, showSender: showSender) })
+ }
+
+ private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ MessageBubble(message: m, showSender: showSender,
+ theme: .named(themeName),
+ blobSource: { model.blobSource($0) },
+ onImageTap: { att, image, _ in
+ viewerImage = IdentifiedImage(id: att.id, image: image)
+ },
+ onVideoTap: { att, _ in playVideo(att) },
+ downloading: videoDownload,
+ actions: MessageActions(
+ canEdit: model.canEdit(m),
+ onReply: { startReply(m) },
+ onEdit: { startEdit(m) },
+ onDelete: { model.deleteMessage(m.id) },
+ onCopy: { UIPasteboard.general.string = m.text },
+ onReact: { model.toggleReaction(m.id, emoji: $0) }),
+ unfurl: { await model.unfurl($0) })
+ .environment(\.chatFontSize, chatFontSize)
+ }
+
+ private func openThread(_ rootID: Int64) {
+ guard rootID > 0, threadRootID != rootID else { return }
+ // Freeze the header stand-in NOW (web threadFallback) — the deepest
+ // quoted snapshot in loaded state, in case the root never pages in.
+ threadFallback = model.threadReplyRef(root: rootID)
+ withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
+ stageThreadReply()
+ }
+
+ // Stage the composer's reply on the thread's root — the view's composer
+ // contract ("Replying to <root author>"). A reply displaces an in-flight
+ // edit, but the keyboard is deliberately NOT popped: it would cover the
+ // thread the reader just opened (the web skips focus on touch too).
+ private func stageThreadReply() {
+ guard let rootID = threadRootID else { return }
+ editingID = nil
+ replyTo = model.threadReplyRef(root: rootID)
+ }
+
+ private func closeThread() {
+ guard let rootID = threadRootID else { return }
+ withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
+ threadFallback = nil
+ // Leaving the thread drops its staged reply — never an unrelated one
+ // staged since (an edit clears replyTo itself, so that survives).
+ if replyTo?.id == rootID { replyTo = nil }
+ }
+
// Scroll to a message and wave it — quote-taps and search hits share this.
private func jumpAndWave(_ id: Int64, proxy: ScrollViewProxy) {
// Animated only for NEAR jumps: animating across hundreds of lazily
@@ -706,7 +789,13 @@ struct NativeChatScreen: View {
.padding(.horizontal, 16)
}
if let replyTo {
- banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) { self.replyTo = nil }
+ banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) {
+ // While the thread view is open its staged reply IS the
+ // view's composer state — cancelling it means leaving the
+ // thread (web parity), not stranding an open thread whose
+ // sends would land outside it.
+ if threadRootID != nil { closeThread() } else { self.replyTo = nil }
+ }
}
if editingID != nil {
banner("Editing message", detail: nil) { cancelEdit() }
@@ -871,7 +960,10 @@ struct NativeChatScreen: View {
if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
staged = []
- replyTo = nil
+ // Sending from an open thread view keeps it open and re-stages the
+ // reply to its root — the new reply folds in live and the next
+ // send continues the same thread, like iMessage (web parity).
+ if threadRootID != nil { stageThreadReply() } else { replyTo = nil }
}
}
@@ -894,6 +986,10 @@ struct NativeChatScreen: View {
private func cancelEdit() {
editingID = nil
draft = ""
+ // An edit inside an open thread view borrowed the composer (startEdit
+ // clears replyTo); hand it back so the next send still continues the
+ // thread (web cancelEdit).
+ if threadRootID != nil { stageThreadReply() }
}
private func banner(_ title: String, detail: String?, onCancel: @escaping () -> Void) -> some View {
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 7142f5f..c00d067 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1058,6 +1058,46 @@ public final class ChatFeedModel: ObservableObject {
Self.replyTarget(in: messages, for: m)
}
+ /// 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
+ /// transitively via threadRoots, so they land in the same thread as their
+ /// ancestors; when the root itself has scrolled out of the loaded window
+ /// the replies still fold (the view renders the quoted snapshot as its
+ /// header instead). Mirrors chat.js's threadItems — the thread view
+ /// re-derives this from current state on every publish, never a cached
+ /// list. Pinned by the web twin in test/apps.test.mjs.
+ nonisolated public static func threadItems(in list: [Message], root rootID: Int64) -> [Message] {
+ let roots = threadRoots(list)
+ var out: [Message] = []
+ if let root = list.first(where: { $0.id == rootID }) { out.append(root) }
+ out.append(contentsOf: list.filter { roots[$0.id] == rootID })
+ return out.sorted { $0.id < $1.id }
+ }
+
+ /// The ReplyRef the open thread view stages on the composer (always the
+ /// thread's root) — doubling as the header stand-in while the root isn't
+ /// loaded. A loaded root snapshots its name/snippet fresh; otherwise fall
+ /// back to the deepest quoted ref any loaded member of the thread carries
+ /// (the web hands openThread the tapped message's replyTarget as this
+ /// fallback; natively the tap only carries the root id, so it's re-derived
+ /// from state).
+ nonisolated public static func threadReplyRef(in list: [Message], root rootID: Int64) -> ReplyRef {
+ if let root = list.first(where: { $0.id == rootID }) {
+ return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
+ }
+ let roots = threadRoots(list)
+ if let m = list.first(where: { roots[$0.id] == rootID }) {
+ return replyTarget(in: list, for: m)
+ }
+ return ReplyRef(id: rootID)
+ }
+
+ /// Live convenience for the containers' openThread/stageThreadReply.
+ public func threadReplyRef(root rootID: Int64) -> ReplyRef {
+ Self.threadReplyRef(in: messages, root: rootID)
+ }
+
// Re-derive each message's reply-thread tally (chat.js's replyCounts fold):
// every reply counts toward its resolved ROOT, so a legacy chain tallies on
// the thread's top message. Runs once per batch, before the list publishes.
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 359bd31..9ee2d3a 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -33,6 +33,20 @@ public extension EnvironmentValues {
}
}
+// Set by ReplyThreadView on its rows: inside the thread view every row already
+// IS the thread, so a per-row quote card or "N Replies" tally would only point
+// back at where the reader is standing — suppress both (the web's
+// `.chatapp-thread .reply-quote, .reply-count { display: none }`).
+private struct InReplyThreadKey: EnvironmentKey {
+ static let defaultValue = false
+}
+public extension EnvironmentValues {
+ var inReplyThread: Bool {
+ get { self[InReplyThreadKey.self] }
+ set { self[InReplyThreadKey.self] = newValue }
+ }
+}
+
// The action catalog a bubble offers — one definition feeding both the hover
// strip and the right-click menu (the design doc's "consistent actions").
public struct MessageActions {
@@ -70,6 +84,11 @@ public struct MessageBubble: View {
/// Jump to another message (a quote's parent, or the first reply) — the
/// container owns scrolling, the leaf just asks.
public let onJump: ((Int64) -> Void)?
+ /// Open the reply-thread view on a root message id. When set, a quote-card
+ /// or "N Replies" tap lifts the whole thread (resolved to its ROOT) instead
+ /// of jumping the feed; onJump stays the belt for containers without a
+ /// thread view (matching the web's scrollToMessage fallback).
+ public let onOpenThread: ((Int64) -> Void)?
/// Resolve an attachment to its encrypted-blob location + key (the feed
/// model owns the thread key; the leaf just renders what it's given).
public let blobSource: ((Attachment) -> AvatarSource?)?
@@ -103,9 +122,11 @@ public struct MessageBubble: View {
@Environment(\.colorScheme) private var scheme
@Environment(\.chatFontSize) private var fontSize
+ @Environment(\.inReplyThread) private var inReplyThread
public init(message: ChatFeedModel.Message, showSender: Bool,
theme: ChatTheme = .classic, onJump: ((Int64) -> Void)? = nil,
+ onOpenThread: ((Int64) -> Void)? = nil,
blobSource: ((Attachment) -> AvatarSource?)? = nil,
onImageTap: ((Attachment, PlatformImage, CGRect) -> Void)? = nil,
onFileTap: ((Attachment) -> Void)? = nil,
@@ -120,6 +141,7 @@ public struct MessageBubble: View {
self.showSender = showSender
self.theme = theme
self.onJump = onJump
+ self.onOpenThread = onOpenThread
self.blobSource = blobSource
self.onImageTap = onImageTap
self.onFileTap = onFileTap
@@ -141,7 +163,7 @@ public struct MessageBubble: View {
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
}
- if let reply = message.reply, reply.name != nil || reply.snippet != nil {
+ if !inReplyThread, let reply = message.reply, reply.name != nil || reply.snippet != nil {
replyQuote(reply)
}
ForEach(message.attachments, id: \.id) { att in
@@ -184,7 +206,7 @@ public struct MessageBubble: View {
.foregroundStyle(.tertiary)
.padding(.horizontal, 6)
}
- if message.replyCount > 0 {
+ if !inReplyThread, message.replyCount > 0 {
repliesTag
}
}
@@ -271,7 +293,15 @@ public struct MessageBubble: View {
// ellipsized snippet; tapping jumps to the quoted message.
private func replyQuote(_ reply: ReplyRef) -> some View {
Button {
- if let id = reply.id, id > 0 { onJump?(id) }
+ // A quote tap opens the whole thread, anchored on its resolved
+ // ROOT (the quoted ref may name a mid-chain parent on a legacy
+ // reply — threadRootID already chased the chain). Jump-to-parent
+ // stays the belt for a container without a thread view.
+ if let open = onOpenThread, let root = message.threadRootID ?? reply.id, root > 0 {
+ open(root)
+ } else if let id = reply.id, id > 0 {
+ onJump?(id)
+ }
} label: {
VStack(alignment: message.mine ? .trailing : .leading, spacing: 1) {
if let name = reply.name, !name.isEmpty {
@@ -297,7 +327,14 @@ public struct MessageBubble: View {
// reply (style.css .reply-count, link-coloured).
private var repliesTag: some View {
Button {
- if let id = message.firstReplyID { onJump?(id) }
+ // The tally only renders under a loaded root — this message IS the
+ // thread's root, so open the view on it (web buildReplyCount);
+ // jump-to-first-reply stays the no-thread-view belt.
+ if let open = onOpenThread {
+ open(message.id)
+ } else if let id = message.firstReplyID {
+ onJump?(id)
+ }
} label: {
Text(message.replyCount == 1 ? "1 Reply" : "\(message.replyCount) Replies")
.font(.system(size: 11, weight: .semibold))
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift
new file mode 100644
index 0000000..0f58959
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift
@@ -0,0 +1,321 @@
+import SwiftUI
+
+// The lifted reply-thread view — the native twin of chat.js's chatapp-thread
+// overlay, shared by the iOS and Mac containers: the thread's root pinned on
+// top (height-capped so a long one can't swallow the replies), its replies
+// chronological below, over a dimmed transcript. The panel is frosted material
+// so the conversation stays visible through it, like the web's
+// backdrop-filter glass.
+//
+// Split of responsibilities: the CONTAINER owns presentation (where the
+// overlay sits, Escape/swipe wiring) and the composer contract (staging the
+// root reply, re-staging after sends/edits, closing on banner-cancel); this
+// view owns the panel. It renders LIVE from the model's published messages
+// through the pure threadItems selector — never a cached list — so replies
+// fold in as they arrive; it pages history backward looking for an unloaded
+// root (capped, with the inline loading note, falling back to the quoted
+// snapshot as its header); and it closes itself when the whole thread is
+// deleted underneath it.
+public struct ReplyThreadView<Row: View>: View {
+ @ObservedObject private var model: ChatFeedModel
+ private let rootID: Int64
+ /// Frozen at open (the web's threadFallback): the header stand-in while
+ /// the root message isn't in loaded history.
+ private let fallback: ReplyRef?
+ private let theme: ChatTheme
+ private let isGroup: Bool
+ /// iOS opts in: dragging the header down rides the panel with the finger
+ /// and releases into a close — the sheet gesture readers expect on touch.
+ private let swipeDownCloses: Bool
+ private let onClose: () -> Void
+ /// Paging just surfaced the root — the container refreshes its staged
+ /// reply banner to the live snapshot (web pageInRoot's tail).
+ private let onRootPaged: (() -> Void)?
+ /// One fully-wired bubble — the container passes the same MessageBubble
+ /// wiring its feed uses, so reactions, attachments, context menus and
+ /// lightboxes all keep working inside the view. Alignment, separators and
+ /// run-grouping are this view's job.
+ private let row: (ChatFeedModel.Message, _ showSender: Bool) -> Row
+
+ @Environment(\.colorScheme) private var scheme
+ // Paging history backward looking for the root (drives the inline note).
+ @State private var paging = false
+ // Whether the replies list sits at its bottom — gates the live auto-follow
+ // (a reader scrolled up a long thread isn't yanked down by an arrival).
+ @State private var bottomVisible = false
+ // The pinned root's measured content height, so its box hugs a short root
+ // instead of always spending the full cap.
+ @State private var rootHeight: CGFloat = 0
+ // Header-drag offset for the swipe-down close.
+ @State private var dragOffset: CGFloat = 0
+
+ public init(model: ChatFeedModel, rootID: Int64, fallback: ReplyRef?,
+ theme: ChatTheme, isGroup: Bool, swipeDownCloses: Bool = false,
+ onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil,
+ @ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) {
+ self.model = model
+ self.rootID = rootID
+ self.fallback = fallback
+ self.theme = theme
+ self.isGroup = isGroup
+ self.swipeDownCloses = swipeDownCloses
+ self.onClose = onClose
+ self.onRootPaged = onRootPaged
+ self.row = row
+ }
+
+ public var body: some View {
+ // Re-derived from current state on every model publish — the view
+ // never caches its list (chat.js refreshThread's rule).
+ let items = ChatFeedModel.threadItems(in: model.messages, root: rootID)
+ let rootLoaded = items.first?.id == rootID
+ let replies = rootLoaded ? Array(items.dropFirst()) : items
+ GeometryReader { geo in
+ ZStack {
+ // Lighter than a sheet dim: the frosted panel reads the
+ // transcript through itself, so the backdrop only needs to
+ // recede, not vanish. Tapping it leaves the thread.
+ Color.black.opacity(0.25)
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onClose)
+ panel(root: rootLoaded ? items.first : nil, replies: replies,
+ cap: geo.size.height * 0.4)
+ .padding(10)
+ .offset(y: dragOffset)
+ .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1)
+ }
+ }
+ // The whole thread got deleted under the open view — nothing left to
+ // show (chat.js refreshThread's close-on-empty).
+ .onChange(of: items.isEmpty) { _, empty in
+ if empty, !paging { onClose() }
+ }
+ .task(id: rootID) { await pageInRoot() }
+ }
+
+ private func panel(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message],
+ cap: CGFloat) -> some View {
+ VStack(spacing: 0) {
+ header(replyCount: replies.count)
+ Divider()
+ rootBox(root, cap: cap)
+ Divider()
+ if let note = note(rootLoaded: root != nil) {
+ Text(note)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ Divider()
+ }
+ repliesList(replies)
+ }
+ // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) —
+ // material adapts to light/dark for free.
+ .background(.ultraThinMaterial,
+ in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .shadow(color: .black.opacity(0.25), radius: 20, y: 6)
+ // Inside the view every row already IS the thread — quotes/tallies off.
+ .environment(\.inReplyThread, true)
+ }
+
+ private func header(replyCount: Int) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text("Thread")
+ .font(.system(size: 15, weight: .bold))
+ Text(replyCount == 1 ? "1 reply" : "\(replyCount) replies")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 28, height: 28)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Close thread")
+ }
+ .padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
+ .contentShape(Rectangle())
+ .gesture(headerDrag)
+ .accessibilityAddTraits(.isModal)
+ }
+
+ // Ride the panel with a downward header drag; release past the threshold
+ // closes, anything else springs back. Header-only, so the replies scroll
+ // and the root's own scroll never fight the gesture; a no-op unless the
+ // container opted in (iOS — the Mac closes via ✕/Escape/dim).
+ private var headerDrag: some Gesture {
+ DragGesture(minimumDistance: 10)
+ .onChanged { v in
+ guard swipeDownCloses else { return }
+ dragOffset = max(0, v.translation.height)
+ }
+ .onEnded { v in
+ guard swipeDownCloses else { return }
+ if dragOffset > 120 || v.predictedEndTranslation.height > 300 {
+ onClose()
+ // The container removes the view; reset for a re-open of
+ // the same struct identity.
+ dragOffset = 0
+ } else {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ dragOffset = 0
+ }
+ }
+ }
+ }
+
+ // The pinned root: the real bubble when loaded (a keyed row, same wiring
+ // as the feed — reactions and edits keep working); the quoted snapshot as
+ // a stand-in while paging hunts for it. Height-capped at ~40% of the
+ // panel, scrolling within itself (web .chatapp-thread-rootmsg).
+ @ViewBuilder private func rootBox(_ root: ChatFeedModel.Message?, cap: CGFloat) -> some View {
+ ScrollView {
+ Group {
+ if let root {
+ HStack(spacing: 0) {
+ if root.mine { Spacer(minLength: 40) }
+ row(root, isGroup)
+ if !root.mine { Spacer(minLength: 40) }
+ }
+ } else {
+ // Root not loaded (yet): the frozen quote stands in
+ // (web .chatapp-thread-rootfall — accent left rule).
+ VStack(alignment: .leading, spacing: 2) {
+ if let name = fallback?.name, !name.isEmpty {
+ Text(name)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ Text(fallback?.snippet ?? "")
+ .font(.system(size: 14))
+ }
+ .padding(.leading, 10)
+ .overlay(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(theme.accent.resolved(scheme))
+ .frame(width: 3)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .padding(EdgeInsets(top: 12, leading: 12, bottom: 10, trailing: 12))
+ .background(GeometryReader { g in
+ Color.clear
+ .onAppear { rootHeight = g.size.height }
+ .onChange(of: g.size.height) { _, h in rootHeight = h }
+ })
+ }
+ // Bootstrap at the cap until measured — a too-tall first frame that
+ // shrinks beats a zero-height one whose content never lays out.
+ .frame(height: min(rootHeight > 0 ? rootHeight : cap, max(cap, 44)))
+ }
+
+ private func note(rootLoaded: Bool) -> String? {
+ if paging { return "Loading the start of this thread…" }
+ if !rootLoaded { return "The original message isn't available — showing its replies." }
+ return nil
+ }
+
+ private func repliesList(_ replies: [ChatFeedModel.Message]) -> some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(Array(replies.enumerated()), id: \.element.id) { i, m in
+ replyRow(m, previous: i > 0 ? replies[i - 1] : nil)
+ .id(m.id)
+ }
+ // Fixed-height follow target, and the "at bottom" probe
+ // that gates it (same sentinel trick as both feeds).
+ Color.clear.frame(height: 1).id("thread-bottom")
+ .onAppear { bottomVisible = true }
+ .onDisappear { bottomVisible = false }
+ }
+ .padding(EdgeInsets(top: 8, leading: 12, bottom: 12, trailing: 12))
+ }
+ .defaultScrollAnchor(.bottom)
+ // Follow the newest reply like the feed does, but only when
+ // already there (web refreshThread's follow rule).
+ .onChange(of: replies.last?.id) { _, id in
+ if id != nil, bottomVisible {
+ withAnimation(.easeOut(duration: 0.15)) {
+ proxy.scrollTo("thread-bottom", anchor: .bottom)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func replyRow(_ m: ChatFeedModel.Message,
+ previous: ChatFeedModel.Message?) -> some View {
+ // The feeds' exact grouping (chat.js viewItems, which the web thread
+ // panel reuses): the first reply opens with a dated separator, then
+ // day-change/15-minute rules; a run only continues uninterrupted.
+ let sep = timeSeparator(at: m.at, previous: previous?.at)
+ let startsRun = sep != nil || previous?.name != m.name
+ if let sep {
+ Group {
+ if sep == .dated {
+ Text(chatDayLabel(m.at)).bold() + Text(" \(chatTime(m.at))")
+ } else {
+ Text(chatTime(m.at))
+ }
+ }
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 12)
+ .padding(.bottom, 6)
+ }
+ if m.action {
+ Text(m.text)
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
+ } else {
+ HStack(spacing: 0) {
+ if m.mine { Spacer(minLength: 40) }
+ row(m, isGroup && startsRun)
+ if !m.mine { Spacer(minLength: 40) }
+ }
+ .padding(EdgeInsets(top: startsRun ? 8 : 3, leading: 0,
+ bottom: 3, trailing: 0))
+ }
+ }
+
+ // Page history backward until the thread's root is loaded — bounded (the
+ // web's THREAD_PAGE_CAP) so a reply deep in a huge history can't spin the
+ // loader forever. Pages are contiguous: once the oldest loaded id is below
+ // the root's without finding it, the root was deleted — more paging can't
+ // surface it. loadOlder self-gates (ready/cooldown/exhausted), so this
+ // shares the feed's scroll-back path safely.
+ private func pageInRoot() async {
+ guard !model.messages.contains(where: { $0.id == rootID }) else { return }
+ paging = true
+ for _ in 0..<5 {
+ if Task.isCancelled { break } // the view closed mid-page
+ if model.messages.contains(where: { $0.id == rootID }) { break }
+ guard let oldest = model.messages.first?.id, oldest > rootID,
+ !model.exhausted else { break }
+ await model.loadOlder()
+ // loadOlder's own half-second cooldown gates re-entry; give it
+ // that beat rather than burning the cap on no-op calls.
+ try? await Task.sleep(nanoseconds: 550_000_000)
+ }
+ paging = false
+ // The root may have just loaded — let the container refresh its
+ // staged reply banner to the live snapshot.
+ if !Task.isCancelled, model.messages.contains(where: { $0.id == rootID }) {
+ onRootPaged?()
+ }
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
index 42cebc6..650a80e 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
@@ -87,6 +87,69 @@ final class ReplyThreadVectorTests: XCTestCase {
XCTAssertEqual(target.snippet, "long ago")
}
+ // threadItems — the thread view's selector; web twin: test/apps.test.mjs
+ // "threadItems selects a root's whole thread — legacy chains resolve into it".
+ private func threadItemsFixture() -> [Message] {
+ [
+ Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false),
+ Message(id: 2, name: "Bo", text: "aside", mine: false, at: 2, edited: false),
+ Message(id: 3, name: "Cy", text: "re", mine: false, at: 3, edited: false,
+ reply: ReplyRef(id: 1, name: "Al", snippet: "root")),
+ // A legacy chain: this reply quotes the REPLY (3), not the root — it
+ // must still resolve transitively into 1's thread.
+ Message(id: 4, name: "Al", text: "re-re", mine: false, at: 4, edited: false,
+ reply: ReplyRef(id: 3, name: "Cy", snippet: "re")),
+ Message(id: 5, name: "Bo", text: "other thread", mine: false, at: 5, edited: false,
+ reply: ReplyRef(id: 2, name: "Bo", snippet: "aside")),
+ ]
+ }
+
+ func testThreadItemsSelectsWholeThread() {
+ let msgs = threadItemsFixture()
+ XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4],
+ "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out")
+ XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 2).map(\.id), [2, 5],
+ "the other thread folds on its own root")
+ }
+
+ func testThreadItemsWithUnloadedRoot() {
+ // Root scrolled out of the loaded window: the thread still folds from the
+ // replies we can see (the view renders the quoted snapshot as its header).
+ let msgs = Array(threadItemsFixture().dropFirst(2))
+ XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [3, 4],
+ "unloaded root — replies only, chain intact")
+ XCTAssertEqual(ChatFeedModel.threadItems(in: threadItemsFixture(), root: 99).map(\.id), [],
+ "an unknown root has no thread")
+ }
+
+ func testThreadReplyRefSnapshotsLoadedRootAndFallsBack() {
+ let msgs = threadItemsFixture()
+ // Loaded root: a fresh snapshot of its name/text (what the composer stages
+ // while the thread view is open).
+ var raw = MessagePayload()
+ raw.name = "Al"
+ raw.text = "root"
+ var withRaw = msgs
+ withRaw[0].raw = raw
+ let staged = ChatFeedModel.threadReplyRef(in: withRaw, root: 1)
+ XCTAssertEqual(staged.id, 1)
+ XCTAssertEqual(staged.name, "Al")
+ XCTAssertEqual(staged.snippet, "root")
+
+ // Unloaded root: the deepest quoted ref a loaded thread member carries
+ // stands in (the web's threadFallback via replyTarget).
+ let partial = Array(msgs.dropFirst(2))
+ let fallback = ChatFeedModel.threadReplyRef(in: partial, root: 1)
+ XCTAssertEqual(fallback.id, 1)
+ XCTAssertEqual(fallback.name, "Al")
+ XCTAssertEqual(fallback.snippet, "root")
+
+ // Nothing of the thread loaded at all: keep at least the root id, so the
+ // staged reply still continues the thread.
+ let bare = ChatFeedModel.threadReplyRef(in: [], root: 7)
+ XCTAssertEqual(bare.id, 7)
+ }
+
func testReplyCycleIsHopCapped() {
// A hostile payload could quote in a cycle — the walk must terminate.
var list = [
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 79972bc..4f8f445 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -111,6 +111,11 @@ struct NativeFeedView: View {
// Staged reply target / message being edited (the composer banners).
@State private var replyTo: ReplyRef?
@State private var editingID: Int64?
+ // The open reply-thread view's root id (nil = closed) and the header
+ // stand-in while that root isn't loaded — frozen at open, like the web's
+ // threadFallback (chat.js openThread's ref argument).
+ @State private var threadRootID: Int64?
+ @State private var threadFallback: ReplyRef?
// 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.
@@ -349,6 +354,15 @@ struct NativeFeedView: View {
}
.animation(.easeOut(duration: 0.12), value: model.ready && settled)
}
+ // The thread view lifts over the FEED pane only (the web's
+ // .chatapp-feedwrap overlay) — applied before the composer inset, so
+ // the real composer below stays live, staged to the thread's root.
+ .overlay {
+ if let rootID = threadRootID {
+ threadOverlay(rootID)
+ .transition(.opacity.combined(with: .offset(y: 12)))
+ }
+ }
// Staged previews FLOAT over the feed, hugging the composer's top edge
// (this overlay is applied BEFORE the inset, so its bottom IS the
// composer's top) — in the inset they grew it and shoved the feed up.
@@ -543,6 +557,7 @@ struct NativeFeedView: View {
MessageBubble(message: m, showSender: isGroup && startsRun,
theme: theme,
onJump: { target in jump(to: target, proxy: proxy) },
+ onOpenThread: { root in openThread(root) },
blobSource: { att in model.blobSource(att) },
onImageTap: { att, image, frame in
app.lightbox = LightboxItem(id: att.id, name: att.name,
@@ -937,11 +952,10 @@ struct NativeFeedView: View {
private func sendDraft() {
// An edit replaces the message in place — no attachments/reply staging.
+ // cancelEdit (not inline clears) so an edit inside an open thread view
+ // hands the composer back to the thread's staged reply.
if let id = editingID {
- if model.edit(id, text: draft) {
- draft = ""
- editingID = nil
- }
+ if model.edit(id, text: draft) { cancelEdit() }
return
}
guard hasContent else { return }
@@ -1009,7 +1023,10 @@ struct NativeFeedView: View {
if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
staged = []
- replyTo = nil
+ // Sending from an open thread view keeps it open and re-stages the
+ // reply to its root — the new reply folds in live and the next
+ // send continues the same thread, like iMessage (web parity).
+ if threadRootID != nil { stageThreadReply() } else { replyTo = nil }
sendQueued = false
}
}
@@ -1025,6 +1042,87 @@ struct NativeFeedView: View {
}
}
+ // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
+
+ // The lifted thread panel over the dimmed feed pane. Rows reuse the exact
+ // MessageBubble wiring the feed uses (right-click menu, lightboxes,
+ // drag-out), so everything keeps working inside the view; reply actions in
+ // here resolve to the thread's ROOT via the same startReply path. Escape
+ // closes it through the reply banner's ✕ (.cancelAction → cancelReply).
+ private func threadOverlay(_ rootID: Int64) -> some View {
+ ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback,
+ theme: theme, isGroup: isGroup,
+ onClose: closeThread,
+ onRootPaged: {
+ // The root just paged in — refresh the banner to its
+ // live snapshot, unless an edit borrowed the composer.
+ if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
+ },
+ row: { m, showSender in threadBubble(m, showSender: showSender) })
+ }
+
+ private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ MessageBubble(message: m, showSender: showSender,
+ theme: theme,
+ blobSource: { att in model.blobSource(att) },
+ onImageTap: { att, image, frame in
+ app.lightbox = LightboxItem(id: att.id, name: att.name,
+ image: image, sourceFrame: frame)
+ },
+ onFileTap: { att in download(att) },
+ onVideoTap: { att, frame in playVideo(att, from: frame) },
+ downloading: videoDownload,
+ actions: MessageActions(
+ canEdit: model.canEdit(m),
+ onReply: { startReply(m) },
+ onEdit: { startEdit(m) },
+ onDelete: { model.deleteMessage(m.id) },
+ onCopy: {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(m.text, forType: .string)
+ },
+ onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) }),
+ dragItem: { att in
+ guard att.isImage || att.isVideo else { return nil }
+ return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
+ mimeType: att.type, isImage: att.isImage,
+ fetch: { await model.blobData(att) })
+ },
+ unfurl: { url in await model.unfurl(url) },
+ onYouTube: { videoID, start, frame in
+ app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start,
+ sourceFrame: frame)
+ })
+ }
+
+ private func openThread(_ rootID: Int64) {
+ guard rootID > 0, threadRootID != rootID else { return }
+ // Freeze the header stand-in NOW (web threadFallback) — the deepest
+ // quoted snapshot in loaded state, in case the root never pages in.
+ threadFallback = model.threadReplyRef(root: rootID)
+ withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
+ stageThreadReply()
+ }
+
+ // Stage the composer's reply on the thread's root — the view's composer
+ // contract ("Replying to <root author>"). A reply displaces an in-flight
+ // edit; desktop focuses the composer, like the web.
+ private func stageThreadReply() {
+ guard let rootID = threadRootID else { return }
+ editingID = nil
+ replyTo = model.threadReplyRef(root: rootID)
+ composerFocused = true
+ }
+
+ private func closeThread() {
+ guard let rootID = threadRootID else { return }
+ withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
+ threadFallback = nil
+ // Leaving the thread drops its staged reply — never an unrelated one
+ // staged since (an edit clears replyTo itself, so that survives).
+ if replyTo?.id == rootID { replyTo = nil }
+ }
+
// ---- reply / edit staging (the composer banners) ----------------------
private func startReply(_ m: ChatFeedModel.Message) {
@@ -1034,7 +1132,12 @@ struct NativeFeedView: View {
composerFocused = true
}
- private func cancelReply() { replyTo = nil }
+ // While the thread view is open its staged reply IS the view's composer
+ // state — cancelling it (the banner's ✕, or Escape via .cancelAction)
+ // means leaving the thread, web parity.
+ private func cancelReply() {
+ if threadRootID != nil { closeThread() } else { replyTo = nil }
+ }
private func startEdit(_ m: ChatFeedModel.Message) {
replyTo = nil
@@ -1046,6 +1149,10 @@ struct NativeFeedView: View {
private func cancelEdit() {
editingID = nil
draft = ""
+ // An edit inside an open thread view borrowed the composer (startEdit
+ // clears replyTo); hand it back so the next send still continues the
+ // thread (web cancelEdit).
+ if threadRootID != nil { stageThreadReply() }
}
private func composerBanner(_ title: String, detail: String?,