+ {offline && connection === "ok" && (
+
+ {COPY.offlineOnDevice}
+
+ )}
{connection !== "ok" && (
{connection === "signed-out" ? COPY.signedOutBody : COPY.stoppedBody}
diff --git a/src/web/components/PendingChanges.tsx b/src/web/components/PendingChanges.tsx
new file mode 100644
index 0000000..0a198f1
--- /dev/null
+++ b/src/web/components/PendingChanges.tsx
@@ -0,0 +1,156 @@
+import { AlertTriangle, CloudOff, Download, RefreshCw, Trash2 } from "lucide-react";
+import { useState } from "react";
+import type { OutboxStore, PendingChange } from "../store.ts";
+import { message, relativeTime } from "../lib/format.ts";
+import { cn } from "../lib/utils.ts";
+import { Button } from "./ui/button.tsx";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx";
+import { useAsk } from "./ui/ask.tsx";
+import { useToast } from "./ui/toast.tsx";
+
+/*
+ Writing kept on this device until it reaches the Roll.
+
+ A store that offers an outbox (GitRoll.com with "keep on this device") lets
+ people save while offline. Those saves are not in the Roll yet, and that is
+ the one thing this must never blur: the light says how many are only here,
+ which are being sent, and which need a person. Nothing is thrown away except
+ by the person, after they have had the chance to download it.
+*/
+
+const STATE_TEXT: Record = {
+ "saved-locally": "On this device",
+ syncing: "Sending…",
+ "needs-attention": "Needs attention",
+};
+
+export function PendingIndicator({ store, version }: { store: OutboxStore; version: string }) {
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const ask = useAsk();
+ const toast = useToast();
+ // version is the redraw key: pending() is read fresh on every render.
+ void version;
+ const items = store.pending();
+ const offline = store.offline();
+ if (!items.length && !offline) return null;
+
+ const attention = items.filter((i) => i.state === "needs-attention").length;
+ const sending = items.some((i) => i.state === "syncing");
+ const Icon = attention ? AlertTriangle : sending ? RefreshCw : CloudOff;
+ const text = attention
+ ? `${attention} need${attention === 1 ? "s" : ""} attention`
+ : items.length
+ ? `${items.length} on this device`
+ : "Offline";
+
+ const download = async (item: PendingChange) => {
+ try {
+ const { name, blob } = await store.exportPending(item.id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
+ } catch (err) {
+ toast.error(message(err));
+ }
+ };
+
+ const discard = async (item: PendingChange) => {
+ const yes = await ask.confirm({
+ title: "Throw this away?",
+ description: "It isn't in your Roll, so once it's gone from this device it's gone. Download it first if you might want it.",
+ confirmLabel: "Throw away",
+ destructive: true,
+ });
+ if (!yes) return;
+ try {
+ await store.discardPending(item.id);
+ } catch (err) {
+ toast.error(message(err));
+ }
+ };
+
+ const retry = async () => {
+ setBusy(true);
+ try {
+ await store.syncPending();
+ } catch (err) {
+ toast.error(message(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
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;