From d7cbad35d9eb28854243ec67b37521bfc1785a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:23:31 +0000 Subject: [PATCH] Show writing kept on this device until it reaches the Roll A store can now offer an outbox (OutboxStore): saves that couldn't reach the Roll are kept on the device and sent later. The interface says so when it saves ('Saved on this device'), shows a banner while offline, and lists what is waiting in the header, with Send now, Download and Throw away. Nothing changes for the app on this computer, which has no outbox. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y7pW8cToM9hhvdZhjSknCT --- src/web/components/App.tsx | 28 +++-- src/web/components/PendingChanges.tsx | 156 ++++++++++++++++++++++++++ src/web/copy.ts | 2 + src/web/store.ts | 50 +++++++++ 4 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/web/components/PendingChanges.tsx diff --git a/src/web/components/App.tsx b/src/web/components/App.tsx index 069ff50..a107d8c 100644 --- a/src/web/components/App.tsx +++ b/src/web/components/App.tsx @@ -8,7 +8,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"; @@ -37,6 +37,7 @@ import type { Scope } from "./QueryBar.tsx"; import { AllRollsResults, allRollsStatus, useAllRolls } from "./AllRolls.tsx"; import { SavedSearchChips, 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"; @@ -54,6 +55,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 only while a page needs them, or when an // address names something that isn't on the timeline (a note). const wantsViews = VIEW_PAGES.has(route.name) || (route.name === "entry" && !entries.some((e) => e.path === route.id)); @@ -172,9 +175,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)}`); @@ -184,9 +187,11 @@ 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); } @@ -457,6 +462,7 @@ export function App({ store }: { store: Store }) { )} + {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 bf2b3d7..9dda384 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. */ @@ -226,6 +232,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;