Skip to content

feat(app): Published tab + Settings → Account + cross-device sync#363

Merged
graydawnc merged 10 commits into
mainfrom
feat/app-published-tab-account
Jun 5, 2026
Merged

feat(app): Published tab + Settings → Account + cross-device sync#363
graydawnc merged 10 commits into
mainfrom
feat/app-published-tab-account

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

What

The renderer side of "what I've already published, and what my account looks like" — the surfaces a signed-in user spends time in after they've made a share.

  • SharesPage: tabbed (Drafts ← existing | Published ← new). The Published tab lists the user's live shares with title, slug, status, last republish time, copy-link, view, unpublish — sourced from the new published_shares_cache.
  • Settings → Account: handle claim (with live availability check), Sign out, Schedule deletion / Cancel deletion (24h grace), Connect provider (when signed-out)
  • published_shares_cache (schema v15): local mirror of /api/me/shares so the Published tab renders instantly on cold start and stays readable offline. NOT the source of truth — D1 on the backend is. Rows are upserted whenever myShares() succeeds; entries dropped from the remote response are pruned via clearAll + upsertMany.
  • usePublishedShares hook (with regression test): SWR shape with optimistic mutation generation counter — a stale myShares response can't clobber an in-flight local revoke/republish.
  • DeleteAccountConfirmModal: centered confirm dialog mirroring the publish-side UnpublishConfirmModal — destructive actions get the same visual weight.
  • Main IPC additions: cached-published, cache-published, get-published-by-draft, schedule-delete, cancel-delete.

Why

A share's lifecycle doesn't end at publish. The user comes back to read, copy, share with someone else, edit, republish, unpublish. None of that worked before this PR; the editor's Share menu could publish but the renderer had no surface to list what you'd published. Bundling these two surfaces lets one cache (published_shares_cache) serve both the Published tab and the editor's "is this draft already published?" lookup.

Cache-first matters because:

  • Sign-in happens on first publish, but most renderer launches don't trigger a fresh sync. The Library / Drafts / Published tabs should render in the same beat as the Library tab — a network-gated render would be a regression
  • The user might be offline. We don't want the Published tab to throw Network error when the user just wants to copy a slug they published last week
  • Cross-device sync is correct: on next online tick, SWR fetches /api/me/shares and reconciles

Data model

flowchart LR
    subgraph local["local (better-sqlite3)"]
        SD[(share_drafts)]
        PSC[(published_shares_cache<br/>v15)]
    end
    subgraph remote["backend D1"]
        PS[(published_shares)]
    end

    SD -- draft_id --> PSC
    PSC -- draft_id --> PS

    subgraph renderer
        PT[Published tab]
        EM[Editor manage view]
        SA[Settings Account]
    end

    PT --> PSC
    EM --> PSC
    SA --> A[GET /api/me]
    PSC -.SWR.-> MS[GET /api/me/shares]
    MS -.upsertMany.-> PSC
Loading

Published tab — SWR with optimistic guard

sequenceDiagram
    autonumber
    participant U as User
    participant ST as SharesPage / Published
    participant H as usePublishedShares
    participant C as published_shares_cache
    participant API as /api/me/shares

    ST->>H: mount
    H->>C: listAll() — instant
    C-->>H: cached rows
    H-->>ST: render rows
    H->>API: fetch (SWR)
    Note over H: localMutationGen captured at fetch start

    par concurrent revoke
        U->>ST: click Unpublish
        ST->>H: noteLocalMutation()
        H->>H: mutationGen++
        ST->>API: POST /api/revoke/:id
        ST->>C: markRevoked(slug)
    end

    API-->>H: { shares: [...] } (started before mutationGen++)
    H->>H: localMutationGen != current → drop response
    Note over H: Without the guard, the stale response would<br/>resurrect the just-revoked row in the cache
Loading

Account deletion lifecycle (renderer side)

