Merge dev into main (v0.55.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 32s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 32s
This commit is contained in:
commit
5c91125758
16 files changed
+365
-170
No files matched your search
+14
-1
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.55.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Verbindungsstatus am Profil mit drei Zuständen: grün „online“ (Live-Verbindung), gelb „eingeschränkt“ (Server erreichbar, aber keine Live-Verbindung, z. B. hinter einem Firmen-Proxy; Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab) und rot „offline“. Am Desktop steht der Status jetzt immer unter dem Namen, nicht nur ohne Verbindung.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Hinter einem Firmen-Proxy stand am Profil dauerhaft „offline“, obwohl die App funktionierte.
|
||||||
|
- Notizen: Wer nach einem Wechsel in eine andere App oder ein anderes Fenster im selben Punkt weitertippte, bekam eine Konfliktmeldung gegen den eigenen Text.
|
||||||
|
- Notizen: Nach einem echten Konflikt entstand bei jedem Weitertippen eine weitere Konfliktkopie. Jetzt gibt es eine Kopie mit dem eigenen Text; der Punkt selbst zeigt nach dem Verlassen die andere Version.
|
||||||
|
- Anmeldung: Tab springt vom Benutzernamen direkt ins Passwortfeld, „Passwort vergessen?“ kommt danach.
|
||||||
|
- Handy: Seiten lassen sich nicht mehr ins Leere scrollen, wenn der Inhalt auf den Bildschirm passt (die Höhe richtet sich nach dem sichtbaren Bereich statt nach der Höhe ohne Adressleiste).
|
||||||
|
|
||||||
## [0.54.0] - 2026-11-02
|
## [0.54.0] - 2026-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -807,7 +819,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...dev
|
||||||
|
[0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0
|
||||||
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
|
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
|
||||||
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
||||||
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
|
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
|||||||
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
||||||
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
||||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
|
- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Fehlt sie länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, ist die Verbindung **eingeschränkt** (z. B. Proxy, der SSE puffert; Notizen laufen dann über Long Polling, andere Änderungen erst beim Zurückkehren in den Tab oder Neuladen), sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
|
||||||
|
|
||||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -99,7 +99,7 @@ const main = async () => {
|
|||||||
await B.page.getByText('Darunter', { exact: true }).first().click();
|
await B.page.getByText('Darunter', { exact: true }).first().click();
|
||||||
await B.page.keyboard.press('Home');
|
await B.page.keyboard.press('Home');
|
||||||
await B.page.keyboard.press('Backspace');
|
await B.page.keyboard.press('Backspace');
|
||||||
const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000);
|
const notice = await waitFor(async () => B.page.getByRole('status').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000);
|
||||||
check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`);
|
check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`);
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort();
|
const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort();
|
||||||
@@ -133,7 +133,7 @@ const main = async () => {
|
|||||||
check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt');
|
check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt');
|
||||||
// The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it
|
// The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it
|
||||||
await A.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
await A.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
||||||
const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000);
|
const refused = await waitFor(async () => A.page.getByRole('status').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000);
|
||||||
check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`);
|
check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`);
|
||||||
// The user holding the original can accept once the copy's author (here a separate API client that never
|
// The user holding the original can accept once the copy's author (here a separate API client that never
|
||||||
// renews its lease) no longer holds the copy, i.e. after at most 20 s
|
// renews its lease) no longer holds the copy, i.e. after at most 20 s
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.54.0",
|
"version": "0.55.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.54.0",
|
"version": "0.55.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.54.0",
|
"version": "0.55.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+26
-19
@@ -60,7 +60,7 @@ import { SharedListsView } from './features/lists/SharedListsView';
|
|||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
import { OAuthConsentView } from './features/auth/OAuthConsentView';
|
import { OAuthConsentView } from './features/auth/OAuthConsentView';
|
||||||
import { OfflineBanner } from './components/OfflineBanner';
|
import { OfflineBanner } from './components/OfflineBanner';
|
||||||
import { useServerOnline } from './lib/useServerOnline';
|
import { useConnectionState } from './lib/useConnectionState';
|
||||||
import { SharedImportToast } from './components/SharedImportToast';
|
import { SharedImportToast } from './components/SharedImportToast';
|
||||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||||
import { DemoCountdown } from './components/DemoCountdown';
|
import { DemoCountdown } from './components/DemoCountdown';
|
||||||
@@ -83,6 +83,17 @@ import { APP_VERSION } from './version';
|
|||||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||||
import { setVoicePreference } from './lib/voice';
|
import { setVoicePreference } from './lib/voice';
|
||||||
|
|
||||||
|
// Connection status at the profile (useConnectionState); full class names for Tailwind
|
||||||
|
const CONNECTION_STATUS = {
|
||||||
|
online: { label: 'online', dot: 'bg-status-ok', title: 'Online – Änderungen kommen sofort an' },
|
||||||
|
limited: {
|
||||||
|
label: 'eingeschränkt',
|
||||||
|
dot: 'bg-status-warn',
|
||||||
|
title: 'Eingeschränkt – der Server ist erreichbar, aber keine Live-Verbindung (z. B. Firmen-Proxy); Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab oder nach dem Neuladen',
|
||||||
|
},
|
||||||
|
offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
||||||
type ActiveTab = (typeof APP_TABS)[number];
|
type ActiveTab = (typeof APP_TABS)[number];
|
||||||
|
|
||||||
@@ -192,7 +203,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const serverOnline = useServerOnline();
|
const connection = useConnectionState();
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||||
@@ -846,7 +857,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
if (bootLoading) {
|
if (bootLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
|
<div className="min-h-[100dvh] bg-canvas text-muted flex items-center justify-center text-xs font-sans">
|
||||||
Verbinde mit MindSift…
|
Verbinde mit MindSift…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -893,7 +904,7 @@ export const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
|
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
|
||||||
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
|
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-[100dvh]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||||
@@ -986,13 +997,11 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||||
{/* Connection status like in a chat app: green online, red without server */}
|
{/* Connection status like in a chat app: green online, yellow limited, red without server */}
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
|
aria-label={CONNECTION_STATUS[connection].title}
|
||||||
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
|
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${CONNECTION_STATUS[connection].dot}`}
|
||||||
serverOnline ? 'bg-status-ok' : 'bg-status-err'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1064,16 +1073,14 @@ export const App: React.FC = () => {
|
|||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</span>
|
</span>
|
||||||
{!serverOnline && (
|
<span
|
||||||
<span
|
role="status"
|
||||||
role="status"
|
title={CONNECTION_STATUS[connection].title}
|
||||||
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
|
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
||||||
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
>
|
||||||
>
|
<span className={`w-1.5 h-1.5 rounded-full ${CONNECTION_STATUS[connection].dot}`} />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
|
{CONNECTION_STATUS[connection].label}
|
||||||
offline
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instan
|
|||||||
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
|
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
||||||
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
|
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
|
||||||
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
|
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
if (authStatus.needsSetup) {
|
if (authStatus.needsSetup) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
||||||
<div className="w-full max-w-xl panel-surface shadow-panel overflow-hidden">
|
<div className="w-full max-w-xl panel-surface shadow-panel overflow-hidden">
|
||||||
{/* Top Brand Bar */}
|
{/* Top Brand Bar */}
|
||||||
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
||||||
@@ -472,7 +472,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
|
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
||||||
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
|
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
|
||||||
@@ -706,23 +706,8 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="relative">
|
||||||
<div className="flex items-baseline justify-between mb-1">
|
<label className="block text-muted mb-1">Passwort</label>
|
||||||
<label className="block text-muted">Passwort</label>
|
|
||||||
{authStatus.passwordReset && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAuthTab('forgot');
|
|
||||||
setError(null);
|
|
||||||
setNotice(null);
|
|
||||||
}}
|
|
||||||
className="text-accent hover:underline text-[11px] py-1"
|
|
||||||
>
|
|
||||||
Passwort vergessen?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -731,6 +716,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
|
{/* After the field in the markup, so Tab goes from the name straight to the password;
|
||||||
|
shown next to the label */}
|
||||||
|
{authStatus.passwordReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('forgot');
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
className="absolute right-0 -top-1 text-accent hover:underline text-[11px] py-1"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
|
|||||||
@@ -270,11 +270,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
document.activeElement === textareaRef.current;
|
document.activeElement === textareaRef.current;
|
||||||
|
|
||||||
const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current;
|
const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current;
|
||||||
if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) {
|
// Also when the cursor leaves: after a conflict the field held the user's own text, which now stands
|
||||||
|
// in the conflict copy, while the block itself keeps the other version
|
||||||
|
if (
|
||||||
|
(!isActivelyEditing || acceptAfterUndo) &&
|
||||||
|
debounceTimerRef.current === null &&
|
||||||
|
block.content !== localContentRef.current
|
||||||
|
) {
|
||||||
setLocalContent(block.content || '');
|
setLocalContent(block.content || '');
|
||||||
localContentRef.current = block.content || '';
|
localContentRef.current = block.content || '';
|
||||||
}
|
}
|
||||||
}, [block.content]);
|
}, [block.content, isLocallyFocused]);
|
||||||
|
|
||||||
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
|
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
let statusListener: ((connected: boolean) => void) | null = null;
|
||||||
|
let currentConnected = false;
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
||||||
|
statusListener = cb;
|
||||||
|
cb(currentConnected);
|
||||||
|
return () => (statusListener = null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useConnectionState, SERVER_OFFLINE_GRACE_MS, SERVER_PROBE_INTERVAL_MS } from '../useConnectionState';
|
||||||
|
|
||||||
|
function setDeviceOnline(value: boolean) {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverAnswers = false;
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
if (!serverAnswers) throw new TypeError('Failed to fetch');
|
||||||
|
return { ok: true } as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs the timers and lets the health request settle
|
||||||
|
async function wait(ms: number) {
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useConnectionState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
currentConnected = false;
|
||||||
|
serverAnswers = false;
|
||||||
|
setDeviceOnline(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays online during the grace period at app start', async () => {
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns offline once the server stays unreachable and back online on reconnect', async () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is limited when the server answers but no realtime connection gets through', async () => {
|
||||||
|
serverAnswers = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('limited');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/health', expect.anything());
|
||||||
|
|
||||||
|
// Asked again while the connection is missing
|
||||||
|
serverAnswers = false;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS + 100);
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
serverAnswers = true;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS + 100);
|
||||||
|
expect(result.current).toBe('limited');
|
||||||
|
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
const calls = fetchMock.mock.calls.length;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS * 2);
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeated failed reconnects do not postpone the check', async () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS / 2);
|
||||||
|
}
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is offline right away without device network', () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
act(() => {
|
||||||
|
setDeviceOnline(false);
|
||||||
|
statusListener?.(false);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
let statusListener: ((connected: boolean) => void) | null = null;
|
|
||||||
let currentConnected = false;
|
|
||||||
vi.mock('../api', () => ({
|
|
||||||
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
|
||||||
statusListener = cb;
|
|
||||||
cb(currentConnected);
|
|
||||||
return () => (statusListener = null);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useServerOnline, SERVER_OFFLINE_GRACE_MS } from '../useServerOnline';
|
|
||||||
|
|
||||||
function setDeviceOnline(value: boolean) {
|
|
||||||
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useServerOnline', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
currentConnected = false;
|
|
||||||
setDeviceOnline(true);
|
|
||||||
});
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it('stays online during the grace period at app start', () => {
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
act(() => statusListener?.(true));
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('turns offline once the server stays unreachable and back online on reconnect', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
act(() => statusListener?.(false));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
act(() => statusListener?.(true));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repeated failed reconnects do not postpone the offline state', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
act(() => statusListener?.(false));
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS / 2));
|
|
||||||
}
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is offline right away without device network', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
act(() => {
|
|
||||||
setDeviceOnline(false);
|
|
||||||
statusListener?.(false);
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -358,6 +358,58 @@ describe('DocSyncEngine', () => {
|
|||||||
expect(contents(engine)).toEqual(['Schreibe']);
|
expect(contents(engine)).toEqual(['Schreibe']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('typing on after leaving the tab causes no conflict with oneself', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const engine = await startEngine(new FakeNet(server));
|
||||||
|
engine.focusBlock('b1');
|
||||||
|
edit(engine, (b) => [{ ...b[0], content: 'Start und' }]);
|
||||||
|
// Tab hidden (window switch, app switch on the phone): pending text goes out with keepalive
|
||||||
|
(engine as any).flushOnExit();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(serverContents(server)).toEqual(['Start und']);
|
||||||
|
|
||||||
|
// Back in the tab, typing on in the same block
|
||||||
|
edit(engine, (b) => [{ ...b[0], content: 'Start und weiter' }]);
|
||||||
|
await engine.syncNow();
|
||||||
|
edit(engine, (b) => [{ ...b[0], content: 'Start und weiter geht es' }]);
|
||||||
|
await engine.syncNow();
|
||||||
|
|
||||||
|
expect(engine.getState().conflicts).toEqual([]);
|
||||||
|
expect(serverContents(server)).toEqual(['Start und weiter geht es']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing on in a block after a conflict updates the one conflict copy', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const a = await startEngine(new FakeNet(server));
|
||||||
|
const b = await startEngine(new FakeNet(server));
|
||||||
|
|
||||||
|
a.focusBlock('b1');
|
||||||
|
edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]);
|
||||||
|
await b.syncNow();
|
||||||
|
await a.syncNow(); // the focused field keeps showing A's own text
|
||||||
|
|
||||||
|
a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]);
|
||||||
|
await a.syncNow(); // conflict: A's text becomes a copy
|
||||||
|
// A keeps typing in the same field, which still shows A's text
|
||||||
|
a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt' }, flatOf(a)[1]]);
|
||||||
|
await a.syncNow();
|
||||||
|
a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt weiter' }, flatOf(a)[1]]);
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow();
|
||||||
|
|
||||||
|
expect(a.getState().conflicts).toHaveLength(1);
|
||||||
|
expect(serverContents(server)).toEqual(['B hat geändert', 'Start + A tippt weiter']);
|
||||||
|
expect(contents(b)).toEqual(['B hat geändert', 'Start + A tippt weiter']);
|
||||||
|
|
||||||
|
// Once the cursor left, the field shows B's version again: editing it changes the block itself
|
||||||
|
a.focusBlock(null);
|
||||||
|
a.focusBlock('b1');
|
||||||
|
edit(a, (blocks) => [{ ...blocks[0], content: 'B hat geändert, A ergänzt' }, blocks[1]]);
|
||||||
|
await a.syncNow();
|
||||||
|
expect(a.getState().conflicts).toHaveLength(1);
|
||||||
|
expect(serverContents(server)).toEqual(['B hat geändert, A ergänzt', 'Start + A tippt weiter']);
|
||||||
|
});
|
||||||
|
|
||||||
it('viewers cannot change anything', async () => {
|
it('viewers cannot change anything', async () => {
|
||||||
const server = new FakeServer();
|
const server = new FakeServer();
|
||||||
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export class DocSyncEngine {
|
|||||||
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
||||||
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
||||||
private uiSeen = new Map<string, number>();
|
private uiSeen = new Map<string, number>();
|
||||||
|
// Focused block -> its conflict copy: the field still shows the user's losing text, so typing on there
|
||||||
|
// goes into that copy instead of making a new copy per keystroke batch. Ends when the field shows the
|
||||||
|
// engine's text again or the cursor leaves the block.
|
||||||
|
private conflictRedirects = new Map<string, string>();
|
||||||
private undoStack: UndoStep[] = [];
|
private undoStack: UndoStep[] = [];
|
||||||
private redoStack: UndoStep[] = [];
|
private redoStack: UndoStep[] = [];
|
||||||
|
|
||||||
@@ -267,15 +271,19 @@ export class DocSyncEngine {
|
|||||||
}
|
}
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const cur = this.local.blocks.get(b.id);
|
const cur = this.local.blocks.get(b.id);
|
||||||
if (cur && !cur.deleted && cur.content === (b.content || '')) this.uiSeen.set(b.id, cur.version);
|
if (cur && !cur.deleted && cur.content === (b.content || '')) {
|
||||||
|
this.uiSeen.set(b.id, cur.version);
|
||||||
|
this.conflictRedirects.delete(b.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
||||||
if (ops.length === 0) return;
|
if (ops.length === 0) return;
|
||||||
const before = this.local;
|
const before = this.local;
|
||||||
const t = this.now();
|
const t = this.now();
|
||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
|
||||||
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
||||||
|
if (op.kind === 'set_content') this.redirectToConflictCopy(op, this.local);
|
||||||
|
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
||||||
}
|
}
|
||||||
this.enqueue(ops);
|
this.enqueue(ops);
|
||||||
this.record(before, ops);
|
this.record(before, ops);
|
||||||
@@ -350,7 +358,12 @@ export class DocSyncEngine {
|
|||||||
const t = this.now();
|
const t = this.now();
|
||||||
const changed = blockId !== this.focused;
|
const changed = blockId !== this.focused;
|
||||||
if (this.focused) this.activity.set(this.focused, t);
|
if (this.focused) this.activity.set(this.focused, t);
|
||||||
|
if (changed && this.focused) this.conflictRedirects.delete(this.focused);
|
||||||
this.focused = blockId;
|
this.focused = blockId;
|
||||||
|
// Entering a block, its field shows the engine's text (unfocused fields take it over), so edits from
|
||||||
|
// here on build on that version, not on one an earlier visit of the field saw
|
||||||
|
const entered = changed && blockId ? this.local.blocks.get(blockId) : undefined;
|
||||||
|
if (entered && !entered.deleted) this.uiSeen.set(entered.id, entered.version);
|
||||||
if (blockId) this.activity.set(blockId, t);
|
if (blockId) this.activity.set(blockId, t);
|
||||||
// Announce the lease right away: with push the next regular sync may be up to 30 s away
|
// Announce the lease right away: with push the next regular sync may be up to 30 s away
|
||||||
if (changed && blockId && this.canEdit && this.collaborative()) {
|
if (changed && blockId && this.canEdit && this.collaborative()) {
|
||||||
@@ -387,7 +400,7 @@ export class DocSyncEngine {
|
|||||||
? this.status === 'error'
|
? this.status === 'error'
|
||||||
? this.pollDelay()
|
? this.pollDelay()
|
||||||
: this.nextBackoff()
|
: this.nextBackoff()
|
||||||
: this.syncAgain || this.pending.length > 0
|
: this.syncAgain || this.pending.length > 0 || this.inflight
|
||||||
? 0
|
? 0
|
||||||
: this.pollDelay();
|
: this.pollDelay();
|
||||||
this.syncAgain = false;
|
this.syncAgain = false;
|
||||||
@@ -462,6 +475,19 @@ export class DocSyncEngine {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends a text change into the conflict copy of its block (see conflictRedirects). */
|
||||||
|
private redirectToConflictCopy(op: Op, doc: Doc, copyId = this.conflictRedirects.get(op.blockId!)): void {
|
||||||
|
if (!copyId) return;
|
||||||
|
const copy = doc.blocks.get(copyId);
|
||||||
|
if (!copy || copy.deleted || copy.props.conflictOf !== op.blockId) {
|
||||||
|
// Resolved or discarded meanwhile: a new change is a new conflict
|
||||||
|
this.conflictRedirects.delete(op.blockId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
op.blockId = copyId;
|
||||||
|
op.baseVersion = copy.version;
|
||||||
|
}
|
||||||
|
|
||||||
private recompute(): void {
|
private recompute(): void {
|
||||||
const v = cloneDoc(this.confirmed);
|
const v = cloneDoc(this.confirmed);
|
||||||
// Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
|
// Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
|
||||||
@@ -537,20 +563,27 @@ export class DocSyncEngine {
|
|||||||
this.pending = [];
|
this.pending = [];
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
const ops = this.canEdit ? this.inflight || [] : [];
|
// flushOnExit may start a new batch while this request is on the wire; only this one is answered here
|
||||||
if (ops.length > 0) this.status = 'saving';
|
let batch = this.canEdit ? this.inflight : null;
|
||||||
|
if (batch && batch.length > 0) this.status = 'saving';
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const res = await this.transport.sync(this.docId, {
|
const res = await this.transport.sync(this.docId, {
|
||||||
clientId: this.clientId,
|
clientId: this.clientId,
|
||||||
lastSeq: this.lastSeq,
|
lastSeq: this.lastSeq,
|
||||||
ops: this.inflight ? ops : [],
|
ops: batch || [],
|
||||||
leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined,
|
leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined,
|
||||||
});
|
});
|
||||||
if (this.inflight) {
|
const lostTo = new Map<string, string>(); // block -> conflict copy of its losing text
|
||||||
const sent = new Map(this.inflight.map((op) => [op.opId, op]));
|
if (batch) {
|
||||||
|
const sent = new Map(batch.map((op) => [op.opId, op]));
|
||||||
for (const r of res.results) {
|
for (const r of res.results) {
|
||||||
if (r.status === 'conflict') this.conflicts = [...this.conflicts, r];
|
if (r.status === 'conflict') this.conflicts = [...this.conflicts, r];
|
||||||
|
const conflicted = sent.get(r.opId);
|
||||||
|
if (r.status === 'conflict' && r.conflictBlockId && conflicted?.kind === 'set_content') {
|
||||||
|
lostTo.set(conflicted.blockId!, r.conflictBlockId);
|
||||||
|
if (this.focused === conflicted.blockId) this.conflictRedirects.set(conflicted.blockId!, r.conflictBlockId);
|
||||||
|
}
|
||||||
// Someone else created a block with the same ID first (e.g. the first block of an empty note):
|
// Someone else created a block with the same ID first (e.g. the first block of an empty note):
|
||||||
// keep the typed text by sending it as a regular text change of that block
|
// keep the typed text by sending it as a regular text change of that block
|
||||||
const op = sent.get(r.opId);
|
const op = sent.get(r.opId);
|
||||||
@@ -559,7 +592,8 @@ export class DocSyncEngine {
|
|||||||
this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 });
|
this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.inflight = null;
|
if (this.inflight === batch) this.inflight = null;
|
||||||
|
batch = null;
|
||||||
}
|
}
|
||||||
this.role = res.role;
|
this.role = res.role;
|
||||||
this.shared = res.shared;
|
this.shared = res.shared;
|
||||||
@@ -581,6 +615,11 @@ export class DocSyncEngine {
|
|||||||
this.lastSeq = entry.seq;
|
this.lastSeq = entry.seq;
|
||||||
}
|
}
|
||||||
if (gap) await this.loadSnapshot();
|
if (gap) await this.loadSnapshot();
|
||||||
|
// Text typed into the same field while the losing batch was on the wire builds on the losing text
|
||||||
|
// and belongs into the copy too
|
||||||
|
for (const op of this.pending) {
|
||||||
|
if (op.kind === 'set_content' && lostTo.has(op.blockId!)) this.redirectToConflictCopy(op, this.confirmed, lostTo.get(op.blockId!));
|
||||||
|
}
|
||||||
await this.persist();
|
await this.persist();
|
||||||
if (!res.hasMore || gap) break;
|
if (!res.hasMore || gap) break;
|
||||||
}
|
}
|
||||||
@@ -690,7 +729,16 @@ export class DocSyncEngine {
|
|||||||
/** Sends what is pending with keepalive (survives page unload) and releases leases. */
|
/** Sends what is pending with keepalive (survives page unload) and releases leases. */
|
||||||
private flushOnExit(): void {
|
private flushOnExit(): void {
|
||||||
if (this.canEdit && (this.inflight || this.pending.length > 0)) {
|
if (this.canEdit && (this.inflight || this.pending.length > 0)) {
|
||||||
const ops = this.inflight || formBatch(this.pending);
|
// The batch becomes the one in flight: the next sync resends exactly these ops (same IDs, applied
|
||||||
|
// once), and text typed afterwards starts a new batch on top instead of being folded into this one
|
||||||
|
// with its older base version, which would conflict with the user's own text
|
||||||
|
if (!this.inflight) {
|
||||||
|
this.inflight = formBatch(this.pending);
|
||||||
|
this.pending = [];
|
||||||
|
this.recompute();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
const ops = this.inflight;
|
||||||
this.transport
|
this.transport
|
||||||
.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true })
|
.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { onRealtimeStatus } from './api';
|
||||||
|
|
||||||
|
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
||||||
|
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
||||||
|
// How often the server is asked while the realtime connection is missing
|
||||||
|
export const SERVER_PROBE_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
|
// online: realtime connection (WebSocket/SSE) is up; limited: the server answers plain requests, but no
|
||||||
|
// realtime connection gets through (e.g. a proxy that buffers event streams; notes fall back to long
|
||||||
|
// polling); offline: no server at all
|
||||||
|
export type ConnectionState = 'online' | 'limited' | 'offline';
|
||||||
|
|
||||||
|
async function serverReachable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/health', { cache: 'no-store' });
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the server is reachable, judged by the app-wide realtime connection. Unlike navigator.onLine it
|
||||||
|
// also notices a server that is down while the device still has network. Without device network it is
|
||||||
|
// offline right away; otherwise, once the connection stayed away for a moment, a request to the server
|
||||||
|
// decides between limited and offline (repeated while the connection is missing).
|
||||||
|
export function useConnectionState(): ConnectionState {
|
||||||
|
const [state, setState] = useState<ConnectionState>('online');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let connected = false;
|
||||||
|
let disposed = false;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const stopProbing = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
};
|
||||||
|
const probe = async () => {
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
if (disposed || connected) return;
|
||||||
|
setState(reachable && navigator.onLine !== false ? 'limited' : 'offline');
|
||||||
|
timer = window.setTimeout(probe, SERVER_PROBE_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
||||||
|
const update = () => {
|
||||||
|
if (connected) {
|
||||||
|
stopProbing();
|
||||||
|
setState('online');
|
||||||
|
} else if (navigator.onLine === false) {
|
||||||
|
stopProbing();
|
||||||
|
setState('offline');
|
||||||
|
} else if (timer === undefined) {
|
||||||
|
timer = window.setTimeout(probe, SERVER_OFFLINE_GRACE_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribe = onRealtimeStatus((isConnected) => {
|
||||||
|
connected = isConnected;
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
window.addEventListener('online', update);
|
||||||
|
window.addEventListener('offline', update);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe();
|
||||||
|
stopProbing();
|
||||||
|
window.removeEventListener('online', update);
|
||||||
|
window.removeEventListener('offline', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { onRealtimeStatus } from './api';
|
|
||||||
|
|
||||||
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
|
||||||
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
|
||||||
|
|
||||||
// Whether the server is reachable, judged by the app-wide realtime connection (WebSocket/SSE). Unlike
|
|
||||||
// navigator.onLine it also notices a server that is down while the device still has network. Without
|
|
||||||
// device network it is offline right away; otherwise only after the connection stayed away for a moment.
|
|
||||||
export function useServerOnline(): boolean {
|
|
||||||
const [online, setOnline] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let connected = false;
|
|
||||||
let timer: number | undefined;
|
|
||||||
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
|
||||||
const update = () => {
|
|
||||||
if (connected) {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
setOnline(true);
|
|
||||||
} else if (navigator.onLine === false) {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
setOnline(false);
|
|
||||||
} else if (timer === undefined) {
|
|
||||||
timer = window.setTimeout(() => setOnline(false), SERVER_OFFLINE_GRACE_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const unsubscribe = onRealtimeStatus((isConnected) => {
|
|
||||||
connected = isConnected;
|
|
||||||
update();
|
|
||||||
});
|
|
||||||
window.addEventListener('online', update);
|
|
||||||
window.addEventListener('offline', update);
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.removeEventListener('online', update);
|
|
||||||
window.removeEventListener('offline', update);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return online;
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user