From 7471fc70f789d003b6ad953f441588264e8d6ad9 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:56:07 +0200 Subject: [PATCH] =?UTF-8?q?#150:=20@-Mentions=20=E2=80=94=20Inline-Node,?= =?UTF-8?q?=20instanzweite=20User-Suche,=20Autocomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username (E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention, Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10, Rate-Limit) und GET /users/brief (Batch-Auflösung für live Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters), Chip-CSS. 5 Unit-Tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/users/user-search.controller.ts | 57 +++++++ apps/api/src/users/users.module.ts | 3 +- apps/web/src/editor/MentionAutocomplete.tsx | 143 ++++++++++++++++++ apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/mention.tsx | 61 ++++++++ apps/web/src/pages/PageEditorPage.tsx | 2 + apps/web/src/styles/base.css | 16 ++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/editor.json | 4 + packages/shared/src/editor-schema/html.ts | 7 + packages/shared/src/editor-schema/index.ts | 1 + packages/shared/src/editor-schema/markdown.ts | 39 +++++ .../shared/src/editor-schema/mention.test.ts | 62 ++++++++ packages/shared/src/editor-schema/mentions.ts | 18 +++ .../shared/src/editor-schema/plain-text.ts | 2 + packages/shared/src/editor-schema/schema.ts | 31 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/users.ts | 10 ++ 18 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/users/user-search.controller.ts create mode 100644 apps/web/src/editor/MentionAutocomplete.tsx create mode 100644 apps/web/src/editor/nodes/mention.tsx create mode 100644 packages/shared/src/editor-schema/mention.test.ts create mode 100644 packages/shared/src/editor-schema/mentions.ts create mode 100644 packages/shared/src/users.ts diff --git a/apps/api/src/users/user-search.controller.ts b/apps/api/src/users/user-search.controller.ts new file mode 100644 index 0000000..decfdd5 --- /dev/null +++ b/apps/api/src/users/user-search.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import type { UserBriefView } from '@dorfteich/shared'; + +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimit } from '../rate-limit/rate-limit.guard'; + +/** Cap for the batch `brief` lookup — a page mentions a handful of people. */ +const BRIEF_MAX_IDS = 50; + +/** + * Instance-wide user lookup for `@` mentions (issue #150). Deliberately + * minimal: only id/username/displayName, only active accounts, only for + * signed-in users, rate-limited, and never enumerable without a query — a + * documented consequence of instance-wide mentions is that logged-in users + * can discover usernames this way. + */ +@Controller('users') +@AuthenticatedOnly() +export class UserSearchController { + constructor(private readonly prisma: PrismaService) {} + + @Get('search') + @RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 }) + async search(@Query('q') q: string | undefined): Promise { + const query = (q ?? '').trim(); + if (query.length < 2) return []; + return this.prisma.user.findMany({ + where: { + status: 'ACTIVE', + OR: [ + { username: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + ], + }, + select: { id: true, username: true, displayName: true }, + orderBy: { username: 'asc' }, + take: 10, + }); + } + + /** Batch resolution of mentioned users for live display names; unknown or + * disabled ids are simply absent (the mention renders as a dead chip). */ + @Get('brief') + async brief(@Query('ids') ids: string | undefined): Promise { + const wanted = (ids ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + .slice(0, BRIEF_MAX_IDS); + if (wanted.length === 0) return []; + return this.prisma.user.findMany({ + where: { id: { in: wanted }, status: 'ACTIVE' }, + select: { id: true, username: true, displayName: true }, + }); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 6ecc4c1..74fa37a 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { SessionsModule } from '../auth/sessions.module'; +import { UserSearchController } from './user-search.controller'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ imports: [SessionsModule], - controllers: [UsersController], + controllers: [UsersController, UserSearchController], providers: [UsersService], exports: [UsersService], }) diff --git a/apps/web/src/editor/MentionAutocomplete.tsx b/apps/web/src/editor/MentionAutocomplete.tsx new file mode 100644 index 0000000..873fcf6 --- /dev/null +++ b/apps/web/src/editor/MentionAutocomplete.tsx @@ -0,0 +1,143 @@ +import type { UserBriefView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import type { Editor } from '@tiptap/react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { apiGet } from '../lib/api'; + +/** An open `@` context: the query typed so far and where the `@` began. */ +interface QueryState { + query: string; + from: number; + coords: { left: number; bottom: number }; +} + +/** Detects `@query` immediately before a collapsed cursor (issue #150) — + * only at a word boundary, so typing an e-mail address never opens it. */ +function detectQuery(editor: Editor): { query: string; from: number } | null { + const { selection } = editor.state; + if (!selection.empty) return null; + const $from = selection.$from; + if (!$from.parent.isTextblock) return null; + const start = Math.max(0, $from.parentOffset - 40); + const before = $from.parent.textBetween(start, $from.parentOffset, undefined, ''); + const match = /(^|[^\w@.-])@([a-zA-Z0-9][\w-]*)$/.exec(before); + if (!match) return null; + const query = match[2] ?? ''; + return { query, from: selection.from - query.length - 1 }; +} + +/** + * Autocomplete popup for `@` mentions (issue #150): instance-wide user + * search (min. two characters), Enter/click inserts the mention node with + * the stable user id. Keyboard handling mirrors the wikilink popup. + */ +export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null { + const { t } = useTranslation('editor'); + const [state, setState] = useState(null); + const [selected, setSelected] = useState(0); + + const search = useQuery({ + queryKey: ['user-search', state?.query ?? ''], + queryFn: () => apiGet(`/users/search?q=${encodeURIComponent(state!.query)}`), + enabled: Boolean(state && state.query.length >= 2), + staleTime: 30 * 1000, + }); + const suggestions = state && state.query.length >= 2 ? (search.data ?? []) : []; + + const live = useRef({ state, suggestions, selected }); + live.current = { state, suggestions, selected }; + + function close(): void { + setState(null); + setSelected(0); + } + + function choose(item: UserBriefView | undefined): void { + const current = live.current.state; + if (!item || !current) return; + const range = { from: current.from, to: editor.state.selection.from }; + editor + .chain() + .focus() + .insertContentAt(range, [ + { type: 'mention', attrs: { userId: item.id, username: item.username } }, + { type: 'text', text: ' ' }, + ]) + .run(); + close(); + } + + // Recompute the open query on every doc/selection change. + useEffect(() => { + const update = (): void => { + const found = detectQuery(editor); + if (!found) { + setState(null); + return; + } + const coords = editor.view.coordsAtPos(editor.state.selection.from); + setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } }); + setSelected(0); + }; + editor.on('transaction', update); + return () => { + editor.off('transaction', update); + }; + }, [editor]); + + // Keyboard navigation, intercepted before ProseMirror (capture phase). + useEffect(() => { + const dom = editor.view.dom; + const onKeyDown = (event: KeyboardEvent): void => { + const { state: s, suggestions: items, selected: sel } = live.current; + if (!s || items.length === 0) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelected((i) => (i + 1) % items.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelected((i) => (i - 1 + items.length) % items.length); + } else if (event.key === 'Enter') { + event.preventDefault(); + choose(items[sel]); + } else if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }; + dom.addEventListener('keydown', onKeyDown, true); + return () => dom.removeEventListener('keydown', onKeyDown, true); + }, [editor]); + + if (!state || suggestions.length === 0) return null; + + return ( +
    + {suggestions.map((user, index) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 4a8730c..7442699 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; import { PluginBlock } from './nodes/plugin-block'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { TaskItem } from './nodes/task-item'; +import { Mention } from './nodes/mention'; import { Transclusion } from './nodes/transclusion'; import { Wikilink } from './nodes/wikilink'; import { @@ -44,6 +45,7 @@ export const documentExtensions: AnyExtension[] = [ Image, PluginBlock, Wikilink, + Mention, Transclusion, Table, TableRow, diff --git a/apps/web/src/editor/nodes/mention.tsx b/apps/web/src/editor/nodes/mention.tsx new file mode 100644 index 0000000..0243596 --- /dev/null +++ b/apps/web/src/editor/nodes/mention.tsx @@ -0,0 +1,61 @@ +import type { UserBriefView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; + +import { apiGet } from '../../lib/api'; +import { attributesFromSpec, nodeSpec } from '../spec-utils'; + +/** + * Renders an `@username` mention (issue #150). The stable reference is the + * user id; the shown text is the user's *current* display name (a rename + * shows everywhere immediately), falling back to `@username`. A mention whose + * user no longer resolves — deleted account, or an unresolved Markdown + * import — renders as a muted "dead" chip and never notifies anyone. + */ +function MentionView({ node }: NodeViewProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const userId = node.attrs.userId as string; + const username = node.attrs.username as string; + + const brief = useQuery({ + queryKey: ['user-brief', userId], + queryFn: () => apiGet(`/users/brief?ids=${encodeURIComponent(userId)}`), + enabled: Boolean(userId), + staleTime: 5 * 60 * 1000, + }); + + const resolved = brief.data?.find((user) => user.id === userId); + const dead = Boolean(userId) && brief.isSuccess && !resolved; + const label = resolved ? `@${resolved.displayName}` : `@${username}`; + + return ( + + + {label} + + + ); +} + +const mentionSpec = nodeSpec('mention'); +export const Mention = Node.create({ + name: 'mention', + group: mentionSpec.group, + inline: mentionSpec.inline, + atom: mentionSpec.atom, + addAttributes() { + return attributesFromSpec(mentionSpec); + }, + parseHTML: () => mentionSpec.parseDOM, + renderHTML: ({ node }) => mentionSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(MentionView); + }, +}); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 68b2afb..db5aff5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; import { useCollabProvider } from '../editor/use-collab-provider'; +import { MentionAutocomplete } from '../editor/MentionAutocomplete'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; @@ -337,6 +338,7 @@ function PageEditor({ )} {canEdit && } + {canEdit && } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 7cb3e8a..4dd2eb4 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3808,3 +3808,19 @@ ul[data-type='task_list'] li p:last-of-type { border-bottom-color: var(--color-accent); } } + +/* `@username` mention chip (issue #150). */ +.dt-mention { + display: inline-block; + padding: 0 0.35em; + border-radius: 999px; + background: var(--color-bg-subtle); + color: var(--color-accent); + font-weight: 500; + white-space: nowrap; +} + +.dt-mention--dead { + color: var(--color-text-muted); + text-decoration: line-through; +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 08fe3f6..49f973d 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -181,5 +181,9 @@ "notFound": { "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "create": "Seite „{{slug}}“ anlegen" + }, + "mention": { + "unresolved": "Unbekannter Nutzer", + "suggestLabel": "Nutzer-Vorschläge" } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 846f907..c183b6e 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -181,5 +181,9 @@ "notFound": { "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "create": "Create the page “{{slug}}”" + }, + "mention": { + "unresolved": "Unknown user", + "suggestLabel": "User suggestions" } } diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 28014c1..ad47a44 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -69,6 +69,13 @@ function renderInline(node: Node): string { const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; out += `${text}`; + } else if (child.type.name === 'mention') { + // A user mention (issue #150): static HTML shows the @username; the + // stable user id rides along for consumers that can resolve it. + const username = escapeHtml(child.attrs.username as string); + const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : ''; + const idAttr = userId ? ` data-mention-user-id="${userId}"` : ''; + out += `@${username}`; } }); return out; diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index 9ee25c1..a871a31 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -5,3 +5,4 @@ export * from './html'; export * from './plain-text'; export * from './outline'; export * from './wikilinks'; +export * from './mentions'; diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index d2a9a83..9590c66 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -192,6 +192,32 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean { return true; } +/** Username shape after the `@` (mirrors the signup `usernameSchema`). */ +const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i; + +/** markdown-it inline rule for `@username` mentions (issue #150). Only fires + * at a word boundary — `stefan@example.org` stays plain text. The parser is + * database-less, so the user id stays empty (purely visual) until the editor + * or a resolver fills it in. */ +function mentionRule(state: StateInline, silent: boolean): boolean { + const start = state.pos; + if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false; + const before = start > 0 ? state.src[start - 1]! : ''; + if (/[\w@.-]/.test(before)) return false; + const match = MENTION_NAME.exec(state.src.slice(start + 1)); + if (!match || match[0].length < 3) return false; + const username = match[0]; + // Trailing user-ish characters would make this an address, not a mention. + const after = state.src[start + 1 + username.length] ?? ''; + if (after === '@') return false; + if (!silent) { + const token = state.push('mention', '', 0); + token.attrs = [['username', username]]; + } + state.pos = start + 1 + username.length; + return true; +} + /** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page * (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */ const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; @@ -280,6 +306,8 @@ function createTokenizer(): MarkdownIt { const md = new MarkdownIt('default', { html: false }); // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. md.inline.ruler.before('link', 'wikilink', wikilinkRule); + // `@username` mentions (issue #150). + md.inline.ruler.before('link', 'mention', mentionRule); // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads // as plain text (issue #135). md.block.ruler.before('paragraph', 'transclusion', transclusionRule); @@ -347,6 +375,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { displayText: tok.attrGet('display') || null, }), }, + mention: { + node: 'mention', + getAttrs: (tok) => ({ + username: tok.attrGet('username') ?? '', + // A Markdown import cannot resolve users — the mention stays visual. + userId: tok.attrGet('userId') ?? '', + }), + }, transclusion: { node: 'transclusion', getAttrs: (tok) => ({ @@ -478,6 +514,9 @@ const markdownSerializer = new MarkdownSerializer( const display = node.attrs.displayText as string | null; state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); }, + mention(state, node) { + state.write(`@${node.attrs.username as string}`); + }, transclusion(state, node) { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; diff --git a/packages/shared/src/editor-schema/mention.test.ts b/packages/shared/src/editor-schema/mention.test.ts new file mode 100644 index 0000000..e312112 --- /dev/null +++ b/packages/shared/src/editor-schema/mention.test.ts @@ -0,0 +1,62 @@ +import { Node } from 'prosemirror-model'; +import { describe, expect, it } from 'vitest'; + +import { docToHtml } from './html'; +import { docToMarkdown, markdownToDoc } from './markdown'; +import { extractMentionUserIds } from './mentions'; +import { docToPlainText } from './plain-text'; +import { editorSchema } from './schema'; + +function firstMention(doc: Node): Node | null { + let found: Node | null = null; + doc.descendants((node) => { + if (!found && node.type.name === 'mention') found = node; + }); + return found; +} + +describe('@username mention (issue #150)', () => { + it('parses @username to a mention node and round-trips', () => { + const doc = markdownToDoc('Hallo @nadia, schau mal.'); + const node = firstMention(doc); + expect(node).not.toBeNull(); + expect(node!.attrs.username).toBe('nadia'); + expect(node!.attrs.userId).toBe(''); + expect(docToMarkdown(doc)).toContain('@nadia'); + }); + + it('leaves e-mail addresses untouched', () => { + const doc = markdownToDoc('Schreib an stefan@example.org bitte.'); + expect(firstMention(doc)).toBeNull(); + expect(docToMarkdown(doc)).toContain('stefan@example.org'); + }); + + it('renders HTML with the username and optional user id', () => { + const doc = markdownToDoc('Ping @zoraya-shahin!'); + expect(docToHtml(doc)).toContain('class="dt-mention" data-mention="zoraya-shahin"'); + expect(docToHtml(doc)).toContain('@zoraya-shahin'); + }); + + it('appears in the plain text for search', () => { + expect(docToPlainText(markdownToDoc('Frag @nadia.'))).toContain('@nadia'); + }); + + it('extracts only resolved user ids', () => { + const doc = Node.fromJSON(editorSchema, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } }, + { type: 'text', text: ' und ' }, + { type: 'mention', attrs: { userId: '', username: 'import-only' } }, + { type: 'text', text: ' und nochmal ' }, + { type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } }, + ], + }, + ], + }); + expect(extractMentionUserIds(doc)).toEqual(['u-1']); + }); +}); diff --git a/packages/shared/src/editor-schema/mentions.ts b/packages/shared/src/editor-schema/mentions.ts new file mode 100644 index 0000000..5d95fb5 --- /dev/null +++ b/packages/shared/src/editor-schema/mentions.ts @@ -0,0 +1,18 @@ +import { Node } from 'prosemirror-model'; + +/** + * The distinct user ids mentioned in a document (issue #150) — the input for + * the derived `page_mentions` rows and the mention notifications (#151). + * Mentions without a resolved user id (e.g. from a Markdown import) are + * purely visual and excluded. + */ +export function extractMentionUserIds(doc: Node): string[] { + const ids = new Set(); + doc.descendants((node) => { + if (node.type.name === 'mention') { + const userId = node.attrs.userId as string; + if (userId) ids.add(userId); + } + }); + return [...ids]; +} diff --git a/packages/shared/src/editor-schema/plain-text.ts b/packages/shared/src/editor-schema/plain-text.ts index 1a9e7b3..45a9671 100644 --- a/packages/shared/src/editor-schema/plain-text.ts +++ b/packages/shared/src/editor-schema/plain-text.ts @@ -9,6 +9,8 @@ export function docToPlainText(doc: Node): string { if (leaf.type.name === 'wikilink') { return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string); } + // A mention reads as `@username` (issue #150), so search finds it. + if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`; return ''; }); return text.replace(/\n{3,}/g, '\n\n').trim(); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 640b5e8..b15aeff 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -256,6 +256,37 @@ export const editorSchema = new Schema({ }, }, + // `@username` user mention (issue #150). An inline atom carrying the + // mentioned user's stable id plus the username as serialization/display + // fallback. Live display-name resolution happens in the editor where a + // user lookup is available; a Markdown import cannot resolve users, so it + // leaves `userId` empty — such a mention is purely visual. + mention: { + group: 'inline', + inline: true, + atom: true, + attrs: { + userId: { default: '' }, + username: { validate: 'string' }, + }, + parseDOM: [ + { + tag: 'span[data-mention]', + getAttrs: (dom) => ({ + username: dom.getAttribute('data-mention'), + userId: dom.getAttribute('data-mention-user-id') || '', + }), + }, + ], + toDOM: (node) => { + const username = node.attrs.username as string; + const userId = node.attrs.userId as string; + const attrs: Record = { 'data-mention': username, class: 'dt-mention' }; + if (userId) attrs['data-mention-user-id'] = userId; + return ['span', attrs, `@${username}`]; + }, + }, + // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that // references another page by slug; the read view / public renderer expands // it to the target page's rendered HTML (permission-checked, recursion diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 900b450..3d06a0f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,7 @@ export * from './labels'; export * from './legal'; export * from './links'; export * from './members'; +export * from './users'; export * from './notifications'; export * from './pages'; export * from './permissions'; diff --git a/packages/shared/src/users.ts b/packages/shared/src/users.ts new file mode 100644 index 0000000..c365bfe --- /dev/null +++ b/packages/shared/src/users.ts @@ -0,0 +1,10 @@ +/** + * Minimal public identity of a user (issue #150): what the instance-wide + * mention search and the mention rendering expose — never more than id, + * username, and display name. + */ +export interface UserBriefView { + id: string; + username: string; + displayName: string; +}