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>
6 files changed,
+676
−12
ios/LChat/Views/NativeChatScreen.swift+98 −2ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+40 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+41 −4ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift+321 −0ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+63 −0ios/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?, |