From b769401bc11123d056456d2376257d36e8eed81a Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:33 +0300 Subject: [PATCH] feat: link pages with mentions and keep images inside notes --- app/frontend/package.json | 3 + app/frontend/src/components/NoteEditor.tsx | 50 ++++- .../src/components/note-extensions.tsx | 181 ++++++++++++++++++ app/frontend/src/pages/NotePage.tsx | 24 ++- app/frontend/src/styles/global.css | 52 +++++ app/frontend/test/notes.spec.tsx | 24 +++ app/package-lock.json | 51 ++++- app/server/src/notes/document-text.ts | 14 ++ app/server/src/realtime/realtime.service.ts | 29 ++- app/server/test/integration/notes.spec.ts | 37 +++- 10 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 app/frontend/src/components/note-extensions.tsx diff --git a/app/frontend/package.json b/app/frontend/package.json index 21e8d5b..1674995 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -30,11 +30,14 @@ "@tiptap/core": "3.31.4", "@tiptap/extension-collaboration": "3.31.4", "@tiptap/extension-collaboration-caret": "3.31.4", + "@tiptap/extension-image": "3.31.4", "@tiptap/extension-list": "3.31.4", + "@tiptap/extension-mention": "3.31.4", "@tiptap/extensions": "3.31.4", "@tiptap/pm": "3.31.4", "@tiptap/react": "3.31.4", "@tiptap/starter-kit": "3.31.4", + "@tiptap/suggestion": "3.31.4", "uqr": "0.1.3", "y-indexeddb": "9.0.12", "yjs": "13.6.33" diff --git a/app/frontend/src/components/NoteEditor.tsx b/app/frontend/src/components/NoteEditor.tsx index 331c886..7e33b8c 100644 --- a/app/frontend/src/components/NoteEditor.tsx +++ b/app/frontend/src/components/NoteEditor.tsx @@ -4,13 +4,18 @@ import { Avatar, LinesSkeleton, useSnackbar } from '@project-graphite/ui'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCaret from '@tiptap/extension-collaboration-caret'; import { TaskItem, TaskList } from '@tiptap/extension-list'; +import type { EditorView } from '@tiptap/pm/view'; import { Placeholder } from '@tiptap/extensions'; import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; +import { useNavigate } from 'react-router'; import { useAuth } from '../auth'; +import { maxUploadBytes, preparedUpload } from '../files'; import { realtimeUrl } from '../notes'; +import type { Item } from '../spaces'; +import { mentionExtension, StoredImageExtension } from './note-extensions'; interface Connection { document: Y.Doc; @@ -68,9 +73,10 @@ function Toolbar({ editor }: { editor: Editor }) { ); } -function CollaborativeEditor({ connection, editable }: { connection: Connection; editable: boolean }) { +function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connection: Connection; editable: boolean; noteId: string; spaceId: string }) { const auth = useAuth(); const show = useSnackbar(); + const navigate = useNavigate(); const [people, setPeople] = useState([]); const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); const [unsynced, setUnsynced] = useState(0); @@ -86,12 +92,48 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection; Placeholder.configure({ placeholder: editable ? 'Start writing…' : '' }), Collaboration.configure({ document: connection.document }), CollaborationCaret.configure({ provider: connection.provider, user: { name: user.displayName, color: colorFor(user.id) } }), + mentionExtension(auth.request, noteId), + StoredImageExtension, ], - editorProps: { attributes: { 'aria-label': 'Page content', class: 'note-content' } }, + editorProps: { + attributes: { 'aria-label': 'Page content', class: 'note-content' }, + handleClickOn: (_view, _position, node) => { + if (node.type.name !== 'mention' || !node.attrs.href) return false; + navigate(String(node.attrs.href)); + return true; + }, + handlePaste: (view, event) => insertImages(view, [...(event.clipboardData?.files ?? [])]), + handleDrop: (view, event) => insertImages(view, [...((event as DragEvent).dataTransfer?.files ?? [])]), + }, }, [connection], ); + function insertImages(view: EditorView, files: File[]) { + const images = files.filter((file) => file.type.startsWith('image/')); + if (!editable || images.length === 0) return false; + void (async () => { + for (const file of images) { + if (file.size > maxUploadBytes) { + show({ message: `${file.name} is over 25 MB.`, tone: 'error' }); + continue; + } + try { + const uploaded = await auth.request(`/spaces/${spaceId}/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(file.name) }, + body: await preparedUpload(file), + }); + const image = view.state.schema.nodes.image!.create({ fileId: uploaded.id, alt: file.name }); + view.dispatch(view.state.tr.replaceSelectionWith(image)); + } catch { + show({ message: `Could not upload ${file.name}.`, tone: 'error' }); + } + } + })(); + return true; + } + useEffect(() => { const { provider } = connection; const onStatus = ({ status: next }: { status: 'connecting' | 'connected' | 'disconnected' }) => setStatus(next); @@ -153,7 +195,7 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection; ); } -export default function NoteEditor({ editable, noteId }: { editable: boolean; noteId: string }) { +export default function NoteEditor({ editable, noteId, spaceId }: { editable: boolean; noteId: string; spaceId: string }) { const auth = useAuth(); const [connection, setConnection] = useState(); const accessToken = useRef(auth.accessToken); @@ -175,5 +217,5 @@ export default function NoteEditor({ editable, noteId }: { editable: boolean; no }, [noteId]); if (!connection) return ; - return ; + return ; } diff --git a/app/frontend/src/components/note-extensions.tsx b/app/frontend/src/components/note-extensions.tsx new file mode 100644 index 0000000..9b2b78b --- /dev/null +++ b/app/frontend/src/components/note-extensions.tsx @@ -0,0 +1,181 @@ +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import Image from '@tiptap/extension-image'; +import Mention from '@tiptap/extension-mention'; +import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type NodeViewProps } from '@tiptap/react'; +import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; +import { readBlob } from '../api'; +import { useAuth } from '../auth'; +import { itemHref } from '../spaces'; + +export interface MentionCandidate { + id: string; + label: string; + href: string; + hint: string; +} + +interface SearchReply { + items: { id: string; spaceId: string; spaceName: string; kind: string; title: string }[]; +} + +type Request = ReturnType['request']; + +interface ListHandle { + onKeyDown: (props: SuggestionKeyDownProps) => boolean; +} + +const MentionList = forwardRef>(function MentionList({ command, items }, ref) { + const [active, setActive] = useState(0); + useEffect(() => setActive(0), [items]); + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (items.length === 0) return false; + if (event.key === 'ArrowDown') setActive((current) => (current + 1) % items.length); + else if (event.key === 'ArrowUp') setActive((current) => (current - 1 + items.length) % items.length); + else if (event.key === 'Enter') command({ id: items[active]!.id, label: items[active]!.label, href: items[active]!.href } as never); + else return false; + return true; + }, + })); + return ( +
+ {items.length === 0 ? ( +

Nothing matches

+ ) : ( + items.map((item, index) => ( + + )) + )} +
+ ); +}); + +export function mentionExtension(request: Request, noteId: string) { + return Mention.extend({ + addAttributes() { + return { + ...this.parent?.(), + href: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-href'), + renderHTML: (attributes: { href?: string | null }) => (attributes.href ? { 'data-href': attributes.href } : {}), + }, + }; + }, + }).configure({ + HTMLAttributes: { class: 'mention' }, + renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`, + renderHTML: ({ node, options }) => ['span', { ...options.HTMLAttributes, 'data-href': node.attrs.href }, `@${node.attrs.label ?? node.attrs.id}`], + suggestion: { + items: async ({ query }) => { + if (query.trim().length < 2) return []; + const reply = await request(`/search?q=${encodeURIComponent(query.trim())}`).catch(() => ({ items: [] })); + return reply.items + .filter((item) => item.id !== noteId) + .slice(0, 8) + .map((item) => ({ id: item.id, label: item.title || 'Untitled', href: itemHref(item) ?? `/spaces/${item.spaceId}`, hint: `${item.kind} · ${item.spaceName}` })); + }, + render: () => { + let renderer: ReactRenderer> | undefined; + const place = (props: SuggestionProps) => { + const box = props.clientRect?.(); + const element = renderer?.element as HTMLElement | undefined; + if (!box || !element) return; + element.style.position = 'fixed'; + element.style.left = `${box.left}px`; + element.style.top = `${box.bottom + 6}px`; + element.style.zIndex = '50'; + }; + return { + onStart: (props) => { + renderer = new ReactRenderer(MentionList, { props, editor: props.editor }); + document.body.append(renderer.element); + place(props); + }, + onUpdate: (props) => { + renderer?.updateProps(props); + place(props); + }, + onKeyDown: (props) => { + if (props.event.key === 'Escape') { + renderer?.destroy(); + renderer?.element.remove(); + renderer = undefined; + return true; + } + return renderer?.ref?.onKeyDown(props) ?? false; + }, + onExit: () => { + renderer?.destroy(); + renderer?.element.remove(); + renderer = undefined; + }, + }; + }, + }, + }); +} + +function StoredImage({ node, selected }: NodeViewProps) { + const { request } = useAuth(); + const [source, setSource] = useState(); + const [failed, setFailed] = useState(false); + const fileId = node.attrs.fileId as string | null; + + useEffect(() => { + if (!fileId) return; + let url = ''; + let cancelled = false; + request(`/files/${fileId}`, {}, readBlob).then( + (blob) => { + if (cancelled) return; + url = URL.createObjectURL(blob); + setSource(url); + }, + () => !cancelled && setFailed(true), + ); + return () => { + cancelled = true; + if (url) URL.revokeObjectURL(url); + }; + }, [fileId, request]); + + return ( + + {failed ? ( + This image is no longer available. + ) : source ? ( + {(node.attrs.alt + ) : ( + + ); +} + +export const StoredImageExtension = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + fileId: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-file-id'), + renderHTML: (attributes: { fileId?: string | null }) => (attributes.fileId ? { 'data-file-id': attributes.fileId } : {}), + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(StoredImage); + }, +}); diff --git a/app/frontend/src/pages/NotePage.tsx b/app/frontend/src/pages/NotePage.tsx index 36cdd6a..05b41a5 100644 --- a/app/frontend/src/pages/NotePage.tsx +++ b/app/frontend/src/pages/NotePage.tsx @@ -5,7 +5,7 @@ import { useAuth } from '../auth'; import { LoadError } from '../components/LoadError'; import { NoteHistory } from '../components/NoteHistory'; import { noteTitle, type Note, type NoteDetails } from '../notes'; -import { useSpaces } from '../spaces'; +import { itemHref, useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; @@ -18,6 +18,11 @@ export function NotePage() { const navigate = useNavigate(); const note = useResource(`/notes/${id}`, true); const pages = useResource(note.data ? `/spaces/${note.data.spaceId}/notes` : null, true); + const links = useResource<{ backlinks: { id: string; kind: string; item: { id: string; spaceId: string; kind: string; title: string } }[] }>( + note.data ? `/items/${note.data.id}/links` : null, + true, + ); + const backlinks = (links.data?.backlinks ?? []).filter((link) => link.kind === 'mention'); const renaming = useAction(); const adding = useAction(); const [history, setHistory] = useState(false); @@ -82,7 +87,7 @@ export function NotePage() { readOnly={!details.editable} /> }> - + {(children.length > 0 || details.editable) && (
@@ -121,6 +126,21 @@ export function NotePage() { )}
)} + {backlinks.length > 0 && ( +
+

Linked from

+
    + {backlinks.map(({ id: linkId, item }) => ( +
  • + + {noteTitle(item)} + + · {item.kind} +
  • + ))} +
+
+ )} {history && setHistory(false)} />} ); diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index a983eae..6dd9ad3 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -160,3 +160,55 @@ white-space: nowrap; user-select: none; } + +.note-content .mention { + border-radius: 0.3rem; + background: var(--color-line-soft); + padding: 0.05rem 0.3rem; + color: var(--color-ink); + cursor: pointer; + white-space: nowrap; +} + +.mention-menu { + display: grid; + width: min(22rem, calc(100vw - 2rem)); + max-height: 18rem; + overflow-y: auto; + border: 1px solid var(--color-line); + border-radius: var(--radius-panel); + background: var(--color-surface); + padding: 0.25rem; + box-shadow: var(--shadow-popover); +} + +.mention-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-radius: var(--radius-field); + padding: 0.45rem 0.6rem; + color: var(--color-ink); + font-size: 0.9rem; + text-align: left; +} + +.mention-option[aria-selected='true'] { + background: var(--color-line-soft); +} + +.note-image { + display: block; + margin: 0.75em 0; +} + +.note-image img { + max-width: 100%; + border-radius: var(--radius-field); +} + +.note-image.is-selected img { + outline: 2px solid var(--color-ink); + outline-offset: 2px; +} diff --git a/app/frontend/test/notes.spec.tsx b/app/frontend/test/notes.spec.tsx index fc565f9..c325527 100644 --- a/app/frontend/test/notes.spec.tsx +++ b/app/frontend/test/notes.spec.tsx @@ -180,4 +180,28 @@ describe('Notes', () => { await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'History')!.click()); expect([...container.querySelectorAll('dialog button')].some((button) => button.textContent === 'Restore this version')).toBe(false); }); + + it('lists the pages that mention this one', async () => { + serve((path) => { + if (path === '/notes/lisbon') return json({ ...note('lisbon', 'Lisbon', 'trips'), editable: true, path: [{ id: 'trips', title: 'Trips' }] }); + if (path === '/items/lisbon/links') { + return json({ + outgoing: [], + backlinks: [ + { id: 'l1', kind: 'mention', item: { id: 'plan', spaceId: 'home', kind: 'note', title: 'Holiday plan' } }, + { id: 'l2', kind: 'reference', item: { id: 'other', spaceId: 'home', kind: 'note', title: 'Not a mention' } }, + { id: 'l3', kind: 'mention', item: { id: 'book', spaceId: 'home', kind: 'task', title: 'Book flights' } }, + ], + }); + } + return undefined; + }); + await render('/notes/lisbon'); + + const section = [...container.querySelectorAll('section')].find((candidate) => candidate.textContent?.startsWith('Linked from'))!; + expect([...section.querySelectorAll('a')].map((link) => [link.textContent, link.getAttribute('href')])).toEqual([ + ['Holiday plan', '/notes/plan'], + ['Book flights', '/spaces/home/tasks'], + ]); + }); }); diff --git a/app/package-lock.json b/app/package-lock.json index 529aaec..055a015 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -24,11 +24,14 @@ "@tiptap/core": "3.31.4", "@tiptap/extension-collaboration": "3.31.4", "@tiptap/extension-collaboration-caret": "3.31.4", + "@tiptap/extension-image": "3.31.4", "@tiptap/extension-list": "3.31.4", + "@tiptap/extension-mention": "3.31.4", "@tiptap/extensions": "3.31.4", "@tiptap/pm": "3.31.4", "@tiptap/react": "3.31.4", "@tiptap/starter-kit": "3.31.4", + "@tiptap/suggestion": "3.31.4", "uqr": "0.1.3", "y-indexeddb": "9.0.12", "yjs": "13.6.33" @@ -199,7 +202,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", - "optional": true, "dependencies": { "@floating-ui/utils": "^0.2.12" } @@ -209,7 +211,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", - "optional": true, "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" @@ -219,8 +220,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@hocuspocus/common": { "version": "4.7.0", @@ -2580,6 +2580,19 @@ "@tiptap/pm": "3.31.4" } }, + "node_modules/@tiptap/extension-image": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.4.tgz", + "integrity": "sha512-vtD+rK73abJnPRKOjU37sQTCa5Auyg451yhqZD/3lZKiFJDBHAZKI7lQARkXBQYK289hBhK6yMIwofoqCBvi3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, "node_modules/@tiptap/extension-italic": { "version": "3.31.4", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", @@ -2650,6 +2663,21 @@ "@tiptap/extension-list": "3.31.4" } }, + "node_modules/@tiptap/extension-mention": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.31.4.tgz", + "integrity": "sha512-O3wkrfD85z0o+tOAOy/XcT9gY0pJPfo3GwdgeJfHRy3zObtoz5XR48QvWeN7pDnXrsvupejpZegQtAmGlg/1Nw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4", + "@tiptap/suggestion": "3.31.4" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "3.31.4", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz", @@ -2817,6 +2845,21 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.4.tgz", + "integrity": "sha512-J9WWMQDAzZOlF9VSdLohpxgrwlB1NscqvNhOPoS3WjwrzQqLj+FAvcchfJQcnybF5mYOtftFZexpOzOt1MqjUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, "node_modules/@tiptap/y-tiptap": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.9.tgz", diff --git a/app/server/src/notes/document-text.ts b/app/server/src/notes/document-text.ts index e15665b..94c933e 100644 --- a/app/server/src/notes/document-text.ts +++ b/app/server/src/notes/document-text.ts @@ -19,3 +19,17 @@ function textOf(node: Y.XmlElement | Y.XmlText | Y.XmlFragment): string { export function documentText(document: Y.Doc, maxLength = 100_000) { return textOf(document.getXmlFragment('default')).replace(/\n{3,}/g, '\n\n').trim().slice(0, maxLength); } + +function collect(node: Y.XmlElement | Y.XmlFragment, found: { mentions: Set; files: Set }) { + for (const child of node.toArray()) { + if (!(child instanceof Y.XmlElement)) continue; + if (child.nodeName === 'mention' && child.getAttribute('id')) found.mentions.add(String(child.getAttribute('id'))); + if (child.nodeName === 'image' && child.getAttribute('fileId')) found.files.add(String(child.getAttribute('fileId'))); + collect(child, found); + } + return found; +} + +export function documentLinks(document: Y.Doc) { + return collect(document.getXmlFragment('default'), { mentions: new Set(), files: new Set() }); +} diff --git a/app/server/src/realtime/realtime.service.ts b/app/server/src/realtime/realtime.service.ts index b9eeb54..8a1c19f 100644 --- a/app/server/src/realtime/realtime.service.ts +++ b/app/server/src/realtime/realtime.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@ne import { ConfigService } from '@nestjs/config'; import { HttpAdapterHost } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; -import { ItemKind, SpaceRole } from '@prisma/client'; +import { ItemKind, LinkKind, SpaceRole } from '@prisma/client'; import { Hocuspocus } from '@hocuspocus/server'; import type { IncomingMessage, Server } from 'node:http'; import type { Duplex } from 'node:stream'; @@ -10,7 +10,7 @@ import { WebSocketServer } from 'ws'; import * as Y from 'yjs'; import { AccessService } from '../access/access.service'; import { JwtStrategy } from '../auth/jwt.strategy'; -import { documentText } from '../notes/document-text'; +import { documentLinks, documentText } from '../notes/document-text'; import { PrismaService } from '../prisma/prisma.service'; export const realtimePath = '/api/v1/realtime'; @@ -19,6 +19,7 @@ const maxDocumentBytes = 5 * 1024 * 1024; const maxDocumentsPerSocket = 20; const recheckMs = 60_000; const versionEveryMs = 15 * 60 * 1000; +const maxLinksPerNote = 200; export interface RealtimeContext { userId: string; @@ -88,6 +89,7 @@ export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy data: { updatedById: lastContext?.userId ?? undefined, updatedAt: new Date() }, }), ]); + if (lastContext?.userId) await this.syncLinks(documentName, document, lastContext.userId); await this.snapshot(documentName, state, lastContext?.userId ?? null, clientsCount === 0); }, onDisconnect: async ({ clientsCount, context, document, documentName, socketId }) => { @@ -114,6 +116,29 @@ export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy this.sockets.close(); } + async syncLinks(noteId: string, document: Y.Doc, userId: string) { + const { files, mentions } = documentLinks(document); + for (const [kind, wanted] of [ + [LinkKind.MENTION, mentions], + [LinkKind.ATTACHMENT, files], + ] as const) { + wanted.delete(noteId); + const existing = await this.prisma.itemLink.findMany({ where: { sourceItemId: noteId, kind }, select: { id: true, targetItemId: true } }); + const stale = existing.filter((link) => !wanted.has(link.targetItemId)).map((link) => link.id); + if (stale.length) await this.prisma.itemLink.deleteMany({ where: { id: { in: stale } } }); + const known = new Set(existing.map((link) => link.targetItemId)); + for (const targetItemId of [...wanted].filter((id) => !known.has(id)).slice(0, maxLinksPerNote)) { + const readable = await this.access.assertItem(userId, targetItemId, 'read').then( + () => true, + () => false, + ); + if (readable) { + await this.prisma.itemLink.createMany({ data: [{ sourceItemId: noteId, targetItemId, kind, createdById: userId }], skipDuplicates: true }); + } + } + } + } + async snapshot(itemId: string, state: Uint8Array, userId: string | null, sessionEnded: boolean) { if (state.byteLength > maxDocumentBytes) return; const latest = await this.prisma.noteVersion.findFirst({ diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts index d1f4907..88c8b41 100644 --- a/app/server/test/integration/notes.spec.ts +++ b/app/server/test/integration/notes.spec.ts @@ -1,5 +1,5 @@ import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; -import { SpaceRole } from '@prisma/client'; +import { ItemKind, SpaceRole } from '@prisma/client'; import { afterEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import * as Y from 'yjs'; @@ -205,4 +205,39 @@ describe('Notes and real-time editing against Postgres', () => { [at(120, 9), at(115, 9), at(10, 15), at(1, 9), at(1, 10)].map((date) => date.toISOString()), ); }); + + it('turns mentions and images into links, and never links what the writer cannot read', async () => { + const owner = await member('linker'); + const stranger = await member('private'); + const note = (await owner.call('POST', `/spaces/${owner.personalSpaceId}/notes`, { title: 'Hub' })).body; + const target = (await owner.call('POST', `/spaces/${owner.personalSpaceId}/notes`, { title: 'Target' })).body; + const hidden = (await stranger.call('POST', `/spaces/${stranger.personalSpaceId}/notes`, { title: 'Hidden' })).body; + const picture = await strata.item(owner.personalSpaceId, 'photo.png', ItemKind.FILE); + const realtime = strata.service(RealtimeService); + + const editing = await open(owner, note.id); + const paragraph = new Y.XmlElement('paragraph'); + const mention = new Y.XmlElement('mention'); + mention.setAttribute('id', target.id); + const secret = new Y.XmlElement('mention'); + secret.setAttribute('id', hidden.id); + paragraph.insert(0, [new Y.XmlText('See '), mention, secret]); + const image = new Y.XmlElement('image'); + image.setAttribute('fileId', picture.id); + editing.document.getXmlFragment('default').push([paragraph, image]); + const links = () => strata.prisma.itemLink.findMany({ where: { sourceItemId: note.id }, orderBy: { kind: 'asc' }, select: { kind: true, targetItemId: true } }); + + await until(async () => textOf(realtime.hocuspocus.documents.get(note.id)!), (text) => text.includes('mention')); + realtime.hocuspocus.flushPendingStores(); + expect(await until(links, (rows) => rows.length === 2)).toEqual([ + { kind: 'MENTION', targetItemId: target.id }, + { kind: 'ATTACHMENT', targetItemId: picture.id }, + ]); + expect((await owner.call('GET', `/items/${target.id}/links`)).body.backlinks).toEqual([expect.objectContaining({ kind: 'mention', item: expect.objectContaining({ id: note.id }) })]); + + editing.document.transact(() => paragraph.delete(1, 2)); + await until(async () => textOf(realtime.hocuspocus.documents.get(note.id)!), (text) => !text.includes('mention')); + realtime.hocuspocus.flushPendingStores(); + expect(await until(links, (rows) => rows.length === 1)).toEqual([{ kind: 'ATTACHMENT', targetItemId: picture.id }]); + }); });