stateDiagram-v2
    [*] --> Idle: /api/me deletion_pending_until = null
    Idle --> ConfirmModal: click "Delete account"
    ConfirmModal --> Idle: cancel
    ConfirmModal --> Scheduling: confirm
    Scheduling --> Pending: 200 from POST /api/me/delete
    Pending --> Cancelling: click "Keep my account"
    Cancelling --> Idle: 200 from DELETE /api/me/delete
    Pending --> Pending: render countdown ("Executes in 23h 45m")
    Pending --> Tombstoned: grace expired, worker ran<br/>(handled in PR #360)
    Tombstoned --> [*]
    Idle --> Pending: deletion_pending_until seeded<br/>from /api/me (cross-device)
Loading

The deletion banner's seed-from-server step matters: if the user scheduled deletion on web at 10:00 and opens the desktop app at 10:15, the desktop surface must show the pending banner and the Cancel CTA. Without this, the desktop UI would look idle and the user couldn't recover within the grace window.

Schema v15

CREATE TABLE published_shares_cache (
  id                 TEXT PRIMARY KEY,         -- slug
  title              TEXT NOT NULL DEFAULT '',
  visibility         TEXT NOT NULL,            -- unlisted|profile-listed
  version            INTEGER NOT NULL DEFAULT 1,
  published_at       INTEGER NOT NULL,
  revoked_at         INTEGER,
  expires_at         INTEGER,
  draft_id           TEXT,                     -- link back to share_drafts
  client_request_id  TEXT,                     -- content hash, drives "Unpublished edits" badge
  updated_at         INTEGER NOT NULL
);
CREATE INDEX idx_published_shares_cache_published_at ON published_shares_cache(published_at DESC);
CREATE INDEX idx_published_shares_cache_draft_id ON published_shares_cache(draft_id) WHERE draft_id IS NOT NULL;

Gating (prod-safe)

Same as PR #361/#362: the Published tab + Settings Account section render through useSharePublish(), which is false in prod (Vite inlines VITE_FEATURE_SHAREPUBLISH at build time, CI doesn't set it, Terser strips every guarded branch). The schema migration runs on every launch (additive, no-op if v15 already applied), but until the gate flips no UI exposes the new cache.

SettingsPanel.tsx only includes the Account tab when useSharePublish() is true.

Verification

  • pnpm --filter @spool-lab/core test — published-shares-cache.test.ts (insert / upsert / list / clearAll / by-draft lookup) + migration-v15
  • pnpm --filter @spool/app test — usePublishedShares.test.ts (the localMutationGen guard) + SettingsAccount tests
  • pnpm --filter @spool/app test:e2e — share-published-tab specs (existing list, revoke triggers row strike-through, copy-link uses the live URL, empty state when never published)
  • Manual: with VITE_FEATURE_SHAREPUBLISH=1, publish two drafts → Published tab lists both; revoke one → tombstone; restart app offline → both rows render from cache; schedule delete in web → desktop banner appears on next foreground

Risk

Schema migration is additive (CREATE TABLE IF NOT EXISTS). The cache layer is opt-in via the gate; until enabled, the rows never get written and the surfaces never render. No callers in main reach into the new IPCs.

Submitted by @graydawnc.

graydawnc and others added 10 commits June 6, 2026 03:32
- LATEST_SCHEMA_VERSION bumped to 12 to match the migration shipped in this PR; the smoke tests that compare against the constant would otherwise miss the new table on a fresh install.
- Rename `expiresSoon` → `hasExpiry` in SharesPage; the flag is true for any future expiry, not just "soon", and a glance at the call site was misleading.
- Replace SettingsAccount's post-claim window.location.reload() with a useShareAuth().refresh() call. The hook now exposes a refresh() that re-fetches /api/me.
- Rename usePublishedShares' `mergeRemoteIntoCache(_local, remote)` to `remoteToCacheItems(remote)` — the local arg was unused and the merge implication was misleading; remote is the source of truth.
- Drop unused publish-logic comments and tighten the unmount race in usePublishedShares (aliveRef instead of a closure flag, so setItems inside refresh() also respects it).
When the user scheduled deletion on web and opens the desktop app
during the 24h grace window, the existing reset-on-user-change effect
clobbered `deletionStatus` back to `idle`. Result: no Cancel CTA,
no surface to recover from. Seed the status from
`user.deletion_pending_until` (now exposed by /api/me) so a
cross-device pending deletion lights up the Cancel button on first
paint.
Account pane and 'Published' tab signed-out state both reuse the shared ConnectCard (settings) / outline Google button (shares page). Removes the inline duplicate 'Sign in to Spool Share' card on Settings → Account in favour of the same component PublishModal now uses, so the three sign-in entry points read consistently.
…w polish

Settings rail: 220px width (was 176), 22px vertical / 14px horizontal padding, h-9 / gap-11 tab buttons, accent-bg active state with dedicated dark accent-bg-dark token. Per the desktop design handoff layout.

SharesPage Published row: rounded-7 (was 6), opacity-55 on revoked rows, title size pinned at 14px to match the handoff PubRow visual hierarchy.
- SettingsPanel: pin Account tab to the bottom of the rail (after Labs
  + Security) so toggling the sharePublish flag doesn't shift the
  feature tabs vertically — matches the GitHub / Slack settings
  convention where identity sits below configuration.
- SharesPage: when sharePublish is off, render a "Drafts" label in the
  same visual slot the tab strip would occupy. The prior placeholder
  was an empty <div className="h-6" />, so the header collapsed to a
  lone + icon, losing the section's identity.
- labsFlags: add 'sharePublish' to the LabsFlag union. The flag was
  already referenced by useFeature('sharePublish') in SettingsPanel
  + SharesPage on this branch, but the type definition wasn't moved
  down from launch-prep — TS broke from PR 8 through PR 11.
- SharesPage PublishedList: after the empty-state Sign in CTA fires,
  call refresh() so the list actually populates. The hook ran its
  initial fetch with no token, got 401, and silently returned empty;
  without a re-trigger the list stayed blank even after auth.
- SharesPage PublishedList: add a window 'focus' listener that calls
  refresh() when the user re-focuses the app. Picks up remote
  revocations (user opened spool.pro/me and unpublished there) that
  the desktop would otherwise miss until restart.
- SharesPage PublishedList: replace the blank `return null` loading
  branch with a 3-row skeleton so network fetches show feedback.
- SharesPage PublishedRow: fix the expiresSoon condition — it was
  triggering on any future expiry (year-from-now included), defeating
  the badge's intent. Now gates on <7 days remaining.
- SettingsAccount: debounce the handle availability check (320ms,
  matching the web /me convention) + add a sequence counter so
  out-of-order responses can't stamp a stale 'available' over a
  fresh 'taken' when the user types fast. Previously fired one
  IPC + network call per keystroke.
The Account tab in Settings and the Published tab in Shares both
gate on `sharePublish`. This commit moves them off the LabsFlag
system (useFeature('sharePublish')) and onto the dedicated
useSharePublish() build-time helper introduced on PR 7.

Reasoning: sharePublish is a pre-launch build-time flag, not an
end-user opt-in. Labs' tri-state (localStorage "0" overrides env)
created a "stale labs pinned the flag off across dev runs" DX trap
— a contributor flipping the env var couldn't see the surface until
they manually cleared localStorage. The new helper is pure env, no
labs, no localStorage. At GA the helper body flips to `return true`.

Also reverts 'sharePublish' from the LabsFlag union; that addition
existed only to satisfy useFeature on this branch and is no longer
needed.
The Settings → Account panel's delete flow now opens
DeleteAccountConfirmModal — the same centered modal pattern the share
editor uses for unpublish. Inline click-twice cards inside a settings
surface signal too little weight for an irreversible account-level
action, and the prior treatment couldn't fit the destructive copy
without truncating button text on common widths. The modal carries
the full 24-hour cool-off explainer and Esc / outside-click dismiss.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@graydawnc
graydawnc added this pull request to the merge queue Jun 5, 2026
Merged via the queue into main with commit bb5ff93 Jun 5, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/app-published-tab-account branch June 5, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant