diff --git a/src/web/components/App.tsx b/src/web/components/App.tsx index 57cf4f1..43c08d0 100644 --- a/src/web/components/App.tsx +++ b/src/web/components/App.tsx @@ -11,7 +11,7 @@ import type { Connection } from "../hooks/useStore.ts"; import { message } from "../lib/format.ts"; import { toggleFilter } from "../lib/query.ts"; import type { SuggestContext } from "../lib/query.ts"; -import { hasAllRolls, hasSavedSearches, hasViews } from "../store.ts"; +import { hasAllRolls, hasOutbox, hasSavedSearches, hasViews } from "../store.ts"; import type { Store, SyncResult } from "../store.ts"; import { discardDraft, readDraft, rememberRoll, writeDraft } from "../drafts.ts"; import { Conflicts } from "./Conflicts.tsx"; @@ -43,6 +43,7 @@ import type { Scope } from "./QueryBar.tsx"; import { AllRollsResults, allRollsStatus, useAllRolls } from "./AllRolls.tsx"; import { QueryError, SavedSearchChips, SavedSearchesContext, savedQuery, useSavedSearches } from "./SavedSearches.tsx"; import { ShortcutsDialog } from "./ShortcutsDialog.tsx"; +import { PendingIndicator } from "./PendingChanges.tsx"; import { SyncIndicator, useSync } from "./SyncStatus.tsx"; import { Timeline } from "./Timeline.tsx"; import { TopicsPage } from "./TopicsPage.tsx"; @@ -60,6 +61,8 @@ export function App({ store }: { store: Store }) { const route = useRoute(); const toast = useToast(); const viewsStore = hasViews(store) ? store : null; + // Writing kept on this device until the Roll can be reached (GitRoll.com). + const outbox = hasOutbox(store) ? store : null; // Notes, to-dos and files are read wherever the store offers them: the // sidebar lists the collections and counts the open to-dos, so every page // needs them, not only the pages made from them. @@ -194,9 +197,9 @@ export function App({ store }: { store: Store }) { toast.error(error); return; } - const { notices } = await store.updateEntry(editing.path, changes, value.files, editing); + const { notices, queued } = await store.updateEntry(editing.path, changes, value.files, editing); discardDraft(rollKey, editing.path); - toast.toast([COPY.edited, ...notices].join(" ")); + toast.toast([queued ? COPY.savedOnDevice : COPY.edited, ...notices].join(" ")); setEditing(null); setValue(emptyValue()); navigate(`#/entry/${encodeURIComponent(editing.path)}`); @@ -206,9 +209,9 @@ export function App({ store }: { store: Store }) { toast.error(error); return; } - const { notices } = await store.addEntry(input, value.files); + const { notices, queued } = await store.addEntry(input, value.files); discardDraft(rollKey, null); - toast.toast([value.files.length ? COPY.savedWithFiles(value.files.length) : COPY.saved, ...notices].join(" ")); + toast.toast([queued ? COPY.savedOnDevice : value.files.length ? COPY.savedWithFiles(value.files.length) : COPY.saved, ...notices].join(" ")); setValue(emptyValue()); setComposerOpen(false); } @@ -468,7 +471,12 @@ export function App({ store }: { store: Store }) { collections={sidebarCollections} savedSearches={savedList ?? []} counts={{ todos: openTodos, issues: openIssues, conflicts: conflictCount }} - syncIndicator={} + syncIndicator={ +
+ + {outbox && } +
+ } newMenu={} onShortcuts={() => setShortcutsOpen(true)} onNavigate={inSheet ? () => setMenuOpen(false) : undefined} @@ -504,6 +512,7 @@ export function App({ store }: { store: Store }) { {conflictCount} {conflictCount === 1 ? "conflict" : "conflicts"} )} + {outbox && } + + + +
+
+

Kept on this device

+

+ {items.length + ? "These aren't in your Roll yet. Each one is sent once, when this browser can reach your Roll." + : "You're offline. What you save stays on this device until you're back online."} +

