diff --git a/docs/specs/drafts.md b/docs/specs/drafts.md new file mode 100644 index 0000000..5ff7194 --- /dev/null +++ b/docs/specs/drafts.md @@ -0,0 +1,81 @@ +# Drafts + +## Overview + +Draft posts are composed locally and persisted to SQLite, allowing users to save work-in-progress posts and resume them later. Drafts survive app restarts and account switches. + +AT Protocol has no concept of private or unpublished records today — all repo data is public and broadcast on the firehose. A "Permissioned Data" initiative is the protocol team's top priority for summer 2026, which may eventually enable server-side drafts. Until then, drafts are local-only. The schema mirrors `app.bsky.feed.post` fields to make future migration straightforward. + +Every other BlueSky client with drafts (Skeets, deck.blue) also uses client-local storage. + +## Data Model + +Drafts live in a `drafts` SQLite table, scoped per account: + +| Column | Type | Notes | +| ------------------ | ------------------ | ----------------------------------------------------- | +| `id` | `TEXT PRIMARY KEY` | UUID | +| `account_did` | `TEXT NOT NULL` | Owning account | +| `text` | `TEXT NOT NULL` | Post body (may be empty string for embed-only drafts) | +| `reply_parent_uri` | `TEXT` | Parent post URI if replying | +| `reply_parent_cid` | `TEXT` | Parent post CID | +| `reply_root_uri` | `TEXT` | Root post URI if replying | +| `reply_root_cid` | `TEXT` | Root post CID | +| `quote_uri` | `TEXT` | Quoted post URI if quote-posting | +| `quote_cid` | `TEXT` | Quoted post CID | +| `title` | `TEXT` | Optional user label for organizing drafts | +| `created_at` | `TEXT NOT NULL` | ISO 8601 creation timestamp | +| `updated_at` | `TEXT NOT NULL` | ISO 8601 last-modified timestamp | + +Media/blob references are excluded from v1 — they require `uploadBlob` which returns ephemeral blob refs that expire. Drafts with intended media should note this in the UI. + +## Commands + +| Command | Args | Returns | Notes | +| -------------- | ------------- | -------------------- | ---------------------------------------------------- | +| `list_drafts` | `account_did` | `Vec` | Ordered by `updated_at` desc | +| `get_draft` | `id` | `Draft` | Single draft by ID | +| `save_draft` | `DraftInput` | `Draft` | Upsert — creates or updates based on `id` presence | +| `delete_draft` | `id` | `()` | Hard delete | +| `submit_draft` | `id` | `CreateRecordResult` | Load draft → `create_post` → delete draft on success | + +`DraftInput` contains all writable fields (text, reply refs, quote ref, title). If `id` is provided, it updates; otherwise it creates with a new UUID. + +## Autosave + +The composer autosaves to a draft after 3 seconds of inactivity (debounced). An active autosave draft is marked by storing its `id` in the composer state. When the user submits or explicitly discards, the autosave draft is deleted. + +On app launch, if an autosave draft exists for the active account, the composer offers to restore it via a non-blocking toast: _"You have an unsaved post. Restore?"_ with Restore / Discard actions. + +## UI + +### Drafts List + +Accessible from a button in the composer header or toolbar. Opens a `Presence` slide-up panel listing all drafts for the active account. + +Each draft card shows: + +- Title (or text preview if no title) +- Reply/quote context indicator (icon + truncated parent) +- Relative timestamp ("2 hours ago") +- Delete action (with confirmation) + +Tap a draft to load it into the composer, replacing current content (with confirmation if composer is non-empty). + +### Composer Integration + +- Autosave indicator in composer footer: subtle "Saved" / "Saving..." text +- "Save as draft" button in composer header (explicit save + close) +- Draft count badge on the drafts list button when drafts exist +- When loading a draft into the composer, the draft ID is tracked so subsequent saves update the same draft rather than creating duplicates + +### Keyboard Shortcuts + +| Key | Action | +| ------------ | ----------------------------------------------- | +| `Ctrl/Cmd+S` | Explicit save current composer content as draft | +| `Ctrl/Cmd+D` | Open drafts list | + +## Constraints + +- **No cross-device sync**: Drafts are local SQLite. If Permissioned Data ships, drafts could migrate to private repo records. diff --git a/docs/tasks/14-drafts.md b/docs/tasks/14-drafts.md new file mode 100644 index 0000000..c6f7b68 --- /dev/null +++ b/docs/tasks/14-drafts.md @@ -0,0 +1,48 @@ +# Milestone 14: Draft Posts + +Spec: [drafts.md](../specs/drafts.md) + +Depends on: Milestone 03 (Feeds — composer, `create_post`) + +## Steps + +### Backend - `src-tauri/src/drafts.rs` + `src-tauri/src/commands/drafts.rs` + +- [ ] SQLite migration: `drafts` table (`id TEXT PRIMARY KEY, account_did TEXT NOT NULL, text TEXT NOT NULL, reply_parent_uri TEXT, reply_parent_cid TEXT, reply_root_uri TEXT, reply_root_cid TEXT, quote_uri TEXT, quote_cid TEXT, title TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL`) +- [ ] `Draft` and `DraftInput` structs mirroring the schema +- [ ] `list_drafts(account_did: String)` — return all drafts for the account, ordered by `updated_at` desc +- [ ] `get_draft(id: String)` — single draft by ID +- [ ] `save_draft(input: DraftInput)` — upsert: if `id` is present and exists, update; otherwise insert with new UUID +- [ ] `delete_draft(id: String)` — hard delete +- [ ] `submit_draft(id: String)` — load draft, call `create_post`, delete draft on success, return `CreateRecordResult` + +### Frontend - Drafts List Panel + +- [ ] Drafts list panel component with `Presence` slide-up from composer +- [ ] Draft cards: title or text preview, reply/quote context indicator, relative timestamp, delete button +- [ ] Tap draft to load into composer (confirmation if composer has content) +- [ ] Delete with confirmation +- [ ] Empty state: *"No drafts yet. Saved posts will appear here."* +- [ ] `Ctrl/Cmd+D` keyboard shortcut to open drafts list + +### Frontend - Composer Integration + +- [ ] Autosave: debounced (3s inactivity) save to draft while composing, tracked by draft `id` in composer state +- [ ] Autosave indicator in composer footer: "Saved" / "Saving..." text +- [ ] "Save as draft" button in composer header — explicit save + close composer +- [ ] Draft count badge on drafts list button +- [ ] `Ctrl/Cmd+S` keyboard shortcut to save current composer as draft +- [ ] On app launch, detect unsaved autosave draft → toast: *"You have an unsaved post. Restore?"* with Restore / Discard + +### Frontend - Draft Lifecycle + +- [ ] Loading a draft into the composer tracks the draft `id` so subsequent autosaves update (not duplicate) +- [ ] Successful post submission deletes the associated draft +- [ ] Explicit discard from composer deletes the autosave draft +- [ ] Account switch clears composer state; autosave draft persists for the original account + +### Parking Lot + +- [ ] Media attachments in drafts (requires local blob caching + re-upload on submit) +- [ ] Thread builder (compose multi-post threads as a single draft) +- Cross-device sync via AT Protocol Permissioned Data (blocked on protocol — expected summer 2026)