Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions src/web/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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)}`);
Expand All @@ -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);
}
Expand Down Expand Up @@ -468,7 +471,12 @@ export function App({ store }: { store: Store }) {
collections={sidebarCollections}
savedSearches={savedList ?? []}
counts={{ todos: openTodos, issues: openIssues, conflicts: conflictCount }}
syncIndicator={<SyncIndicator state={sync} status={info.sync} />}
syncIndicator={
<div className="flex flex-wrap items-center gap-1">
<SyncIndicator state={sync} status={info.sync} />
{outbox && <PendingIndicator store={outbox} version={version} />}
</div>
}
newMenu={<NewMenu actions={newActions} />}
onShortcuts={() => setShortcutsOpen(true)}
onNavigate={inSheet ? () => setMenuOpen(false) : undefined}
Expand Down Expand Up @@ -504,6 +512,7 @@ export function App({ store }: { store: Store }) {
{conflictCount} {conflictCount === 1 ? "conflict" : "conflicts"}
</a>
)}
{outbox && <PendingIndicator store={outbox} version={version} />}
<SyncIndicator state={sync} status={info.sync} />
<Button size="icon" onClick={startNew} className="rounded-full" aria-label={COPY.composerOpen}>
<Plus aria-hidden="true" />
Expand All @@ -512,7 +521,7 @@ export function App({ store }: { store: Store }) {

<SavedSearchesContext.Provider value={savedList}>
<main id="main" tabIndex={-1} className={`mx-auto flex w-full min-w-0 flex-col gap-4 px-4 py-4 outline-none md:flex-1 md:px-8 md:py-6 ${WIDE.has(route.name) ? "max-w-6xl" : "max-w-3xl"}`}>
<Banners connection={connection} warnings={info.warnings} problems={info.problems} />
<Banners connection={connection} offline={!!outbox?.offline()} warnings={info.warnings} problems={info.problems} />

{route.name === "timeline" && (
<>
Expand Down Expand Up @@ -762,16 +771,24 @@ const WIDE = new Set<string>(["home", "records", "ledger", "inventory", "contact

function Banners({
connection,
offline,
warnings,
problems,
}: {
connection: Connection;
/** Shown from this device's copy, with writing kept here until it can be sent. */
offline: boolean;
warnings: string[];
problems: { path: string; error: string }[];
}) {
if (connection === "ok" && !warnings.length && !problems.length) return null;
if (connection === "ok" && !offline && !warnings.length && !problems.length) return null;
return (
<div className="flex flex-col gap-2">
{offline && connection === "ok" && (
<p role="status" className="rounded-lg border border-border bg-muted px-3 py-2 text-sm">
{COPY.offlineOnDevice}
</p>
)}
{connection !== "ok" && (
<p role="alert" className="rounded-lg border border-destructive/40 bg-destructive-bg px-3 py-2 text-sm text-destructive">
{connection === "signed-out" ? COPY.signedOutBody : COPY.stoppedBody}
Expand Down
156 changes: 156 additions & 0 deletions src/web/components/PendingChanges.tsx
Original file line number Diff line number Diff line change
@@ -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<PendingChange["state"], string> = {
"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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"tap-target inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
"hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
attention ? "text-destructive" : "text-muted-foreground",
)}
>
<Icon className={cn("size-3.5", sending && "animate-spin")} aria-hidden="true" />
<span className="max-sm:sr-only">{text}</span>
</button>
</PopoverTrigger>

<PopoverContent className="w-80">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Kept on this device</p>
<p className="text-xs text-muted-foreground">
{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."}
</p>
</div>

{items.length > 0 && (
<ul className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{items.map((item) => (
<li key={item.id} className="flex flex-col gap-1 rounded-md border border-border px-2.5 py-2">
<p className="truncate text-sm">
{item.kind === "edit" ? "Edit: " : ""}
{item.title || "Untitled"}
</p>
<p className={cn("text-xs", item.state === "needs-attention" ? "text-destructive" : "text-muted-foreground")}>
{STATE_TEXT[item.state]}
{item.attachments > 0 && ` · ${item.attachments === 1 ? "1 file" : `${item.attachments} files`} waiting`}
{" · "}
{relativeTime(new Date(item.createdAt).toISOString())}
{item.message && <span className="block">{item.message}</span>}
</p>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => void download(item)}>
<Download aria-hidden="true" />
Download
</Button>
{item.state !== "syncing" && (
<Button variant="ghost" size="sm" onClick={() => void discard(item)}>
<Trash2 aria-hidden="true" />
Throw away
</Button>
)}
</div>
</li>
))}
</ul>
)}

{items.length > 0 && (
<Button variant="secondary" size="sm" disabled={busy || sending} onClick={() => void retry()}>
<RefreshCw className={cn((busy || sending) && "animate-spin")} aria-hidden="true" />
{busy || sending ? "Sending…" : "Send now"}
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
2 changes: 2 additions & 0 deletions src/web/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
50 changes: 50 additions & 0 deletions src/web/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -233,6 +239,50 @@ export interface AllRollsStore {

export const hasAllRolls = (store: Store): store is Store & AllRollsStore => typeof (store as Partial<AllRollsStore>).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<void>;
/** 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<void>;
}

export const hasOutbox = (store: Store): store is Store & OutboxStore => typeof (store as Partial<OutboxStore>).pending === "function";

/** An event that was deleted, as it stood just before it went. */
export interface DeletedItem {
path: string;
Expand Down
Loading