From d259edef26acfb18a2cfdad6ce8e75b211b2ad21 Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Thu, 3 Sep 2026 14:06:19 +0200 Subject: [PATCH] more web --- deno.json | 4 +- deploy/load/index.js | 11 + deploy/load/service.json | 2 +- docs/WEB-APP.md | 22 +- src/components/zn-base-window.ts | 205 +++++++++--- src/components/zn-connections.ts | 51 ++- src/state/backend.ts | 36 +- src/state/desk.ts | 8 +- src/sync/protocol.ts | 3 + src/sync/provider.ts | 10 + src/sync/richtext-suggest.test.ts | Bin 0 -> 8337 bytes src/sync/spaces/provider.test.ts | 35 +- src/sync/spaces/provider.ts | 170 ++++++++-- src/types.ts | 14 + src/web/app.ts | 202 ++++++----- src/web/host.ts | 48 ++- src/web/webbackend.ts | 367 ++++++++++++++++++++ src/web/webhost.test.ts | 35 +- src/web/zn-web-doc.ts | 465 ++++++++++++++++++++++++++ static/app.js | 240 +++++++++---- tiles/built/checklist.tile | Bin 8497 -> 11050 bytes tiles/built/livetext.tile | Bin 103107 -> 105660 bytes tiles/built/note.tile | Bin 2704 -> 5257 bytes tiles/built/paper.tile | Bin 51654 -> 54207 bytes tiles/built/richtext.tile | Bin 383754 -> 390893 bytes tiles/built/tictactoe.tile | Bin 11652 -> 14205 bytes tiles/checklist/errors.js | 54 +++ tiles/checklist/index.html | 1 + tiles/livetext/errors.js | 54 +++ tiles/livetext/index.html | 1 + tiles/note/errors.js | 54 +++ tiles/note/index.html | 1 + tiles/paper/errors.js | 54 +++ tiles/paper/index.html | 1 + tiles/richtext/errors.js | 54 +++ tiles/richtext/index.html | 154 ++------- tiles/richtext/suggest.js | 209 ++++++++++++ tiles/tictactoe/errors.js | 54 +++ tiles/tictactoe/index.html | 1 + tools/build-tiles.ts | 3 + tools/build-webapp.ts | 50 +++ tools/entries/tile-errors.js | 54 +++ webapp/site/base.css | 42 +++ webapp/site/index.html | 181 +++------- webapp/site/models/checklist.icon.svg | 5 + webapp/site/models/checklist.tile | Bin 0 -> 11050 bytes webapp/site/models/livetext.icon.svg | 5 + webapp/site/models/livetext.tile | Bin 0 -> 105660 bytes webapp/site/models/models.json | 50 +++ webapp/site/models/note.icon.svg | 5 + webapp/site/models/note.tile | Bin 0 -> 5257 bytes webapp/site/models/paper.icon.svg | 7 + webapp/site/models/paper.tile | Bin 0 -> 54207 bytes webapp/site/models/richtext.icon.svg | 5 + webapp/site/models/richtext.tile | Bin 0 -> 390893 bytes webapp/site/models/tictactoe.icon.svg | 6 + webapp/site/models/tictactoe.tile | Bin 0 -> 14205 bytes webapp/site/tokens.css | 88 +++++ website/site/account/index.html | 14 +- website/site/d/index.html | 18 +- 60 files changed, 2607 insertions(+), 546 deletions(-) create mode 100644 src/sync/richtext-suggest.test.ts create mode 100644 src/web/webbackend.ts create mode 100644 src/web/zn-web-doc.ts create mode 100644 tiles/checklist/errors.js create mode 100644 tiles/livetext/errors.js create mode 100644 tiles/note/errors.js create mode 100644 tiles/paper/errors.js create mode 100644 tiles/richtext/errors.js create mode 100644 tiles/richtext/suggest.js create mode 100644 tiles/tictactoe/errors.js create mode 100644 tools/entries/tile-errors.js create mode 100644 webapp/site/base.css create mode 100644 webapp/site/models/checklist.icon.svg create mode 100644 webapp/site/models/checklist.tile create mode 100644 webapp/site/models/livetext.icon.svg create mode 100644 webapp/site/models/livetext.tile create mode 100644 webapp/site/models/models.json create mode 100644 webapp/site/models/note.icon.svg create mode 100644 webapp/site/models/note.tile create mode 100644 webapp/site/models/paper.icon.svg create mode 100644 webapp/site/models/paper.tile create mode 100644 webapp/site/models/richtext.icon.svg create mode 100644 webapp/site/models/richtext.tile create mode 100644 webapp/site/models/tictactoe.icon.svg create mode 100644 webapp/site/models/tictactoe.tile create mode 100644 webapp/site/tokens.css diff --git a/deno.json b/deno.json index e94b6f9..daa6188 100644 --- a/deno.json +++ b/deno.json @@ -24,8 +24,8 @@ "deploy:app": "deno task build:webapp && cd webapp && sm deploy", "stage:load": "deno run -A tools/stage-loadserver.ts", "deploy:load": "deno task stage:load && cd deploy/load && sm deploy", - "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts", - "test:sync": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/spaces/webstore.test.ts && deno run -A src/web/home.test.ts && deno run -A src/web/webhost.test.ts && deno run -A src/web/tiles.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", + "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A src/sync/richtext-suggest.test.ts", + "test:sync": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/spaces/webstore.test.ts && deno run -A src/web/home.test.ts && deno run -A src/web/webhost.test.ts && deno run -A src/web/tiles.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A src/sync/richtext-suggest.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", "desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor --include tiles --include build main.ts", "build": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --output dist/Ziran.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran.app", "build:mac": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app", diff --git a/deploy/load/index.js b/deploy/load/index.js index de19f66..b9f845d 100644 --- a/deploy/load/index.js +++ b/deploy/load/index.js @@ -56,6 +56,17 @@ const vendorRoot = new URL('./vendor/', import.meta.url); const app = express(); app.set('trust proxy', true); // behind the supramundane front +app.disable('x-powered-by'); + +// The front's generic nice-headers snippet is OFF for this service +// ("headers": false): its X-Frame-Options sameorigin would forbid the very +// framing this server exists for, and its other headers duplicated (and +// contradicted) the hardened set below. HSTS was the one generic header +// worth keeping — set it here, on everything. +app.use((_req, res, next) => { + res.set('strict-transport-security', 'max-age=63072000; preload'); + next(); +}); app.get('/health', (_req, res) => res.json({ ok: true })); diff --git a/deploy/load/service.json b/deploy/load/service.json index f630d0c..f67d5a2 100644 --- a/deploy/load/service.json +++ b/deploy/load/service.json @@ -17,7 +17,7 @@ "*.load.ziran.space" ] }, - "headers": true, + "headers": false, "upstreamPort": 3080, "data": false, "wildcardTls": "acmedns" diff --git a/docs/WEB-APP.md b/docs/WEB-APP.md index 0238144..3f6ef55 100644 --- a/docs/WEB-APP.md +++ b/docs/WEB-APP.md @@ -239,9 +239,25 @@ same cadences, from the worker. Two web-specific notes: materializes the SAVED state → ops both ways). **Remaining (human):** `deno task deploy:load` + `deno task deploy:app`, then the exit pass in real browsers — open an imported doc, edit, reload, second browser. -- **D. Shell.** Ribbon + subtabs; connections panel, invitations strip, - presence — the components as they are, over the RPC backend. Import/ - export. `/d/` page gains the web path. +- **D. Shell — built (2026-09-01), Robin's redesign notes applied.** The + interim custom page is gone: past the sign-in gate the web app renders + the SAME components as the desktop — `zn-app`/`zn-base-window` (two + columns, Most recent | By location with a "Home" group, Models column, + ink ribbon, identity chip with avatar/name/sign-out, invitations strip, + ⌘K, drag-drop import) over `src/web/webbackend.ts`, installed with + `setBackend()` so no component knows the difference. Documents open in + ordinary BROWSER TABS (`#doc=[&ref=…]`) rendered by + `src/web/zn-web-doc.ts`: slim ink bar with the doc's zn-connections chip, + invitations when they arrive, identity, then the isolated tile frame. + "Open Tile" is the same button (browser file dialog); starters ship as + static assets (`build:webapp` stages tiles/built + tokens/base css); + ".tile" scrubbed from user-facing copy. Polling became polite: + hot 5s only for the visible doc tab, warm 60s, listSpaces 120s, inbox + 90s, hidden tabs drop to warm, visibility-return polls immediately (no + push exists in the protocol yet — the notify bridge stays the recorded + follow-up). Filesystem-only affordances (move, reveal, rename) answer + honestly. Remaining for the phase: export-to-file, and the desktop + `/d/` page web path. - **E. Ship.** sm services; smoke suite (browser harness driving two identities end to end); website "use it in the browser" entry; alpha caveats page (Safari matrix, no offline background sync). diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts index fdf14e2..a32da36 100644 --- a/src/components/zn-base-window.ts +++ b/src/components/zn-base-window.ts @@ -2,7 +2,7 @@ import { css, html, LitElement, nothing } from 'lit'; import { baseStyles } from '../styles/shared.ts'; import { desk } from '../state/desk.ts'; import { keyed, modelIconUrl, recentIconUrl, relativeTime } from '../state/backend.ts'; -import type { ModelEntry, RecentEntry } from '../types.ts'; +import type { ModelEntry, RecentEntry, SyncSpace } from '../types.ts'; import { icons, zmark } from './icons.ts'; @@ -35,7 +35,7 @@ export class ZnBaseWindow extends LitElement { this.query = ''; this.dropping = false; this.menuRecent = undefined; - this.docSort = 'recent'; + this.docSort = 'location'; this.confirmModel = undefined; this.menuConfirmDelete = false; } @@ -320,12 +320,26 @@ export class ZnBaseWindow extends LitElement { min-height: 32px; min-width: 0; } - .invite-dot { - width: 7px; - height: 7px; + .invite-avatar { + width: 22px; + height: 22px; border-radius: 50%; - background: var(--signal); + object-fit: cover; flex: none; + display: block; + background: var(--panel); + } + .invite-avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.7rem; + font-weight: var(--w-semibold); + line-height: 1; + } + .invite-handle { + color: var(--ink-muted); } .invite-text { flex: 1; @@ -456,6 +470,42 @@ export class ZnBaseWindow extends LitElement { flex-direction: column; min-height: 0; } + .space-people { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2) var(--sp-3); + padding: 2px var(--sp-2) 8px; + font-size: var(--text-xs); + color: var(--ink-muted); + } + .space-people .person { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + } + .space-people .person.pending { + opacity: 0.6; + } + .space-people .p-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: var(--panel); + } + .space-people .p-avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.6rem; + font-weight: var(--w-semibold); + line-height: 1; + } .scroll { overflow-y: auto; min-height: 0; @@ -740,6 +790,12 @@ export class ZnBaseWindow extends LitElement { display: inline-grid; transform: scale(0.9); } + footer .foot-err { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } @media (max-width: 839px) { .regions { grid-template-columns: 1fr; @@ -807,23 +863,13 @@ export class ZnBaseWindow extends LitElement {
- ${!desk.recents.length && !q - ? html` -
-

Your documents live here.

-

- Ziran documents are tiles: they carry their own editor and live on this - machine, with no cloud and no account. Invite someone and a document - syncs live, everyone keeping their own copy. Start from a model to make - your first one, or drop a .tile file anywhere on this window. -

-
- ` - : this.docSort === 'location' - ? this.renderByLocation(recents, open) + ${this.docSort === 'location' + ? this.renderByLocation(recents, open) + : !desk.recents.length && !q + ? html`

No documents

` : recents.length ? recents.map((d) => this.renderRow(d, open.has(d.id), d === enterDoc)) - : html`

Nothing called “${this.query}” — try a model instead?

`} + : html`

No documents match “${this.query}”

`}
@@ -832,8 +878,8 @@ export class ZnBaseWindow extends LitElement { ${models.length ? models.map((m) => this.renderModel(m, m === enterModel)) : q - ? html`

No model matches “${this.query}”.

` - : html`

No models in your library yet.

`} + ? html`

No models match “${this.query}”

` + : html`

No models

`}
@@ -859,6 +905,17 @@ export class ZnBaseWindow extends LitElement { `; } + // Sync being down is footer-truth even before anything is shared — + // an empty spaces list with no explanation reads as "you have none". + if (sync?.provider === 'spaces' && canLive && !sync.relayOk) { + const why = sync.relayError ?? `${sync.relay ?? 'your PDS'} is unreachable; Ziran keeps trying`; + return html` +
+ ${icons.warning} + Live sync is paused: ${why} +
+ `; + } if (sync && shared.length && !canLive) { return html`
@@ -980,8 +1037,7 @@ export class ZnBaseWindow extends LitElement { @@ -990,10 +1046,11 @@ export class ZnBaseWindow extends LitElement { } /** The person's avatar, or their initial when they haven't set one. */ - private renderAvatar(at: { hasAvatar?: boolean }, name: string, size = 22) { + private renderAvatar(at: { hasAvatar?: boolean; avatarUrl?: string }, name: string, size = 22) { const style = `width:${size}px;height:${size}px`; - return at.hasAvatar - ? html`` + const src = at.avatarUrl ?? (at.hasAvatar ? keyed('/api/at/avatar') : undefined); + return src + ? html`` : html``; @@ -1011,19 +1068,32 @@ export class ZnBaseWindow extends LitElement { if (!invitations.length) return nothing; return html`
- ${invitations.map((inv) => html` -
- - - ${inv.inviterHandle ?? inv.inviterDid} invited you to - “${inv.title}” as ${inv.role} - - ${inv.pending - ? html`Preparing your copy…` - : html``} - -
- `)} + ${invitations.map((inv) => { + // A person, not an address: the profile cache turns the DID into + // avatar + name + handle (and upgrades the row when it lands). + const profile = desk.profile(inv.inviterDid); + const name = profile?.displayName ?? profile?.handle ?? inv.inviterHandle ?? '…'; + const handle = profile?.handle ?? inv.inviterHandle; + return html` +
+ ${profile?.avatar + ? html`` + : html``} + + ${name}${handle && handle !== name + ? html` @${handle}` + : nothing} + invited you to “${inv.title}”${inv.spaceName ? ` in ${inv.spaceName}` : ''} as ${inv.role} + + ${inv.pending + ? html`Preparing your copy…` + : html``} + +
+ `; + })}
`; } @@ -1047,22 +1117,25 @@ export class ZnBaseWindow extends LitElement { const byName = (list: T[]) => [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); const local = byName(recents.filter((r) => !inSpace.has(r.id))); - const spaces = [...(sync?.spaces ?? [])].sort((a, b) => + // The home space never lists as a space here: on the web its documents + // ARE the recents list — a second section would show them all twice. + const spaces = (sync?.spaces ?? []).filter((s) => !s.home).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ); return html` -
- Local Device · ${local.length} +
+ ${desk.desktop ? 'Local Device' : 'Home'} · ${local.length} ${local.length ? local.map((d) => this.renderRow(d, open.has(d.id))) - : html`

Nothing lives only on this machine.

`} + : html`

No documents

`}
${spaces.map((s) => { const here = byName(recents.filter((r) => inSpace.get(r.id) === s.ref)); const away = byName(s.available.filter((a) => !q || a.name.toLowerCase().includes(q))); return html` -
+
${s.name || 'unnamed space'} · ${here.length + away.length} + ${this.renderSpacePeople(s)} ${here.map((d) => this.renderRow(d, open.has(d.id)))} ${away.map((a) => html` `)} - ${!here.length && !away.length ? html`

No documents in this space yet.

` : nothing} + ${!here.length && !away.length ? html`

No documents

` : nothing}
`; })} `; } + /** Who is in a space: avatar + name each, invited-but-not-arrived dimmed. + Never a DID — the profile cache upgrades '…' as it resolves. */ + private renderSpacePeople(s: SyncSpace) { + const members = s.members ?? []; + if (!members.length) return nothing; + const at = desk.sync?.at; + return html` +
+ ${members.map((m) => { + const me = m.did === at?.did; + const profile = me ? undefined : desk.profile(m.did); + const name = me + ? (at?.displayName ?? at?.handle ?? 'You') + : profile?.displayName ?? profile?.handle ?? '…'; + const src = me ? at?.avatarUrl ?? (at?.hasAvatar ? keyed('/api/at/avatar') : undefined) : profile?.avatar; + return html` + + ${src + ? html`` + : html``} + ${name}${me ? ' · you' : m.pending ? ' · invited' : ''} + + `; + })} +
+ `; + } + private renderRow(d: RecentEntry, isOpen: boolean, enterTarget = false) { return html` - + ${desk.desktop + ? html`` + : nothing}
- - + ${desk.desktop || !live?.space + ? html`
+
+ ${d.path} + +
+
` + : html`
+
+ + +
+
`} `; } @@ -612,6 +619,10 @@ export class ZnConnections extends LitElement { `; } if (sync.provider === 'spaces') { + // A doc in the home space is yours alone by definition: the way to + // bring people in is moving it into a shared space, and that is + // exactly what Go live does for it. + if (s.home) return this.renderGoLiveSpaces(); const open = s.roles?.filter((r) => r.invitable) ?? []; const link = this.lastInvite?.link ?? s.link; return html` @@ -717,9 +728,11 @@ export class ZnConnections extends LitElement { ${members.map((m) => { const me = m.did === myDid; const profile = desk.profile(m.did); + // Never a DID: while the profile resolves, an ellipsis holds the + // seat (the row upgrades the moment the batch lands). const name = me ? (desk.sync?.at?.displayName ?? desk.sync?.at?.handle ?? 'You') - : profile?.displayName ?? profile?.handle ?? m.handle ?? `${m.did.slice(0, 14)}…`; + : profile?.displayName ?? profile?.handle ?? m.handle ?? '…'; const canAssign = s.mine === true && !me && !m.pending; const canRemove = s.owner === true && !me; const confirming = this.confirmRemove === m.did; @@ -771,14 +784,24 @@ export class ZnConnections extends LitElement { private renderMemberAvatar(profile: AtProfile | undefined, name: string, me: boolean) { // Own avatar is served same-origin and needs the key; others come - // straight from their PDS/CDN. - const src = me && desk.sync?.at?.hasAvatar ? keyed('/api/at/avatar') : profile?.avatar; + // straight from their PDS/CDN. On the web our own is a direct URL too. + const src = me + ? desk.sync?.at?.avatarUrl ?? (desk.sync?.at?.hasAvatar ? keyed('/api/at/avatar') : undefined) + : profile?.avatar; if (src) return html``; return html``; } + /** The same document, in the desktop app: the deeplink the app registers. + The browser asks before switching; nothing happens if it isn't + installed — the link stays a quiet offer. */ + private openInApp(live: { space?: string; syncId: string }) { + const rkey = live.syncId.split('/').pop()!; + location.href = `ziran://space?ref=${encodeURIComponent(live.space!)}&doc=${encodeURIComponent(rkey)}`; + } + private kick(did: string) { if (this.confirmRemove !== did) { this.confirmRemove = did; @@ -852,7 +875,7 @@ export class ZnConnections extends LitElement { /** Going live into a space (Spaces provider): pick one of yours, or name a new one. Membership is per-space, so the copy says exactly that. */ private renderGoLiveSpaces() { - const spaces = desk.sync?.spaces ?? []; + const spaces = (desk.sync?.spaces ?? []).filter((s) => !s.home); const wantsNew = this.shareSpaceRef === ''; return html`
${this.busy ? '…' : 'Go live'}
-

- Everyone in a space sees every document in it, synced through your PDS - (not end-to-end encrypted). -

+

Everyone in a space sees every document in it.

`; } @@ -980,8 +1000,7 @@ export class ZnConnections extends LitElement {

- Ziran opens your provider in the browser — no new account, no password here. - No AT identity yet? + You sign in at your own provider — no password here. No AT identity yet? Create one.

`; diff --git a/src/state/backend.ts b/src/state/backend.ts index 68d2aa9..50945c3 100644 --- a/src/state/backend.ts +++ b/src/state/backend.ts @@ -11,6 +11,8 @@ export interface OpenResult { existing: boolean; /** Desktop mode: the server already raised the existing window. */ focused?: boolean; + /** Web mode: open the url as an ordinary browser tab, not a popup. */ + tab?: boolean; } /** Launch-time bearer token, delivered in the URL the server navigated this @@ -52,7 +54,35 @@ async function post(url: string, body?: unknown): Promise { return data as T; } -export const backend = { +// The desktop implementation, and the swap seam the web app uses. The +// exported `backend` is a Proxy delegating to whichever implementation is +// current: methods come back BOUND to the implementation (a class instance +// with private fields must be its own `this`), and property writes (like +// desk assigning `onState`) land on the implementation too. Object.assign +// is exactly wrong here — class methods live on the prototype, so a copy +// silently keeps the desktop's HTTP methods and the web app ends up +// JSON.parsing its own index.html. +export type Backend = typeof desktopBackend; +let current: Backend = undefined as unknown as Backend; // set right below +export function setBackend(impl: unknown): void { + current = impl as Backend; +} + +export const backend: Backend = new Proxy({} as Backend, { + get(_t, key) { + const value = (current as unknown as Record)[key]; + return typeof value === 'function' ? (value as (...a: unknown[]) => unknown).bind(current) : value; + }, + set(_t, key, value) { + (current as unknown as Record)[key] = value; + return true; + }, + has(_t, key) { + return key in (current as object); + }, +}); + +const desktopBackend = { onState: undefined as ((state: ServerState) => void) | undefined, /** Non-state events (sync traffic); fan-out to whoever subscribes. */ events: new EventTarget(), @@ -181,12 +211,14 @@ export const backend = { // Icon and bundle URLs are consumed by / the tile loader, which cannot // send headers — the key rides in the query string. +current = desktopBackend; + export const docIconUrl = (d: DocInfo): string | undefined => d.hasIcon ? keyed(`/api/docs/${d.id}/icon`) : undefined; export const recentIconUrl = (d: DocInfo): string | undefined => d.hasIcon ? keyed(`/api/recents/${d.id}/icon`) : undefined; export const modelIconUrl = (m: ModelEntry): string | undefined => - m.hasIcon ? keyed(`/api/models/${encodeURIComponent(m.id)}/icon`) : undefined; + m.icon ?? (m.hasIcon ? keyed(`/api/models/${encodeURIComponent(m.id)}/icon`) : undefined); export const bundleUrl = (docId: string): string => keyed(`${location.origin}/api/docs/${docId}/bundle.tile`); diff --git a/src/state/desk.ts b/src/state/desk.ts index c21941c..141526b 100644 --- a/src/state/desk.ts +++ b/src/state/desk.ts @@ -125,6 +125,12 @@ class DeskStore extends EventTarget { /* ——— opening things (from the base window) ——— */ private handleOpened(res: OpenResult): void { + // Web mode: documents live in ordinary browser tabs — named per doc, + // so opening one twice reuses (and refocuses) the same tab. + if (res.tab) { + globalThis.open(res.url, res.win.id)?.focus(); + return; + } // Desktop mode: the server spawned the OS window. Browser mode: we open // it ourselves (we are inside the user's click, so popups are allowed). if (!this.desktop && !res.existing) { @@ -186,7 +192,7 @@ class DeskStore extends EventTarget { let path = picked.path; if (picked.unsupported || !path) { // Genuinely no native dialog (a bare Linux box): ask for a path. - path = prompt('Absolute path to a .tile file:') ?? undefined; + path = prompt('Absolute path to the tile file:') ?? undefined; if (!path) return; } try { diff --git a/src/sync/protocol.ts b/src/sync/protocol.ts index 45200c1..1ad8d60 100644 --- a/src/sync/protocol.ts +++ b/src/sync/protocol.ts @@ -52,6 +52,9 @@ export interface Invitation { docId: string; /** Human label for the document, shown in the invitee's inbox. */ title: string; + /** The space the document lives in (Spaces provider), so the inbox can + say "X in Y" — accepting joins the space, the doc is the way in. */ + spaceName?: string; inviterDid: string; inviterHandle?: string; inviteeDid: string; diff --git a/src/sync/provider.ts b/src/sync/provider.ts index b18b8bc..8a2d611 100644 --- a/src/sync/provider.ts +++ b/src/sync/provider.ts @@ -35,6 +35,9 @@ export interface DocSummary { roles: Array<{ name: string; label: string; invitable: boolean; full: boolean }>; /** Spaces only: the space this doc lives in. */ space?: string; + /** Spaces only: the doc lives in the single-person home space — sharing + it means moving it into a shared space. */ + home?: boolean; /** Spaces only: the shareable HTTPS link to this doc. */ link?: string; /** This identity owns the doc's space (may remove members). */ @@ -48,6 +51,11 @@ export interface SpaceSummary { name: string; /** True when this identity owns the space (owner-only invites). */ mine: boolean; + /** The single-person home space (never a go-live target). */ + home?: boolean; + /** Everyone in the space: owner + writers (pending = invited, not yet + arrived), minus the removed. */ + members?: Array<{ did: string; pending?: boolean }>; /** Docs discovered in the space that are not instantiated locally yet (the Documents column's "By location" raw material). */ available: Array<{ docRkey: string; name: string; creator: string }>; @@ -63,6 +71,8 @@ export interface ProviderSummary { did?: string; displayName?: string; hasAvatar?: boolean; + /** Direct avatar URL (web: straight from the person's PDS). */ + avatarUrl?: string; /** A stored sign-in was retired because it predated the permissions live documents need; the UI asks for a fresh sign-in by name. */ staleScope?: boolean; diff --git a/src/sync/richtext-suggest.test.ts b/src/sync/richtext-suggest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f3b3635c3a30aa33dbd42ea73e459429fdfde22 GIT binary patch literal 8337 zcmdPbR|qamPfsl_$;{7F$jwhl)ln!(EiOq-QAkWr%*-n;Q7FksRS5C)aa9ji@DKM> zNX}15wNfa_$ShXK%q_?-DkjDM-Q~vocZ> zQ*u&^i**!=^A%FdQj01}GBWeh6;dmbQwvHI3NkBFbBYx*AvP5h<)@TFOf1MONX^O2 zOVtAz5>%RJrI3=Em#5Ug>YPOaz<)yBG~c(Up-Ko%|S>O zgC(sLk`jwElVNHgf=(bIgv!K}6rarE62JVERESSNHi316gsr)_a#Bka(h@UsN{dp9 z6>Jp@U|N$iQj@b4Y!x(e5|dJMtQ3k%iZb)kbrkZmtrU{-^K()Y^K=waQcDsua};b9 z)YUZ=Z0!`PxfB#YQIwyPs+W_Wu92Wpou92>ub>{_7#yswV5Ok$=IH68UaO#>QVlk@ zR;3zhhP^@pC~qjh1ZopN%GGNVG_AQ56f)BkG!*l*HQ`Rx*0$!VwdR6`FU0E#whDQv z```0Ed}gaY1Ub9#~pOLA@ZcC^5Y#u^>YsDJMTUTT5L>K^@Fe z*VN%sP{>Uz$}WZmHbf&>1eCK<^HRVTfxMlYo0?Z*1r7rR1qFq~l9HlgD}`!>%oHny z>RN?bkWnCs%)I2B(&EgrR4awF#GK+(n0QIPi@&dxf<~^DLSkMeI6R}(iwhF-)O8f9 z71UD_OA>XHb4%2%6ms>zhU;afDAejG7{o$MfLfrZrw2`nC<&WOL7`SthpSc-8s{)I zdFcwa3MECQsj!HLN#y5&(gQ|z0DBh_-XLo;i(y$MFWpK(1MF&;33=%t73CTEFbVad z{G`l0b*L-!wX_r*71D}Qi!&6!iC-bVEVW2M$s;u+iKA2=8YM-Vu#&14n)yJ%0xBrL zIv}Qj$^tM~FC{-&52U~;wWK^XHBZAp2NKs{iRApelGMBsz2eNOR2>C%26atXA_QkJ z#}ZUybrcHni>(y$N^_G^iy$ctRAz#WDoQQR&nZjQD9A6?1V^!BT1je=y&l*g1$%pi z=vbK9#i{9_l#LV&5VIh)c_t`6(i1_|Fhm8&cu=O%*8-JKj~b>tYM7#6sHad;SpcdV zGxK2e2)JPZsvSXgrlwdcG=eqrOqhQzW|1$Bs_kcSbrrMxZE6O)th0Oc|*Pd8rj8 zNUlS64cLiD2`4iJWP=fAa2bKZD-#l0;IPX~0f!e@95cW`4n+?!BaoA8xwuf$xsjei zNq%~I4k)FAf(O(NQOHkAQ%FoMDNW4DsZ_{GO)N_-R>&+NI#q*%4rh3hn!>?}4!@De z$r#i?fF@yBxPogya7_$qX<#=Bscb0DNKJu8B-n0994DowZKLs=YnELK>?A&K(UetD(4W91d5x?VujK?NFfADE{KE&br3jD zK^%;kaS*B@wxNU)q@94CNR0Iq5|hC_gzVJR0!Z%wl;(64O7n_KDsw<(A+)rFxVE?? zv9eeJ?9-uTTucrL=zmR(6$NKwcz#>sv1ag1-0un)E_l012?Vm z@{vkAa7$MqCpE1Ece*hEn}R);U`x;77+2R(Fa(z`pms8-2L~2K%`Kp?gZdpMu_Wd} zQ&(yVxHc^=Nh~TUR!CHE3vvziKz6-G9;nX>ZXV_5q$p&jXsYWdfNerf_~7~lEDDJ; z1w~t11xRrT7X^hcBt*eN>KNGsEDVY-^t@oAr%;rdm6}|lkO*q0g6dCD)D@-X=7WmK z)QZI9lAOvRQ(Pt`rl8jN)K2%{au*bk@Nx|jd?*ROEHN*$I3u-KAsN)|%&A08){uxo zZkD6u3{7h;^hh$*gC!f?l+>Kml2mZ-HaoT08k*D;Ai~Agu*fV*EiTCi^^hU$KTs18 zf71J)n*mL=U7{0ZQU2`T4o2 zDd08=s5cBPI#WO$|Dw|5Y=x4HqWseI40X`(VU9g`bO2I>L(&mOT?@;x5E~vfEQ1ZU zL&FX<>;|$Z5!`%1t~k_nKxGTE70^HrP)N!z$xtXytSkn_Kd8%}0_rH+ zLV5t8VmvLsNI?S{!>Q?@ViMf&1S!+hQ~*)%-V}(V1FPK7iZ7^KYPueQ!VB!6%FNW9 z6i8bhDV$;61(}87TU3vNrNQX}>IrZRLCiw&87x&I%VTL%nCpQXp2Y<@nI*6Z4p7Ulq$sffRHlL3 zgz!QvHL(QVM^nhnOi9T}1y2n@nirrkO+3Rw;Isx0954@*xj>UWWDF63qX}#XP9q^* z5Xd-CVp4KSY8pzhgS$-#8h?TG4D<7HD!~B+@vK5pr9wtxUJ7Jhp#UP2Sd^^*>gFma zC8sDUXcTAWmgXduq=IK{xD-IG9}u@l0aV5+lz>XylvG_%KNr*+fSOxeQj}VfoS_NI zOZYtnsg*%9KN=P~3WlJ8N$|V~WXKRSh7KC2g5*|6<|_bqJZzD>eR^q`IVGt@Xk{uW zK){^}aM(gSHHivP*QTaGV_X58SRi=}(c)0Z%qz(U*#Rnniq&-#z}6zyrA8RV7g!0z zsn|VDsQZa8&p-mISi#@VClVe}sVQ(L>L?)kU65FSl@egLpk$_`WR&a#QGg{kS?DQ% z<|jdo07&8km1PRKiIobFz5t|dg$y!+XALy+@)goE@{3C}2c%)3i7)U$r5^flYDsPZ zN*)5Ids_u_9fgv71zQC}18agKk0f;>K<0zYCfT78D9%g)O#l;-15g{`$k_new+BaB zWqxUqLVkH3W?aLD6+y!j;EER#xsWyk%5bp(s4JidE}S8$Ne?`~mXcbmp$-mY9fbsy zY9#qu1(j;_u}@I56{1@cJmFXinOKBPW5VZ0?G+MY@>Hr}{8|Ojbf&8dc&aicFEJ-a jA-yOyH4ik8;gXt{ua{bpS)u_`tzfTUs9>c4Y6$}XHTDW| literal 0 HcmV?d00001 diff --git a/src/sync/spaces/provider.test.ts b/src/sync/spaces/provider.test.ts index 89776ba..3968064 100644 --- a/src/sync/spaces/provider.test.ts +++ b/src/sync/spaces/provider.test.ts @@ -298,8 +298,9 @@ check('non-owner invite is refused', notOwner instanceof Error && /owner/.test(n await carol.provider.pollNow(true); const carolInv = carol.provider.summary().invitations; check( - 'carol sees the invitation with the space name', - carolInv.length === 1 && carolInv[0]!.title === 'Test Space' && carolInv[0]!.inviterDid === a.did, + 'carol sees the invitation as doc-in-space', + carolInv.length === 1 && carolInv[0]!.title === 'Shared Doc' && + carolInv[0]!.spaceName === 'Test Space' && carolInv[0]!.inviterDid === a.did, JSON.stringify(carolInv), ); const { path: carolPath } = await carol.provider.accept(carolInv[0]!.token); @@ -334,6 +335,36 @@ await carolAgain.pollNow(true); check('a handled invitation stays handled after restart', carolAgain.summary().invitations.length === 0); carolAgain.close(); +// The WEB case: a fresh profile (new browser tab — empty registry). The +// settled marker in carol's own inbox repo must suppress the invitation, +// because the tab remembers nothing. +const carolFresh = await fixture('carol', 'carolFresh'); +await carolFresh.provider.pollNow(true); +check( + 'an accepted invitation never resurfaces on a fresh profile', + carolFresh.provider.summary().invitations.length === 0, + JSON.stringify(carolFresh.provider.summary().invitations), +); +carolFresh.provider.close(); + +// Same for a dismissal that never accepted anything. +const erin = await fixture('erin', 'erin'); +await a2.provider.invite(aDocId, erin.did, 'editor'); +await erin.provider.pollNow(true); +const erinInv = erin.provider.summary().invitations; +check('erin sees her invitation', erinInv.length === 1, JSON.stringify(erinInv)); +erin.provider.dismiss(erinInv[0]!.token); +await new Promise((r) => setTimeout(r, 50)); // settle write is fire-and-forget +const erinFresh = await fixture('erin', 'erinFresh'); +await erinFresh.provider.pollNow(true); +check( + 'a dismissed invitation never resurfaces on a fresh profile', + erinFresh.provider.summary().invitations.length === 0, + JSON.stringify(erinFresh.provider.summary().invitations), +); +erin.provider.close(); +erinFresh.provider.close(); + // Inviting someone with no inbox yet: membership lands, delivery cannot. const inboxless = await a2.provider.invite(aDocId, net.account('dave').did, 'editor'); check('inviting an inboxless identity reports pending + link', inboxless.invitation.pending === true); diff --git a/src/sync/spaces/provider.ts b/src/sync/spaces/provider.ts index a1fcb8b..5159b32 100644 --- a/src/sync/spaces/provider.ts +++ b/src/sync/spaces/provider.ts @@ -29,7 +29,7 @@ import { type DocRec, loadRegistry, saveRegistry, type SpaceRec, type SpacesRegi import { type CheckpointRecord, instantiateFromCheckpoint, publishCheckpoint } from './checkpoint.ts'; import { setTileAt } from '../../server/tilefile.ts'; import { resolveIdentity } from '../atproto.ts'; -import { ZIRAN_INBOX_TYPE, ZIRAN_SPACE_TYPE } from './scope.ts'; +import { ZIRAN_HOME_TYPE, ZIRAN_INBOX_TYPE, ZIRAN_SPACE_TYPE } from './scope.ts'; import { zfs } from '../../lib/zfs.ts'; /** Runtime-enforced defaults when a tile's manifest declares no sync roles. */ @@ -150,6 +150,12 @@ export interface SpacesProviderOptions { handle?: () => string | undefined; /** Tests: disable timers and drive polls with pollNow(). */ autoPoll?: boolean; + /** Cadence overrides (ms). The web host slows these down — a browser tab + has no business polling like a desktop daemon. */ + hotPollMs?: number; + warmPollMs?: number; + listSpacesMs?: number; + inboxPollMs?: number; } interface DiscoveredDoc { @@ -348,8 +354,14 @@ export class SpacesProvider implements CollabProvider { try { if (!this.#client) await this.#connect(); if (!this.#client) return; - if (force || Date.now() - this.#lastListSpaces > LIST_SPACES_MS) { - await this.#refreshSpaces().catch(() => {}); + if (force || Date.now() - this.#lastListSpaces > (this.#opts.listSpacesMs ?? LIST_SPACES_MS)) { + // A refused listSpaces means the By-location view quietly shows + // nothing — surface it like any other backend refusal. + await this.#refreshSpaces().catch((err) => { + const classified = classifySpaceError(err); + this.#error = classified.message; + this.#errorKind = classified.kind; + }); } for (const space of this.#registry.spaces) { const due = this.#nextPoll.get(space.ref) ?? 0; @@ -364,7 +376,10 @@ export class SpacesProvider implements CollabProvider { this.#errorKind = classified.kind; } const hot = this.#registry.docs.some((d) => d.space === space.ref && this.#opts.host.isOpen(d.localId)); - this.#nextPoll.set(space.ref, Date.now() + (hot ? HOT_POLL_MS : WARM_POLL_MS)); + this.#nextPoll.set( + space.ref, + Date.now() + (hot ? this.#opts.hotPollMs ?? HOT_POLL_MS : this.#opts.warmPollMs ?? WARM_POLL_MS), + ); } // Frames stranded by an earlier failed flush retry on the poll beat. for (const [localId, live] of this.#live) { @@ -373,7 +388,7 @@ export class SpacesProvider implements CollabProvider { } } await this.#writePresence().catch(() => {}); - if (force || Date.now() - this.#lastInboxPoll > INBOX_POLL_MS) { + if (force || Date.now() - this.#lastInboxPoll > (this.#opts.inboxPollMs ?? INBOX_POLL_MS)) { await this.#pollInbox().catch(() => {}); } } finally { @@ -395,26 +410,37 @@ export class SpacesProvider implements CollabProvider { } catch { return; // no inbox yet (creation failed above); retried next sweep } + // Handled invitations leave a settled marker under the SAME token in our + // own inbox repo — the durable form of the registry list (which, in a + // browser tab, forgets on every load). + const settled = new Set( + (await client.listRecords(inbox, client.auth.did, 'space.ziran.invite').catch(() => [])) + .map((r) => r.rkey), + ); let changed = false; for (const repo of repos) { if (repo.did === client.auth.did) continue; const records = await client.listRecords(inbox, repo.did, 'space.ziran.invite').catch(() => []); for (const record of records) { const token = record.rkey; - if (this.#registry.dismissedInvites.includes(token) || this.#invitations.has(token)) continue; - const v = record.value as { space?: string; name?: string; doc?: string; createdAt?: string }; + if (settled.has(token) || this.#registry.dismissedInvites.includes(token) || this.#invitations.has(token)) continue; + const v = record.value as { space?: string; name?: string; doc?: string; docName?: string; createdAt?: string }; if (typeof v.space !== 'string') continue; const doc = typeof v.doc === 'string' ? v.doc : undefined; - // Already here (adopted, or a known space when no doc is named)? + // Already a member (the space is in our list — accepting happened on + // some device), or the named doc is already here? Then the + // invitation is answered, whatever this tab remembers. + if (this.#registry.spaces.some((s) => s.ref === v.space)) continue; if (doc && this.#registry.docs.some((d) => d.space === v.space && d.docRkey === doc)) continue; - if (!doc && this.#registry.spaces.some((s) => s.ref === v.space)) continue; this.#invitations.set(token, { space: v.space, doc, inv: { token, docId: doc ?? '', - title: v.name ?? 'a shared space', + // The document is what you open; the space is what you join. + title: v.docName ?? v.name ?? 'a shared space', + spaceName: v.docName ? v.name : undefined, inviterDid: repo.did, inviteeDid: client.auth.did, role: 'member', @@ -624,7 +650,11 @@ export class SpacesProvider implements CollabProvider { const doc = this.#opts.host.doc(localId); if (!doc) throw new Error('document is not open'); const existing = this.#registry.docs.find((d) => d.localId === localId); - if (existing) return existing; + // A doc in the HOME space is one person's by definition — "go live" for + // it means moving it into a shared space, not a no-op. Any other + // registration really is already live. + if (existing && !existing.space.includes(`/space/${ZIRAN_HOME_TYPE}/`)) return existing; + if (existing) return await this.#shareFromHome(existing, opts); // The space: an existing one I'm in, or a new named one created here. let spaceRef = opts.space; @@ -703,6 +733,49 @@ export class SpacesProvider implements CollabProvider { return rec; } + /** Move a home-space doc into a shared space: register it there fresh + (records + go-live checkpoint via the normal share path), then retire + the home records. The checkpoint carries the tile's full state, so + nothing is lost by leaving the home op history behind. */ + async #shareFromHome(rec: DocRec, opts: ShareOptions): Promise { + if (!opts.space && !opts.newSpaceName?.trim()) { + throw new Error('pick a space, or name a new one'); + } + const old = { space: rec.space, docRkey: rec.docRkey }; + const idx = this.#registry.docs.indexOf(rec); + this.#registry.docs.splice(idx, 1); + let moved: DocRec; + try { + moved = await this.share(rec.localId, opts); + } catch (err) { + // The move failed before anything was created: the doc stays home. + this.#registry.docs.splice(idx, 0, rec); + await this.#persist(); + throw err; + } + const client = this.#requireClient(); + for (const coll of ['space.ziran.doc', 'space.ziran.checkpoint', 'space.ziran.presence']) { + await client.deleteRecord(old.space, coll, old.docRkey).catch(() => {}); + } + return moved; + } + + /** Propagate a rename to the doc's space record, so every member (and the + home-space document list) sees the new name. No-op when the doc isn't + registered. */ + async renameDoc(localId: string, name: string): Promise { + const rec = this.#registry.docs.find((d) => d.localId === localId); + if (!rec) return; + const client = this.#requireClient(); + // Records live in their author's member repo; only the author can + // rewrite one (the same rule roles follow). + if (rec.creator !== client.auth.did) { + throw new Error('only whoever shared the document can rename it'); + } + const { value } = await client.getRecord(rec.space, client.auth.did, 'space.ziran.doc', rec.docRkey); + await client.putRecord(rec.space, 'space.ziran.doc', rec.docRkey, { ...value, name }); + } + #me(rec: DocRec): Member { const did = this.#client?.auth.did ?? rec.creator; return { did, handle: this.#opts.handle?.(), role: this.#roleOf(rec, did) }; @@ -815,6 +888,7 @@ export class SpacesProvider implements CollabProvider { if (entry.space === spaceRef && (!entry.doc || entry.doc === docRkey)) { this.#invitations.delete(token); this.#registry.dismissedInvites.push(token); + this.#settleInvite(token); } } await this.#persist(); @@ -902,9 +976,18 @@ export class SpacesProvider implements CollabProvider { await this.#persist(); await this.#maybeCheckpoint(rec); this.#nextPoll.set(rec.space, 0); // pull others' work opportunistically + this.#error = undefined; + this.#errorKind = undefined; } catch (err) { live.outbox.unshift(...frames); // kept; the poll beat retries live.firstQueuedAt ??= Date.now(); + // A refused upload is a sync outage the person must SEE: it flips the + // doc to "paused" and puts the server's own words in the panel — + // edits queuing silently under a green "live" badge was how a broken + // send looked like "sync is just slow". + const classified = classifySpaceError(err); + this.#error = classified.message; + this.#errorKind = classified.kind; throw err; } finally { live.flushing = false; @@ -973,6 +1056,9 @@ export class SpacesProvider implements CollabProvider { const rec = this.#registry.docs.find((d) => d.localId === localId); if (!rec) throw new Error('go live first — pick a space for this document, then invite people to it'); if (!rec.manifest.roles[role]) throw new Error(`this document has no “${role}” role`); + if (rec.space.includes(`/space/${ZIRAN_HOME_TYPE}/`)) { + throw new Error('this document lives in your home space, which is yours alone — share it into a shared space to invite people'); + } if (authorityOf(rec.space) !== client.auth.did) { throw new Error('only the space owner can invite people — membership belongs to the space, not the document'); } @@ -996,6 +1082,7 @@ export class SpacesProvider implements CollabProvider { await this.#persist(); } const spaceName = spaceRec?.name || 'a shared space'; + const docName = this.#opts.host.doc(localId)?.info.name ?? 'document'; const link = shareLink(rec.space, rec.docRkey); let pending = false; try { @@ -1004,6 +1091,7 @@ export class SpacesProvider implements CollabProvider { space: rec.space, name: spaceName, doc: rec.docRkey, + docName, createdAt: new Date().toISOString(), }); } catch { @@ -1017,7 +1105,8 @@ export class SpacesProvider implements CollabProvider { invitation: { token: rec.docRkey, docId: rec.docRkey, - title: this.#opts.host.doc(localId)?.info.name ?? 'document', + title: docName, + spaceName, inviterDid: client.auth.did, inviteeDid: did, role, @@ -1135,10 +1224,27 @@ export class SpacesProvider implements CollabProvider { dismiss(token: string): void { if (!this.#invitations.delete(token)) return; this.#registry.dismissedInvites.push(token); + this.#settleInvite(token); this.#persist().catch(() => {}); this.#opts.host.broadcast(); } + /** Mark an invitation handled DURABLY: a record under the same token in + our own inbox repo. The local registry list dies with a browser tab + (per-tab memory), which made every reload re-serve old invitations — + the inbox itself is the only store that travels. */ + #settleInvite(token: string): void { + const client = this.#client; + if (!client) return; + client.putRecord(inboxRef(client.auth.did), 'space.ziran.invite', token, { + $type: 'space.ziran.invite', + settled: true, + createdAt: new Date().toISOString(), + }).catch(() => { + // Best-effort: the registry entry still suppresses it this session. + }); + } + /* ——— bookkeeping ——— */ moved(oldId: string, newId: string, newPath: string): void { @@ -1183,8 +1289,11 @@ export class SpacesProvider implements CollabProvider { const did = this.#client?.auth.did ?? ''; const docs: ProviderSummary['docs'] = {}; for (const rec of this.#registry.docs) { - // Only the space owner can invite (simplespace restriction). - const canInvite = Boolean(did) && authorityOf(rec.space) === did; + // The home space is one person by definition: no invites, no share + // link (nobody else could follow it). Shared spaces: owner-only + // invites (simplespace restriction). + const home = rec.space.includes(`/space/${ZIRAN_HOME_TYPE}/`); + const canInvite = Boolean(did) && !home && authorityOf(rec.space) === did; docs[rec.localId] = { syncId: rec.at, role: rec.role, @@ -1192,7 +1301,8 @@ export class SpacesProvider implements CollabProvider { members: this.#roster(rec), inviteBase: '', space: rec.space, - link: shareLink(rec.space, rec.docRkey), + home, + link: home ? undefined : shareLink(rec.space, rec.docRkey), owner: canInvite, mine: Boolean(did) && rec.creator === did, roles: Object.entries(rec.manifest.roles).map(([name, spec]) => ({ @@ -1203,14 +1313,28 @@ export class SpacesProvider implements CollabProvider { })), }; } - const spaces: SpaceSummary[] = this.#registry.spaces.map((s) => ({ - ref: s.ref, - name: s.name || 'unnamed space', - mine: s.mine, - available: [...(this.#catalog.get(s.ref)?.values() ?? [])] - .filter((d) => !this.#registry.docs.some((r) => r.space === s.ref && r.docRkey === d.docRkey)) - .map((d) => ({ docRkey: d.docRkey, name: d.name, creator: d.creator })), - })); + const spaces: SpaceSummary[] = this.#registry.spaces.map((s) => { + // Who is in the space: the owner, everyone who has written, and the + // invited-but-not-arrived (pending) — minus the removed. + const removed = new Set(s.removed ?? []); + const present = new Set( + [authorityOf(s.ref), ...(this.#writers.get(s.ref) ?? [])].filter((d) => !removed.has(d)), + ); + const members: Array<{ did: string; pending?: boolean }> = [...present].map((d) => ({ did: d })); + for (const i of s.invited ?? []) { + if (!present.has(i.did)) members.push({ did: i.did, pending: true }); + } + return { + ref: s.ref, + name: s.name || 'unnamed space', + mine: s.mine, + home: s.ref.includes(`/space/${ZIRAN_HOME_TYPE}/`), + members, + available: [...(this.#catalog.get(s.ref)?.values() ?? [])] + .filter((d) => !this.#registry.docs.some((r) => r.space === s.ref && r.docRkey === d.docRkey)) + .map((d) => ({ docRkey: d.docRkey, name: d.name, creator: d.creator })), + }; + }); return { provider: 'spaces', identity: { did, handle: this.#opts.handle?.() ?? did }, diff --git a/src/types.ts b/src/types.ts index 97d51bc..bc7e238 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,8 @@ export interface ModelEntry { name: string; description?: string; hasIcon: boolean; + /** Direct icon URL (web: a static asset; overrides the API route). */ + icon?: string; } export interface SyncMember { @@ -60,6 +62,8 @@ export interface PendingInvitation { token: string; docId: string; title: string; + /** The space the document lives in — "X in Y" in the inbox strip. */ + spaceName?: string; inviterDid: string; inviterHandle?: string; role: string; @@ -74,6 +78,10 @@ export interface SyncSpace { name: string; /** This identity owns the space (owner-only invites). */ mine: boolean; + /** The single-person home space (never a go-live target). */ + home?: boolean; + /** Everyone in the space (pending = invited, not yet arrived). */ + members?: Array<{ did: string; pending?: boolean }>; /** Docs in the space that are not instantiated on this machine yet. */ available: Array<{ docRkey: string; name: string; creator: string }>; } @@ -90,6 +98,9 @@ export interface SyncState { did?: string; displayName?: string; hasAvatar?: boolean; + /** Direct avatar URL (web: straight from the person's PDS). When set it + wins over the hasAvatar/same-origin-proxy path. */ + avatarUrl?: string; /** The stored sign-in was retired: it predated the permissions live documents need, so a fresh sign-in is required. */ staleScope?: boolean; @@ -117,6 +128,9 @@ export interface SyncState { roles: Array<{ name: string; label: string; invitable: boolean; full: boolean }>; /** Spaces: the space this document lives in. */ space?: string; + /** Spaces: this doc lives in the single-person home space — sharing it + means moving it into a shared space. */ + home?: boolean; /** Spaces: the shareable HTTPS link to this document. */ link?: string; /** This identity owns the doc's space (may remove members). */ diff --git a/src/web/app.ts b/src/web/app.ts index a8a9dee..e53c541 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -1,27 +1,31 @@ -// app.ziran.space, Phase B (docs/WEB-APP.md): sign in with your AT -// identity, get your home space, see and import your documents. The shell -// with subtabs and live tiles is Phase C/D — this phase proves the spine: -// browser OAuth → home space → doc records + checkpoints, no server of ours -// anywhere. +// app.ziran.space: Ziran in the browser (docs/WEB-APP.md). This entry does +// exactly three things — restore or start the OAuth session, install the +// web backend under the SAME desk store and components the desktop renders, +// and mount either the base window (the app) or a document tab (`#doc=…`). +// Everything the person sees past sign-in is shared code. import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-browser'; import { SpaceClient } from '../sync/spaces/client.ts'; import { makeSessionFetch } from '../sync/spaces/session-fetch.ts'; import { ZIRAN_SCOPE } from '../sync/spaces/scope.ts'; import { resolveIdentity } from '../sync/atproto.ts'; +import { getProfile } from '../server/atprofiles.ts'; +import { setBackend } from '../state/backend.ts'; import { APP_ORIGIN, clientMetadata } from './client-metadata.ts'; -import { ensureHomeSpace, type HomeDoc, importTileToHome, listHomeDocs } from './home.ts'; +import { ensureHomeSpace } from './home.ts'; import { WebHost } from './host.ts'; -import { renderDocTile } from './tiles.ts'; +import { WebBackend } from './webbackend.ts'; const $ = (id: string): T => document.getElementById(id) as T; function show(id: string, visible: boolean) { - $(id).hidden = !visible; + const el = $(id); + if (el) el.hidden = !visible; } function setError(message: string | null, hint?: string) { const box = $('error'); + if (!box) return; box.hidden = !message; if (message) { $('error-msg').textContent = message; @@ -50,23 +54,28 @@ function explain(err: unknown): { message: string; hint: string } { let oauth: BrowserOAuthClient; async function main() { - if (location.origin !== APP_ORIGIN) { - show('dev-note', true); // sign-in is origin-bound; say so instead of failing mutely + if (location.origin !== APP_ORIGIN) show('dev-note', true); + // Fresh from the account page: `#handle=` prefills the sign-in + // field so the person only confirms what they just created. + { + const pre = new URLSearchParams(location.hash.slice(1)).get('handle'); + if (pre) { + $('handle').value = pre; + history.replaceState(null, '', '/'); + } } oauth = new BrowserOAuthClient({ clientMetadata: clientMetadata(), handleResolver: 'https://public.api.bsky.app', responseMode: 'fragment', // codes ride the fragment, out of server logs - // The session store is shared by every tab; when a refresh race or a - // revocation kills the session, say so rather than erroring forever. onSessionDeleted: () => { renderSignedOut('Your sign-in expired or was ended elsewhere. Signing in again reconnects everything.'); }, }); - $('signin-form').addEventListener('submit', onSignIn); + $('signin-form')?.addEventListener('submit', onSignIn); try { const restored = await oauth.init(); - if (restored?.session) return await renderSignedIn(restored.session); + if (restored?.session) return await boot(restored.session); } catch (err) { const { message, hint } = explain(err); setError(message, hint); @@ -80,6 +89,11 @@ async function onSignIn(e: Event) { const handle = input.value.trim().replace(/^@/, ''); if (!handle) return; $('signin-go').disabled = true; + // A share link's document address rides the fragment, and the OAuth + // round-trip overwrites it — stash it so sign-in lands ON the document. + try { + if (docTarget()) sessionStorage.setItem('ziran-doc-return', location.hash); + } catch { /* storage may be unavailable; the person just lands on base */ } try { await oauth.signInRedirect(handle, { scope: ZIRAN_SCOPE }); } catch (err) { @@ -90,124 +104,94 @@ async function onSignIn(e: Event) { function renderSignedOut(note?: string) { show('signin', true); - show('home', false); - show('who', false); if (note) setError(note); } -let host: WebHost | undefined; +/** Doc tabs carry their target in the fragment: `#doc=[&ref=]`, + or `#new=` for a tab that creates its document first (the click + gets its tab before the slow work). The OAuth callback also rides the + fragment, so only a hash with one of these params is a document address. */ +function docTarget(): { rkey: string; ref?: string; newModelId?: string } | null { + const params = new URLSearchParams(location.hash.slice(1)); + const newModelId = params.get('new'); + if (newModelId) return { rkey: '', newModelId }; + const rkey = params.get('doc'); + if (!rkey) return null; + return { rkey, ref: params.get('ref') ?? undefined }; +} -async function renderSignedIn(session: OAuthSession) { +async function boot(session: OAuthSession) { show('signin', false); - show('home', true); setError(null); + // Back from the OAuth round-trip with a stashed document address: restore + // it before deciding what to mount. + try { + const stash = sessionStorage.getItem('ziran-doc-return'); + if (stash) { + sessionStorage.removeItem('ziran-doc-return'); + if (!docTarget()) history.replaceState(null, '', `/${stash}`); + } + } catch { /* no storage, no restore */ } const did = session.did; - const identity = await resolveIdentity(did).catch(() => null); - $('who').textContent = identity?.handle ? `@${identity.handle}` : did; - show('who', true); - $('signout').onclick = async () => { - await session.signOut().catch(() => {}); - location.reload(); - }; if (!identity?.pds) { setError('Could not resolve your PDS from your identity.', `DID: ${did}`); + renderSignedOut(); return; } - const pds = identity.pds.replace(/\/+$/, ''); const fetchImpl = makeSessionFetch(pds, (pathname, init) => session.fetchHandler(pathname, init)); const auth = { did, pds, headers: () => Promise.resolve({}) }; const client = new SpaceClient({ auth, fetch: fetchImpl }); - host = new WebHost(auth, fetchImpl); - await host.init(); + + // The shared dataHub (zn-app inits it) is the single tile-message + // listener; the host's own hub stays off. + const host = new WebHost(auth, fetchImpl, { initHub: false }); + + // Who this person is, for the identity chip — best-effort, own-PDS only. + const profile = await getProfile(did).catch(() => undefined); + const backendImpl = new WebBackend(host, client, { + did, + handle: identity.handle ?? profile?.handle, + displayName: profile?.displayName, + avatarUrl: profile?.avatar, + signOut: async () => { + await session.signOut().catch(() => {}); + location.href = '/'; + }, + }); try { await ensureHomeSpace(client); - renderDocs(await listHomeDocs(client)); } catch (err) { const { message, hint } = explain(err); setError(message, hint); - renderDocs([]); - } - $('doc-back').onclick = () => closeDoc(client); - - $('import').onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - show('importing', true); - setError(null); - try { - const imported = await importTileToHome(client, new Uint8Array(await file.arrayBuffer()), file.name); - renderDocs(await listHomeDocs(client)); - await openDoc(imported.rkey, imported.name); // straight into the editor - } catch (err) { - const { message, hint } = explain(err); - setError(`Could not import “${file.name}”: ${message}`, hint); - } finally { - show('importing', false); - (e.target as HTMLInputElement).value = ''; - } - }; -} - -function renderDocs(docs: HomeDoc[]) { - const list = $('docs'); - list.textContent = ''; - show('empty', docs.length === 0); - for (const doc of docs) { - const li = document.createElement('li'); - const open = document.createElement('button'); - open.className = 'doc-open'; - open.textContent = doc.name; - open.addEventListener('click', () => openDoc(doc.rkey, doc.name)); - const when = document.createElement('time'); - when.textContent = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; - li.append(open, when); - list.append(li); - } -} - -/* ——— the document view: adopt → materialize → isolated tile frame ——— */ - -let showing: string | null = null; - -async function openDoc(rkey: string, name: string) { - if (!host) return; - setError(null); - show('home', false); - show('docview', true); - $('doc-title').textContent = name; - $('doc-status').textContent = 'Fetching from your home space…'; - try { - const doc = await host.openHomeDoc(rkey); - const mount = $('mount'); - const { iframe } = await renderDocTile(doc.localId, doc.tile, mount.getBoundingClientRect().height || 480); - mount.replaceChildren(iframe); - host.hub.register(doc.localId, iframe); - host.setOpen(doc.localId); - showing = doc.localId; - $('doc-title').textContent = doc.name; - $('doc-status').textContent = ''; - } catch (err) { - const { message, hint } = explain(err); - $('doc-status').textContent = ''; - setError(`Could not open “${name}”: ${message}`, hint); - show('docview', false); - show('home', true); } -} - -async function closeDoc(client: SpaceClient) { - if (host && showing) { - host.hub.unregister(showing); - host.setOpen(null); + await host.init(); + await backendImpl.start(); + setBackend(backendImpl as never); + + const target = docTarget(); + const mount = $('app-root'); + if (target) { + const { ZnWebDoc } = await import('./zn-web-doc.ts'); + const { dataHub } = await import('../ui/data-protocol.ts'); + const { desk } = await import('../state/desk.ts'); + dataHub.init(); + desk.init(); + const el = new ZnWebDoc(); + el.docRkey = target.rkey; + el.spaceRef = target.ref; + el.newModelId = target.newModelId; + el.host = host; + mount.replaceChildren(el); + } else { + await import('../components/zn-app.ts'); + mount.replaceChildren(document.createElement('zn-app')); } - showing = null; - $('mount').replaceChildren(); - show('docview', false); - show('home', true); - renderDocs(await listHomeDocs(client).catch(() => [])); } -main().catch((err) => setError('Ziran could not start.', (err as Error).message)); +main().catch((err) => { + setError('Ziran could not start.', (err as Error).message); + renderSignedOut(); +}); diff --git a/src/web/host.ts b/src/web/host.ts index 9868bbe..6ce0ddc 100644 --- a/src/web/host.ts +++ b/src/web/host.ts @@ -9,7 +9,7 @@ import { SpaceClient, type SessionAuth } from '../sync/spaces/client.ts'; import type { ResolverOptions } from '../sync/atproto.ts'; import { SpacesProvider } from '../sync/spaces/provider.ts'; import { homeRef } from '../sync/spaces/scope.ts'; -import { docIdForPath, parseTile, type ParsedTile, readTileData, writeTileData } from '../server/tilefile.ts'; +import { docIdForPath, parseTile, type ParsedTile, readTileData, renameTile, writeTileData } from '../server/tilefile.ts'; import { DataProtocolHub, type HostBridge } from '../ui/protocol-hub.ts'; interface OpenDoc { @@ -26,6 +26,10 @@ export interface WebHostOptions { autoPoll?: boolean; /** How long saves settle before the durability checkpoint (tests shrink it). */ publishDelayMs?: number; + /** Skip wiring the tile-protocol hub (the app uses the shared dataHub + singleton instead — two message listeners would double-handle every + tile frame). */ + initHub?: boolean; } export class WebHost { @@ -55,7 +59,8 @@ export class WebHost { : undefined; }, openPath: (path) => this.openPath(path), - isOpen: (id) => this.#openId === id, + isOpen: (id) => + this.#openId === id && (typeof document === 'undefined' || document.visibilityState !== 'hidden'), broadcast: () => this.onChange?.(), broadcastEvent: (event) => { const type = String(event.type ?? ''); @@ -71,6 +76,14 @@ export class WebHost { atInfo: () => ({ signedIn: true }), handle: () => undefined, autoPoll: options.autoPoll, + // A browser tab is a guest, not a daemon: slower everywhere, and a + // hidden tab drops to warm entirely (isOpen below). The protocol has + // no push a browser can use yet — the notify bridge is the recorded + // follow-up; until then, polite polling. + hotPollMs: 5_000, + warmPollMs: 60_000, + listSpacesMs: 120_000, + inboxPollMs: 90_000, }); const bridge: HostBridge = { syncAttach: (docId) => { @@ -106,7 +119,14 @@ export class WebHost { }; this.bridge = bridge; this.hub = new DataProtocolHub(bridge); - this.hub.init(); + if (options.initHub !== false) this.hub.init(); + // Coming back to a hidden tab: catch up immediately instead of waiting + // out the warm interval. + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') this.provider.pollNow(true).catch(() => {}); + }); + } } #publishTimers = new Map(); @@ -136,7 +156,12 @@ export class WebHost { render: the provider's adopt() instantiates from the newest checkpoint and replays the op tail through normal polling. */ async openHomeDoc(docRkey: string): Promise { - const ref = homeRef(this.#did); + return await this.openSpaceDoc(homeRef(this.#did), docRkey); + } + + /** Same, for any space this identity can read (shared docs open in their + own tabs exactly like home docs). */ + async openSpaceDoc(ref: string, docRkey: string): Promise { const { localId, path } = await this.provider.adopt(ref, docRkey); if (!this.#docs.has(localId)) await this.openPath(path); const doc = this.#docs.get(localId); @@ -162,6 +187,21 @@ export class WebHost { return this.#docs.get(localId); } + /** Rename an open document: the materialized tile's manifest, the space + record (so every member and the home list see it), and a checkpoint so + re-materialization keeps the name. */ + async rename(localId: string, name: string): Promise { + const doc = this.#docs.get(localId); + if (!doc) throw new Error('document is not open'); + const clean = name.trim(); + if (!clean || clean === doc.name) return; + await renameTile(doc.path, clean); + doc.tile = await parseTile(doc.path, { verify: false }); + doc.name = clean; + await this.provider.renameDoc(localId, clean); + this.#publishSoon(localId); + } + /** Mark which doc's frame is showing — hot polling follows it. */ setOpen(localId: string | null): void { this.#openId = localId; diff --git a/src/web/webbackend.ts b/src/web/webbackend.ts new file mode 100644 index 0000000..39a4c15 --- /dev/null +++ b/src/web/webbackend.ts @@ -0,0 +1,367 @@ +// The web implementation of the app's backend (docs/WEB-APP.md, Phase D +// pulled forward): the SAME desk store and zn-* components the desktop +// renders, over the home space instead of a local server. Installed with +// setBackend() before anything mounts, so no component knows the difference. +// +// Mapping: recents = the home space's documents; models = starters shipped +// as static assets; documents open as ordinary browser TABS (`#doc=…`); +// filesystem-only affordances answer honestly instead of pretending. + +import type { AtProfile, DocInfo, ModelEntry, RecentEntry, ServerState, SyncState } from '../types.ts'; +import type { Backend, OpenResult, PickResult } from '../state/backend.ts'; +import { SpaceClient } from '../sync/spaces/client.ts'; +import { homeRef } from '../sync/spaces/scope.ts'; +import { getProfiles, searchActors } from '../server/atprofiles.ts'; +import { type HomeDoc, importTileToHome, listHomeDocs } from './home.ts'; +import type { WebHost } from './host.ts'; + +export interface WebIdentity { + did: string; + handle?: string; + displayName?: string; + avatarUrl?: string; + signOut(): Promise; +} + +const HOME_LIST_MS = 60_000; + +export class WebBackend { + onState: ((state: ServerState) => void) | undefined; + readonly events: EventTarget; + + #host: WebHost; + #client: SpaceClient; + #me: WebIdentity; + #homeRef: string; + #homeDocs: HomeDoc[] = []; + #models: ModelEntry[] = []; + #modelStems = new Map(); + /** Files picked but not yet imported, keyed by the fake path handed to + openPath (the pick→open contract is path-shaped on the desktop). */ + #pendingFiles = new Map(); + #timer: number | undefined; + + constructor(host: WebHost, client: SpaceClient, me: WebIdentity) { + this.#host = host; + this.#client = client; + this.#me = me; + this.#homeRef = homeRef(me.did); + this.events = host.events; + host.onChange = () => this.#push(); + } + + /* ——— state assembly ——— */ + + async start(): Promise { + await this.#loadModels(); + await this.#refreshHome(); + this.#timer = setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + this.#refreshHome().catch(() => {}); + }, HOME_LIST_MS) as unknown as number; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') this.#refreshHome().catch(() => {}); + }); + } + } + + stop(): void { + if (this.#timer !== undefined) clearInterval(this.#timer); + } + + async #loadModels(): Promise { + try { + const res = await fetch('/models/models.json'); + if (!res.ok) return; + const entries = (await res.json()) as Array; + this.#models = entries; + for (const e of entries) this.#modelStems.set(e.id, e.stem); + } catch { + // no starters shipped: the column just says the library is empty + } + } + + async #refreshHome(): Promise { + this.#homeDocs = await listHomeDocs(this.#client); + this.#push(); + } + + #recents(): RecentEntry[] { + return this.#homeDocs.map((d) => ({ + id: d.rkey, + path: `home:${d.rkey}`, + name: d.name, + damaged: false, + hasIcon: false, + modelId: d.model, + size: 0, + mtime: Date.parse(d.createdAt ?? '') || 0, + fingerprint: '', + lastOpened: Date.parse(d.createdAt ?? '') || 0, + })); + } + + #snapshot(): ServerState { + const summary = this.#host.provider.summary(); + const sync: SyncState = { + ...summary, + at: { + signedIn: true, + did: this.#me.did, + handle: this.#me.handle, + displayName: this.#me.displayName, + avatarUrl: this.#me.avatarUrl, + }, + spaces: (summary.spaces ?? []).map((s) => + s.ref === this.#homeRef ? { ...s, name: 'Home' } : s + ), + }; + return { + recents: this.#recents(), + models: this.#models, + open: [], + windows: [], + tileServerPort: 0, + desktop: false, + sync, + }; + } + + #push(): void { + this.onState?.(this.#snapshot()); + } + + /* ——— Backend surface ——— */ + + connect(_winId: string): void { + queueMicrotask(() => this.#push()); + } + + state(): Promise { + return Promise.resolve(this.#snapshot()); + } + + postBounds(): Promise { + return Promise.resolve({}); + } + beaconBounds(): void {} + winOp(): Promise { + return Promise.reject(new Error('window layout lives on the desktop')); + } + + /** A tab per document: the doc's identity rides the fragment. */ + #tabResult(docRkey: string, name: string, ref = this.#homeRef): OpenResult { + const at = ref === this.#homeRef ? '' : `&ref=${encodeURIComponent(ref)}`; + return { + doc: { id: docRkey, path: `home:${docRkey}`, name, damaged: false, hasIcon: false, size: 0, mtime: 0, fingerprint: '' } as DocInfo, + win: { id: `tab-${docRkey}`, panes: [], ratio: 0.5, createdAt: 0 }, + url: `/#doc=${encodeURIComponent(docRkey)}${at}`, + existing: false, + tab: true, + }; + } + + async openPath(path: string): Promise { + if (path.startsWith('home:')) { + const rkey = path.slice(5); + const doc = this.#homeDocs.find((d) => d.rkey === rkey); + return this.#tabResult(rkey, doc?.name ?? 'document'); + } + const file = this.#pendingFiles.get(path); + if (file) { + this.#pendingFiles.delete(path); + const imported = await importTileToHome(this.#client, new Uint8Array(await file.arrayBuffer()), file.name); + await this.#refreshHome(); + return this.#tabResult(imported.rkey, imported.name); + } + throw new Error('that document is not in your space'); + } + + /** Creating from a model means fetching + importing + checkpointing — too + slow to sit behind a silent click. The click gets its tab IMMEDIATELY + (`#new=` target); the tab itself runs createFromModel with a visible + "creating" state, then renames itself to the real doc address. */ + fromModel(modelId: string): Promise { + const model = this.#models.find((m) => m.id === modelId); + if (!model) return Promise.reject(new Error('unknown model')); + return Promise.resolve({ + doc: { id: '', path: '', name: model.name, damaged: false, hasIcon: false, size: 0, mtime: 0, fingerprint: '' } as DocInfo, + win: { id: `new-${crypto.randomUUID().slice(0, 8)}`, panes: [], ratio: 0.5, createdAt: 0 }, + url: `/#new=${encodeURIComponent(modelId)}`, + existing: false, + tab: true, + }); + } + + /** The slow half of fromModel, run inside the new tab. */ + async createFromModel(modelId: string): Promise<{ rkey: string; name: string }> { + const stem = this.#modelStems.get(modelId); + if (!stem) throw new Error('unknown model'); + const res = await fetch(`/models/${encodeURIComponent(stem)}.tile`); + if (!res.ok) throw new Error('the model could not be fetched'); + const imported = await importTileToHome( + this.#client, + new Uint8Array(await res.arrayBuffer()), + `${stem}.tile`, + ); + await this.#refreshHome(); + return { rkey: imported.rkey, name: imported.name }; + } + + /** The desktop's native dialog, browser-shaped: an . */ + pick(): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.tile'; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (!file) return resolve({ cancelled: true }); + const token = `upload:${crypto.randomUUID().slice(0, 8)}`; + this.#pendingFiles.set(token, file); + resolve({ path: token }); + }); + input.addEventListener('cancel', () => resolve({ cancelled: true })); + input.click(); + }); + } + + pickSave(): Promise { + return Promise.resolve({ error: 'saving to disk arrives with export' }); + } + + async importTile(name: string, bytes: ArrayBuffer): Promise { + const imported = await importTileToHome(this.#client, new Uint8Array(bytes), name); + await this.#refreshHome(); + return this.#tabResult(imported.rkey, imported.name); + } + + async rename(docId: string, name: string): Promise { + await this.#host.rename(docId, name); + await this.#refreshHome(); + const doc = this.#host.doc(docId)!; + return { + id: docId, + path: doc.path, + name: doc.name, + damaged: false, + hasIcon: false, + size: doc.tile.size, + mtime: doc.tile.mtime, + fingerprint: doc.tile.fingerprint, + } as DocInfo; + } + saveAsModel(): Promise { + return Promise.reject(new Error('saving models on the web arrives with the next phase')); + } + moveDoc(): Promise<{ oldId: string; info: DocInfo }> { + return Promise.reject(new Error('documents on the web live in your space, not at paths')); + } + reveal(): Promise<{ ok: boolean }> { + return Promise.resolve({ ok: false }); + } + removeRecent(): Promise { + return Promise.reject(new Error('this list is your space itself — use Delete to remove a document')); + } + + /** Delete = remove the document from the home space (its records and + checkpoint; ops stay, by design). */ + async deleteDoc(docId: string): Promise { + await this.#client.deleteRecord(this.#homeRef, 'space.ziran.doc', docId); + await this.#client.deleteRecord(this.#homeRef, 'space.ziran.checkpoint', docId).catch(() => {}); + await this.#client.deleteRecord(this.#homeRef, 'space.ziran.presence', docId).catch(() => {}); + await this.#refreshHome(); + return { ok: true }; + } + + removeModel(): Promise { + return Promise.reject(new Error('starter models are built in on the web')); + } + + /* ——— collaboration: straight through to the provider ——— */ + + async syncShare(docId: string, opts?: { space?: string; newSpaceName?: string }): Promise { + await this.#host.provider.share(docId, opts); + return { ok: true }; + } + + syncAdopt(space: string, docRkey: string): Promise { + const name = this.#host.provider.summary().spaces?.find((s) => s.ref === space) + ?.available.find((d) => d.docRkey === docRkey)?.name ?? 'document'; + return Promise.resolve(this.#tabResult(docRkey, name, space)); + } + + syncRetry(): Promise { + this.#host.provider.retryNow(); + return Promise.resolve({ ok: true }); + } + + async syncInvite(docId: string, did: string, role: string): Promise<{ link: string; invitation: { pending?: boolean } }> { + return await this.#host.provider.invite(docId, did, role); + } + + syncAttach(docId: string) { + return this.#host.bridge.syncAttach(docId) as ReturnType; + } + async syncSend(docId: string, b64: string): Promise<{ seq: number }> { + return { seq: await this.#host.provider.send(docId, b64) }; + } + syncEphemeral(docId: string, b64: string): Promise { + this.#host.provider.ephemeral(docId, b64); + return Promise.resolve({}); + } + async syncSetRole(docId: string, did: string, role: string): Promise { + await this.#host.provider.setRole(docId, did, role); + return { ok: true }; + } + async syncRemoveMember(docId: string, did: string): Promise { + await this.#host.provider.removeMember(docId, did); + return { ok: true }; + } + + async acceptInvitation(token: string): Promise { + // Adopt in this tab (materializes + registers), then hand the person a + // proper document tab for it. + const { path } = await this.#host.provider.accept(token); + const summary = this.#host.provider.summary(); + for (const [localId, d] of Object.entries(summary.docs)) { + const doc = this.#host.doc(localId); + if (doc?.path === path && d.space) { + const rkey = d.syncId.split('/').pop()!; + return this.#tabResult(rkey, doc.name, d.space); + } + } + throw new Error('the invitation was accepted but the document did not surface — reload and look under By location'); + } + + dismissInvitation(token: string): Promise { + this.#host.provider.dismiss(token); + return Promise.resolve({ ok: true }); + } + + /* ——— identity ——— */ + + atSignIn(): Promise<{ url: string }> { + return Promise.reject(new Error('already signed in')); + } + async atSignOut(): Promise { + await this.#me.signOut(); + return { ok: true }; + } + atProfiles(dids: string[]): Promise { + return getProfiles(dids.slice(0, 50).filter((d) => d.startsWith('did:'))).catch(() => []); + } + atSearch(q: string): Promise { + return searchActors(q).catch(() => []); + } + + /* ——— tile self-storage ——— */ + + loadData(docId: string, name: string): Promise { + return this.#host.bridge.loadData(docId, name); + } + putData(docId: string, name: string, data: Uint8Array): Promise { + return this.#host.bridge.putData(docId, name, data); + } +} diff --git a/src/web/webhost.test.ts b/src/web/webhost.test.ts index 5eb4b34..4367021 100644 --- a/src/web/webhost.test.ts +++ b/src/web/webhost.test.ts @@ -10,7 +10,7 @@ import { passwordSession } from '../sync/spaces/client.ts'; import { FakeSpacesNetwork } from '../sync/spaces/fake.ts'; import { SpaceClient } from '../sync/spaces/client.ts'; import { parseTile, writeTile } from '../server/tilefile.ts'; -import { importTileToHome } from './home.ts'; +import { importTileToHome, listHomeDocs } from './home.ts'; import { WebHost } from './host.ts'; let failures = 0; @@ -100,6 +100,39 @@ check( JSON.stringify(aEvents.map((e) => unb64(String(e.b64)))), ); +/* ——— rename: the manifest, the space record, and the list all follow ——— */ + +setZiranFs(aMem); +await a.rename(aDoc.localId, 'Renamed on the web'); +check('rename updates the open doc', a.doc(aDoc.localId)?.name === 'Renamed on the web'); +check( + 'rename rewrites the materialized manifest', + (await parseTile(aDoc.path, { verify: false })).manifest.name === 'Renamed on the web', +); +check( + 'rename reaches the home space record (the document list)', + (await listHomeDocs(client)).find((d) => d.rkey === imported.rkey)?.name === 'Renamed on the web', +); + +/* ——— go live from home: the doc MOVES into a shared space ——— */ + +const moved = await a.provider.share(aDoc.localId, { newSpaceName: 'Web Crew' }); +check('sharing a home doc lands it in a real space', !moved.space.includes('/space/space.ziran.home/'), moved.space); +const sum = a.provider.summary(); +const dSum = sum.docs[aDoc.localId]; +check('…same localId, new address', dSum?.space === moved.space && dSum?.home !== true); +check('…now with a share link and invitable roles', Boolean(dSum?.link) && (dSum?.roles ?? []).some((r) => r.invitable)); +check('…the space carries the renamed title', moved.at.length > 0 && + (await client.getRecord(moved.space, auth.did, 'space.ziran.doc', moved.docRkey)).value.name === 'Renamed on the web'); +const movedCp = await client.getRecord(moved.space, auth.did, 'space.ziran.checkpoint', moved.docRkey) + .then(() => true).catch(() => false); +check('…with a go-live checkpoint (others can instantiate)', movedCp); +check('the home space no longer lists it', !(await listHomeDocs(client)).some((d) => d.rkey === imported.rkey)); +check('sharing again is idempotent (already live)', (await a.provider.share(aDoc.localId)) === moved); +check('the home space itself is flagged, never a go-live target', + sum.spaces?.some((s) => s.home === true) === true && + sum.spaces?.every((s) => s.home !== true || s.ref !== moved.space) === true); + a.close(); b.close(); await net.close(); diff --git a/src/web/zn-web-doc.ts b/src/web/zn-web-doc.ts new file mode 100644 index 0000000..a728974 --- /dev/null +++ b/src/web/zn-web-doc.ts @@ -0,0 +1,465 @@ +// One document, one browser tab (docs/WEB-APP.md, Robin's direction): the +// tile runs isolated in its frame with real context on top — a slim ink bar +// carrying the doc's connections chip, any waiting invitations, and the +// signed-in identity. The same desk/backend the base tab uses; only the +// layout is tab-shaped. + +import { css, html, LitElement, nothing } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import { desk } from '../state/desk.ts'; +import { dataHub } from '../ui/data-protocol.ts'; +import { icons, zmark } from '../components/icons.ts'; +import '../components/zn-connections.ts'; +import type { DocInfo } from '../types.ts'; +import type { WebHost } from './host.ts'; +import { renderDocTile } from './tiles.ts'; + +export class ZnWebDoc extends LitElement { + static override properties = { + docRkey: { attribute: false }, + spaceRef: { attribute: false }, + newModelId: { attribute: false }, + host: { attribute: false }, + docInfo: { state: true }, + status: { state: true }, + problem: { state: true }, + toast: { state: true }, + renaming: { state: true }, + }; + + declare docRkey: string; + declare spaceRef: string | undefined; + /** Set instead of docRkey when this tab's job is to CREATE the document + from a model first (the click got its tab before the slow work). */ + declare newModelId: string | undefined; + declare host: WebHost; + declare private docInfo: DocInfo | undefined; + declare private status: string; + declare private problem: string; + declare private toast: string; + declare private renaming: boolean; + + #localId: string | null = null; + #toastTimer: number | undefined; + + constructor() { + super(); + this.newModelId = undefined; + this.docInfo = undefined; + this.status = 'Fetching from your space…'; + this.problem = ''; + this.toast = ''; + this.renaming = false; + } + + private onStore = () => { + this.#followAddress(); + this.requestUpdate(); + }; + + /** Going live moves a home doc into a shared space — same localId, new + address. Keep the tab's fragment (and name) pointing at where the + document actually lives, so a reload finds it. */ + #followAddress() { + if (!this.#localId) return; + const s = desk.syncDoc(this.#localId); + if (!s?.space) return; + const rkey = s.syncId.split('/').pop()!; + const ref = s.home ? undefined : s.space; + const frag = `#doc=${encodeURIComponent(rkey)}${ref ? `&ref=${encodeURIComponent(ref)}` : ''}`; + if (location.hash === frag) return; + history.replaceState(null, '', `/${frag}`); + (globalThis as unknown as { name: string }).name = `tab-${rkey}`; + this.docRkey = rkey; + this.spaceRef = ref; + } + private onToast = (e: Event) => { + this.toast = (e as CustomEvent).detail; + if (this.#toastTimer !== undefined) clearTimeout(this.#toastTimer); + this.#toastTimer = setTimeout(() => (this.toast = ''), 3600) as unknown as number; + }; + + override connectedCallback() { + super.connectedCallback(); + desk.addEventListener('change', this.onStore); + desk.addEventListener('toast', this.onToast); + this.#open(); + } + + override disconnectedCallback() { + desk.removeEventListener('change', this.onStore); + desk.removeEventListener('toast', this.onToast); + if (this.#localId) { + dataHub.unregister(this.#localId); + this.host.setOpen(null); + } + super.disconnectedCallback(); + } + + static override styles = [ + baseStyles, + css` + :host { + display: flex; + flex-direction: column; + height: 100dvh; + background: var(--surface); + } + header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-4); + background: var(--ink); + color: var(--on-ink); + user-select: none; + } + .mark { + display: inline-grid; + place-items: center; + color: var(--signal-strong); + flex: none; + transform: scale(0.85); + } + .title { + font-weight: var(--w-semibold); + font-size: var(--text-sm); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .title-input { + font: inherit; + font-weight: var(--w-semibold); + font-size: var(--text-sm); + color: var(--on-ink); + background: oklch(0.3 0.01 352); + border: 1px solid var(--signal-strong); + border-radius: var(--r-sm); + padding: 2px 8px; + min-width: 0; + width: 24rem; + max-width: 40vw; + } + .title-input:focus-visible { + outline: none; + } + .status { + color: var(--on-ink-muted); + font-size: var(--text-sm); + flex: none; + } + .grow { + flex: 1; + } + .id-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: var(--text-sm); + color: var(--on-ink); + background: oklch(0.3 0.01 352); + border: none; + border-radius: var(--r-full); + padding: 3px 10px; + cursor: pointer; + } + .id-chip:hover { + background: oklch(0.35 0.012 352); + } + .id-chip:focus-visible { + outline: 2px solid var(--signal-strong); + outline-offset: 2px; + } + .avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + display: block; + } + .avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.65rem; + font-weight: var(--w-semibold); + } + .menu { + position: fixed; + top: 46px; + right: var(--sp-4); + left: auto; + margin: 0; + padding: var(--sp-1); + min-width: 180px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + } + .menu button { + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 7px 10px; + cursor: pointer; + } + .menu button:hover { + background: var(--panel); + } + .invites { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--line-soft); + } + .invite-row { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-sm); + } + .inviter-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: var(--panel); + } + .inviter-avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.65rem; + font-weight: var(--w-semibold); + line-height: 1; + } + .inviter-handle { + color: var(--ink-muted); + } + .invite-row .text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .invite-row button { + font: inherit; + font-size: var(--text-xs); + font-weight: var(--w-medium); + border-radius: var(--r-full); + padding: 2px 10px; + cursor: pointer; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + } + .invite-row button.go { + border-color: var(--ink); + background: var(--ink); + color: var(--surface); + } + .mount { + flex: 1; + min-height: 0; + } + .mount iframe { + display: block; + width: 100%; + height: 100%; + border: none; + } + .problem { + flex: 1; + display: grid; + place-content: center; + gap: var(--sp-2); + text-align: center; + padding: var(--sp-6); + color: var(--ink-muted); + } + .toast { + position: fixed; + bottom: var(--sp-5); + left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: var(--surface); + font-size: var(--text-sm); + padding: 9px 18px; + border-radius: var(--r-full); + box-shadow: var(--shadow-pop); + } + `, + ]; + + override render() { + const at = desk.sync?.at; + const name = at?.displayName || at?.handle || 'You'; + const invitations = desk.sync?.invitations ?? []; + return html` +
+ ${zmark} + ${this.renaming && this.docInfo + ? html` this.#commitRename((e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + if (e.key === 'Escape') this.renaming = false; + }} + />` + : html` { + if (this.docInfo) this.renaming = true; + }} + >${this.docInfo?.name ?? (this.newModelId ? 'New document' : 'Opening…')}`} + + ${this.status ? html`${this.status}` : nothing} + ${this.docInfo ? html`` : nothing} + + +
+ ${invitations.length + ? html` +
+ ${invitations.map((inv) => { + const profile = desk.profile(inv.inviterDid); + const inviter = profile?.displayName ?? profile?.handle ?? inv.inviterHandle ?? '…'; + const handle = profile?.handle ?? inv.inviterHandle; + return html` +
+ ${profile?.avatar + ? html`` + : html``} + + ${inviter}${handle && handle !== inviter + ? html` @${handle}` + : nothing} + invited you to “${inv.title}”${inv.spaceName ? ` in ${inv.spaceName}` : ''} + + + +
+ `; + })} +
+ ` + : nothing} + ${this.problem + ? html`` + : html`
`} + ${this.toast ? html`
${this.toast}
` : nothing} + `; + } + + async #open() { + try { + if (this.newModelId) { + // This tab exists so the click had an instant answer; the slow work + // happens here, in full view. + this.status = 'Creating your document…'; + const { backend } = await import('../state/backend.ts'); + const { rkey } = await (backend as unknown as { + createFromModel(id: string): Promise<{ rkey: string; name: string }>; + }).createFromModel(this.newModelId); + this.docRkey = rkey; + this.newModelId = undefined; + // Become the document's own tab: reopening it from the base window + // finds this one instead of minting a second. + (globalThis as unknown as { name: string }).name = `tab-${rkey}`; + history.replaceState(null, '', `/#doc=${encodeURIComponent(rkey)}`); + this.status = 'Fetching from your space…'; + } + const doc = this.spaceRef + ? await this.host.openSpaceDoc(this.spaceRef, this.docRkey) + : await this.host.openHomeDoc(this.docRkey); + this.#localId = doc.localId; + const manifest = doc.tile.manifest as { modes?: string[]; model?: { id?: string; name?: string } }; + this.docInfo = { + id: doc.localId, + path: doc.path, + name: doc.name, + damaged: false, + hasIcon: false, + size: doc.tile.size, + mtime: doc.tile.mtime, + fingerprint: doc.tile.fingerprint, + modes: manifest.modes, + modelId: manifest.model?.id, + modelName: manifest.model?.name, + }; + document.title = doc.name; + await this.updateComplete; + const mount = this.renderRoot.querySelector('.mount') as HTMLElement; + const { iframe } = await renderDocTile(doc.localId, doc.tile, mount.getBoundingClientRect().height || 480); + mount.replaceChildren(iframe); + dataHub.register(doc.localId, iframe); + this.host.setOpen(doc.localId); + this.status = ''; + } catch (err) { + this.status = ''; + this.problem = (err as Error).message; + } + } + + async #commitRename(value: string) { + this.renaming = false; + const clean = value.trim(); + if (!this.#localId || !this.docInfo || !clean || clean === this.docInfo.name) return; + try { + const { backend } = await import('../state/backend.ts'); + await backend.rename(this.#localId, clean); + this.docInfo = { ...this.docInfo, name: clean }; + document.title = clean; + } catch (err) { + desk.toast(`Could not rename: ${(err as Error).message}`); + } + } + + protected override updated() { + if (this.renaming) { + const input = this.renderRoot.querySelector('.title-input'); + if (input && this.shadowRoot?.activeElement !== input) input.select(); + } + } +} + +customElements.define('zn-web-doc', ZnWebDoc); + +declare global { + interface HTMLElementTagNameMap { + 'zn-web-doc': ZnWebDoc; + } +} diff --git a/static/app.js b/static/app.js index 172ba03..219685e 100644 --- a/static/app.js +++ b/static/app.js @@ -659,7 +659,21 @@ async function post(url, body) { if (!res.ok) throw new Error(data.error ?? `${url} failed (${res.status})`); return data; } -var backend = { +var current = void 0; +var backend = new Proxy({}, { + get(_t, key) { + const value = current[key]; + return typeof value === "function" ? value.bind(current) : value; + }, + set(_t, key, value) { + current[key] = value; + return true; + }, + has(_t, key) { + return key in current; + } +}); +var desktopBackend = { onState: void 0, /** Non-state events (sync traffic); fan-out to whoever subscribes. */ events: new EventTarget(), @@ -806,9 +820,10 @@ var backend = { if (!res.ok) throw new Error(`putData failed (${res.status})`); } }; +current = desktopBackend; var docIconUrl = (d3) => d3.hasIcon ? keyed(`/api/docs/${d3.id}/icon`) : void 0; var recentIconUrl = (d3) => d3.hasIcon ? keyed(`/api/recents/${d3.id}/icon`) : void 0; -var modelIconUrl = (m2) => m2.hasIcon ? keyed(`/api/models/${encodeURIComponent(m2.id)}/icon`) : void 0; +var modelIconUrl = (m2) => m2.icon ?? (m2.hasIcon ? keyed(`/api/models/${encodeURIComponent(m2.id)}/icon`) : void 0); var bundleUrl = (docId) => keyed(`${location.origin}/api/docs/${docId}/bundle.tile`); var MIN = 6e4; var HOUR = 36e5; @@ -927,6 +942,10 @@ var DeskStore = class extends EventTarget { } /* ——— opening things (from the base window) ——— */ handleOpened(res) { + if (res.tab) { + globalThis.open(res.url, res.win.id)?.focus(); + return; + } if (!this.desktop && !res.existing) { const b3 = res.win.bounds; const features = `popup,width=${b3?.w ?? 820},height=${b3?.h ?? 680}` + (b3 ? `,left=${b3.x},top=${b3.y}` : ""); @@ -974,7 +993,7 @@ var DeskStore = class extends EventTarget { } let path = picked.path; if (picked.unsupported || !path) { - path = prompt("Absolute path to a .tile file:") ?? void 0; + path = prompt("Absolute path to the tile file:") ?? void 0; if (!path) return; } try { @@ -1531,7 +1550,7 @@ var ZnBaseWindow = class extends i4 { this.query = ""; this.dropping = false; this.menuRecent = void 0; - this.docSort = "recent"; + this.docSort = "location"; this.confirmModel = void 0; this.menuConfirmDelete = false; } @@ -1812,12 +1831,26 @@ var ZnBaseWindow = class extends i4 { min-height: 32px; min-width: 0; } - .invite-dot { - width: 7px; - height: 7px; + .invite-avatar { + width: 22px; + height: 22px; border-radius: 50%; - background: var(--signal); + object-fit: cover; flex: none; + display: block; + background: var(--panel); + } + .invite-avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.7rem; + font-weight: var(--w-semibold); + line-height: 1; + } + .invite-handle { + color: var(--ink-muted); } .invite-text { flex: 1; @@ -1948,6 +1981,42 @@ var ZnBaseWindow = class extends i4 { flex-direction: column; min-height: 0; } + .space-people { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2) var(--sp-3); + padding: 2px var(--sp-2) 8px; + font-size: var(--text-xs); + color: var(--ink-muted); + } + .space-people .person { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + } + .space-people .person.pending { + opacity: 0.6; + } + .space-people .p-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: var(--panel); + } + .space-people .p-avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.6rem; + font-weight: var(--w-semibold); + line-height: 1; + } .scroll { overflow-y: auto; min-height: 0; @@ -2232,6 +2301,12 @@ var ZnBaseWindow = class extends i4 { display: inline-grid; transform: scale(0.9); } + footer .foot-err { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } @media (max-width: 839px) { .regions { grid-template-columns: 1fr; @@ -2293,23 +2368,13 @@ var ZnBaseWindow = class extends i4 {
- ${!desk.recents.length && !q ? b2` -
-

Your documents live here.

-

- Ziran documents are tiles: they carry their own editor and live on this - machine, with no cloud and no account. Invite someone and a document - syncs live, everyone keeping their own copy. Start from a model to make - your first one, or drop a .tile file anywhere on this window. -

-
- ` : this.docSort === "location" ? this.renderByLocation(recents, open) : recents.length ? recents.map((d3) => this.renderRow(d3, open.has(d3.id), d3 === enterDoc)) : b2`

Nothing called “${this.query}” — try a model instead?

`} + ${this.docSort === "location" ? this.renderByLocation(recents, open) : !desk.recents.length && !q ? b2`

No documents

` : recents.length ? recents.map((d3) => this.renderRow(d3, open.has(d3.id), d3 === enterDoc)) : b2`

No documents match “${this.query}”

`}

Models

- ${models.length ? models.map((m2) => this.renderModel(m2, m2 === enterModel)) : q ? b2`

No model matches “${this.query}”.

` : b2`

No models in your library yet.

`} + ${models.length ? models.map((m2) => this.renderModel(m2, m2 === enterModel)) : q ? b2`

No models match “${this.query}”

` : b2`

No models

`}
@@ -2334,6 +2399,15 @@ var ZnBaseWindow = class extends i4 {
`; } + if (sync?.provider === "spaces" && canLive && !sync.relayOk) { + const why = sync.relayError ?? `${sync.relay ?? "your PDS"} is unreachable; Ziran keeps trying`; + return b2` +
+ ${icons.warning} + Live sync is paused: ${why} +
+ `; + } if (sync && shared.length && !canLive) { return b2`