v0.2.76
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.75"
|
version = "0.2.76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.75"
|
version = "0.2.76"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -345,6 +345,9 @@
|
|||||||
"editor.compactView": "Compact view",
|
"editor.compactView": "Compact view",
|
||||||
"editor.connectionError": "Editor connection error",
|
"editor.connectionError": "Editor connection error",
|
||||||
"editor.connectionInterrupted": "Connection interrupted",
|
"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.copyOpen": "Copy its link and open it in a new tab",
|
||||||
"editor.copyThisLink": "Copy this link",
|
"editor.copyThisLink": "Copy this link",
|
||||||
"editor.currentLocation": "Current location",
|
"editor.currentLocation": "Current location",
|
||||||
@@ -835,7 +838,9 @@
|
|||||||
"status.accessFull": "Access: full",
|
"status.accessFull": "Access: full",
|
||||||
"status.accessReadOnly": "Access: read only",
|
"status.accessReadOnly": "Access: read only",
|
||||||
"status.applicationError": "Application error",
|
"status.applicationError": "Application error",
|
||||||
|
"status.connected": "Connected",
|
||||||
"status.connecting": "Connecting…",
|
"status.connecting": "Connecting…",
|
||||||
|
"status.connectionError": "Connection error",
|
||||||
"status.heartbeatTimeout": "heartbeat timeout",
|
"status.heartbeatTimeout": "heartbeat timeout",
|
||||||
"status.inactiveTimeout": "The connection stopped responding after the tab was inactive.",
|
"status.inactiveTimeout": "The connection stopped responding after the tab was inactive.",
|
||||||
"status.measuring": "Measuring",
|
"status.measuring": "Measuring",
|
||||||
|
|||||||
@@ -345,6 +345,9 @@
|
|||||||
"editor.compactView": "Widok kompaktowy",
|
"editor.compactView": "Widok kompaktowy",
|
||||||
"editor.connectionError": "Błąd połączenia edytora",
|
"editor.connectionError": "Błąd połączenia edytora",
|
||||||
"editor.connectionInterrupted": "Połączenie przerwane",
|
"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.copyOpen": "Skopiuj link i otwórz go w nowej karcie",
|
||||||
"editor.copyThisLink": "Kopiuj ten link",
|
"editor.copyThisLink": "Kopiuj ten link",
|
||||||
"editor.currentLocation": "Bieżąca lokalizacja",
|
"editor.currentLocation": "Bieżąca lokalizacja",
|
||||||
@@ -835,7 +838,9 @@
|
|||||||
"status.accessFull": "Dostęp: pełny",
|
"status.accessFull": "Dostęp: pełny",
|
||||||
"status.accessReadOnly": "Dostęp: tylko odczyt",
|
"status.accessReadOnly": "Dostęp: tylko odczyt",
|
||||||
"status.applicationError": "Błąd aplikacji",
|
"status.applicationError": "Błąd aplikacji",
|
||||||
|
"status.connected": "Połączono",
|
||||||
"status.connecting": "Łączenie…",
|
"status.connecting": "Łączenie…",
|
||||||
|
"status.connectionError": "Błąd połączenia",
|
||||||
"status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat",
|
"status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat",
|
||||||
"status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.",
|
"status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.",
|
||||||
"status.measuring": "Pomiar",
|
"status.measuring": "Pomiar",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const MODULES: &[&str] = &[
|
|||||||
"clipboard",
|
"clipboard",
|
||||||
"collaboration",
|
"collaboration",
|
||||||
"collaboration-session",
|
"collaboration-session",
|
||||||
|
"connection-state",
|
||||||
"editor-format",
|
"editor-format",
|
||||||
"emoji-data",
|
"emoji-data",
|
||||||
"emoji-picker",
|
"emoji-picker",
|
||||||
|
|||||||
+22
-3
@@ -695,6 +695,12 @@ textarea:focus {
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status__dot.is-connecting,
|
||||||
|
.status__dot.is-reconnecting {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__dot.is-error,
|
||||||
.status__dot.is-offline {
|
.status__dot.is-offline {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -710,9 +716,9 @@ textarea:focus {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 11px 14px;
|
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;
|
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);
|
box-shadow: 0 16px 42px var(--shadow-38);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -725,6 +731,11 @@ textarea:focus {
|
|||||||
transform: translate(-50%, 0) scale(1);
|
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 {
|
.connection-notice.is-restored {
|
||||||
border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong));
|
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));
|
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--success));
|
||||||
@@ -739,6 +750,10 @@ textarea:focus {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
border-radius: 50%;
|
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);
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,10 +765,14 @@ textarea:focus {
|
|||||||
width: 3px;
|
width: 3px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--danger);
|
background: var(--warning);
|
||||||
animation: connection-pulse .9s ease-in-out infinite;
|
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) {
|
.connection-notice__signal span:nth-child(2) {
|
||||||
animation-delay: .12s;
|
animation-delay: .12s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
+41
-12
@@ -52,7 +52,7 @@ export function startNoteEditor(adapter) {
|
|||||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||||
const collaboration = new CollaborationSession(collaborationClientId);
|
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 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;
|
let pendingPreviewViewport = null;
|
||||||
const editHistory = {
|
const editHistory = {
|
||||||
entries: [],
|
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 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 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);
|
clearTimeout(connectionNoticeTimer);
|
||||||
connectionNoticeTitle.textContent = title;
|
connectionNoticeTitle.textContent = title;
|
||||||
connectionNoticeMessage.textContent = message;
|
connectionNoticeMessage.textContent = message;
|
||||||
connectionNotice.hidden = false;
|
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"));
|
requestAnimationFrame(() => connectionNotice.classList.add("is-visible"));
|
||||||
if (restored) connectionNoticeTimer = window.setTimeout(() => {
|
if (state === "restored") connectionNoticeTimer = window.setTimeout(() => {
|
||||||
connectionNotice.classList.remove("is-visible", "is-restored");
|
connectionNotice.classList.remove("is-visible", "is-restored");
|
||||||
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
|
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
|
||||||
}, 1800);
|
}, 1800);
|
||||||
}
|
}
|
||||||
function hideConnectionNotice() {
|
function hideConnectionNotice() {
|
||||||
clearTimeout(connectionNoticeTimer);
|
clearTimeout(connectionNoticeTimer);
|
||||||
connectionNotice.classList.remove("is-visible", "is-restored");
|
connectionNotice.classList.remove("is-visible", "is-restored", "is-error", "is-reconnecting");
|
||||||
connectionNotice.hidden = true;
|
connectionNotice.hidden = true;
|
||||||
}
|
}
|
||||||
function handleSocketStatus(status, details = {}) {
|
function handleSocketStatus(status, details = {}) {
|
||||||
if (status === "online") {
|
if (status === "online") {
|
||||||
setStatus("online", "Connected");
|
setStatus("online", t("status.connected", {}, "Connected"));
|
||||||
if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true);
|
if (connectionWasInterrupted || details.restored) showConnectionNotice(
|
||||||
|
t("editor.connectionRestored", {}, "Connection restored"),
|
||||||
|
t("editor.connectionRestored.message", {}, "Live editing is active again."),
|
||||||
|
"restored",
|
||||||
|
);
|
||||||
connectionWasInterrupted = false;
|
connectionWasInterrupted = false;
|
||||||
return;
|
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") {
|
if (status === "reconnecting") {
|
||||||
connectionWasInterrupted = true;
|
connectionWasInterrupted = true;
|
||||||
setStatus("offline", "Reconnecting…");
|
setStatus("reconnecting", t("status.reconnecting", {}, "Reconnecting..."));
|
||||||
showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically.");
|
showConnectionNotice(
|
||||||
|
t("editor.connectionInterrupted", {}, "Connection interrupted"),
|
||||||
|
translateSource(details.message || t("editor.reconnectAuto", {}, "Trying to reconnect automatically.")),
|
||||||
|
"reconnecting",
|
||||||
|
);
|
||||||
return;
|
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}`; }
|
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
||||||
let mermaidRenderVersion = 0;
|
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 = '<p class="empty">Loading…</p>'; 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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; 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 = `<p class="error">${escapeHtml(e.message)}</p>`; 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 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 = '<p class="empty">Loading…</p>'; 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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; 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 = `<p class="error">${escapeHtml(e.message)}</p>`; 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" }); } });
|
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("error", () => notifyFrontendError());
|
||||||
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
window.addEventListener("unhandledrejection", () => notifyFrontendError());
|
||||||
initialize();
|
initialize();
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,7 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { reconnectStatus } from "@rustpad/connection-state";
|
||||||
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
||||||
@@ -68,8 +69,12 @@ class RoomSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.intentionalClose = false;
|
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,
|
attempt: this.reconnectAttempt,
|
||||||
|
persistent: status === "error",
|
||||||
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
||||||
});
|
});
|
||||||
this.emitDiagnostics();
|
this.emitDiagnostics();
|
||||||
@@ -122,6 +127,7 @@ class RoomSocket {
|
|||||||
}
|
}
|
||||||
if (message.type === "error") {
|
if (message.type === "error") {
|
||||||
this.intentionalClose = true;
|
this.intentionalClose = true;
|
||||||
|
this.onStatus?.("error", { message: message.message, fatal: true });
|
||||||
this.onError?.(message.message);
|
this.onError?.(message.message);
|
||||||
socket.close();
|
socket.close();
|
||||||
return;
|
return;
|
||||||
@@ -204,7 +210,13 @@ class RoomSocket {
|
|||||||
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
||||||
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
||||||
this.totalReconnects += 1;
|
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.emitDiagnostics();
|
||||||
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
||||||
}
|
}
|
||||||
@@ -212,7 +224,13 @@ class RoomSocket {
|
|||||||
reconnectNow(message) {
|
reconnectNow(message) {
|
||||||
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
|
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
|
||||||
clearTimeout(this.reconnectTimer);
|
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();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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\(\)\)/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user