"use client"; /** * mention-model — the TASK-0094 R3 mention popup's pure data layer. * * The composer/inline `@` picker shows a grouped, fuzzy-ranked list of * mentionable things in the CURRENT workspace — People (members), Teams, * Pages, Databases — capped at 20 per group. Selecting one inserts the * canonical token `@:` (the frozen shared `parseMentions` * contract — the server derives `mentionIds`/`pageMentionIds` + the * notification fan-out from those tokens, NOT from a rendered `@Name`). * * This module is framework-free (no React) so the grouping/cap/rank rules * are unit-testable (the R3 Tests item). `MentionPopup` fetches the four * sources and hands them to `buildMentionGroups`; `MentionInput` consumes * the result for its dropdown. */ import { fuzzyScore } from "@factory/editor/slash"; /** The four mention kinds (the token prefix). */ export type MentionKind = "user" | "team" | "page" | "database"; /** * A mentionable person — the minimal shape the popup needs (a `User` and a * workspace member's `user` ref both satisfy it; `User`'s `createdAt` is * not needed for the picker). */ export interface MentionMember { id: string; name: string; email?: string | null; image?: string | null; } /** * A mentionable candidate. `label` is what the popup shows; `token` is what * gets inserted (the server parses it); `sublabel` is optional context * (e.g. a team's member count); `user` renders a People-row avatar. */ export interface MentionItem { kind: MentionKind; id: string; label: string; sublabel?: string; token: string; user?: { id: string; name: string; avatarUrl?: string | null }; } /** A named group of items (the popup's section headers, in order). */ export interface MentionGroup { key: string; label: string; kind: MentionKind; items: MentionItem[]; } /** The four sources, already scoped to one workspace. */ export interface MentionSource { people: MentionMember[]; teams: { id: string; name: string; memberCount?: number }[]; pages: { id: string; title: string }[]; databases: { id: string; title: string; icon?: string | null }[]; } /** Per-group result cap (the R3 "max 20 fuzzy results"). */ export const MENTION_GROUP_CAP = 20; const TOKEN_PREFIX: Record = { user: "@user:", team: "@team:", page: "@page:", database: "@database:" }; export function mentionToken(kind: MentionKind, id: string): string { return TOKEN_PREFIX[kind] + id; } /** * The open mention at a caret position: an `@` at the start of the text or * after whitespace, with the query = the chars typed after it (the R3 * word-boundary trigger). `start` = index of the `@`; `end` = the caret. */ const MENTION_TRIGGER = /(?:^|\s)@([A-Za-z0-9._-]*)$/; export interface ActiveMention { start: number; end: number; query: string; } export function mentionActive(textUpToCaret: string): ActiveMention | null { const m = MENTION_TRIGGER.exec(textUpToCaret); const query = m?.[1]; if (!m || query === undefined) return null; // The '@' position: the match is (boundary)@query — the boundary is either // the start of the string (zero-width) or one whitespace char, so the '@' // is at m.index + (index of '@' within the match). const at = m.index + m[0].lastIndexOf("@"); return { start: at, end: at + 1 + query.length, query }; } /** Build the canonical token for a kind + id (the insertion string). */ export function tokenFor(kind: MentionKind, id: string): string { return mentionToken(kind, id); } /** Map the four sources to the canonical group shape (uncapped, unranked). */ function toGroups(source: MentionSource): MentionGroup[] { return [ { key: "people", label: "People", kind: "user", items: source.people.map((u) => ({ kind: "user", id: u.id, label: u.name, token: mentionToken("user", u.id), user: { id: u.id, name: u.name, avatarUrl: u.image ?? null } })) }, { key: "teams", label: "Teams", kind: "team", items: source.teams.map((t) => ({ kind: "team", id: t.id, label: t.name, sublabel: t.memberCount != null ? `${t.memberCount} members` : undefined, token: mentionToken("team", t.id) })) }, { key: "pages", label: "Pages", kind: "page", items: source.pages.map((p) => ({ kind: "page", id: p.id, label: p.title, token: mentionToken("page", p.id) })) }, { key: "databases", label: "Databases", kind: "database", items: source.databases.map((d) => ({ kind: "database", id: d.id, label: d.title, token: mentionToken("database", d.id) })) } ]; } /** * The R3 grouping, in one place: * 1. group order is fixed — People → Teams → Pages → Databases; * 2. People excludes the current user (you can't mention yourself); * 3. items whose token is ALREADY in the text are dropped (no duplicates); * 4. a non-empty `query` keeps only fuzzy matches, ranked best-first; * an empty `query` shows everything in source order; * 5. each group is capped at MENTION_GROUP_CAP. */ export function buildMentionGroups( source: MentionSource, opts: { query?: string; currentUserId?: string | null; existingText?: string } = {} ): MentionGroup[] { const q = (opts.query ?? "").trim(); const existing = opts.existingText ?? ""; const self = opts.currentUserId ?? null; return toGroups(source) .map((group) => { let items = group.items; if (group.kind === "user" && self) items = items.filter((i) => i.id !== self); if (existing) items = items.filter((i) => !existing.includes(i.token)); if (q) { const scored = items .map((item) => ({ item, score: fuzzyScore(q, item.label) })) .filter((s): s is { item: MentionItem; score: number } => s.score !== null) .sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label)); items = scored.map((s) => s.item); } return { ...group, items: items.slice(0, MENTION_GROUP_CAP) }; }) .filter((group) => group.items.length > 0); }