${snippet}
diff --git a/Cargo.lock b/Cargo.lock index f3e095b..b94da93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.75" +version = "0.2.76" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index c2faa0f..9bc6a31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.75" +version = "0.2.76" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/lang/en.json b/lang/en.json index 6e4768e..f27ccae 100644 --- a/lang/en.json +++ b/lang/en.json @@ -345,6 +345,9 @@ "editor.compactView": "Compact view", "editor.connectionError": "Editor connection error", "editor.connectionInterrupted": "Connection interrupted", + "editor.connectionPersistent": "The live connection is still unavailable. Automatic reconnect continues.", + "editor.interfaceError": "Interface error", + "editor.interfaceError.message": "An interface error occurred. Connection status is monitored separately.", "editor.copyOpen": "Copy its link and open it in a new tab", "editor.copyThisLink": "Copy this link", "editor.currentLocation": "Current location", @@ -835,7 +838,9 @@ "status.accessFull": "Access: full", "status.accessReadOnly": "Access: read only", "status.applicationError": "Application error", + "status.connected": "Connected", "status.connecting": "Connecting…", + "status.connectionError": "Connection error", "status.heartbeatTimeout": "heartbeat timeout", "status.inactiveTimeout": "The connection stopped responding after the tab was inactive.", "status.measuring": "Measuring", diff --git a/lang/pl.json b/lang/pl.json index baf9adf..94e190b 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -345,6 +345,9 @@ "editor.compactView": "Widok kompaktowy", "editor.connectionError": "Błąd połączenia edytora", "editor.connectionInterrupted": "Połączenie przerwane", + "editor.connectionPersistent": "Połączenie na żywo nadal jest niedostępne. Automatyczne ponowne łączenie trwa.", + "editor.interfaceError": "Błąd interfejsu", + "editor.interfaceError.message": "Wystąpił błąd interfejsu. Stan połączenia jest monitorowany niezależnie.", "editor.copyOpen": "Skopiuj link i otwórz go w nowej karcie", "editor.copyThisLink": "Kopiuj ten link", "editor.currentLocation": "Bieżąca lokalizacja", @@ -835,7 +838,9 @@ "status.accessFull": "Dostęp: pełny", "status.accessReadOnly": "Dostęp: tylko odczyt", "status.applicationError": "Błąd aplikacji", + "status.connected": "Połączono", "status.connecting": "Łączenie…", + "status.connectionError": "Błąd połączenia", "status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat", "status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.", "status.measuring": "Pomiar", diff --git a/src/assets.rs b/src/assets.rs index cbf72c2..0ace524 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -19,6 +19,7 @@ const MODULES: &[&str] = &[ "clipboard", "collaboration", "collaboration-session", + "connection-state", "editor-format", "emoji-data", "emoji-picker", diff --git a/static/css/styles.css b/static/css/styles.css index 152b1d7..385e519 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -695,6 +695,12 @@ textarea:focus { background: var(--success); } +.status__dot.is-connecting, +.status__dot.is-reconnecting { + background: var(--warning); +} + +.status__dot.is-error, .status__dot.is-offline { background: var(--danger); } @@ -710,9 +716,9 @@ textarea:focus { align-items: center; gap: 12px; padding: 11px 14px; - border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong)); + border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--border-strong)); border-radius: 12px; - background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--danger)); + background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--warning)); box-shadow: 0 16px 42px var(--shadow-38); opacity: 0; pointer-events: none; @@ -725,6 +731,11 @@ textarea:focus { transform: translate(-50%, 0) scale(1); } +.connection-notice.is-error { + border-color: color-mix(in srgb, var(--danger) 58%, var(--border-strong)); + background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--danger)); +} + .connection-notice.is-restored { border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong)); background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--success)); @@ -739,6 +750,10 @@ textarea:focus { flex: 0 0 auto; gap: 3px; border-radius: 50%; + background: color-mix(in srgb, var(--warning) 18%, transparent); +} + +.connection-notice.is-error .connection-notice__signal { background: color-mix(in srgb, var(--danger) 18%, transparent); } @@ -750,10 +765,14 @@ textarea:focus { width: 3px; height: 10px; border-radius: 3px; - background: var(--danger); + background: var(--warning); animation: connection-pulse .9s ease-in-out infinite; } +.connection-notice.is-error .connection-notice__signal span { + background: var(--danger); +} + .connection-notice__signal span:nth-child(2) { animation-delay: .12s; } diff --git a/static/js/connection-state.js b/static/js/connection-state.js new file mode 100644 index 0000000..5044156 --- /dev/null +++ b/static/js/connection-state.js @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +export const CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS = 5; + +export function reconnectStatus(attempt, networkOnline = true) { + const normalizedAttempt = Math.max(0, Number(attempt) || 0); + if (!networkOnline || normalizedAttempt < CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS) return "reconnecting"; + return "error"; +} diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 8cde00c..04de709 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -52,7 +52,7 @@ export function startNoteEditor(adapter) { : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; const collaboration = new CollaborationSession(collaborationClientId); let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false, accountSession = null; - let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; + let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false, frontendErrorToastAt = 0; let pendingPreviewViewport = null; const editHistory = { entries: [], @@ -370,37 +370,66 @@ export function startNoteEditor(adapter) { function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); } function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; } - function showConnectionNotice(title, message, restored = false) { + function showConnectionNotice(title, message, state = "reconnecting") { clearTimeout(connectionNoticeTimer); connectionNoticeTitle.textContent = title; connectionNoticeMessage.textContent = message; connectionNotice.hidden = false; - connectionNotice.classList.toggle("is-restored", restored); + connectionNotice.classList.remove("is-restored", "is-error", "is-reconnecting"); + connectionNotice.classList.add(`is-${state}`); requestAnimationFrame(() => connectionNotice.classList.add("is-visible")); - if (restored) connectionNoticeTimer = window.setTimeout(() => { + if (state === "restored") connectionNoticeTimer = window.setTimeout(() => { connectionNotice.classList.remove("is-visible", "is-restored"); connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220); }, 1800); } function hideConnectionNotice() { clearTimeout(connectionNoticeTimer); - connectionNotice.classList.remove("is-visible", "is-restored"); + connectionNotice.classList.remove("is-visible", "is-restored", "is-error", "is-reconnecting"); connectionNotice.hidden = true; } function handleSocketStatus(status, details = {}) { if (status === "online") { - setStatus("online", "Connected"); - if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true); + setStatus("online", t("status.connected", {}, "Connected")); + if (connectionWasInterrupted || details.restored) showConnectionNotice( + t("editor.connectionRestored", {}, "Connection restored"), + t("editor.connectionRestored.message", {}, "Live editing is active again."), + "restored", + ); connectionWasInterrupted = false; return; } + if (status === "error") { + connectionWasInterrupted = true; + setStatus("error", t("status.connectionError", {}, "Connection error")); + const message = details.persistent + ? t("editor.connectionPersistent", {}, "The live connection is still unavailable. Automatic reconnect continues.") + : translateSource(details.message || "The server connection was interrupted."); + showConnectionNotice(t("editor.connectionError", {}, "Editor connection error"), message, "error"); + return; + } if (status === "reconnecting") { connectionWasInterrupted = true; - setStatus("offline", "Reconnecting…"); - showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically."); + setStatus("reconnecting", t("status.reconnecting", {}, "Reconnecting...")); + showConnectionNotice( + t("editor.connectionInterrupted", {}, "Connection interrupted"), + translateSource(details.message || t("editor.reconnectAuto", {}, "Trying to reconnect automatically.")), + "reconnecting", + ); return; } - setStatus(null, "Connecting…"); + setStatus("connecting", t("status.connecting", {}, "Connecting...")); + } + function notifyFrontendError() { + const now = Date.now(); + if (now - frontendErrorToastAt < 30000) return; + frontendErrorToastAt = now; + try { + toast.warning( + t("editor.interfaceError.message", {}, "An interface error occurred. Connection status is monitored separately."), + { title: t("editor.interfaceError", {}, "Interface error"), duration: 6500 }, + ); + } catch { } } function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } let mermaidRenderVersion = 0; @@ -2343,7 +2372,7 @@ export function startNoteEditor(adapter) { const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '
Loading…
'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `${snippet}
No history yet.
'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `${escapeHtml(e.message)}
`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } }); - window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); }); - window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); }); + window.addEventListener("error", () => notifyFrontendError()); + window.addEventListener("unhandledrejection", () => notifyFrontendError()); initialize(); } diff --git a/static/js/socket.js b/static/js/socket.js index b6936ac..854fac1 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -7,6 +7,7 @@ * See LICENSE file in repository root for details. */ +import { reconnectStatus } from "@rustpad/connection-state"; import { logError, logInfo, logWarn } from "@rustpad/logger"; const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000; @@ -68,8 +69,12 @@ class RoomSocket { } this.intentionalClose = false; - this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", { + const status = this.reconnectAttempt + ? reconnectStatus(this.reconnectAttempt, navigator.onLine) + : "connecting"; + this.onStatus?.(status, { attempt: this.reconnectAttempt, + persistent: status === "error", message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.", }); this.emitDiagnostics(); @@ -122,6 +127,7 @@ class RoomSocket { } if (message.type === "error") { this.intentionalClose = true; + this.onStatus?.("error", { message: message.message, fatal: true }); this.onError?.(message.message); socket.close(); return; @@ -204,7 +210,13 @@ class RoomSocket { const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4))); const retryInMs = navigator.onLine ? baseDelay : 3000; this.totalReconnects += 1; - this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs }); + const status = reconnectStatus(this.reconnectAttempt, navigator.onLine); + this.onStatus?.(status, { + attempt: this.reconnectAttempt, + persistent: status === "error", + message, + retryInMs, + }); this.emitDiagnostics(); this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs); } @@ -212,7 +224,13 @@ class RoomSocket { reconnectNow(message) { if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return; clearTimeout(this.reconnectTimer); - this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 }); + const status = reconnectStatus(this.reconnectAttempt, navigator.onLine); + this.onStatus?.(status, { + attempt: this.reconnectAttempt, + persistent: status === "error", + message, + retryInMs: 0, + }); this.connect(); } diff --git a/tests/connection_status.mjs b/tests/connection_status.mjs new file mode 100644 index 0000000..d9d88a1 --- /dev/null +++ b/tests/connection_status.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const connectionStateSource = await readFile(new URL("../static/js/connection-state.js", import.meta.url), "utf8"); +const connectionState = await import(`data:text/javascript;base64,${Buffer.from(connectionStateSource).toString("base64")}`); +const { + CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS, + reconnectStatus, +} = connectionState; + +test("temporary reconnects remain reconnecting", () => { + for (let attempt = 0; attempt < CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS; attempt += 1) { + assert.equal(reconnectStatus(attempt, true), "reconnecting"); + } +}); + +test("persistent reconnect failures become an error while online", () => { + assert.equal(reconnectStatus(CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS, true), "error"); + assert.equal(reconnectStatus(CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS + 10, true), "error"); +}); + +test("offline devices stay in reconnecting state instead of a hard error", () => { + assert.equal(reconnectStatus(CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS + 10, false), "reconnecting"); +}); + +test("frontend exceptions do not overwrite the connection indicator", async () => { + const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8"); + assert.doesNotMatch(editorSource, /setStatus\([^\n]*Application error/); + assert.match(editorSource, /window\.addEventListener\("error", \(\) => notifyFrontendError\(\)\)/); + assert.match(editorSource, /window\.addEventListener\("unhandledrejection", \(\) => notifyFrontendError\(\)\)/); +});