ios: @mentions render as tappable chips; tap opens the profile card (#1344)
The native twin of the web's mention layer (chat.js renderRichText): - Mentions.swift — the render-layer scan (published-label matching, the web's exact boundary rules), viewer-name display (#577), self-mentions bold-but-not-tappable, MentionLink (the app-internal URL chips carry). - MessageBubble styles mention runs semibold in plain text runs only (never code or link labels); memo keys carry the index rev so renames and late label decrypts restyle; the plain-Text fast path steps aside only for text that could hold a mention. - ChatFeedModel/NativeFeedModelCache/ThreadStore plumb the index the senderNames route, so live and parked feeds restyle together. - Tapping a chip opens MemberProfileSheet (iOS: the header-profile sheet, navigation via the Route path; Mac: NativeChatView's sheet + selection). - MemberProfileSheet gains the web card's missing "Message them" — ThreadStore.openOrCreateDM reopens the one DM per pair or creates it, with the closed-door refusal copy inline (#578). Wired from the DM header, members sheets, and mention taps on both platforms.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+506
−21
ios/LChat/Views/FlippedChatFeed.swift+14 −1ios/LChat/Views/GroupSheets.swift+7 −1ios/LChat/Views/NativeChatScreen.swift+32 −4ios/LChat/Views/SchemaAppScreen.swift+6 −2ios/LChat/Views/StockChatFeed.swift+5 −1ios/LChat/Views/ThreadListView.swift+2 −2ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+4 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+41 −0ios/LChatCore/Sources/LChatCore/Mentions.swift+127 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+61 −5ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+15 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+34 −0ios/LChatCore/Tests/LChatCoreTests/MentionsTests.swift+118 −0ios/LChatMac/GroupSheets.swift+7 −1ios/LChatMac/NativeChatView.swift+17 −3ios/LChatMac/NativeFeedView.swift+16 −1
diff --git a/ios/LChat/Views/FlippedChatFeed.swift b/ios/LChat/Views/FlippedChatFeed.swift| index 433da39..9da5f45 100644 |
| --- a/ios/LChat/Views/FlippedChatFeed.swift |
| +++ b/ios/LChat/Views/FlippedChatFeed.swift |
| @@ -73,6 +73,10 @@ struct FeedHooks { |
| // already-resolved card (.some(nil) = known-empty; nil = not fetched). |
| var peekUnfurl: (URL) -> LinkPreview?? = { _ in nil } |
| var startReply: (ChatFeedModel.Message) -> Void |
| + // @mentions: the index bubbles style "@Name" runs against (resolved at |
| + // configure time, like senderName), and the tap → profile-card hook. |
| + var mentionIndex: () -> MentionIndex? = { nil } |
| + var onMentionTap: (String) -> Void = { _ in } |
| } |
| // What a feed container owes the screen: the two scroll verbs. |
| @@ -293,7 +297,8 @@ struct FlippedChatFeed: UIViewRepresentable { |
| model.$senderNames.removeDuplicates().map { _ in () } |
| .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) |
| .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) |
| - .dropFirst(3) |
| + .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) |
| + .dropFirst(4) |
| .sink { [weak self] in self?.reconfigureAll() } |
| .store(in: &cancellables) |
| // An empty chat has no first layout to anchor — end the mount |
| @@ -890,6 +895,14 @@ struct FeedMessageRow: View { |
| unfurl: hooks.unfurl, |
| peekUnfurl: hooks.peekUnfurl) |
| .environment(\.chatFontSize, hooks.chatFontSize) |
| + .environment(\.mentionIndex, hooks.mentionIndex()) |
| + // A tapped @mention chip carries a MentionLink URL — intercept it |
| + // into the profile card; every real link keeps the system route. |
| + .environment(\.openURL, OpenURLAction { url in |
| + guard let uid = MentionLink.uid(from: url) else { return .systemAction } |
| + hooks.onMentionTap(uid) |
| + return .handled |
| + }) |
| // The far-side gap + speaking-side alignment as two unary layout |
| // nodes instead of HStack + Spacers: the profile pad's hot stacks |
| // were StackLayout.sizeThatFits recursion — every stack/spacer is |
diff --git a/ios/LChat/Views/GroupSheets.swift b/ios/LChat/Views/GroupSheets.swift| index 5c708f1..890dc65 100644 |
| --- a/ios/LChat/Views/GroupSheets.swift |
| +++ b/ios/LChat/Views/GroupSheets.swift |
| @@ -9,6 +9,9 @@ import SwiftUI |
| struct MembersSheet: View { |
| let store: ThreadStore |
| let thread: ChatThread |
| + // A profile card's "Message them" — the presenter navigates; this sheet |
| + // just gets out of the way first. |
| + var onOpenThread: ((String) -> Void)? |
| @Environment(\.dismiss) private var dismiss |
| @State private var selected: Set<String> = [] |
| @@ -56,7 +59,10 @@ struct MembersSheet: View { |
| ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } |
| } |
| .sheet(item: $profile) { target in |
| - MemberProfileSheet(store: store, uid: target.id) { e in |
| + MemberProfileSheet(store: store, uid: target.id, |
| + onOpenThread: onOpenThread.map { open in |
| + { id in profile = nil; dismiss(); open(id) } |
| + }) { e in |
| Avatar(name: store.displayName(for: e), size: 72, |
| source: store.avatarSource(for: e)) |
| } |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 9d54329..81da335 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -28,11 +28,17 @@ struct NativeChatScreen: View { |
| // methods; the feed itself renders from its own ChatFeedModel. |
| let store: ThreadStore |
| @ObservedObject private var model: ChatFeedModel |
| + // The navigation path this screen sits on — for surfaces that hop |
| + // threads (a profile card's "Message them", like ElevenChatScreen's |
| + // "Say hi" chip). |
| + @Binding private var path: [Route] |
| - init(instance: SavedInstance, thread: ChatThread, store: ThreadStore) { |
| + init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| + path: Binding<[Route]>) { |
| self.instance = instance |
| self.thread = thread |
| self.store = store |
| + self._path = path |
| self.model = NativeFeedModelCache.shared.model(for: instance, thread: thread, |
| myUserId: store.myUserId) |
| // Opened from a notification tap: capture the read watermark NOW — |
| @@ -373,7 +379,13 @@ struct NativeChatScreen: View { |
| }) |
| } |
| .sheet(item: $headerProfile) { target in |
| - MemberProfileSheet(store: store, uid: target.id) { e in |
| + // Message them hops to (or creates) their DM — hidden when |
| + // this chat already IS their DM (the web card's gate). |
| + MemberProfileSheet(store: store, uid: target.id, |
| + onOpenThread: dmPartner == target.id ? nil : { id in |
| + headerProfile = nil |
| + path.append(.thread(id)) |
| + }) { e in |
| Avatar(name: store.displayName(for: e), size: 72, |
| source: store.avatarSource(for: e)) |
| } |
| @@ -500,7 +512,13 @@ struct NativeChatScreen: View { |
| } message: { |
| Text("It closes for both of you, and no new messages can be sent unless someone reopens it.") |
| } |
| - .sheet(isPresented: $showMembers) { MembersSheet(store: store, thread: thread) } |
| + .sheet(isPresented: $showMembers) { |
| + MembersSheet(store: store, thread: thread, |
| + onOpenThread: { id in |
| + showMembers = false |
| + path.append(.thread(id)) |
| + }) |
| + } |
| .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } |
| .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } |
| .onAppear { |
| @@ -734,7 +752,11 @@ struct NativeChatScreen: View { |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| unfurl: { await model.unfurl($0) }, |
| peekUnfurl: { model.cachedUnfurl($0) }, |
| - startReply: { startReply($0) }) |
| + startReply: { startReply($0) }, |
| + mentionIndex: { model.mentionIndex }, |
| + // A tapped @mention opens the same profile card the DM |
| + // header does — Message them, rename, mute, block (#577/8). |
| + onMentionTap: { headerProfile = HeaderProfileTarget(id: $0) }) |
| } |
| // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) -- |
| @@ -784,6 +806,12 @@ struct NativeChatScreen: View { |
| actions: messageActions(m), |
| unfurl: { await model.unfurl($0) }) |
| .environment(\.chatFontSize, chatFontSize) |
| + .environment(\.mentionIndex, model.mentionIndex) |
| + .environment(\.openURL, OpenURLAction { url in |
| + guard let uid = MentionLink.uid(from: url) else { return .systemAction } |
| + headerProfile = HeaderProfileTarget(id: uid) |
| + return .handled |
| + }) |
| } |
| } |
diff --git a/ios/LChat/Views/SchemaAppScreen.swift b/ios/LChat/Views/SchemaAppScreen.swift| index 996bc57..500b7e5 100644 |
| --- a/ios/LChat/Views/SchemaAppScreen.swift |
| +++ b/ios/LChat/Views/SchemaAppScreen.swift |
| @@ -19,21 +19,25 @@ struct SchemaAppScreen: View { |
| // The Interface IS the app (the web's takeover default); Chat is the |
| // companion conversation, one tap away. |
| @State private var mode: AppView = .interface |
| + // Passed through to the companion chat: its profile cards hop threads. |
| + @Binding private var path: [Route] |
| - init?(instance: SavedInstance, thread: ChatThread, store: ThreadStore) { |
| + init?(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| + path: Binding<[Route]>) { |
| guard let schemaStore = SchemaAppStoreCache.shared.store(for: instance, thread: thread, |
| myUserId: store.myUserId) else { return nil } |
| self.instance = instance |
| self.thread = thread |
| self.store = store |
| self.schemaStore = schemaStore |
| + self._path = path |
| } |
| var body: some View { |
| Group { |
| switch mode { |
| case .chat: |
| - NativeChatScreen(instance: instance, thread: thread, store: store) |
| + NativeChatScreen(instance: instance, thread: thread, store: store, path: $path) |
| case .interface: |
| SchemaAppView(store: schemaStore) |
| .navigationTitle(thread.title) |
diff --git a/ios/LChat/Views/StockChatFeed.swift b/ios/LChat/Views/StockChatFeed.swift| index da12c78..b60a95d 100644 |
| --- a/ios/LChat/Views/StockChatFeed.swift |
| +++ b/ios/LChat/Views/StockChatFeed.swift |
| @@ -496,7 +496,8 @@ struct StockChatFeed: UIViewControllerRepresentable { |
| model.$senderNames.removeDuplicates().map { _ in () } |
| .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) |
| .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) |
| - .dropFirst(3) |
| + .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) |
| + .dropFirst(4) |
| .sink { [weak self] in self?.reconfigureAll() } |
| .store(in: &cancellables) |
| // An empty chat has no first layout to anchor — end the mount |
| @@ -1004,6 +1005,9 @@ struct StockChatFeed: UIViewControllerRepresentable { |
| unfurl: hooks.unfurl, |
| peekUnfurl: hooks.peekUnfurl) |
| .environment(\.chatFontSize, hooks.chatFontSize) |
| + // Mention chips show the viewer's name for the person — the |
| + // measured height must see the same text the row renders. |
| + .environment(\.mentionIndex, hooks.mentionIndex()) |
| let hc = UIHostingController(rootView: bubble) |
| hc.safeAreaRegions = [] |
| let size = hc.sizeThatFits(in: CGSize( |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index fb7a591..06e2178 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -599,13 +599,13 @@ struct ThreadDestination: View { |
| // fallback, seeded with the app's own default view — a takeover app |
| // (Admin) opens on its interface, never an empty chat. |
| if !t.isApp { |
| - NativeChatScreen(instance: store.instance, thread: t, store: store) |
| + NativeChatScreen(instance: store.instance, thread: t, store: store, path: $path) |
| } else { |
| switch store.registry.presentation(t.appId ?? "") { |
| case .flow: |
| ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path) |
| case .schema where !t.threadKeyB64.isEmpty: |
| - if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store) { |
| + if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store, path: $path) { |
| screen |
| } else { |
| ChatView(instance: store.instance, thread: t, store: store, |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index ff21303..591a53a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -98,6 +98,10 @@ public final class ChatFeedModel: ObservableObject { |
| /// route senderNames takes. Render-level only: the fold below is |
| /// untouched, so unmuting reveals history intact. |
| @Published public var mutedPeople: Set<String> = [] |
| + /// The roster's @mention index (Mentions.swift) — seeded and refreshed by |
| + /// NativeFeedModelCache from each ThreadStore fold, the senderNames route. |
| + /// Render-level only: bubbles style "@PublishedName" runs against it. |
| + @Published public var mentionIndex: MentionIndex? |
| /// Muted stubs revealed this session, by message id. Deliberately not |
| /// persisted: reopening the app re-folds — a mute means muted (the web's |
| /// revealedMuted set, mirrored). |
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift| index 1e65037..1702b68 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift |
| @@ -8,6 +8,10 @@ import SwiftUI |
| public struct MemberProfileSheet<AvatarView: View>: View { |
| @ObservedObject var store: ThreadStore |
| let uid: String |
| + // Message them (the web card's action): the presenter supplies the |
| + // navigation to a thread id — nil when the card was reached from THEIR |
| + // DM (where the button is noise) or from a surface that can't hop. |
| + private let onOpenThread: ((String) -> Void)? |
| // The avatar slot: each app supplies its own avatar view (they live at the |
| // app layer, not in LChatCore). |
| private let avatar: (RosterEntry) -> AvatarView |
| @@ -17,6 +21,8 @@ public struct MemberProfileSheet<AvatarView: View>: View { |
| @State private var draft = "" |
| @State private var syncing = false |
| @State private var showReport = false |
| + @State private var msgBusy = false |
| + @State private var msgNote: String? |
| @State private var blockBusy = false |
| @State private var blockFailed = false |
| @State private var removeBusy = false |
| @@ -26,9 +32,11 @@ public struct MemberProfileSheet<AvatarView: View>: View { |
| @FocusState private var draftFocused: Bool |
| public init(store: ThreadStore, uid: String, |
| + onOpenThread: ((String) -> Void)? = nil, |
| @ViewBuilder avatar: @escaping (RosterEntry) -> AvatarView) { |
| self.store = store |
| self.uid = uid |
| + self.onOpenThread = onOpenThread |
| self.avatar = avatar |
| } |
| @@ -56,6 +64,39 @@ public struct MemberProfileSheet<AvatarView: View>: View { |
| } |
| } |
| + // Message them — the web card's action when it was reached from |
| + // somewhere other than their DM (the member sheet, a tapped |
| + // @mention); on someone you've blocked it's a contradiction |
| + // (Unblock is below). Anyone else, bots included. Their DM door |
| + // may be closed (#578) — say so in place, never a silent failure. |
| + if let onOpenThread, uid != store.myUserId, !store.blockedPeople.contains(uid) { |
| + if let note = msgNote { |
| + // Inline, occupying layout space — never a floating toast. |
| + Text(note) |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + .padding(.top, 4) |
| + } else { |
| + Button("Message \(shownName.isEmpty ? "them" : shownName)") { |
| + msgBusy = true |
| + Task { |
| + if let id = await store.openOrCreateDM(uid: uid) { |
| + dismiss() |
| + onOpenThread(id) |
| + } else { |
| + // The store's own copy — "They're not |
| + // accepting messages" on a closed DM door. |
| + msgBusy = false |
| + msgNote = store.loadError ?? "Couldn't start the chat. Try again." |
| + } |
| + } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .disabled(msgBusy) |
| + .padding(.top, 4) |
| + } |
| + } |
| + |
| // Mute them (#578) — their messages in shared chats fold to a |
| // quiet stub and never notify. Private: they don't find out, and |
| // the server can't read the muted set either. Anyone but yourself |
diff --git a/ios/LChatCore/Sources/LChatCore/Mentions.swift b/ios/LChatCore/Sources/LChatCore/Mentions.swift| new file mode 100644 |
| index 0000000..01669d1 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/Mentions.swift |
| @@ -0,0 +1,127 @@ |
| +import Foundation |
| + |
| +// @mentions in message text — the render-layer scan (the twin of the web's |
| +// renderRichText plain hook in apps/chat.js). A mention is plain text on the |
| +// wire: "@" + the person's PUBLISHED label, so every member can resolve it |
| +// against their own decrypted roster; nothing here touches the markdown tree |
| +// or the payload (vector `mention_is_render_layer` pins that). Matching is on |
| +// published labels; only what THIS viewer sees on the chip is their name for |
| +// the person (#577). |
| +public struct MentionIndex: Hashable { |
| + public struct Entry: Hashable { |
| + /// The published name mentions travel as — the match key. |
| + public let label: String |
| + public let uid: String |
| + /// What this viewer shows on the chip (their nickname when set). |
| + public let display: String |
| + /// False for yourself — a self-mention stays a plain bold chip. |
| + public let tappable: Bool |
| + public init(label: String, uid: String, display: String, tappable: Bool) { |
| + self.label = label |
| + self.uid = uid |
| + self.display = display |
| + self.tappable = tappable |
| + } |
| + } |
| + |
| + /// Longest label first, so "@Ada Lovelace" wins over a member named "Ada" |
| + /// (the web sorts its alternation the same way). |
| + let entries: [Entry] |
| + /// Cheap cache-buster: bubbles bake mention runs into memoised |
| + /// AttributedStrings, so a roster/nickname change must miss the memo — |
| + /// the web's repaintMentionsOf problem, solved at the key. |
| + public let rev: Int |
| + |
| + public init(entries: [Entry]) { |
| + self.entries = entries |
| + .filter { !$0.label.isEmpty && !$0.uid.isEmpty } |
| + .sorted { $0.label.count > $1.label.count } |
| + var h = Hasher() |
| + h.combine(self.entries) |
| + self.rev = h.finalize() |
| + } |
| + |
| + public var isEmpty: Bool { entries.isEmpty } |
| + |
| + /// Fast pre-check so plain messages keep their no-AttributedString fast |
| + /// paths: only text containing "@" can hold a mention. |
| + public func mayMention(_ s: String) -> Bool { |
| + !entries.isEmpty && s.utf8.contains(UInt8(ascii: "@")) |
| + } |
| + |
| + public enum Segment: Equatable { |
| + case text(String) |
| + case mention(Entry) |
| + } |
| + |
| + /// Split one plain text run into text + mention segments, by the web's |
| + /// exact rules (chat.js renderRichText): the "@" must not sit mid-word or |
| + /// in an email (previous char not [A-Za-z0-9_@.]), the longest published |
| + /// label at that spot wins, and the label must not be followed by a word |
| + /// char — so "@Ada," mentions Ada but "user@Ada.com" mentions no one. |
| + public func split(_ s: String) -> [Segment] { |
| + guard mayMention(s) else { return s.isEmpty ? [] : [.text(s)] } |
| + var out: [Segment] = [] |
| + var plainStart = s.startIndex |
| + var i = s.startIndex |
| + var prev: Character? |
| + while i < s.endIndex { |
| + if s[i] == "@", prev.map(Self.blocksMentionStart) != true, |
| + let entry = match(at: s.index(after: i), in: s) { |
| + if plainStart < i { out.append(.text(String(s[plainStart..<i]))) } |
| + out.append(.mention(entry)) |
| + i = s.index(s.index(after: i), offsetBy: entry.label.count) |
| + plainStart = i |
| + prev = "@" // any char that blocks a directly-adjacent "@" run |
| + continue |
| + } |
| + prev = s[i] |
| + i = s.index(after: i) |
| + } |
| + if plainStart < s.endIndex { out.append(.text(String(s[plainStart...]))) } |
| + return out |
| + } |
| + |
| + private func match(at start: String.Index, in s: String) -> Entry? { |
| + for e in entries where s[start...].hasPrefix(e.label) { |
| + let end = s.index(start, offsetBy: e.label.count) |
| + if end == s.endIndex || !Self.isWordChar(s[end]) { return e } |
| + } |
| + return nil |
| + } |
| + |
| + // JS \w — ASCII letters, digits, underscore (the web regex has no /u). |
| + private static func isWordChar(_ c: Character) -> Bool { |
| + c.isASCII && (c.isLetter || c.isNumber || c == "_") |
| + } |
| + |
| + // The web's prevCh guard: /[\w@.]/ — mid-word and email domains never |
| + // start a mention. |
| + private static func blocksMentionStart(_ c: Character) -> Bool { |
| + isWordChar(c) || c == "@" || c == "." |
| + } |
| +} |
| + |
| +// The tappable chip's URL — an app-internal scheme the feed containers |
| +// intercept with an OpenURLAction (AttributedString's .link is the one way a |
| +// run inside a wrapping Text can be tapped). Never registered with the OS; |
| +// it exists only between buildInline and the feed's handler. |
| +public enum MentionLink { |
| + public static let scheme = "lchat-mention" |
| + |
| + public static func url(uid: String) -> URL? { |
| + var c = URLComponents() |
| + c.scheme = scheme |
| + c.host = "" |
| + c.path = "/" + uid // URLComponents percent-encodes what needs it |
| + return c.url |
| + } |
| + |
| + /// The uid a mention tap carries, nil for any other URL. |
| + public static func uid(from url: URL) -> String? { |
| + guard url.scheme == scheme else { return nil } |
| + // URL.path is already percent-DEcoded — no second decode. |
| + let uid = String(url.path.drop(while: { $0 == "/" })) |
| + return uid.isEmpty ? nil : uid |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index f7d4abc..acbc0c0 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -47,6 +47,20 @@ public extension EnvironmentValues { |
| } |
| } |
| +// The roster's @mention index (Mentions.swift) — injected by the feed |
| +// containers; nil (surfaces with no roster: Eleven conversations, previews) |
| +// renders text exactly as before. With it, plain runs grow bold mention |
| +// chips, tappable via MentionLink + the container's OpenURLAction. |
| +private struct MentionIndexKey: EnvironmentKey { |
| + static let defaultValue: MentionIndex? = nil |
| +} |
| +public extension EnvironmentValues { |
| + var mentionIndex: MentionIndex? { |
| + get { self[MentionIndexKey.self] } |
| + set { self[MentionIndexKey.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 { |
| @@ -149,6 +163,7 @@ public struct MessageBubble: View { |
| @Environment(\.colorScheme) private var scheme |
| @Environment(\.chatFontSize) private var fontSize |
| @Environment(\.inReplyThread) private var inReplyThread |
| + @Environment(\.mentionIndex) private var mentionIndex |
| public init(message: ChatFeedModel.Message, showSender: Bool, |
| senderName: String? = nil, |
| @@ -519,7 +534,8 @@ public struct MessageBubble: View { |
| // structure is identical in both feeds (vector-pinned). |
| MDBlockListView( |
| blocks: Self.cachedTree(message.text), |
| - ctx: MDContext(fontSize: fontSize, ink: ink, mine: message.mine)) |
| + ctx: MDContext(fontSize: fontSize, ink: ink, mine: message.mine, |
| + mentions: mentionIndex)) |
| #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| // Selectable AND right-clickable: the MessageRightClick catcher |
| // above only claims right/ctrl-clicks, so left-drag selection |
| @@ -546,6 +562,7 @@ public struct MessageBubble: View { |
| let fontSize: CGFloat |
| let ink: Color |
| let mine: Bool |
| + let mentions: MentionIndex? |
| } |
| // Blocks stack exactly as web's .bubble children do (6pt gaps). The two |
| @@ -571,7 +588,10 @@ public struct MessageBubble: View { |
| // A single unstyled run — most messages — skips the |
| // AttributedString build/replay entirely: plain Text lays |
| // out measurably cheaper, and this runs per cell recycle. |
| - if children.count == 1, case .text(let s) = children[0] { |
| + // A possible @mention (any "@" while a roster is injected) |
| + // must take the rich path to grow its chip. |
| + if children.count == 1, case .text(let s) = children[0], |
| + !(ctx.mentions?.mayMention(s) ?? false) { |
| Text(s) |
| .font(.system(size: ctx.fontSize)) |
| .lineSpacing(2) |
| @@ -663,6 +683,10 @@ public struct MessageBubble: View { |
| private struct InlineKey: Hashable { |
| let size: CGFloat |
| let mine: Bool |
| + // Mention chips bake the viewer's roster into the runs, so the |
| + // index's rev keys the memo — a rename or late label decrypt must |
| + // rebuild, not replay (the web's repaintMentionsOf, done here). |
| + let mentionRev: Int |
| let children: [MDInline] |
| } |
| // Two-generation memos (Memo.swift) — the old `removeAll()`-at-600 caps |
| @@ -687,7 +711,8 @@ public struct MessageBubble: View { |
| @MainActor static func inlineText(_ children: [MDInline], ctx: MDContext) -> AttributedString { |
| // The built runs bake in \.chatFontSize, so it's part of the key — a |
| // ⌘+ / Text Size change must rebuild, not replay the old size. |
| - let key = InlineKey(size: ctx.fontSize, mine: ctx.mine, children: children) |
| + let key = InlineKey(size: ctx.fontSize, mine: ctx.mine, |
| + mentionRev: ctx.mentions?.rev ?? 0, children: children) |
| return richMemo.get(key) { buildInline(children, ctx: ctx) } |
| } |
| @@ -707,11 +732,37 @@ public struct MessageBubble: View { |
| if strike { a.strikethroughStyle = .single } |
| return a |
| } |
| + // An @mention chip: the web's .mention — semibold, bubble ink, no |
| + // underline. Tappable ones carry the MentionLink URL for the feed's |
| + // OpenURLAction; a self-mention stays plain bold (web mentionable()). |
| + func mentionRun(_ e: MentionIndex.Entry) -> AttributedString { |
| + var a = AttributedString("@" + e.display) |
| + var f: Font = .system(size: ctx.fontSize, weight: bold ? .bold : .semibold) |
| + if italic { f = f.italic() } |
| + a.font = f |
| + if strike { a.strikethroughStyle = .single } |
| + if e.tappable, let url = MentionLink.url(uid: e.uid) { |
| + a.link = url |
| + a.foregroundColor = ctx.ink // never the tint blue links get |
| + } |
| + return a |
| + } |
| var out = AttributedString() |
| for node in children { |
| switch node { |
| case .text(let s): |
| - out += styled(s) |
| + // Mentions are scanned only in plain runs — never inside |
| + // code or link labels — matching the web's plain hook. |
| + if let idx = ctx.mentions, idx.mayMention(s) { |
| + for seg in idx.split(s) { |
| + switch seg { |
| + case .text(let t): out += styled(t) |
| + case .mention(let e): out += mentionRun(e) |
| + } |
| + } |
| + } else { |
| + out += styled(s) |
| + } |
| case .br: |
| out += AttributedString("\n") |
| case .code(let s): |
| @@ -723,7 +774,12 @@ public struct MessageBubble: View { |
| if strike { a.strikethroughStyle = .single } |
| out += a |
| case .link(let href, let linkChildren): |
| - var a = buildInline(linkChildren, ctx: ctx, bold: bold, italic: italic, strike: strike) |
| + // No mention scan inside a link's label (web parity: the |
| + // plain hook never runs inside <a>) — and a chip's own link |
| + // must never fight the label's real href. |
| + let linkCtx = MDContext(fontSize: ctx.fontSize, ink: ctx.ink, |
| + mine: ctx.mine, mentions: nil) |
| + var a = buildInline(linkChildren, ctx: linkCtx, bold: bold, italic: italic, strike: strike) |
| if let url = URL(string: href) { a.link = url } |
| a.underlineStyle = .single |
| a.foregroundColor = ctx.ink |
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift| index 0809b8e..bdd4076 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| @@ -126,6 +126,20 @@ public final class NativeFeedModelCache { |
| onNamesUpdate?(instanceID, labels) |
| } |
| + // The @mention index per instance — the senderNames treatment: seeded |
| + // into models handed out later, pushed into live/parked ones now, so a |
| + // rename (or a late label decrypt) restyles chips in every open feed. |
| + private var mentionIndexes: [String: MentionIndex] = [:] |
| + |
| + /// A fresh roster fold's mention index — from ThreadStore, beside its |
| + /// updateSenderNames call. |
| + public func updateMentionIndex(instance instanceID: String, index: MentionIndex) { |
| + mentionIndexes[instanceID] = index |
| + for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| + if entry.model.mentionIndex != index { entry.model.mentionIndex = index } |
| + } |
| + } |
| + |
| // The muted-people set per instance (#578) — the senderNames treatment: |
| // seeded into models handed out later, pushed into live/parked ones now, |
| // so a mute folds the person's bubbles in every open feed at once. |
| @@ -170,6 +184,7 @@ public final class NativeFeedModelCache { |
| let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId) |
| model.senderNames = senderNames[instance.id] ?? [:] |
| model.mutedPeople = mutedPeople[instance.id] ?? [] |
| + model.mentionIndex = mentionIndexes[instance.id] |
| model.integrity = integrityMonitor(for: instance) // tamper-evidence gossip |
| model.start() |
| entries[key] = Entry(model: model, seedKey: thread.threadKeyB64) |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index cf96725..c81008f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -342,6 +342,12 @@ public final class ThreadStore: ObservableObject { |
| // bubble headers resolve senders by uid, my name for them winning. |
| NativeFeedModelCache.shared.updateSenderNames(instance: instance.id, |
| labels: folded.displayLabels) |
| + // And the @mention index the bubbles style "@Name" runs against: |
| + // published labels to MATCH (a mention travels as text every member |
| + // resolves), my names to DISPLAY, yourself bold-but-not-tappable — |
| + // the web chatHost's mentionNames/mentionDisplay/mentionable in one. |
| + NativeFeedModelCache.shared.updateMentionIndex( |
| + instance: instance.id, index: currentMentionIndex()) |
| // Park every member's decrypted profile photo (downscaled) in the |
| // shared cache — the way the fold parks thread keys in |
| // SharedThreadKeys — so the notification extension can show a sender's |
| @@ -772,6 +778,19 @@ public final class ThreadStore: ObservableObject { |
| return name.isEmpty ? entry.label : name |
| } |
| + /// The feed's @mention index (Mentions.swift): matching stays on |
| + /// PUBLISHED labels — a mention travels as text every member must |
| + /// resolve, so "@MyNickname" would highlight for no one else — while the |
| + /// chip shows this viewer's name for them (#577). Your own label indexes |
| + /// as not-tappable: a self-mention is a plain bold chip, like the web. |
| + public func currentMentionIndex() -> MentionIndex { |
| + MentionIndex(entries: roster.map { e in |
| + .init(label: e.label, uid: e.user_id, |
| + display: displayName(for: e), |
| + tappable: e.user_id != myUserId) |
| + }) |
| + } |
| + |
| /// The web picker's rosterMatches, natively: case-insensitive substring on |
| /// the published label OR my nickname for them (displayName), so the filter |
| /// finds people by whichever name the picker row shows. An empty query |
| @@ -1187,6 +1206,21 @@ public final class ThreadStore: ObservableObject { |
| setHidden(threads[i], false) |
| } |
| + /// Open the DM with someone, creating it on first use — the web's |
| + /// openOrCreateDM, behind every "Message them" (the profile card, a |
| + /// tapped @mention). There's exactly one DM per pair, so an existing one |
| + /// reopens (un-hiding a closed row) rather than spawning a second. |
| + /// Returns the thread id to navigate to, or nil — the refusal copy |
| + /// ("They're not accepting messages" when their DM door is closed, #578) |
| + /// is already in loadError, exactly as createChat leaves it. |
| + public func openOrCreateDM(uid: String) async -> String? { |
| + if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) { |
| + reopenIfHidden(dm) |
| + return dm.id |
| + } |
| + return await createChat(memberIDs: [uid], groupName: nil) |
| + } |
| + |
| // MARK: DM privacy + end conversation (#578) |
| /// End a DM for BOTH people (the web's endConversation): it drops off both |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MentionsTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MentionsTests.swift| new file mode 100644 |
| index 0000000..e1f68ff |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/MentionsTests.swift |
| @@ -0,0 +1,118 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// The @mention render-layer scan (Mentions.swift) — the native twin of the |
| +// web's renderRichText mention rules (apps/chat.js), so both feeds agree on |
| +// exactly which "@…" runs are mentions. |
| +final class MentionsTests: XCTestCase { |
| + private func index(me: String = "me") -> MentionIndex { |
| + MentionIndex(entries: [ |
| + .init(label: "Ada", uid: "u1", display: "Ada", tappable: true), |
| + .init(label: "Ada Lovelace", uid: "u2", display: "Ada L", tappable: true), |
| + .init(label: "Bob", uid: "u3", display: "Bobby", tappable: true), |
| + .init(label: "Me", uid: me, display: "Me", tappable: false), |
| + ]) |
| + } |
| + |
| + func testBasicMention() { |
| + let segs = index().split("hi @Bob!") |
| + XCTAssertEqual(segs, [ |
| + .text("hi "), |
| + .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), |
| + .text("!"), |
| + ]) |
| + } |
| + |
| + func testLongestLabelWins() { |
| + // "@Ada Lovelace" must resolve to the two-word member, never "Ada"+text. |
| + let segs = index().split("@Ada Lovelace hello") |
| + guard case .mention(let e) = segs.first else { return XCTFail("no mention") } |
| + XCTAssertEqual(e.uid, "u2") |
| + XCTAssertEqual(segs.dropFirst().first, .text(" hello")) |
| + } |
| + |
| + func testMidWordAndEmailNeverMatch() { |
| + // The web's prevCh guard: /[\w@.]/ blocks mid-word @, emails, and |
| + // domain-ish text. |
| + XCTAssertEqual(index().split("mail@Bob.com"), [.text("mail@Bob.com")]) |
| + XCTAssertEqual(index().split("x@Bob"), [.text("x@Bob")]) |
| + XCTAssertEqual(index().split("a.@Bob"), [.text("a.@Bob")]) |
| + } |
| + |
| + func testTrailingWordCharBlocks() { |
| + // (?![\w]) — "@Bobcat" is not a mention of Bob… |
| + XCTAssertEqual(index().split("@Bobcat"), [.text("@Bobcat")]) |
| + // …but trailing punctuation is fine. |
| + guard case .mention(let e)? = index().split("@Bob,").first else { |
| + return XCTFail("no mention") |
| + } |
| + XCTAssertEqual(e.uid, "u3") |
| + } |
| + |
| + func testStartAndAfterWhitespaceMatch() { |
| + XCTAssertEqual(index().split("@Bob").count, 1) |
| + let segs = index().split("(@Bob)") |
| + XCTAssertEqual(segs, [ |
| + .text("("), |
| + .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), |
| + .text(")"), |
| + ]) |
| + } |
| + |
| + func testSelfMentionNotTappable() { |
| + guard case .mention(let e)? = index().split("@Me too").first else { |
| + return XCTFail("no mention") |
| + } |
| + XCTAssertFalse(e.tappable) |
| + } |
| + |
| + func testAdjacentMentions() { |
| + // "@Ada@Bob" — the web's regex leaves the second as plain text (its |
| + // prev char is a word char / the chip boundary). |
| + XCTAssertEqual(index().split("@Ada@Bob"), [ |
| + .mention(.init(label: "Ada", uid: "u1", display: "Ada", tappable: true)), |
| + .text("@Bob"), |
| + ]) |
| + // Separated by a space both match. |
| + let both = index().split("@Ada @Bob") |
| + XCTAssertEqual(both.count, 3) |
| + } |
| + |
| + func testUnknownNameStaysText() { |
| + XCTAssertEqual(index().split("@Nobody here"), [.text("@Nobody here")]) |
| + } |
| + |
| + func testMayMentionFastPath() { |
| + XCTAssertFalse(index().mayMention("no at sign")) |
| + XCTAssertTrue(index().mayMention("hi @Bob")) |
| + XCTAssertFalse(MentionIndex(entries: []).mayMention("hi @Bob")) |
| + } |
| + |
| + func testRevChangesWithDisplay() { |
| + // The memo key: a rename (display change) must produce a new rev. |
| + let a = index() |
| + let b = MentionIndex(entries: [ |
| + .init(label: "Ada", uid: "u1", display: "Ada", tappable: true), |
| + .init(label: "Ada Lovelace", uid: "u2", display: "Ada L", tappable: true), |
| + .init(label: "Bob", uid: "u3", display: "Robert", tappable: true), |
| + .init(label: "Me", uid: "me", display: "Me", tappable: false), |
| + ]) |
| + XCTAssertNotEqual(a.rev, b.rev) |
| + } |
| + |
| + func testUnicodeTextAroundMentions() { |
| + let segs = index().split("héllo 👋 @Bob — ça va?") |
| + XCTAssertEqual(segs, [ |
| + .text("héllo 👋 "), |
| + .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), |
| + .text(" — ça va?"), |
| + ]) |
| + } |
| + |
| + func testMentionLinkRoundTrip() { |
| + let uid = "user id/with?odd#chars" |
| + guard let url = MentionLink.url(uid: uid) else { return XCTFail("no url") } |
| + XCTAssertEqual(MentionLink.uid(from: url), uid) |
| + XCTAssertNil(MentionLink.uid(from: URL(string: "https://example.com/x")!)) |
| + } |
| +} |
diff --git a/ios/LChatMac/GroupSheets.swift b/ios/LChatMac/GroupSheets.swift| index ae6b680..ff80e7a 100644 |
| --- a/ios/LChatMac/GroupSheets.swift |
| +++ b/ios/LChatMac/GroupSheets.swift |
| @@ -8,6 +8,9 @@ import SwiftUI |
| struct MembersSheet: View { |
| let store: ThreadStore |
| let thread: ChatThread |
| + // A profile card's "Message them" — the presenter navigates; this sheet |
| + // just gets out of the way first. |
| + var onOpenThread: ((String) -> Void)? |
| @Environment(\.dismiss) private var dismiss |
| @State private var selected: Set<String> = [] |
| @@ -88,7 +91,10 @@ struct MembersSheet: View { |
| .frame(width: 300) |
| .tint(accent) |
| .sheet(item: $profile) { target in |
| - MemberProfileSheet(store: store, uid: target.id) { e in |
| + MemberProfileSheet(store: store, uid: target.id, |
| + onOpenThread: onOpenThread.map { open in |
| + { id in profile = nil; dismiss(); open(id) } |
| + }) { e in |
| NativeChatView.ThreadAvatar(title: store.displayName(for: e), |
| source: store.avatarSource(for: e), size: 72) |
| } |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index f1f21d8..7441d12 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -219,7 +219,11 @@ struct NativeChatView: View { |
| } |
| .onReceive(store.$threads) { _ in publishPaletteChats() } |
| .sheet(item: $membersSheetThread) { t in |
| - MembersSheet(store: store, thread: t) |
| + MembersSheet(store: store, thread: t, |
| + onOpenThread: { id in |
| + membersSheetThread = nil |
| + selectedID = id |
| + }) |
| } |
| // End conversation (#578): the destructive confirm behind the menu |
| // item — ending is mutual, so it never fires on a bare click. |
| @@ -243,7 +247,14 @@ struct NativeChatView: View { |
| Text("It closes for both of you, and no new messages can be sent unless someone reopens it.") |
| } |
| .sheet(item: $headerProfile) { target in |
| - MemberProfileSheet(store: store, uid: target.id) { e in |
| + // Message them selects (or creates) their DM — hidden when the |
| + // selected chat already IS their DM (the web card's gate). |
| + let theirDM = store.threads.first { $0.kind == "dm" && $0.members.contains(target.id) } |
| + MemberProfileSheet(store: store, uid: target.id, |
| + onOpenThread: (theirDM != nil && theirDM?.id == selectedID) ? nil : { id in |
| + headerProfile = nil |
| + selectedID = id |
| + }) { e in |
| ThreadAvatar(title: store.displayName(for: e), |
| source: store.avatarSource(for: e), size: 72) |
| } |
| @@ -667,7 +678,10 @@ struct NativeChatView: View { |
| // The switch cover's drop signal: this feed is |
| // now visibly landed, so the outgoing chat held |
| // over its settle beat can go. |
| - onSettled: { feedDidSettle(thread.id) }) |
| + onSettled: { feedDidSettle(thread.id) }, |
| + // A tapped @mention opens the same profile card |
| + // the DM header does (#577/#578). |
| + onMemberProfile: { headerProfile = HeaderProfileTarget(id: $0) }) |
| .id(thread.id + "|" + thread.threadKeyB64 + "|" |
| + String(UInt(bitPattern: ObjectIdentifier(model).hashValue))) |
| // The Text Size pref (View menu ⌘+/⌘−, Settings slider) drives the |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 0788f62..d6303e3 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -125,7 +125,8 @@ struct NativeFeedView: View { |
| onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil, |
| onMutePerson: ((String, Bool) -> Void)? = nil, |
| onReport: ((ChatFeedModel.Message) -> Void)? = nil, |
| - onSettled: (() -> Void)? = nil) { |
| + onSettled: (() -> Void)? = nil, |
| + onMemberProfile: ((String) -> Void)? = nil) { |
| // The model arrives PARKED (NativeFeedModelCache) — folded messages and |
| // a live socket survive leaving the chat, so re-opening never flashes. |
| _model = StateObject(wrappedValue: model) |
| @@ -164,6 +165,7 @@ struct NativeFeedView: View { |
| self.onMutePerson = onMutePerson |
| self.onReport = onReport |
| self.onSettled = onSettled |
| + self.onMemberProfile = onMemberProfile |
| } |
| private let defaultCommand: AppDefaultCommand.Spec? |
| @@ -191,6 +193,8 @@ struct NativeFeedView: View { |
| // true) — the chat switcher uses it to drop the outgoing feed it holds |
| // over the settle beat. |
| private let onSettled: (() -> Void)? |
| + // A tapped @mention chip — the container opens the member's profile card. |
| + private let onMemberProfile: ((String) -> Void)? |
| // The companion app's start-of-history intro (chatIntro on the web), |
| // shown above the earliest message once all history is loaded. |
| @@ -718,6 +722,17 @@ struct NativeFeedView: View { |
| // Instead RootView owns the ONE drop surface for the whole window |
| // and PaneDropRouter (top of file) hands each drop to whichever |
| // registered feed belongs to the pane selected at drag time. |
| + // @mentions: the roster index bubbles style "@Name" runs against |
| + // (feed rows and the reply-thread view alike), and the chip-tap |
| + // interception — a MentionLink URL opens the member's profile card; |
| + // every real link keeps the system route. |
| + .environment(\.mentionIndex, model.mentionIndex) |
| + .environment(\.openURL, OpenURLAction { url in |
| + guard let uid = MentionLink.uid(from: url), let onMemberProfile |
| + else { return .systemAction } |
| + onMemberProfile(uid) |
| + return .handled |
| + }) |
| } |
| private func updateJumpButtonVisible(gap: CGFloat) { |