diff --git a/public/editor/collab.ts b/public/editor/collab.ts index be2cd0c..c535058 100644 --- a/public/editor/collab.ts +++ b/public/editor/collab.ts @@ -61,6 +61,8 @@ interface ConnectOptions { user: CollabUser; /** Called when the server signals the session ended (saved or discarded). */ onReload: () => void; + /** Fired once, when the shared doc has first synced from the server. */ + onSynced?: () => void; onStatus?: (status: "connecting" | "connected" | "closed") => void; } @@ -84,6 +86,7 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { let ws: WebSocket | null = null; let closedByUs = false; + let synced = false; let reconnectTimer: ReturnType | undefined; const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${opts.wsPath}`; @@ -117,6 +120,12 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { if (type === MSG_SYNC) { const reply = readSyncFrame(decoder, ydoc, docOrigin); if (reply) send(reply); + // First sync frame means the server's state (content) is now flowing in; + // the editor can mount against the shared doc instead of falling back. + if (!synced) { + synced = true; + opts.onSynced?.(); + } } else if (type === MSG_AWARENESS) { applyAwarenessUpdate( awareness, diff --git a/public/editor/editor.ts b/public/editor/editor.ts index d09cdce..c835e07 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -25,6 +25,10 @@ import { wikilinkCompletion } from "./wikilink-autocomplete.ts"; let activeView: EditorView | null = null; let activeCollab: CollabHandle | null = null; +// How long to wait for the collab socket to sync before falling back to a plain +// editor (e.g. when WebSockets are blocked). Keeps "no editor at all" off the table. +const COLLAB_CONNECT_TIMEOUT_MS = 5000; + /** * Save flow for live-collaboration mode. Reuses the normal edit route — the * shared Y.Doc text is posted through the same `POST .../edit` endpoint as the @@ -122,6 +126,11 @@ function initEditor(root: Document | Element = document): void { const form = textarea.closest("form"); if (!form) return; + // Non-null aliases: control-flow narrowing from the guards above is not carried + // into the nested mountEditor closure, so capture the proven-non-null refs. + const contentTextarea = textarea; + const editForm = form; + textarea.style.display = "none"; const wrapper = document.createElement("div"); @@ -195,9 +204,11 @@ function initEditor(root: Document | Element = document): void { ] : []; - // Live-collaboration mode (opt-in via the Collaborate button → ?collab=1). - // The editor connects to the shared Y.Doc; content arrives over the wire, so - // CodeMirror starts empty and uses the Yjs undo stack instead of CM history. + // Live collaboration is always on for anyone with edit access. The editor + // connects to the shared Y.Doc; content arrives over the wire, so in collab + // mode CodeMirror starts empty and uses the Yjs undo stack. If the socket + // can't sync within a timeout (e.g. WebSockets blocked), we mount a plain + // editor seeded from the server-rendered content so editing still works. const collabEnabled = textarea.dataset["collab"] === "1"; const collabWs = textarea.dataset["collabWs"]; const userDid = textarea.dataset["did"]; @@ -205,152 +216,200 @@ function initEditor(root: Document | Element = document): void { const displayName = textarea.dataset["displayName"] ?? ""; const noteHref = textarea.dataset["noteUrl"] ?? ""; - let collabHandle: CollabHandle | null = null; - if (collabEnabled && collabWs && userDid && userHandle) { - collabHandle = connectCollab({ - wsPath: collabWs, - user: { did: userDid, handle: userHandle, displayName }, - onReload: () => { - if (noteHref) location.assign(noteHref); - }, - }); - activeCollab = collabHandle; - } + // Builds the CodeMirror view and wires the toolbar, uploads and save flow. + // Called once: either bound to a synced collab doc, or in plain mode with the + // initial content. `collabHandle` is null for a plain (solo/fallback) editor. + function mountEditor( + initialDoc: string, + collabHandle: CollabHandle | null, + ): void { + const textarea = contentTextarea; + const form = editForm; + + // Plain editor: there is no live session, so drop the collab-only chrome. + if (!collabHandle) { + root.querySelector("[data-collab-banner]")?.remove(); + root.querySelector("[data-collab-discard]")?.remove(); + } - const view = new EditorView({ - state: EditorState.create({ - doc: collabHandle ? "" : textarea.value, - extensions: [ - lineNumbers(), - drawSelection(), - highlightActiveLine(), + const view = new EditorView({ + state: EditorState.create({ + doc: collabHandle ? "" : initialDoc, + extensions: [ + lineNumbers(), + drawSelection(), + highlightActiveLine(), - ...(collabHandle ? [] : [history()]), + ...(collabHandle ? [] : [history()]), - keymap.of([ - indentWithTab, - ...defaultKeymap, - ...(collabHandle ? yUndoManagerKeymap : historyKeymap), - ]), + keymap.of([ + indentWithTab, + ...defaultKeymap, + ...(collabHandle ? yUndoManagerKeymap : historyKeymap), + ]), - markdown(), + markdown(), - themedEditor, - ...autocompleteExt, - ...(collabHandle ? [collabHandle.extension] : []), + themedEditor, + ...autocompleteExt, + ...(collabHandle ? [collabHandle.extension] : []), - EditorView.lineWrapping, + EditorView.lineWrapping, - EditorView.updateListener.of((update) => { - if (update.docChanged) { - clearTimeout(debounceTimer); + EditorView.updateListener.of((update) => { + if (update.docChanged) { + clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - renderPreview(preview, view.state.doc.toString()); - }, 150); - } - }), + debounceTimer = setTimeout(() => { + renderPreview(preview, view.state.doc.toString()); + }, 150); + } + }), - EditorView.domEventHandlers({ - drop(event) { - const files = event.dataTransfer?.files; - if (!files?.length) return false; + EditorView.domEventHandlers({ + drop(event) { + const files = event.dataTransfer?.files; + if (!files?.length) return false; - const imageFile = Array.from(files).find((f) => - f.type.startsWith("image/"), - ); + const imageFile = Array.from(files).find((f) => + f.type.startsWith("image/"), + ); - if (!imageFile) return false; + if (!imageFile) return false; - event.preventDefault(); + event.preventDefault(); - const pos = view.state.selection.main.head; + const pos = view.state.selection.main.head; - uploadImage(imageFile, view, pos); + uploadImage(imageFile, view, pos); - return true; - }, + return true; + }, - paste(event) { - const files = event.clipboardData?.files; - if (!files?.length) return false; + paste(event) { + const files = event.clipboardData?.files; + if (!files?.length) return false; - const imageFile = Array.from(files).find((f) => - f.type.startsWith("image/"), - ); + const imageFile = Array.from(files).find((f) => + f.type.startsWith("image/"), + ); - if (!imageFile) return false; + if (!imageFile) return false; - event.preventDefault(); + event.preventDefault(); - const pos = view.state.selection.main.head; + const pos = view.state.selection.main.head; - uploadImage(imageFile, view, pos); + uploadImage(imageFile, view, pos); - return true; - }, - }), - ], - }), + return true; + }, + }), + ], + }), - parent: editorPane, - }); + parent: editorPane, + }); - activeView = view; + activeView = view; - // Also apply inline styles as a fallback - view.dom.style.height = "100%"; - view.dom.style.flex = "1"; + // Also apply inline styles as a fallback + view.dom.style.height = "100%"; + view.dom.style.flex = "1"; - const scroller = view.scrollDOM; + const scroller = view.scrollDOM; - if (scroller) { - scroller.style.overflowY = "auto"; - } + if (scroller) { + scroller.style.overflowY = "auto"; + } - if (textarea.dataset["noToolbar"] !== "1") { - const toolbar = createToolbar(view, fileInput); - editorPane.insertBefore(toolbar, editorPane.firstChild); - } - editorPane.appendChild(fileInput); + if (textarea.dataset["noToolbar"] !== "1") { + const toolbar = createToolbar(view, fileInput); + editorPane.insertBefore(toolbar, editorPane.firstChild); + } + editorPane.appendChild(fileInput); - fileInput.addEventListener("change", () => { - const file = fileInput.files?.[0]; + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; - if (file) { - const pos = view.state.selection.main.head; + if (file) { + const pos = view.state.selection.main.head; - uploadImage(file, view, pos); + uploadImage(file, view, pos); - fileInput.value = ""; - } - }); + fileInput.value = ""; + } + }); - renderPreview(preview, textarea.value); + renderPreview(preview, textarea.value); + + form.addEventListener("submit", (event) => { + if (activeCollab) { + event.preventDefault(); + void collabSave(form, activeCollab, noteHref); + return; + } + textarea.value = view.state.doc.toString(); + + syncBlobMetadata(); + }); - form.addEventListener("submit", (event) => { if (activeCollab) { - event.preventDefault(); - void collabSave(form, activeCollab, noteHref); - return; + const discardBtn = root.querySelector( + "[data-collab-discard]", + ); + discardBtn?.addEventListener("click", () => { + const collab = activeCollab; + if (!collab) return; + const confirmText = + discardBtn.dataset["confirm"] ?? "Discard the shared draft?"; + if (confirm(confirmText)) { + collab.discard(); + if (noteHref) setTimeout(() => location.assign(noteHref), 2500); + } + }); } - textarea.value = view.state.doc.toString(); - - syncBlobMetadata(); - }); + } - if (activeCollab) { - const discardBtn = root.querySelector("[data-collab-discard]"); - discardBtn?.addEventListener("click", () => { - const collab = activeCollab; - if (!collab) return; - const confirmText = - discardBtn.dataset["confirm"] ?? "Discard the shared draft?"; - if (confirm(confirmText)) { - collab.discard(); - if (noteHref) setTimeout(() => location.assign(noteHref), 2500); + if (collabEnabled && collabWs && userDid && userHandle) { + // Show a placeholder until the doc syncs or we give up and fall back. + const placeholder = document.createElement("div"); + placeholder.style.padding = "0.75rem"; + placeholder.style.color = "var(--text-muted)"; + placeholder.textContent = + textarea.dataset["collabConnecting"] ?? "Connecting…"; + editorPane.appendChild(placeholder); + + let handle: CollabHandle; + let settled = false; + let fallbackTimer: ReturnType; + + const finish = (useCollab: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(fallbackTimer); + placeholder.remove(); + if (useCollab) { + activeCollab = handle; + mountEditor("", handle); + } else { + handle.destroy(); + mountEditor(textarea.value, null); } + }; + + handle = connectCollab({ + wsPath: collabWs, + user: { did: userDid, handle: userHandle, displayName }, + onReload: () => { + if (noteHref) location.assign(noteHref); + }, + onSynced: () => finish(true), }); + + fallbackTimer = setTimeout(() => finish(false), COLLAB_CONNECT_TIMEOUT_MS); + } else { + mountEditor(textarea.value, null); } } diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 0360df1..5820dab 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -94,7 +94,7 @@ export const en: Messages = { titlePlaceholder: "My Note Title", importFile: "Import from file", importFileBrowse: "Browse", - collaborate: "Collaborate", + collabConnecting: "Connecting…", collabActive: "Live collaboration", discardDraft: "Discard draft", confirmDiscardDraft: diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index dfe8655..1bb06ec 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -96,7 +96,7 @@ export const fr: PartialMessages = { titlePlaceholder: "Titre de ma note", importFile: "Importer depuis un fichier", importFileBrowse: "Parcourir", - collaborate: "Collaborer", + collabConnecting: "Connexion…", collabActive: "Collaboration en direct", discardDraft: "Abandonner le brouillon", confirmDiscardDraft: diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 8a36068..6374b4a 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -94,7 +94,7 @@ export interface Messages { titlePlaceholder: string; importFile: string; importFileBrowse: string; - collaborate: string; + collabConnecting: string; collabActive: string; discardDraft: string; confirmDiscardDraft: string; diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 7116f56..e262a15 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -64,15 +64,6 @@ export function historyNoteUrl( return `/@${handle}/${wikiSlug}/${noteSlug}/-/history`; } -/** Opens the editor in live-collaboration mode. */ -export function editCollabUrl( - handle: string, - wikiSlug: string, - noteSlug: string, -): string { - return `${editNoteUrl(handle, wikiSlug, noteSlug)}?collab=1`; -} - /** * Path for the collab WebSocket. The client prepends ws(s):// + host. Note: no * `@` before the handle here — it matches the `/collab/:handle/...` route param. diff --git a/src/server/db/queries/draft.ts b/src/server/db/queries/draft.ts index 82bbfb7..8c65943 100644 --- a/src/server/db/queries/draft.ts +++ b/src/server/db/queries/draft.ts @@ -40,3 +40,18 @@ export function deleteDraft(noteAtUri: string): void { const db = getDb(); db.run("DELETE FROM drafts WHERE note_at_uri = ?", [noteAtUri]); } + +/** + * Delete drafts untouched for longer than `maxAgeDays`. Abandoned collab drafts + * would otherwise linger forever — the canonical note lives on the PDS, so an + * old unsaved draft is just stale scratch state safe to drop. Returns the number + * of rows removed. + */ +export function deleteStaleDrafts(maxAgeDays = 30): number { + const db = getDb(); + const result = db.run( + "DELETE FROM drafts WHERE last_modified < datetime('now', ?)", + [`-${maxAgeDays} days`], + ); + return result.changes; +} diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 2474912..0669e85 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -8,7 +8,12 @@ export { upsertBookmark, } from "./bookmark.ts"; export { getCursor, setCursor } from "./cursor.ts"; -export { deleteDraft, loadDraft, saveDraftState } from "./draft.ts"; +export { + deleteDraft, + deleteStaleDrafts, + loadDraft, + saveDraftState, +} from "./draft.ts"; export { deleteMembership, deleteMembershipByUri, diff --git a/src/server/index.ts b/src/server/index.ts index 9f3f063..3f8d1fe 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ import { isAuthEnabled } from "../atproto/env.ts"; import { initHighlighter } from "../lib/markdown/highlight-plugin.ts"; import { buildApp } from "./app.ts"; +import { deleteStaleDrafts } from "./db/queries/index.ts"; if (!isAuthEnabled()) { console.warn( @@ -8,6 +9,19 @@ if (!isAuthEnabled()) { ); } +// Sweep abandoned collab drafts older than 30 days — on boot, then daily. +const DRAFT_GC_INTERVAL_MS = 24 * 60 * 60 * 1000; +function sweepStaleDrafts(): void { + try { + const removed = deleteStaleDrafts(); + if (removed > 0) console.log(`Swept ${removed} stale collab draft(s)`); + } catch (err) { + console.error("Draft sweep failed:", err); + } +} +sweepStaleDrafts(); +setInterval(sweepStaleDrafts, DRAFT_GC_INTERVAL_MS).unref(); + await initHighlighter(); const app = buildApp().listen(Number(process.env["PORT"] ?? 3000)); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 0a06783..0415cc5 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -75,47 +75,39 @@ export const noteRoutes = new Elysia() throw err; } }) - .get( - "/@:handle/:wikiSlug/:noteSlug/edit", - async ({ params, request, query }) => { - const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); - const ctx = await resolveWikiContext( - request, - urlHandle, - wikiSlug, - "edit", - ); + .get("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); - const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); - if (!data) - throw new NotFoundError("Note not found", { i18nKey: "noteNotFound" }); + const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); + if (!data) + throw new NotFoundError("Note not found", { i18nKey: "noteNotFound" }); - // Opt-in live collaboration (?collab=1). Requires a session; the WS is - // access-checked again on connect. - const collab = - query["collab"] === "1" && ctx.did - ? { - enabled: true, - wsPath: collabWsPath(ctx.ownerHandle, ctx.wiki.slug, noteSlug), - did: ctx.did, - handle: ctx.session?.handle ?? ctx.did, - displayName: "", - noteUrl: noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug), - } - : undefined; + // Live collaboration is always on for anyone with edit access — there is + // no separate "solo" mode. The WS is access-checked again on connect, and + // the client falls back to a plain editor if the socket can't be opened. + const collab = ctx.did + ? { + enabled: true, + wsPath: collabWsPath(ctx.ownerHandle, ctx.wiki.slug, noteSlug), + did: ctx.did, + handle: ctx.session?.handle ?? ctx.did, + displayName: "", + noteUrl: noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug), + } + : undefined; - return htmlResponse( - editNotePage( - wikiIdentity(ctx), - noteSlug, - data.note.title, - data.current.content, - { ...wikiLayoutOptions(ctx), ...EDITOR_LAYOUT_EXTRAS }, - collab, - ), - ); - }, - ) + return htmlResponse( + editNotePage( + wikiIdentity(ctx), + noteSlug, + data.note.title, + data.current.content, + { ...wikiLayoutOptions(ctx), ...EDITOR_LAYOUT_EXTRAS }, + collab, + ), + ); + }) .post("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index 09262a0..0280d7a 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -32,6 +32,7 @@ export function editNotePage( ? ` data-collab="1" data-collab-ws="${escapeHtml(collab.wsPath)}" + data-collab-connecting="${escapeHtml(msg.editor.collabConnecting)}" data-did="${escapeHtml(collab.did)}" data-handle="${escapeHtml(collab.handle)}" data-display-name="${escapeHtml(collab.displayName)}" @@ -39,7 +40,7 @@ export function editNotePage( : ""; const collabBanner = collab?.enabled - ? `
+ ? `
${msg.editor.collabActive}
` diff --git a/src/views/layout.ts b/src/views/layout.ts index d9beeb7..64bb8ad 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -4,7 +4,6 @@ import { escapeHtml } from "../lib/html.ts"; import { type Locale, SUPPORTED_LOCALES, t } from "../lib/i18n/index.ts"; import { renderMarkdown } from "../lib/markdown.ts"; import { - editCollabUrl, editNoteUrl, exportNoteUrl, exportUrl, @@ -138,11 +137,6 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { ? `${msg.wiki.edit}` : ""; - const collaborateLink = - options.currentNoteSlug && canEdit(level) - ? `${msg.editor.collaborate}` - : ""; - const newNoteLink = canEdit(level) ? `${msg.wiki.newNote}` : ""; @@ -212,7 +206,6 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { ${options.bookmarkHtml ? `
${options.bookmarkHtml}
` : ""} ${renderSearchButton(locale)} ${editLink} - ${collaborateLink} ${newNoteLink}