1"use client";
2
3
18import { fuzzyScore } from "@factory/editor/slash";
19
20
21export type MentionKind = "user" | "team" | "page" | "database";
22
23
28export interface MentionMember {
29 id: string;
30 name: string;
31 email?: string | null;
32 image?: string | null;
33}
34
35
40export interface MentionItem {
41 kind: MentionKind;
42 id: string;
43 label: string;
44 sublabel?: string;
45 token: string;
46 user?: { id: string; name: string; avatarUrl?: string | null };
47}
48
49
50export interface MentionGroup {
51 key: string;
52 label: string;
53 kind: MentionKind;
54 items: MentionItem[];
55}
56
57
58export interface MentionSource {
59 people: MentionMember[];
60 teams: { id: string; name: string; memberCount?: number }[];
61 pages: { id: string; title: string }[];
62 databases: { id: string; title: string; icon?: string | null }[];
63}
64
65
66export const MENTION_GROUP_CAP = 20;
67
68const TOKEN_PREFIX: Record<MentionKind, string> = {
69 user: "@user:",
70 team: "@team:",
71 page: "@page:",
72 database: "@database:"
73};
74
75export function mentionToken(kind: MentionKind, id: string): string {
76 return TOKEN_PREFIX[kind] + id;
77}
78
79
84const MENTION_TRIGGER = /(?:^|\s)@([A-Za-z0-9._-]*)$/;
85export interface ActiveMention {
86 start: number;
87 end: number;
88 query: string;
89}
90export function mentionActive(textUpToCaret: string): ActiveMention | null {
91 const m = MENTION_TRIGGER.exec(textUpToCaret);
92 const query = m?.[1];
93 if (!m || query === undefined) return null;
94
95
96
97 const at = m.index + m[0].lastIndexOf("@");
98 return { start: at, end: at + 1 + query.length, query };
99}
100
101
102export function tokenFor(kind: MentionKind, id: string): string {
103 return mentionToken(kind, id);
104}
105
106
107function toGroups(source: MentionSource): MentionGroup[] {
108 return [
109 {
110 key: "people",
111 label: "People",
112 kind: "user",
113 items: source.people.map((u) => ({
114 kind: "user",
115 id: u.id,
116 label: u.name,
117 token: mentionToken("user", u.id),
118 user: { id: u.id, name: u.name, avatarUrl: u.image ?? null }
119 }))
120 },
121 {
122 key: "teams",
123 label: "Teams",
124 kind: "team",
125 items: source.teams.map((t) => ({
126 kind: "team",
127 id: t.id,
128 label: t.name,
129 sublabel: t.memberCount != null ? `${t.memberCount} members` : undefined,
130 token: mentionToken("team", t.id)
131 }))
132 },
133 {
134 key: "pages",
135 label: "Pages",
136 kind: "page",
137 items: source.pages.map((p) => ({
138 kind: "page",
139 id: p.id,
140 label: p.title,
141 token: mentionToken("page", p.id)
142 }))
143 },
144 {
145 key: "databases",
146 label: "Databases",
147 kind: "database",
148 items: source.databases.map((d) => ({
149 kind: "database",
150 id: d.id,
151 label: d.title,
152 token: mentionToken("database", d.id)
153 }))
154 }
155 ];
156}
157
158
167export function buildMentionGroups(
168 source: MentionSource,
169 opts: { query?: string; currentUserId?: string | null; existingText?: string } = {}
170): MentionGroup[] {
171 const q = (opts.query ?? "").trim();
172 const existing = opts.existingText ?? "";
173 const self = opts.currentUserId ?? null;
174
175 return toGroups(source)
176 .map((group) => {
177 let items = group.items;
178 if (group.kind === "user" && self) items = items.filter((i) => i.id !== self);
179 if (existing) items = items.filter((i) => !existing.includes(i.token));
180 if (q) {
181 const scored = items
182 .map((item) => ({ item, score: fuzzyScore(q, item.label) }))
183 .filter((s): s is { item: MentionItem; score: number } => s.score !== null)
184 .sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label));
185 items = scored.map((s) => s.item);
186 }
187 return { ...group, items: items.slice(0, MENTION_GROUP_CAP) };
188 })
189 .filter((group) => group.items.length > 0);
190}
191