+
+ + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • +

    + {item.kind === "edit" ? "Edit: " : ""} + {item.title || "Untitled"} +

    +

    + {STATE_TEXT[item.state]} + {item.attachments > 0 && ` · ${item.attachments === 1 ? "1 file" : `${item.attachments} files`} waiting`} + {" · "} + {relativeTime(new Date(item.createdAt).toISOString())} + {item.message && {item.message}} +

    +
    + + {item.state !== "syncing" && ( + + )} +
    +
  • + ))} +
+ )} + + {items.length > 0 && ( + + )} +
+
+ + ); +} diff --git a/src/web/copy.ts b/src/web/copy.ts index b43249b..eff9a18 100644 --- a/src/web/copy.ts +++ b/src/web/copy.ts @@ -12,6 +12,8 @@ export const COPY = { saved: "Saved.", savedWithFiles: (n: number) => `Saved, with ${n === 1 ? "the file" : `${n} files`}.`, edited: "Saved. The earlier version is in History.", + savedOnDevice: "Saved on this device. It goes to your Roll when you're back online.", + offlineOnDevice: "You're offline. Keep writing: what you save stays on this device and goes to your Roll when you're back online.", deleted: "Gone from your timeline. Git still has it in History.", confirmDeleteTitle: "Delete this event?", confirmDeleteBody: "It disappears from your timeline, but it stays in History and in your Git repository. Nothing is really lost.", diff --git a/src/web/store.ts b/src/web/store.ts index c49657c..c53442a 100644 --- a/src/web/store.ts +++ b/src/web/store.ts @@ -69,6 +69,12 @@ export interface Saved { entry: LoadedEntry; /** Privacy notices to show the user, e.g. location removed from a photo. */ notices: string[]; + /** + * Kept on this device and not in the Roll yet (an OutboxStore that couldn't + * reach the Roll). `entry` is then only what the person wrote, not a file + * in the Roll: it goes there when the device is back online. + */ + queued?: boolean; } /** The Roll as the web app sees it: in memory, refreshed from the folder on this computer. */ @@ -233,6 +239,50 @@ export interface AllRollsStore { export const hasAllRolls = (store: Store): store is Store & AllRollsStore => typeof (store as Partial).searchAllRolls === "function"; +/** + * A change kept on this device until it reaches the Roll: GitRoll.com's + * "keep on this device", where writing goes on while the browser is offline. + */ +export interface PendingChange { + id: string; + kind: "add" | "edit"; + /** What the person wrote, as the list shows it: its title or first line. */ + title: string; + /** The event an edit changes; null for a new one. */ + path: string | null; + /** + * saved-locally: waiting for a connection. syncing: being sent now. + * needs-attention: the Roll refused it, or can't say whether it arrived, and + * trying again unchanged won't help; `message` says why. Nothing in any + * state is thrown away except by the person. + */ + state: "saved-locally" | "syncing" | "needs-attention"; + message?: string; + /** Files that go with it, kept on this device with it. */ + attachments: number; + createdAt: number; +} + +/** + * A store that keeps writing on this device while the Roll can't be reached + * and sends it, exactly once, when it can. It changes its version() whenever + * the list changes and calls storeChanged(), so the interface redraws. + */ +export interface OutboxStore { + /** Changes waiting on this device, oldest first. */ + pending(): PendingChange[]; + /** The Roll is being shown from this device's copy because it can't be reached. */ + offline(): boolean; + /** Sends the waiting changes now, those that need attention included. */ + syncPending(): Promise; + /** A waiting change as a file the person can keep, before discarding it or for their records. */ + exportPending(id: string): Promise<{ name: string; blob: Blob }>; + /** Throws a waiting change away. Only ever on the person's word. */ + discardPending(id: string): Promise; +} + +export const hasOutbox = (store: Store): store is Store & OutboxStore => typeof (store as Partial).pending === "function"; + /** An event that was deleted, as it stood just before it went. */ export interface DeletedItem { path: string;