eleven / messenger Public

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>
Paul Campbell e027ee72bc39a46a6b215ccbc3b0a6833d96dd99 parent 49da77a
16 files changed, +506 −21
  • ios/LChat/Views/FlippedChatFeed.swift +14 −1
  • ios/LChat/Views/GroupSheets.swift +7 −1
  • ios/LChat/Views/NativeChatScreen.swift +32 −4
  • ios/LChat/Views/SchemaAppScreen.swift +6 −2
  • ios/LChat/Views/StockChatFeed.swift +5 −1
  • ios/LChat/Views/ThreadListView.swift +2 −2
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +41 −0
  • ios/LChatCore/Sources/LChatCore/Mentions.swift +127 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +61 −5
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +15 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +34 −0
  • ios/LChatCore/Tests/LChatCoreTests/MentionsTests.swift +118 −0
  • ios/LChatMac/GroupSheets.swift +7 −1
  • ios/LChatMac/NativeChatView.swift +17 −3
  • ios/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) {