#150: @-Mentions — Inline-Node, instanzweite User-Suche, Autocomplete

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
Claude Fable 5 2026-07-20 00:56:07 +02:00
parent 7252bd16e0
commit 7471fc70f7
18 changed files with 462 additions and 1 deletions

View File

@ -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<UserBriefView[]> {
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<UserBriefView[]> {
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 },
});
}
}

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SessionsModule } from '../auth/sessions.module'; import { SessionsModule } from '../auth/sessions.module';
import { UserSearchController } from './user-search.controller';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@Module({ @Module({
imports: [SessionsModule], imports: [SessionsModule],
controllers: [UsersController], controllers: [UsersController, UserSearchController],
providers: [UsersService], providers: [UsersService],
exports: [UsersService], exports: [UsersService],
}) })

View File

@ -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<QueryState | null>(null);
const [selected, setSelected] = useState(0);
const search = useQuery({
queryKey: ['user-search', state?.query ?? ''],
queryFn: () => apiGet<UserBriefView[]>(`/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 (
<ul
className="wikilink-suggest"
role="listbox"
aria-label={t('mention.suggestLabel')}
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
>
{suggestions.map((user, index) => (
<li key={user.id}>
<button
type="button"
role="option"
aria-selected={index === selected}
className={
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
}
onMouseDown={(event) => {
event.preventDefault();
choose(user);
}}
>
<span className="dt-mention">@{user.username}</span> {user.displayName}
</button>
</li>
))}
</ul>
);
}

View File

@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block'; import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item'; import { TaskItem } from './nodes/task-item';
import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion'; import { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink'; import { Wikilink } from './nodes/wikilink';
import { import {
@ -44,6 +45,7 @@ export const documentExtensions: AnyExtension[] = [
Image, Image,
PluginBlock, PluginBlock,
Wikilink, Wikilink,
Mention,
Transclusion, Transclusion,
Table, Table,
TableRow, TableRow,

View File

@ -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<UserBriefView[]>(`/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 (
<NodeViewWrapper as="span" className="dt-mention-nodeview">
<span
className={dead || !userId ? 'dt-mention dt-mention--dead' : 'dt-mention'}
title={dead || !userId ? t('mention.unresolved') : `@${username}`}
contentEditable={false}
>
{label}
</span>
</NodeViewWrapper>
);
}
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);
},
});

View File

@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip'; import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar'; import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider'; import { useCollabProvider } from '../editor/use-collab-provider';
import { MentionAutocomplete } from '../editor/MentionAutocomplete';
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions'; import { usePageActionsSlot } from '../layout/page-actions';
@ -337,6 +338,7 @@ function PageEditor({
)} )}
<EditorContent editor={editor} className="editor-content" /> <EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />} {canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div> </div>
</PluginBlockContext.Provider> </PluginBlockContext.Provider>
</WikilinkContext.Provider> </WikilinkContext.Provider>

View File

@ -3808,3 +3808,19 @@ ul[data-type='task_list'] li p:last-of-type {
border-bottom-color: var(--color-accent); 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;
}

View File

@ -181,5 +181,9 @@
"notFound": { "notFound": {
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
"create": "Seite „{{slug}}“ anlegen" "create": "Seite „{{slug}}“ anlegen"
},
"mention": {
"unresolved": "Unbekannter Nutzer",
"suggestLabel": "Nutzer-Vorschläge"
} }
} }

View File

@ -181,5 +181,9 @@
"notFound": { "notFound": {
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
"create": "Create the page “{{slug}}”" "create": "Create the page “{{slug}}”"
},
"mention": {
"unresolved": "Unknown user",
"suggestLabel": "User suggestions"
} }
} }

View File

@ -69,6 +69,13 @@ function renderInline(node: Node): string {
const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`; out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
} 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 += `<span class="dt-mention" data-mention="${username}"${idAttr}>@${username}</span>`;
} }
}); });
return out; return out;

View File

@ -5,3 +5,4 @@ export * from './html';
export * from './plain-text'; export * from './plain-text';
export * from './outline'; export * from './outline';
export * from './wikilinks'; export * from './wikilinks';
export * from './mentions';

View File

@ -192,6 +192,32 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
return true; 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 /** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */ * (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
@ -280,6 +306,8 @@ function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false }); const md = new MarkdownIt('default', { html: false });
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
md.inline.ruler.before('link', 'wikilink', wikilinkRule); 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 // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
// as plain text (issue #135). // as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule); md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
@ -347,6 +375,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
displayText: tok.attrGet('display') || null, 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: { transclusion: {
node: 'transclusion', node: 'transclusion',
getAttrs: (tok) => ({ getAttrs: (tok) => ({
@ -478,6 +514,9 @@ const markdownSerializer = new MarkdownSerializer(
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
}, },
mention(state, node) {
state.write(`@${node.attrs.username as string}`);
},
transclusion(state, node) { transclusion(state, node) {
const slug = node.attrs.targetSlug as string; const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;

View File

@ -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']);
});
});

View File

@ -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<string>();
doc.descendants((node) => {
if (node.type.name === 'mention') {
const userId = node.attrs.userId as string;
if (userId) ids.add(userId);
}
});
return [...ids];
}

View File

@ -9,6 +9,8 @@ export function docToPlainText(doc: Node): string {
if (leaf.type.name === 'wikilink') { if (leaf.type.name === 'wikilink') {
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string); 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 '';
}); });
return text.replace(/\n{3,}/g, '\n\n').trim(); return text.replace(/\n{3,}/g, '\n\n').trim();

View File

@ -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<string, string> = { '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 // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
// references another page by slug; the read view / public renderer expands // references another page by slug; the read view / public renderer expands
// it to the target page's rendered HTML (permission-checked, recursion // it to the target page's rendered HTML (permission-checked, recursion

View File

@ -20,6 +20,7 @@ export * from './labels';
export * from './legal'; export * from './legal';
export * from './links'; export * from './links';
export * from './members'; export * from './members';
export * from './users';
export * from './notifications'; export * from './notifications';
export * from './pages'; export * from './pages';
export * from './permissions'; export * from './permissions';

View File

@ -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;
}