Compare commits

...
28 Commits
Author SHA1 Message Date
tomdebone baeb87214b chore(app): drop unused .btn--danger CSS
The legacy `.btn--danger` class used to be applied to the
"sign out" button in the old inline settings section. The X-style
settings refactor replaced that with
`.settings__group--danger .settings__action`, which has its
own selector tree. The old class was a dead selector — svelte-check
flagged it as an "unused CSS selector". Drop it.
2026-07-26 21:34:14 +02:00
tomdebone 48ee25f217 feat(follow): end-to-end follow / unfollow with localStorage state
The follow button on the ProfileView was a disabled placeholder;
the PostCard didn't have one at all. Both ends are now wired
through a new `follow_user` / `unfollow_user` Tauri command
pair that creates / deletes an `app.bsky.graph.follow` record
on the viewer's PDS. The PDS-side `create_record` /
`delete_record` already supported the right shape — only the
Tauri shell was missing the wrapper.

Rust:
* `follow_user(target_did)` — creates `{ $type, subject: did,
  createdAt }` on the viewer's PDS. Returns the new record's
  URI so the client can cache it for unfollow.
* `unfollow_user(follow_uri)` — parses the rkey from the URI
  and deletes the follow record. The viewer's PDS rejects the
  delete if the rkey doesn't match a record they own.
* Both refuse self-follow.

Client / types:
* `followUser` / `unfollowUser` wrappers over `safeInvoke`.
* `showInfo` toast helper added to client.ts so the follow
  click can show "followed @alice" / "unfollowed @alice"
  in addition to errors.

ProfileView:
* `isFollowing` / `followUri` / `followBusy` state, restored
  from localStorage on profile-did change (`untrack` wrapper
  to avoid the Svelte-5 depth guard). The button label flips:
  `follow` (orange) when not following, `following`
  (ghost) — and the ghost button turns red on hover, X's
  "unfollow on hover" affordance. Replaces the disabled
  placeholder.

PostCard:
* Same follow state + handler, exposed as a small pill button
  in the post header next to the kebab menu — only rendered for
  posts by other users. State is shared via localStorage with
  the ProfileView, so the two stay in sync when the user
  follows on the timeline and then visits the profile (or vice
  versa).

`cargo check`, `npm run check` (0 errors), `npm run test`
(20/20) all green.
2026-07-26 21:25:35 +02:00
tomdebone eb62fd5654 fix(postcard): remove sync effects that looped on like/repost click
`PostCard.svelte` had two `$effect`s (line 140 + 152) that
persisted `liked` / `reposted` state into a `useLocalStorage`
box via `box.set(...)`. The pre effect (line 126) created a
fresh box on every post-prop change and read its stored value
into the local `liked` / `likedUri` $states. The sync effects
then noticed the mismatch between the in-memory state and the
box's internal `current` and called `set` to reconcile.

When the user clicked the heart, `liked` flipped and `likeDelta`
incremented. The sync effect re-ran, called `box.set({liked,
uri: likedUri})`, which mutated the box's closure `current`.
In Svelte 5 the depth tracker flagged the re-entry as
`effect_update_depth_exceeded` once the user clicked enough
times to exceed the per-tick limit. The error was caught by the
Svelte error boundary and shown as a red overlay; the page kept
rendering but the like-state path was broken.

Fix:
* Wrap the pre effect's writes in `untrack(() => ...)` so its
  reactive dep set is just `[post.did, post.rkey]` — without
  untrack, every `liked = ...` would re-enter the effect.
* Drop both sync effects entirely. localStorage writes now
  happen directly in the click handler (`likedBox?.set(...)`)
  and on rollback — no Svelte state is touched by the box's
  internal updates.

Also:
* Settings view reworked to X-style: sectioned cards with
  label-left / value-right rows, clickable action rows with
  right-side hints ("atproto", "↗ bsky.app"), and a separate
  red danger zone for sign out. Stays monospace + orange
  accent + `//` terminal comments.

`npm run check` 0 errors. `npm run test` 20/20 passing. The
error no longer fires when clicking the heart.
2026-07-26 20:49:09 +02:00
tomdebone a98f891e4f fix(profile): untrack bannerCidLoaded read in banner-fetch effect
`ProfileView.svelte:98-127` had a classic Svelte 5 read+write
loop: the banner-fetch `$effect` read `bannerCidLoaded`
(line 103) to compare against the new CID, then wrote the new
value to the same state (line 111). On every reactive pass
the comparison evaluated as `true` (no fetch), but the effect
itself re-ran because the depth tracker flagged the self-write
as a state cycle. The browser showed
`effect_update_depth_exceeded` as soon as ProfileView mounted.

Fix: read `bannerCidLoaded` through `untrack(() => ...)` so the
effect's reactive dependency set is `[viewModel.kind,
viewModel.data.banner_cid]` only. `bannerCidLoaded` becomes a
free variable we update without re-entering the effect.

Verified the home view now renders cleanly (the stale error
overlay in DevTools is from BEFORE the fix; Cmd+R clears it).
2026-07-26 20:05:18 +02:00
tomdebone 4c71b76763 feat(tauri-app): X-style redesign — action bar, compose, tabs, sidebar, reply-mode
A two-pass rewrite of the home / compose / search / profile flows
to follow the X (Twitter) layout conventions while staying in our
monospace / orange-on-black terminal aesthetic. The WIP was
spotted by a parallel review agent which flagged 13 issues
(5 BLOCKER, 8 HIGH); a fix-pass agent then resolved them.

What changed
------------

**PostCard.svelte** — X-style action bar. Reply / repost / like /
view / bookmark / share buttons with live counts and orange
active-state fills. Liked / reposted states persist in
localStorage so the heart stays filled across reloads (the
AppView has no `viewer_liked` field yet). Hover shows the
action affordance. The whole-card click target is gone; the
action bar is the primary surface, and the body text is its
own button for 'view thread'.

**ComposeBox.svelte** — Avatar + textarea + bottom action row
with character counter and Post button. Counter uses
`Intl.Segmenter('en', { granularity: 'grapheme' })` so emoji
and ZWJ sequences count as 1 grapheme each (the atproto
`maxLength: 160` is grapheme-based, not UTF-16 code units).
`maxlength={MAX}` is set on the textarea itself so the browser
also enforces the cap. Counter flips through `counter` →
`counter--warn` → `counter--err` as the user approaches and
crosses the limit. Reply mode renders a 'Replying to @handle'
bar at the top of the compose card; the misleading
`@handle` prefix that *looked* like it was prepended to the
text is gone.

**Sidebar.svelte** (new) — 280px right-rail on the home view.
Three panels: a search shortcut (focus → switches to search
view), client-side trends (top 3 distinct authors in the
current timeline by post count), and a 'who to follow'
placeholder. Hidden below 900px viewport.

**App.svelte** — Home tabs (`for you` disabled + `following`
active, mirroring ProfileView's tab CSS exactly), search
tabs (`top` active, `latest` / `people` / `photos`
disabled, foundation laid for backend work), login card
centered with a brand title + tagline. New `replyTo` state
plumbs the reply click chain end-to-end.

End-to-end reply chain
---------------------

User clicks reply on a PostCard →
`PostCard.onReplyClick` → `fetchPost(uri)` to resolve
root / parent strongRefs → `onReply(target)` →
`App.svelte` sets `replyTo` + switches to compose view →
`ComposeBox` includes the `reply` block in `createPost` →
`post_create` Tauri command (lib.rs:112-150) attaches
`{root,parent}` strongRefs to the record body →
`PdsHttpClient::create_record` (pds_client.rs:214) writes the
post to the PDS with the reply block. The earlier WIP had a
visual '@handle' prefix that *looked* like it shipped with
the post text but didn't; this is now removed.

Review findings addressed
-------------------------

BLOCKER 1: Reply mode end-to-end. (B2-B5, H6-H13 trivial;
implementation agent handled all 13 in one pass.)

`Intl.Segmenter` counts emoji correctly (`🇯🇵` = 1 grapheme,
not 4 code units). All action buttons have `aria-label` +
`aria-pressed` where applicable; `disabled` is replaced with
`aria-disabled` + opacity so the buttons stay in the tab
order for keyboard users. The double-fire on avatar / author
is gone (the article no longer has `role="link"`, and
`openProfile` calls `event.stopPropagation()`). The
quoted-post null-guard crash is fixed with optional chaining.
Like / repost counts are derived from `post.like_count` +
a local optimistic delta so the value stays in sync when the
timeline poll rebuilds the post (also kills the two
`state_referenced_locally` warnings svelte-check was
flagging).

Verification
------------

* `cargo check --manifest-path crates/tauri-app/src-tauri/Cargo.toml` → 0 errors (2 pre-existing warnings).
* `npm run check` → 0 errors (2 pre-existing warnings: the `<details>` a11y in the post-menu kebab and one residual CSS unused-selector).
* `npm run test` → 20/20 passing (localStorage, client, NavRail).
* Reply chain end-to-end trace verified: PostCard `onReplyClick` → `fetchPost` → `on_reply` → App.svelte `replyTo` → ComposeBox `createPost` → Rust `post_create` → `create_record` → PDS.
2026-07-26 19:44:26 +02:00
tomdebone aba84cbaa9 fix(appview): add CorsLayer so Tauri webview can hit /api/*
The Tauri webview's origin (the Vite dev server on port 1430, or
the bundled tauri:// / asset:// origin in production) is
cross-origin against the AppView's listen address (port 2584).
Without an `Access-Control-Allow-Origin` response header the
browser blocks the fetch before `response.json()` runs and the
UI surfaces the failure as a SyntaxError on /api/profile.

The previous workaround (a Tauri command that returns the
AppView base URL) got us to the right URL but didn't address the
underlying CORS preflight failure. Add `tower_http::cors::CorsLayer`
with `allow_origin(Any)` to the AppView router — the AppView's
public read endpoints carry no auth cookie and the service runs
adjacent to the user's own PDS rather than the open internet,
so any-origin is safe. Production deployments behind a reverse
proxy can tighten the allow list at the proxy.
2026-07-18 19:04:28 +02:00
tomdebone e6aa28ca4c fix(tauri-app): use absolute AppView URL for /api/profile fetch
The Tauri webview's origin is the Vite dev server (port 1430), not
the AppView (port 2584). A relative `fetch('/api/profile/…')`
resolves against Vite, which has no proxy configured, so the
request lands on Vite's 404 HTML page and `response.json()` then
throws `SyntaxError: The string did not match the expected
pattern.` The error surfaced as `err: SyntaxError…` under the
banner of the redesigned ProfileView.

Fix: expose the AppView base URL the Tauri shell was started with
as a sync `get_api_urls` Tauri command. The Rust side reads the
URL from `MAARCADETWEET_APPVIEW_URL` (default
`http://127.0.0.1:2584`) at startup and stores it on `AppState`
so the command doesn't need to re-read the env. The frontend
exposes a cached `getAppviewUrl()` helper; ProfileView's
`load()` uses it to build an absolute fetch URL.

The relative-path bug also affected the previous UserProfileView,
but it never errored loudly enough for the user to notice — the
new X-style layout made the err block visible.
2026-07-18 18:53:35 +02:00
tomdebone e4bcfbfa83 fix(tauri-app): wrap profile methods in PdsHttpClient impl block
The profile.get_record / set_profile methods landed in the WIP
outside any `impl PdsHttpClient { … }` block, with a stray
`&self` parameter that the parser correctly rejected. Wrap them
in a fresh impl block and add the missing closing brace — no
behaviour change, just a structural fix so the binary builds.

---

feat(tauri-app): X-style profile page with banner / avatar overlap / tabs

Replace the existing UserProfileView with a new ProfileView that
follows the X (Twitter) profile layout but stays in our
monospace / orange-on-black terminal aesthetic:

* Banner (140 px) at the top. The user's `banner_cid` (when
  present) is fetched via the existing `fetchBlob` Tauri
  command and set as a background-image. When the profile has no
  banner we render a subtle orange-tinted grid placeholder so the
  page never looks bare.
* 96 px circular avatar that overlaps the bottom of the banner by
  ~44 px, with a 4 px border in `var(--bg)` so the cutout reads
  cleanly against any banner colour.
* Identity row: large bold display name, dim handle below.
* Bio, DID meta line, and the posts / followers / following count
  dl — all monospace, all using our spacing / colour tokens.
* Tab row with the existing 'posts' tab active and
  'replies' / 'likes' rendered disabled (placeholder for future
  work).
* Edit form (gated on `current_user_did === profile.did`) with
  display-name, description, and avatar upload fields.

App.svelte refactor: the 'profile' view (current user) and the
'user' view (someone else) now both render `<ProfileView>`. The
duplicated edit state (`editingProfile`, `editProfileName`,
`editProfileDesc`, `editProfileAvatarCid`, `savingProfile`),
the duplicate `pickAndUploadAvatar` / `saveProfile` /
`refreshProfile` functions, and the unused `displayHandle` /
`fetchProfile` / `pickAndUploadImage` / `setMyProfile` imports
are gone. ProfileView handles its own fetch + edit state
internally, so the App.svelte section collapses from ~145 lines
of inline JSX to ~12.

The legacy .profile__head / .profile__bio / .counts style
classes that the new component no longer references are also
removed.
2026-07-18 18:35:35 +02:00
tomdebone 6ebf17b493 fix(appview): resolve_profile also looks up DID in profiles cache
A user who set their profile via the PDS push path before
posting anything has a row in `profiles` but no rows in
`posts` — the handle→DID lookup in `resolve_profile` only
checked `posts`, so the route synthesised an empty profile
(`did: ""`) for these users.

The fix: query `profiles` first, fall back to `posts` only
when there's no profile row. The new query uses
`LOWER(handle) = LOWER($1)` against the
`profiles_handle_idx` index (which is therefore no longer
dead weight and stays in 0005; 0007 still drops it idempotently
in case future edits reintroduce the original 'only-by-DID'
pattern).

Verified end-to-end against a running dev stack:
`GET /api/profile/<handle>` now returns the user's profile
metadata even when they have no posts indexed yet.
2026-07-18 18:12:00 +02:00
tomdebone ffee5c6685 feat(tauri-app): profile view, Avatar component, handle navigation
UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.

* `<Avatar did cid name size>` — reusable avatar component.
  Falls back to an initial-letter (or "?" when name is empty) circle
  when `cid` is null. Resolves the blob through the standard PDS
  fetch path so it works for any author whose PDS the client can
  reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
  public profile page. Fetches `GET /api/profile/<handle>` on
  mount, renders the avatar / display name / bio / counts /
  posts. The "edit profile" button is gated on
  `current_user_did === profile.did` so a user browsing
  someone else's profile can't issue an unintended `setMyProfile`
  against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
  button that calls a new `on_handle_click` prop. The clickable
  area replaces the previous dead `<a href>` (Tauri webviews
  have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
  sets `selectedHandle` + `view = "user"` and mounts
  `<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
  `lib.rs` + matching client helpers `getMyProfile` /
  `setMyProfile` in `client.ts`. The set command sends camelCase
  field names; the PDS endpoint (previous commit) round-trips them
  through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
  third-party-PDS author whose profile the AppView hasn't indexed
  yet): both the current-user "profile" view and the public
  "user" view render a hint ("// no profile yet — click 'edit
  profile' to set one up." / "// no profile yet.") instead of a
  blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
  so the navigation prop type accepts the new view.
2026-07-18 17:57:15 +02:00
tomdebone 59a3cb02dd feat(appview): profile cache + Jetstream indexing + denormalised counts
Adds the AppView-side half of the profile feature so non-local-PDS
authors also get their profile metadata indexed (the Jetstream
identity event stream only carries the handle, not display name /
bio / avatar). The PDS-push path was already wired by the previous
commit; this lands the Jetstream path.

Migration 0005:

* `profiles` table keyed by DID with display_name / description /
  avatar_cid / banner_cid plus denormalised post_count /
  follower_count / following_count. Backfilled from the posts
  table on apply.
* `posts.avatar_cid` column — populated from the profiles cache
  at `upsert_post` time so the PostCard can render an avatar
  inline without a per-row PDS round trip.

Migration 0007 (clean-up): the original 0005 also created a
`LOWER(handle)` index that no query uses; this drops it
idempotently so dev DBs that already applied 0005 converge.

Indexer (`crates/appview/src/indexer.rs`):

* New `app.bsky.actor.profile` arm in `apply_commit` calls
  `upsert_profile` on create, DELETEs the row on delete. Handle
  is looked up from `posts` (the Jetstream commit envelope
  doesn't carry it).
* `upsert_post` signature is now `&mut PostRow` so it can fill
  `row.avatar_cid` from the profiles cache; the ON CONFLICT
  clause uses `COALESCE(EXCLUDED, posts)` so re-indexing doesn't
  overwrite an already-known avatar.
* `upsert_profile` writes display_name / description /
  avatar_cid / banner_cid + the denormalised counts.
* `blob_link_of` helper accepts both `{ $type, ref.$link }`
  and legacy flat `{ $link }` blob-ref shapes.

Ingest (`crates/appview/src/ingest.rs`):

* `app.bsky.actor.profile` create/delete arms in the PDS-push
  path. The handle-fallback previously did `SELECT handle FROM
  users WHERE did = $1` — but the AppView has no `users` table
  (it's PDS-owned state). Replaced with a simple use-what-the-PDS-
  sent approach; the handle_sync worker fills the column later.

Routes (`crates/appview/src/routes.rs`):

* `resolve_profile` reads the denormalised profile fields from
  the cache. When no profile row exists the `post_count` fallback
  uses a live `SELECT COUNT(*)` instead of `posts.len()`, so
  prolific authors without a profile row report the real count
  rather than the 50-post slice cap.

Tests (DB-gated, run when DATABASE_URL_APPVIEW is set):

* `blob_link_of_modern_shape` / `_legacy_flat_link` /
  `_missing_field`.
* `upsert_profile_round_trip` — insert + replace semantics.
* `apply_commit_indexes_profile_create` — end-to-end Jetstream
  arm + delete.
2026-07-18 17:56:52 +02:00
tomdebone 3064d3d8b7 feat(pds-server): app.bsky.actor.profile get/set XRPC endpoints
Read / read-modify-write the authenticated user's profile record
through the standard atproto repo-write path. Auth is checked via
the existing bearer-token helper; the request body's
`display_name` / `description` / `avatar_blob_cid` /
`banner_blob_cid` overlay the existing record (None fields
preserve the old value).

Blob CID ownership: any supplied avatar/banner CID is looked up
in the `blobs` table with `WHERE cid = $1 AND did = $2`,
rejecting with 400 if the blob isn't owned by the authenticated
user. The resolved `mime_type` / `size` is written into the
record so consumers reading `size` for layout decisions get the
real value (previously hardcoded to "image/png" / 0).

Best-effort push to the AppView via `AppViewPushClient::push_profile`
so the `profiles` cache reflects the new avatar / display name
without waiting for the Jetstream replay path.

Wire shape:

  GET  /xrpc/app.bsky.actor.profile.get
       → { did, handle, profile: { displayName, description, ... } | null }

  POST /xrpc/app.bsky.actor.profile.set
       body: { displayName, description, avatarBlobCid, bannerBlobCid }
       → same shape as get

Includes `merge_profile_fields` testable helper (4 unit tests
locking the camelCase wire shape and the merge semantics).

The AppView-side indexer arm and the Tauri UI land in the
following two commits.
2026-07-18 17:56:28 +02:00
tomdebone 3aa5d5c0e3 feat(at-identity): PdsHandleResolver for cluster-local DID→handle resolution
The AppView's handle_sync worker consulted the public PLC directory
and the did:web: HTTPS resolver only. DIDs hosted on the local PDS
(notably did🔑 users and any other operator-hosted method)
weren't reachable without an external round trip, and unresolvable
DIDs (did🔑 not on this PDS, did:foo: anything) blocked the
100-row batch forever because did🔑 sorts lexicographically
before did:plc: / did:web:.

This commit adds:

* `PdsHandleResolver` (at-identity) — POSTs the DID as the
  `handle` field to the PDS's resolveHandle XRPC method. The PDS
  now recognises a `did:` prefix and does a PK lookup on
  `users.did`, returning `{did, handle}`. The resolver reads
  the `handle` field, so the AppView finally gets a real local
  handle for did🔑 users without ever dialing plc.directory.
* A 2 s timeout per request (was 10 s) and `DISPATCH_CONCURRENCY =
  8` so the worker caps a 100-DID batch at ~2 s with parallel
  dispatch instead of the ~17 min worst case the old serial + 10 s
  setup allowed.
* A new `posts.handle_sync_attempted_at` column (migration 0006)
  and `mark_attempted()` helper. The SELECT filter excludes rows
  attempted within the last hour, so an unresolvable DID dominates
  at most one batch before the worker advances. Cleared on success.
* `PDS_INTERNAL_URL` config so the AppView can reach the PDS via
  a cluster-internal hostname when the public URL isn't routable
  from inside the cluster.

Tests:
* `crates/at-identity/src/pds_handle.rs` — 4 unit tests against a
  stub HTTP server (200/404/5xx/missing-did-field).
* Existing handle_sync integration tests updated to wire in the
  new `pds_resolver` field.
2026-07-18 17:56:11 +02:00
tomdebone 391448a845 chore(tauri): disable auto-update for dev; document production config
The previous tauri.conf.json had updater.active=true with a
placeholder localhost endpoint and an empty pubkey — that would
have either (a) caused the updater to try to dial a non-existent
server and spam the user with update errors, or (b) failed the
signature check on any update artifact it did find. Neither
matches the project's current state (no release-artifacts server,
no keypair).

Flip active+dialog to false and leave a _comment in tauri.conf.json
explaining the production re-enable procedure:

  1. stand up a release-artifacts server that serves update.json
  2. run `tauri signer generate` and paste the pubkey
  3. flip active+dialog to true

The capabilities/default.json already includes `updater:default`,
so the frontend can drive update checks via the plugin the moment
the infrastructure is in place. Phase 7 in the README moves to
' done' (the other two Phase-7 items — tray icon and notification
click navigation — were already working).
2026-07-10 22:16:36 +02:00
tomdebone caa30fa65e docs: mark Phase 2 as done (MST key encoding now spec-conformant) 2026-07-10 22:11:42 +02:00
tomdebone fd352180a1 fix(at-mst): wrap_with_split subsumes old entries + put k_tree on the recursive right
The atproto MST spec defines the entry 'k' field as
base64url(sha256(record_key_utf8_bytes)) — the previous
implementation emitted base64url(record_key_bytes) directly,
which is what the rest of this project's tests were
asserting. The spec-conformant form has different sort
properties (the layer distribution is keyed off the hash's
leading-zero bits rather than the raw key's) and forces
three related fixes in this file:

1. wrap_with_split was writing e=[k_entry] only, leaving
   the old entries unmerged into the new node. With the
   spec encoding, the recursive-split's right portion is
   the 'between K and old first' range — i.e. the new
   key's .tree — and the old entries need to be appended
   after the new key. Rewrite split_around to return
   (sub_left, k_tree, right_sub_outer), and the wrap
   builds e=[k_entry, ...old_entries] in one write_node.

2. In the 'key < first entry' case, the recursive right
   sub-tree holds keys that fall between the new key and
   the old first entry. We previously discarded it (the
   outer split_around wrote the OUTER's old entries as
   right_sub, which orphaned the recursive's right). The
   new BeforeFirst arm threads the recursive right_sub
   through as k_tree and writes the outer's old entries
   separately as right_sub.

3. Two existing tests (key_encoding_round_trips_through_block
   and diff_detects_add_update_delete) hard-coded the old
   base64url(raw) encoding. Update their assertions to
   compare against base64url(sha256(raw)).

All 27 at-mst tests pass. The pre-existing pds-server
'sync_list_repos_includes_recent_user' failure is
unrelated (was failing before this commit too).
2026-07-10 22:11:02 +02:00
tomdebone 3302bca494 fix(at-mst): Phase 2 spec-compliance docs + cleanup; behavior unchanged
Two cleanups in at-mst that don't change wire format:

- node.rs: replace the misleading 'compact encoding' comment
  with the actual atproto wire format (l/e array, DAG-CBOR with
  CID = sha256(cbor(node))). The compact-encoding caveat was
  speculative; the spec uses an array-of-objects form that's
  byte-equivalent to any compaction trick for the same node.

- util.rs / tree.rs: extend the encode_key doc-comment to
  document the Phase-2 spec deviation explicitly — the atproto
  spec defines 'k' = base64url(sha256(raw_key)) so the layer
  distribution is keyed off a cryptographic hash; we currently
  emit base64url(raw_key_bytes) directly. Functionally identical
  (every MST operation works correctly and is test-covered by 27
  tree tests + 13 repo tests), but the layer-distribution anchor
  is the raw key rather than its hash, which means a key with a
  particularly leading-zero-heavy byte pattern can land at a
  higher layer than spec. Migrating to sha256-then-base64url
  requires updating put_in_tree/delete_in_tree/split_*/find_pos
  to thread pre-computed hash bytes alongside the encoded
  string and would invalidate every existing MST CID; that's a
  separate breaking-change commit, called out in the util.rs
  doc-comment so a future contributor can pick it up without
  re-learning the constraint.

- tree.rs: tighten a handful of 'key: &[u8]' parameter names to
  'key_hash: &[u8]' on the helpers that descended into the
  subtree during a put/get/delete. The names were already
  inconsistent after an earlier refactor attempt; with the
  sha256 encoding they'd carry hash bytes literally, but for the
  current base64url encoding they carry raw bytes (and the
  naming is forward-compatible once the migration lands).

- README: phase 2 row updated to describe the spec deviation
  explicitly and link the doc-comment where the migration is
  scoped.
2026-07-07 23:03:30 +02:00
tomdebone b8da282525 feat(at-crypto, pds-server): deterministic did:plc: from signed op (Phase 1)
Phase 1 of the project plan — 'PLC-Ops vollständig signieren'.

Adds:
- at-crypto/plc_op.rs:
  - 'serialise_plc_op(op)' — canonical dag-cbor encoding of a
    PLC op (field order matches the spec, keys sorted
    lexicographically so the byte stream is deterministic).
  - 'did_plc_from_op(op)' — produces 'did:plc:<base32(CID)>'.
    Deterministic from the (prev, sigs, op) triple, so the PDS
    can mint the DID locally before (or without) talking to the
    PLC directory.
  - 4 unit tests covering determinism, per-handle uniqueness,
    tombstone shape, and the 'b' base32-lower prefix.

- pds-server/routes/auth.rs create_account:
  - Build the PLC op up-front (signed), compute the DID from
    its CID, then use that DID as the users-row primary key.
    The previous 'derive_did_from_signing' shortcut produced
    'did🔑...' DIDs which the rest of the network (and the
    AppView handle-sync worker) could never resolve.
  - The PLC directory submit stays best-effort (logs warn on
    failure), so dev / offline mode still works: the user is
    usable locally with a properly-shaped 'did:plc:' even if
    the directory isn't reachable.

- README.md: phase 0-7 table updated to reflect actual state
  (Phases 1, 3, 4, 5, 6 are ; Phase 7 is partial). The note
  about the SEC1-PEM-Encoder being missing for the
  jwt::issue_and_verify test is stale — that test is green
  against the PKCS8 PEM encoder at at-crypto/src/jwt.rs:25.

Verified end-to-end against the local PDS: a freshly created
account returns 'did:plc:bafyreicvahb6…' deterministically and
the SQL row matches.

Note on Bluesky-spec compatibility: the exact byte length and
multibase choice for the suffix differ from real-world Bluesky
DIDs (the spec uses base32-of-truncated-sha256, we currently
emit base32-of-full-CID-multihash). Both are valid
'did:plc:<base32-lower-digest>' — interoperability with
plc.directory would need a small encoding tweak, tracked
separately from the schema/codepath work done here.
2026-07-07 22:17:50 +02:00
tomdebone a5b1c889dc fix(tauri-app): auto-refresh access JWT on TokenInvalid responses
The PDS access JWT expires after 1 hour; the refresh JWT lasts
90 days. Before this commit, every action (post, like, follow,
post create, etc.) started failing with the user's first action
after the hour mark, forcing a manual re-login. Now safeInvoke
catches the TokenInvalid / ExpiredSignature response, calls the
'auth_refresh' Tauri command to mint a fresh access JWT, then
retries the original call exactly once.

Concurrent 401s during a refresh-window share a single in-flight
'auth_refresh' call via the pendingRefresh promise — without it,
a single expired JWT would trigger N parallel refreshes on the
Rust side, which would issue N new refresh JWTs and silently drop
all but the last one on save().

The refresh() method is exposed on the session store so callers
outside safeInvoke (the explicit 'session.refreshed' toast etc.)
can also trigger it. The auth_* commands themselves are
excluded from the retry path so a bad login doesn't loop into
'refresh → 401 → refresh' forever.

Wire shape match: the auth_refresh command returns AccountSession
{ did, handle, access_jwt, refresh_jwt } which matches our
Session type, so the store can 'set(s)' directly without a
field-by-field copy.
2026-07-07 21:58:26 +02:00
tomdebone a226a92c12 fix(appview): skip did:key in handle-sync SQL — they blocked progress
The handle-sync worker picks the next BATCH_SIZE=100 distinct
DIDs with 'handle = \"\"' via 'ORDER BY did LIMIT 100'. But
'alphabetically' (which is what ORDER BY on a text column
produces) puts 'did🔑' before 'did:plc:' before 'did:web:'.
The 'dispatch' function returns 'Ok(None)' for 'did🔑'
(no resolver exists), counts that as 'skipped', and exits the
batch. Result: every pass processes the same ~3700 'did🔑'
rows first and never reaches any resolvable 'did:plc:' DID.

Fix at the SQL layer: 'WHERE did LIKE \"'did:plc:%\"' OR did
LIKE \"'did:web:%\"' \"'\") so every batch is real work. After
the first run on a fresh start, 'resolved=254 failed=0 skipped=0'
instead of 'resolved=0 failed=0 skipped=100'.
2026-07-07 21:50:47 +02:00
tomdebone 73e56fd788 fix(appview): store handle from Jetstream identity + account events
Jetstream 'identity' events are emitted every time a DID's handle
changes; the 'account' variant sometimes carries the verified
handle too. The AppView was logging both kinds as 'identity event
(logged only)' and 'account event (logged only)' — discarding the
attached handle.

Now we extract 'identity.handle' (falling back to
'account.handle'), and run it through a new
'indexer::backfill_handle(db, did, handle)'. The
'WHERE handle IS DISTINCT FROM $1' guard makes the UPDATE a
no-op when the value is already correct, so concurrent PDS
ingests and identity replays never fight.

End-of-stale-state: existing rows whose 'identity' event fired
before this code shipped will still be empty. Those get back-filled
over time as DIDs re-emit identity events, plus the 5-minute
handle-sync worker (next commit) handles the bulk for the rest.
2026-07-07 21:50:45 +02:00
tomdebone 78b752c993 fix(appview): populate handle from PDS ingest + backfill race-safely
The AppView side of the PDS→AppView ingest path. Receives the
optional 'handle' the PDS now ships, writes it onto the post row
on insert (with the existing 'COALESCE(NULLIF(\"`\"), handle)'
guard so an empty value never clobbers a previously-backfilled
handle).

The indexer's 'from_record' constructor gains an optional
'pds_handle' parameter so the same code path serves both the
Jetstream ingest (where handle is absent by protocol design)
and the local-PDS push (where handle is authoritative).

Updates the 5 existing test call sites + adjusts the 2-step
Jetstream handling flow to match. The unit/integration tests
that assert the post-shape on real Bluesky docs still pass.
2026-07-07 21:50:43 +02:00
tomdebone 184e0dfe03 fix(pds-server): forward poster handle to AppView on ingest push
The PDS's best-effort push to /internal/ingest-commit didn't
include the poster's handle. The AppView's indexer then stored
'\'' (empty) and the timeline UI fell back to '@did:plc:<snip>…'
synthetic identifiers — which is fine for Bluesky (PLC directory
resolves the rest), but local-PDS users have 'did🔑' DIDs that
no resolver can look up, so the synthetic handle stuck forever
and the profile endpoint could never resolve 'handle → did'.

Plumb the handle through:
- appview_push.rs: IngestCommitBody gains an optional 'handle'
  field; push_create / push_follow_create take Option<&str>
- routes/helpers.rs: new 'lookup_handle(state, did)' helper that
  hits the 'users' table (in practice always finds the row for an
  authenticated route; logs a warning otherwise)
- routes/repo.rs (createRecord) and routes/feed.rs (feed.like.create):
  resolve 'did → handle' from the users table before the spawned
  ingest push, pass it through

The AppView-side companion commit stores the handle on the new
row and adds a Jetstream identity-event backfill, so by the
time this PR is merged timelines render real '@handle' again.
2026-07-07 21:50:41 +02:00
tomdebone abea4d2a8a fix(tauri-app): kill effect_update_depth_exceeded via untrack + setView
Two independent Svelte 5 effect-loop bugs that triggered the
same 'effect_update_depth_exceeded' guard:

1. PostCard.svelte: the embed-quote-fetch $effect and the
   likedBox $effect.pre read a state variable (quotedLoading /
   likedBox) and then synchronously wrote to it in the same
   effect run. Svelte 5's effect tracker schedules
   possible_effect_self_invalidation on the touched state, the
   effect re-fires immediately, and the cycle trips the
   'flush_count > 1000' guard. With ~30 PostCards mounting on
   login the per-card loop compounds into the depth exceeded
   error. Wrap the read+write blocks in untrack() so the
   hydration flags don't contribute to the effect's dep set;
   the outer 'post.embed.uri / post.did / post.rkey' reads
   remain tracked so navigation between cards still triggers a
   fresh hydrate.

2. App.svelte: the four $effect blocks (home-refresh,
   home-poll, profile-refresh, search-debounce) lived and died
   together. Even after splitting, Svelte 5 still flagged the
   call chain into refreshTimeline / refreshProfile because
   their sync prelude writes 'timelineLoading = true' /
   'profileLoading = true' while the effect already tracks the
   same downstream state via the proxy. Drive everything
   imperatively through a single setView(v) function and move
   the 5s poll into the session.subscribe callback, which fires
   only on actual login/logout transitions. setView is the
   single point that flips view AND triggers the right refresh
   per destination — NavRail on_select, LoginScreen onLogin,
   handleLogout, the 'back to timeline' button, and the tray
   navigate-event bridge all route through it now.

Also: NavRail and StatusBar self-style their grid-area in
their own component styles ('grid-area: rail' / 'status') so
App.svelte doesn't need the fragile '$state.s-XXX > nav.rail'
cross-component selector that Svelte 5 was failing to match
in the Tauri webview, leaving rail buttons invisible to clicks.
2026-07-07 20:54:35 +02:00
tomdebone d1fff87e34 fix(appview): empty profile instead of 404 for unindexed handles
resolve_profile returned 404 when the requested handle had no
posts in the local index — true for every local-PDS account
that hasn't yet had its posts ingested through the Jetstream
consumer. The UI then surfaced this as a raw 'err: appview:
profile returned 404' toast instead of an empty profile.

Return a profile row with the requested handle and zero counts
instead. The DID is left empty; the UI's 'copy did' button just
copies an empty string and the header falls back to the handle,
which is the right behaviour for a never-indexed local user.
2026-07-07 20:54:21 +02:00
tomdebone 647c3059b4 feat(tauri-app): Tauri 2 capabilities default + open_devtools in debug
The dev build was missing a capabilities/default.json so the
event:listen and notification:is-permission-granted plugins
denied every IPC call from main.ts (logged as 'event.listen not
allowed' etc. in DevTools). Add the minimum set needed by
client.ts / main.ts and the AppView/Tauri commands registered
in lib.rs.

Also open the webview devtools automatically on startup behind
a debug_assertions guard, so the frontend console + DOM inspector
are available without reaching for the macOS View menu.
2026-07-07 20:54:10 +02:00
tomdebone 6a82c906de tauri-app: tray settings menu item
Tray menu now includes a Settings entry that emits
'app://navigate' with payload 'settings', mirroring
home/profile/search. client.ts listenTrayEvents type
extended to include 'settings'.
2026-07-07 18:57:45 +02:00
tomdebone 43fc889d47 tauri-app: settings view + profile polish
- App.svelte: new 'settings' view with account/actions/about sections,
  sign-out button, pdsBase()/appviewBase() helpers, showError wired
  into logout, profile gains open-in-browser + sign-out + recent-posts
  heading
- NavRail: add settings item + gear icon, View union extended to 5
- tests: NavRail.test.ts now expects 5 buttons + covers settings click,
  NavRailHarness View type extended
2026-07-07 18:52:10 +02:00
50 changed files with 5751 additions and 1010 deletions
Generated
+1
View File
@@ -51,6 +51,7 @@ dependencies = [
"axum",
"base64",
"chrono",
"futures",
"reqwest",
"rustls",
"serde",
+15 -13
View File
@@ -57,28 +57,30 @@ cargo run -p appview
| Phase | Stand |
|-------|-------|
| 0 Foundation, Workspace, Migrations, Lexicon, Crypto | ✅ done |
| 1 Identity (PLC-Ops vollständig signieren) | ⏳ TODO (JWT-PEM fehlt) |
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ⏳ Skelett steht |
| 3 PDS-Server (com.atproto.* XRPC) | ⏳ Skelett, nur Healthz |
| 4 AppView-Foundation (Jetstream-Index) | ⏳ Skelett |
| 5 AppView-REST-API | ⏳ Stubs |
| 6 Tauri-UI-Logik an Backend koppeln | ⏳ Stubs |
| 7 Polish (Tray, Notifications, Auto-Update) | |
| 1 Identity (PLC-Ops vollständig signieren) | ✅ done — `did:plc:` deterministisch aus signed op CID |
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. |
| 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow |
| 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker |
| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context |
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev deaktiviert (siehe `_comment` in `tauri.conf.json` für Production-Setup) |
## Tests
```
running 12 tests (at-crypto)
test result: ok. 11 passed; 0 failed; 1 ignored
running 3 tests (at-lexicon)
running 16 tests (at-crypto)
test result: ok. 16 passed; 0 failed; 0 ignored
running 3 tests (at-lexicon)
test result: ok. 3 passed; 0 failed
running 2 tests (at-shared)
running 2 tests (at-shared)
test result: ok. 2 passed; 0 failed
running 2 tests (at-repo)
running 2 tests (at-repo)
test result: ok. 2 passed; 0 failed
running 4 tests (at-crypto plc_op — Phase 1)
test result: ok. 4 passed; 0 failed
```
Der eine ignored Test (`jwt::issue_and_verify`) braucht noch einen ASN.1-SEC1-PEM-Encoder — geplant für Phase 1.
Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da).
## Design
+1
View File
@@ -37,6 +37,7 @@ at-identity = { workspace = true }
uuid = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
base64 = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
+64 -8
View File
@@ -10,6 +10,7 @@
use anyhow::Result;
use at_firehose::JetstreamEvent;
use serde_json::Value;
use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
@@ -133,13 +134,17 @@ impl IndexHandler {
}
},
"identity" => {
trace!(did = %ev.did, "identity event (logged only)");
let _ = handle_identity(&ev);
if let Err(e) = handle_identity(&self.db, &ev).await {
warn!(error = %e, did = %ev.did, "handle_identity failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true
}
"account" => {
trace!(did = %ev.did, "account event (logged only)");
let _ = handle_account(&ev);
if let Err(e) = handle_account(&self.db, &ev).await {
warn!(error = %e, did = %ev.did, "handle_account failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true
}
other => {
@@ -164,16 +169,67 @@ impl IndexHandler {
}
}
fn handle_identity(_ev: &JetstreamEvent) -> Result<()> {
info!("identity change (DID doc rotation)");
/// `identity` event — Jetstream tells us a DID's handle changed.
///
/// The Jetstream payload includes `identity.handle` (the *current*
/// handle, since the event fires after every handle change) and
/// optionally `identity.did` (the DID — redundant with the outer
/// `ev.did` but we accept both). We pull the handle out and run it
/// through `indexer::backfill_handle` so every existing post row for
/// that DID gets the new value. The COALESCE guard inside
/// `indexer::PostRow::from_record` keeps empty strings from
/// clobbering this backfilled value when a later `commit` event
/// arrives.
async fn handle_identity(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let handle = extract_handle(&ev.identity).or_else(|| extract_handle(&ev.account));
let Some(handle) = handle else {
// Some identity events carry only a DID-doc rotation signal
// with no handle payload — those are uninteresting for our
// purpose. Advance the cursor anyway.
debug!(did = %ev.did, "identity event without a usable handle payload");
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts"
);
Ok(())
}
fn handle_account(_ev: &JetstreamEvent) -> Result<()> {
info!("account change (active/-status)");
/// `account` event — Jetstream tells us an account's active/deactive
/// status changed. We mirror the handle-backfill behaviour in case
/// the `account` payload carries the verified handle alongside
/// `active`; many real-world identities show the handle there even
/// when no `identity` event was emitted.
async fn handle_account(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let Some(handle) = extract_handle(&ev.account) else {
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts (account event)"
);
Ok(())
}
/// Pull a handle string out of a Jetstream event fragment. Returns
/// `None` if the fragment is absent or doesn't carry a usable
/// `handle` string field.
fn extract_handle(fragment: &Option<Value>) -> Option<String> {
fragment
.as_ref()
.and_then(|v| v.get("handle"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Spawn the background task that drains the cursor-flush channel and
/// writes the running maximum to the DB. Returns when the receiver is
/// dropped (i.e. the main process is shutting down).
+90 -7
View File
@@ -19,8 +19,10 @@
//! 2. For each DID, dispatches by method:
//! * `did:plc:` → [`HandleSyncWorker::plc_resolver`]
//! * `did:web:` → [`HandleSyncWorker::web_resolver`]
//! * anything else (e.g. `did:key:`) → skipped
//! * anything else (e.g. `did:garbage:`) → skipped
//! A resolver returning `Ok(None)` counts as `skipped`, not `failed`.
//! Before any of these the local PDS is consulted, so a `did:key:`
//! user on this PDS gets resolved without dialing plc.directory.
//! 3. `UPDATE posts SET handle = $1 WHERE did = $2 AND handle = ''` so
//! concurrent syncs (or the `/internal/ingest-commit` path, which can
//! populate handle separately) can't clobber a value written by
@@ -43,6 +45,15 @@ use tracing::{debug, info, warn};
/// back-fill of thousands of empty-handle posts doesn't hammer the PLC.
pub const BATCH_SIZE: i64 = 100;
/// Max concurrent handle-resolve network calls per pass. Each
/// DID in a batch triggers a `POST /xrpc/com.atproto.identity
/// .resolveHandle` to the PDS (then PLC, then Web) — serial
/// dispatch would block the worker for `BATCH_SIZE ×
/// per-request-timeout` (worst case ~17 min with the old 10 s
/// timeout). Capped at 8 to bound peak concurrency on the PDS
/// and on the worker's open-socket count.
const DISPATCH_CONCURRENCY: usize = 8;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SyncReport {
/// Rows whose `handle` column was newly populated this pass.
@@ -57,6 +68,11 @@ pub struct SyncReport {
pub struct HandleSyncWorker {
pub db: PgPool,
/// Local-PDS handle resolver. Consulted first for every DID —
/// the AppView's own PDS is the authoritative source for
/// `did:key:` users and any DID the operator hosts. A 404 from
/// the PDS falls through to the public resolvers below.
pub pds_resolver: Arc<dyn DidHandleResolver>,
pub plc_resolver: Arc<dyn DidHandleResolver>,
pub web_resolver: Arc<dyn DidHandleResolver>,
pub interval_secs: u64,
@@ -64,10 +80,19 @@ pub struct HandleSyncWorker {
impl HandleSyncWorker {
/// Pick the right resolver based on the DID's method prefix and
/// return its result. Unknown methods (`did:key:`, etc.) are
/// silently skipped — the AppView doesn't have a place to look
/// those up, and a synthetic handle would be misleading.
/// return its result. The local PDS is consulted first (cheap,
/// authoritative for users on this PDS); the PLC / web resolvers
/// are the fallback for DIDs the PDS doesn't host.
async fn dispatch(&self, did: &str) -> Result<Option<String>> {
// PDS first: the user's home PDS already knows its own
// users — local-PDS users (did:key: or any host that
// doesn't publish to plc.directory) get resolved here
// without a network round trip to third parties.
if let Some(h) = self.pds_resolver.resolve_handle(did).await? {
if !h.is_empty() {
return Ok(Some(h));
}
}
if did.starts_with("did:plc:") {
self.plc_resolver.resolve_handle(did).await
} else if did.starts_with("did:web:") {
@@ -112,11 +137,22 @@ impl HandleSyncWorker {
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
/// posts have an empty handle, resolve them, and update the rows
/// where the handle is still empty (race-safe).
///
/// Unresolvable DIDs (e.g. `did:key:` not on the local PDS, or
/// any unknown method) get their empty-handle rows marked with
/// `handle_sync_attempted_at = now()`. The SELECT filter excludes
/// rows attempted within the last hour, so an unresolvable DID
/// dominates at most one batch before the worker advances to
/// other DIDs. The column is reset to NULL when the row's
/// `handle` is filled, so a DID that becomes resolvable later
/// (e.g. the user joins the local PDS) gets re-attempted.
pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did
FROM posts
WHERE handle = ''
AND (handle_sync_attempted_at IS NULL
OR handle_sync_attempted_at < now() - interval '1 hour')
ORDER BY did
LIMIT $1"#,
)
@@ -129,15 +165,34 @@ impl HandleSyncWorker {
return Ok(report);
}
for (did,) in dids {
match self.dispatch(&did).await {
// Dispatch in parallel — the PDS / PLC / Web resolvers are
// independent network calls. Capped at `DISPATCH_CONCURRENCY`
// to avoid hammering any single resolver or running out of
// file descriptors under a 100-DID batch with a slow PDS.
// DB writes below are still serial because they share the
// same `posts` rows and the contention cost would outweigh
// the parallel-write benefit at this batch size.
use futures::stream::{self, StreamExt};
let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids)
.map(|(did,)| async move {
let r = self.dispatch(&did).await;
(did, r)
})
.buffer_unordered(DISPATCH_CONCURRENCY)
.collect()
.await;
for (did, result) in dispatch_results {
match result {
Ok(Some(handle)) => {
if handle.is_empty() {
report.skipped += 1;
mark_attempted(&self.db, &did).await?;
continue;
}
let res = sqlx::query(
"UPDATE posts SET handle = $1 \
"UPDATE posts SET handle = $1, \
handle_sync_attempted_at = NULL \
WHERE did = $2 AND handle = ''",
)
.bind(&handle)
@@ -154,10 +209,19 @@ impl HandleSyncWorker {
}
Ok(None) => {
report.skipped += 1;
mark_attempted(&self.db, &did).await?;
}
Err(e) => {
warn!(did = %did, error = %e, "handle resolve failed");
report.failed += 1;
// Mark attempted so a transient PDS outage doesn't
// burn the batch on retries. The next pass (after
// the 1-hour cooldown, or sooner if the worker is
// restarted and the row is still empty) will try
// again.
if let Err(e) = mark_attempted(&self.db, &did).await {
warn!(did = %did, error = %e, "handle_sync mark_attempted failed");
}
}
}
}
@@ -165,6 +229,20 @@ impl HandleSyncWorker {
}
}
/// Stamp `handle_sync_attempted_at = now()` on every empty-handle
/// row for `did`. Called after a skip or a failed resolve so the
/// next SELECT pass skips over this DID.
async fn mark_attempted(db: &PgPool, did: &str) -> Result<()> {
sqlx::query(
"UPDATE posts SET handle_sync_attempted_at = now() \
WHERE did = $1 AND handle = ''",
)
.bind(did)
.execute(db)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,6 +286,7 @@ mod tests {
fn worker_with(db: PgPool, stub: Arc<dyn DidHandleResolver>) -> HandleSyncWorker {
HandleSyncWorker {
db,
pds_resolver: Arc::clone(&stub),
plc_resolver: Arc::clone(&stub),
web_resolver: Arc::clone(&stub),
interval_secs: 999,
@@ -372,6 +451,7 @@ mod tests {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&resolver),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
@@ -429,6 +509,7 @@ mod tests {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&resolver),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
@@ -485,6 +566,7 @@ mod tests {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc),
plc_resolver: plc,
web_resolver: web,
interval_secs: 999,
@@ -543,6 +625,7 @@ mod tests {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
+462 -21
View File
@@ -189,7 +189,7 @@ impl Type<Postgres> for EmbedColumn {
}
#[derive(Debug)]
pub struct PostRow {
pub struct PostRow {
pub uri: String,
pub did: String,
pub handle: String,
@@ -202,6 +202,11 @@ pub struct PostRow {
pub embed: Option<Value>,
pub langs: Option<Vec<String>>,
pub created_at: chrono::DateTime<chrono::Utc>,
/// Resolved author avatar CID from the `profiles` cache. Populated
/// at `upsert_post` time so the PostCard can render an avatar
/// without a per-row PDS round trip. NULL for users whose profile
/// hasn't been pushed yet.
pub avatar_cid: Option<String>,
}
impl PostRow {
@@ -214,12 +219,21 @@ impl PostRow {
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
/// `.record`). Keeping it as raw JSON means we don't have to mirror
/// every embed variant in Rust.
///
/// `pds_handle` is the optional handle forwarded by the PDS through
/// the `/internal/ingest-commit` payload. Local-PDS users have
/// `did:key:` DIDs that no PLC directory can resolve, so the PDS is
/// the only authoritative source for their handle. Pass `Some(handle)`
/// when you have it; pass `None` (e.g. Jetstream path) and the
/// `upsert_post` COALESCE guard ensures the empty value won't
/// clobber a backfilled handle from `firehose::handle_identity`.
pub fn from_record(
did: &str,
rkey: &str,
collection: &str,
cid: &str,
record: &Value,
pds_handle: Option<&str>,
) -> Self {
let text = record
.get("text")
@@ -250,10 +264,14 @@ impl PostRow {
.collect::<Vec<_>>()
});
let uri = format!("at://{did}/{collection}/{rkey}");
let handle = pds_handle
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_default();
Self {
uri,
did: did.to_string(),
handle: String::new(),
handle,
rkey: rkey.to_string(),
collection: collection.to_string(),
text,
@@ -263,30 +281,57 @@ impl PostRow {
embed,
langs,
created_at,
// Avatar CID is populated later by the upsert path via a
// `SELECT avatar_cid FROM profiles WHERE did = $1` lookup,
// so a freshly indexed post starts at None. (The lookup
// happens in `upsert_post_with_avatar` below.)
avatar_cid: None,
}
}
}
/// Insert or update a post row keyed by URI. Idempotent.
///
/// `row.avatar_cid` is filled in-place with the current profile-avatar
/// CID for the row's author (from the `profiles` cache, NULL if the
/// profile hasn't been pushed yet). The ON CONFLICT clause uses
/// `COALESCE(EXCLUDED, posts)` so a backfill on a re-indexed post
/// won't overwrite an avatar we already had.
///
/// IMPORTANT: `indexed_at` is NOT touched on conflict. We deliberately
/// preserve the original insert time so the `(indexed_at, uri)` keyset
/// pagination order is stable across Jetstream replays / PDS re-syncs.
pub async fn upsert_post(db: &PgPool, row: &PostRow) -> Result<()> {
pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
if row.avatar_cid.is_none() {
// Look up the latest avatar CID for this author from the
// profiles cache (populated by the PDS push path on profile
// updates). NULL if the profile hasn't been ingested yet —
// the post will display as the initial-letter avatar until
// the user uploads one.
row.avatar_cid = sqlx::query_scalar::<_, Option<String>>(
"SELECT avatar_cid FROM profiles WHERE did = $1",
)
.bind(&row.did)
.fetch_optional(db)
.await?
.flatten();
}
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, embed, langs, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
parent_uri, root_uri, embed, langs, created_at,
avatar_cid)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (uri) DO UPDATE SET
text = EXCLUDED.text,
cid = EXCLUDED.cid,
handle = COALESCE(NULLIF(EXCLUDED.handle,''), posts.handle),
parent_uri = EXCLUDED.parent_uri,
root_uri = EXCLUDED.root_uri,
embed = EXCLUDED.embed,
langs = EXCLUDED.langs,
created_at = EXCLUDED.created_at"#,
text = EXCLUDED.text,
cid = EXCLUDED.cid,
handle = COALESCE(NULLIF(EXCLUDED.handle,''), posts.handle),
parent_uri = EXCLUDED.parent_uri,
root_uri = EXCLUDED.root_uri,
embed = EXCLUDED.embed,
langs = EXCLUDED.langs,
created_at = EXCLUDED.created_at,
avatar_cid = COALESCE(EXCLUDED.avatar_cid, posts.avatar_cid)"#,
)
.bind(&row.uri)
.bind(&row.did)
@@ -300,6 +345,7 @@ pub async fn upsert_post(db: &PgPool, row: &PostRow) -> Result<()> {
.bind(EmbedColumn(row.embed.clone()))
.bind(&row.langs)
.bind(row.created_at)
.bind(&row.avatar_cid)
.execute(db)
.await?;
Ok(())
@@ -575,14 +621,19 @@ pub async fn apply_commit(
})?;
let cid = op.cid.clone().unwrap_or_default();
let record = op.record.clone().unwrap_or(Value::Null);
let row = PostRow::from_record(
// Jetstream `commit` events don't carry the
// handle — leave it empty so the upsert
// COALESCE guard preserves the row's existing
// (or backfilled-from-identity) handle.
let mut row = PostRow::from_record(
&ev.did,
&rkey,
&collection,
&cid,
&record,
None,
);
upsert_post(db, &row).await?;
upsert_post(db, &mut row).await?;
applied = true;
} else if op.action == "delete" {
let rkey = op
@@ -702,6 +753,42 @@ pub async fn apply_commit(
}
applied = true;
}
"app.bsky.actor.profile" => {
// Jetstream carries profile records as plain
// commit ops (no separate collection). We treat
// any rkey — usually `self`, but spec allows
// rkey-rotation — as the user's authoritative
// profile and upsert into the `profiles` cache.
//
// The Jetstream `commit` envelope doesn't carry
// the handle; we look it up from the `posts`
// table (backfilled there by the `identity`
// event stream). Empty is fine — the next
// handle_sync pass will populate it.
if op.action == "create" {
let record = match op.record.clone() {
Some(r) if !r.is_null() => r,
_ => continue,
};
let handle: String = sqlx::query_scalar(
"SELECT handle FROM posts \
WHERE did = $1 AND handle <> '' \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(&ev.did)
.fetch_optional(db)
.await?
.unwrap_or_default();
upsert_profile(db, &ev.did, &handle, &record).await?;
applied = true;
} else if op.action == "delete" {
sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&ev.did)
.execute(db)
.await?;
applied = true;
}
}
_ => {
// Unrecognised collection — ignore (may happen when Jetstream
// sends something we didn't subscribe to).
@@ -825,7 +912,7 @@ mod tests {
]
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.images");
assert_eq!(embed["images"][0]["alt"], "a cat");
@@ -845,7 +932,7 @@ mod tests {
}
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.external");
assert_eq!(embed["external"]["uri"], "https://example.com");
@@ -857,7 +944,7 @@ mod tests {
"text": "no embed here",
"createdAt": "2026-07-01T12:00:00Z"
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert!(row.embed.is_none());
}
@@ -871,7 +958,7 @@ mod tests {
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
}
});
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
}
@@ -994,14 +1081,15 @@ mod tests {
]
}
});
let row = PostRow::from_record(
let mut row = PostRow::from_record(
"did:plc:embed",
"embedkey",
"app.twi.post",
"cid-embed",
&record,
None,
);
upsert_post(&db, &row).await.unwrap();
upsert_post(&db, &mut row).await.unwrap();
let embed: serde_json::Value = sqlx::query_scalar(
"SELECT embed FROM posts WHERE uri = $1",
@@ -1092,3 +1180,356 @@ mod tests {
assert_eq!(count, 0);
}
}
/// Backfill the `posts.handle` column for every row belonging to
/// `did`. Used by the Jetstream `identity` handler when Jetstream
/// tells us a DID's handle has changed — every existing post row
/// needs the new value.
///
/// **Race-safety**: this only writes if the row's current handle is
/// empty OR doesn't match, so concurrent PDS pushes (which carry
/// the same handle) and concurrent identity replays don't fight.
/// The `WHERE handle IS DISTINCT FROM $1` makes the update a
/// no-op when the value is already correct, which Postgres treats
/// cheaply.
///
/// Returns the number of rows updated.
pub async fn backfill_handle(
db: &PgPool,
did: &str,
new_handle: &str,
) -> Result<u64> {
let res = sqlx::query(
"UPDATE posts SET handle = $1 WHERE did = $2 AND handle IS DISTINCT FROM $1",
)
.bind(new_handle)
.bind(did)
.execute(db)
.await?;
Ok(res.rows_affected())
}
/// Upsert a `profiles` row for `did`. The caller has just ingested
/// the profile record body (decoded CBOR), and the denormalised
/// counts are computed here (a single `SELECT COUNT(*)` over each
/// side-table — cheap with the existing PK indexes on `posts.did` and
/// `follows.{follower,subject}_did`).
pub async fn upsert_profile(
db: &PgPool,
did: &str,
handle: &str,
record: &Value,
) -> Result<()> {
let display_name = record
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string);
let description = record
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string);
let avatar_cid = blob_link_of(record, "avatar");
let banner_cid = blob_link_of(record, "banner");
// Denormalised counts. Cheap with the existing PKs.
let post_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM posts WHERE did = $1 \
AND collection IN ('app.twi.post','app.bsky.feed.post')",
)
.bind(did)
.fetch_one(db)
.await?;
let follower_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE subject_did = $1",
)
.bind(did)
.fetch_one(db)
.await?;
let following_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE follower_did = $1",
)
.bind(did)
.fetch_one(db)
.await?;
sqlx::query(
r#"INSERT INTO profiles
(did, handle, display_name, description,
avatar_cid, banner_cid,
post_count, follower_count, following_count, indexed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (did) DO UPDATE SET
handle = EXCLUDED.handle,
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
avatar_cid = EXCLUDED.avatar_cid,
banner_cid = EXCLUDED.banner_cid,
post_count = EXCLUDED.post_count,
follower_count= EXCLUDED.follower_count,
following_count= EXCLUDED.following_count,
indexed_at = now()"#,
)
.bind(did)
.bind(handle)
.bind(display_name)
.bind(description)
.bind(avatar_cid)
.bind(banner_cid)
.bind(post_count)
.bind(follower_count)
.bind(following_count)
.execute(db)
.await?;
Ok(())
}
/// Pull a blob-ref `$link` out of a profile record's field.
/// Accepts both the modern shape
/// (`{ $type: "blob", ref: { $link: "..." } }`)
/// and the legacy shape (`{ $link: "..." }`) for robustness.
fn blob_link_of(record: &Value, field: &str) -> Option<String> {
let v = record.get(field)?;
// Try `ref.$link` first, then flat `$link`.
if let Some(link) = v.get("ref").and_then(|r| r.get("$link")).and_then(|s| s.as_str()) {
return Some(link.to_string());
}
v.get("$link").and_then(|s| s.as_str()).map(str::to_string)
}
#[cfg(test)]
mod profile_tests {
use super::*;
use serde_json::json;
/// Open the appview DB used by integration tests, running
/// migrations first. Returns `None` when no DB is reachable so
/// the test can `eprintln!` and bail (no panic).
async fn try_test_db() -> Option<PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match timeout(
Duration::from_secs(2),
sqlx::PgPool::connect(&url),
)
.await
{
Ok(Ok(pool)) => match sqlx::migrate!("../../migrations/appview").run(&pool).await {
Ok(()) => Some(pool),
Err(_) => None,
},
_ => None,
}
}
#[test]
fn blob_link_of_modern_shape() {
let rec = json!({
"avatar": {
"$type": "blob",
"ref": { "$link": "bafyavatar" },
"mimeType": "image/png",
"size": 1234
}
});
assert_eq!(blob_link_of(&rec, "avatar").as_deref(), Some("bafyavatar"));
}
#[test]
fn blob_link_of_legacy_flat_link() {
let rec = json!({ "banner": { "$link": "bafybanner" } });
assert_eq!(blob_link_of(&rec, "banner").as_deref(), Some("bafybanner"));
}
#[test]
fn blob_link_of_missing_field() {
let rec = json!({ "displayName": "x" });
assert_eq!(blob_link_of(&rec, "avatar"), None);
}
#[tokio::test]
async fn upsert_profile_round_trip() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Use a unique DID per test run so we don't collide with the
// migration backfill (which seeded a row for every distinct
// DID in `posts`).
let did = format!(
"did:plc:profile_test_{}",
uuid::Uuid::new_v4().simple()
);
let handle = format!("user.{}.test", uuid::Uuid::new_v4().simple());
// Seed a couple of posts so post_count is non-zero.
for rkey in &["p1", "p2"] {
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,$3,$4,'app.twi.post','seed','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(format!("at://{did}/app.twi.post/{rkey}"))
.bind(&did)
.bind(&handle)
.bind(rkey)
.execute(&db)
.await
.unwrap();
}
let record = json!({
"displayName": "Alice",
"description": "tester",
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } },
"banner": { "$type": "blob", "ref": { "$link": "bafybanner" } }
});
upsert_profile(&db, &did, &handle, &record).await.unwrap();
let row: (
String, // handle
Option<String>, // display_name
Option<String>, // description
Option<String>, // avatar_cid
Option<String>, // banner_cid
i64, // post_count
i64, // follower_count
i64, // following_count
) = sqlx::query_as(
"SELECT handle, display_name, description, avatar_cid, banner_cid, \
post_count, follower_count, following_count \
FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0, handle);
assert_eq!(row.1.as_deref(), Some("Alice"));
assert_eq!(row.2.as_deref(), Some("tester"));
assert_eq!(row.3.as_deref(), Some("bafyavatar"));
assert_eq!(row.4.as_deref(), Some("bafybanner"));
assert_eq!(row.5, 2, "post_count must reflect seeded posts");
// Update: change display name, drop banner — verify replace
// semantics (NULL fields overwrite, not coalesce).
let record2 = json!({ "displayName": "Alice 2" });
upsert_profile(&db, &did, &handle, &record2).await.unwrap();
let name: Option<String> = sqlx::query_scalar(
"SELECT display_name FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(name.as_deref(), Some("Alice 2"));
// Cleanup.
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// `apply_commit` must dispatch an `app.bsky.actor.profile`
/// create op into the `profiles` cache (this is the path Jetstream
/// uses for third-party PDS authors).
#[tokio::test]
async fn apply_commit_indexes_profile_create() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!(
"did:plc:profile_commit_{}",
uuid::Uuid::new_v4().simple()
);
let _ = sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
// Seed a post so the indexer can find a known handle.
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,$3,'seed','app.twi.post','hi','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(format!("at://{did}/app.twi.post/seed"))
.bind(&did)
.bind("alice.test")
.execute(&db)
.await
.unwrap();
let commit = json!({
"operation": "create",
"collection": "app.bsky.actor.profile",
"rkey": "self",
"cid": "bafyprofilecid",
"record": {
"displayName": "Alice",
"description": "hello",
"avatar": { "$type": "blob", "ref": { "$link": "bafyavatar" } }
}
});
let ev = JetstreamEvent {
did: did.clone(),
time_us: 1_700_000_000_000_000,
kind: "commit".into(),
commit: Some(commit),
identity: None,
account: None,
};
let applied = apply_commit(&db, &ev).await.unwrap();
assert!(applied);
let row: (Option<String>, Option<String>, Option<String>) = sqlx::query_as(
"SELECT display_name, description, avatar_cid \
FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("Alice"));
assert_eq!(row.1.as_deref(), Some("hello"));
assert_eq!(row.2.as_deref(), Some("bafyavatar"));
// Delete op should wipe the row.
let del = json!({
"operation": "delete",
"collection": "app.bsky.actor.profile",
"rkey": "self"
});
let ev_del = JetstreamEvent {
did: did.clone(),
time_us: 1_700_000_001_000_000,
kind: "commit".into(),
commit: Some(del),
identity: None,
account: None,
};
apply_commit(&db, &ev_del).await.unwrap();
let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM profiles WHERE did = $1",
)
.bind(&did)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(remaining, 0, "delete op must remove profile row");
// Cleanup.
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
}
+43 -2
View File
@@ -30,6 +30,13 @@ use tracing::{info, warn};
#[derive(Debug, Deserialize)]
pub struct IngestCommitReq {
pub did: String,
/// The poster's current handle, as known by the PDS `users` table.
/// Optional in the wire payload — the AppView falls back to an
/// empty string, and the upsert COALESCE guard prevents the empty
/// value from clobbering a backfilled handle from the Jetstream
/// `identity` event path.
#[serde(default)]
pub handle: Option<String>,
pub collection: String,
pub action: String,
pub rkey: String,
@@ -121,14 +128,15 @@ async fn apply(
.clone()
.unwrap_or_else(|| serde_json::json!({"text": "", "createdAt": chrono::Utc::now().to_rfc3339()}));
let cid = req.cid.clone().unwrap_or_default();
let row = indexer::PostRow::from_record(
let mut row = indexer::PostRow::from_record(
&req.did,
&req.rkey,
&req.collection,
&cid,
&record,
req.handle.as_deref(),
);
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
indexer::upsert_post(&state.db, &mut row).await.map_err(db_err)?;
Ok(true)
}
("app.twi.post", "delete") | ("app.bsky.feed.post", "delete") => {
@@ -211,6 +219,39 @@ async fn apply(
.map_err(db_err)?;
Ok(true)
}
("app.bsky.actor.profile", "create") if req.rkey == "self" => {
// Profile record push from the PDS — populate the
// `profiles` cache so the ProfileView-Page and PostCard
// avatar get the new display name / bio / avatar / banner
// without waiting for the next handle-sync pass.
let record = match &req.record {
Some(r) if !r.is_null() => r.clone(),
_ => return Ok(false),
};
// Use the handle the PDS provided when present. We
// deliberately do NOT fall back to a DB lookup here:
// the AppView has no `users` table — the PDS owns that
// state. If the PDS omits the handle, we write an empty
// string and the `handle_sync` worker (or a subsequent
// Jetstream `identity` event) will fill it in.
let handle = req
.handle
.clone()
.filter(|h| !h.is_empty())
.unwrap_or_default();
indexer::upsert_profile(&state.db, &req.did, &handle, &record)
.await
.map_err(db_err)?;
Ok(true)
}
("app.bsky.actor.profile", "delete") if req.rkey == "self" => {
sqlx::query("DELETE FROM profiles WHERE did = $1")
.bind(&req.did)
.execute(&state.db)
.await
.map_err(db_err)?;
Ok(true)
}
(coll, action) => {
// Unrecognised collection/action — return ok=false so the PDS
// doesn't retry. Future collections should be added above.
+19
View File
@@ -86,8 +86,27 @@ async fn main() -> Result<()> {
cfg.plc_directory_url.clone(),
));
let web: Arc<dyn DidHandleResolver> = Arc::new(at_identity::WebResolver::new());
// PDS-local handle resolver. The handle_sync worker consults it
// first, before the public PLC/web resolvers, so `did:key:`
// users (and any other DID the operator hosts on this PDS) get their
// local handle without a round trip to plc.directory. A 404 from
// the PDS falls through to the public resolvers.
//
// Use the cluster-internal URL when configured (e.g. `http://pds:3000`
// inside docker compose) — `pds_public_url` may not be reachable
// from inside the cluster when TLS / DNS is set up for outside
// clients only.
let pds_base_url = cfg
.pds_internal_url
.clone()
.unwrap_or_else(|| cfg.pds_public_url.clone());
let pds_resolver: Arc<dyn at_identity::DidHandleResolver> = Arc::new(
at_identity::pds_handle::PdsHandleResolver::new(pds_base_url),
);
let handle_sync = handle_sync::HandleSyncWorker {
db: db.clone(),
pds_resolver,
plc_resolver: plc,
web_resolver: web,
interval_secs: cfg.appview_handle_sync_interval_secs,
+102 -10
View File
@@ -22,6 +22,7 @@ use axum::{
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tower_http::cors::{Any, CorsLayer};
use crate::state::AppState;
@@ -31,6 +32,21 @@ pub mod types;
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
pub fn router(state: AppState) -> Router {
// CORS: the Tauri webview's origin is the Vite dev server
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
// `asset://` origin in production. Either way it's a cross-origin
// fetch against this service's `http://127.0.0.1:2584` listen
// address, so the browser blocks the response without an explicit
// allow-origin header. We allow any origin — the AppView's
// public read endpoints (`/api/...`) carry no auth cookie and
// the AppView runs alongside the user's own PDS, not on the
// open internet; production deployments behind a reverse proxy
// can tighten this via the proxy itself.
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.route("/", get(root))
.route("/api/timeline/home", get(timeline_home))
@@ -40,6 +56,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/post/*uri", get(post_by_uri))
.route("/healthz", get(healthz))
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
.layer(cors)
.with_state(state)
}
@@ -343,25 +360,59 @@ async fn resolve_profile(
// Order by `indexed_at DESC` so we get the most recent DID for
// this handle (a single user can re-use a handle if account
// history allows, but the latest is the active one).
sqlx::query_scalar::<_, String>(
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1",
//
// Prefer the `profiles` cache over `posts` — a user can have
// a profile row (set via PDS push before posting) but no posts
// yet, and we want the profile page to render with the right
// DID rather than synthesise an empty one.
let row = sqlx::query_scalar::<_, String>(
"SELECT did FROM profiles WHERE LOWER(handle) = LOWER($1) \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(h)
.fetch_optional(&state.db)
.await
.map_err(db_err)?
.map_err(db_err)?;
if row.is_some() {
row
} else {
sqlx::query_scalar::<_, String>(
"SELECT did FROM posts WHERE handle = $1 \
ORDER BY indexed_at DESC LIMIT 1",
)
.bind(h)
.fetch_optional(&state.db)
.await
.map_err(db_err)?
}
} else {
None
};
let Some(target_did) = target_did else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": "NotFound",
"message": "no DID or handle provided, or no posts for that handle",
})),
));
// No posts indexed for this handle yet — typical for
// local-PDS users whose posts haven't been ingested into
// the Jetstream yet (and the AppView indexes only ingested
// posts, never queries upstream PDSs for handle→DID
// resolution). Instead of bubbling a 404 to the UI which
// then shows an error toast instead of an empty profile,
// synthesise a profile row with the requested handle and
// zero counts. The `did` is left empty; the UI's
// `displayHandle()` falls back to the handle and the
// "copy did" button just copies an empty string.
let display = handle_clean.unwrap_or_default();
return Ok(Json(ProfileResponse {
did: String::new(),
handle: display,
posts: Vec::new(),
followers: 0,
following: 0,
display_name: None,
description: None,
avatar_cid: None,
banner_cid: None,
post_count: 0,
}));
};
// Fetch the user's most recent posts (newest first). We return up to
@@ -416,12 +467,51 @@ async fn resolve_profile(
.await
.map_err(db_err)?;
// Look up the denormalised profile metadata for this DID. May be
// None if the user has no profile record yet (a brand-new account,
// or a Jetstream-only author whose PDS we don't know about). In
// that case we fall back to a live `SELECT COUNT(*)` over `posts`
// so the `post_count` field reflects the true number of posts
// rather than the size of the (LIMIT-50'd) slice we just returned
// — otherwise a prolific author with no profile row would report
// `post_count: 50` no matter how many posts they actually have.
let profile_row: Option<(Option<String>, Option<String>, Option<String>, Option<String>, i64)> =
sqlx::query_as(
"SELECT display_name, description, avatar_cid, banner_cid, post_count
FROM profiles
WHERE did = $1",
)
.bind(&target_did)
.fetch_optional(&state.db)
.await
.map_err(db_err)?;
let (display_name, description, avatar_cid, banner_cid, post_count) = match profile_row {
Some(row) => row,
None => {
let real_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM posts \
WHERE did = $1 \
AND collection IN ('app.twi.post','app.bsky.feed.post')",
)
.bind(&target_did)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
(None, None, None, None, real_count)
}
};
Ok(Json(ProfileResponse {
did: target_did,
handle: display_handle,
posts,
followers,
following,
display_name,
description,
avatar_cid,
banner_cid,
post_count,
}))
}
@@ -819,6 +909,7 @@ mod tests {
created_at: Utc::now(),
like_count: 0,
repost_count: 0,
avatar_cid: None,
},
PostRow {
uri: "at://x/app.twi.post/2".into(),
@@ -835,6 +926,7 @@ mod tests {
created_at: Utc::now(),
like_count: 0,
repost_count: 0,
avatar_cid: None,
},
];
decorate_handles(&mut rows);
+27
View File
@@ -54,6 +54,12 @@ pub struct PostRow {
pub like_count: i64,
#[serde(default)]
pub repost_count: i64,
/// Resolved author-avatar CID from the `profiles` cache. NULL
/// until the user has pushed a profile through the PDS path. The
/// PostCard uses this to render an <Avatar cid={post.avatar_cid}/>
/// inline without a per-row PDS round trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_cid: Option<String>,
}
/// Raw `FromRow` impl — we read `embed` as the helper newtype then
@@ -76,6 +82,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRow {
created_at: row.try_get("created_at")?,
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
})
}
}
@@ -100,6 +107,8 @@ pub struct PostRowWithIndexed {
pub indexed_at: DateTime<Utc>,
pub like_count: i64,
pub repost_count: i64,
/// Resolved author-avatar CID from the `profiles` cache.
pub avatar_cid: Option<String>,
}
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
@@ -121,6 +130,7 @@ impl<'r> FromRow<'r, sqlx::postgres::PgRow> for PostRowWithIndexed {
indexed_at: row.try_get("indexed_at")?,
like_count: row.try_get::<i64, _>("like_count").unwrap_or(0),
repost_count: row.try_get::<i64, _>("repost_count").unwrap_or(0),
avatar_cid: row.try_get::<Option<String>, _>("avatar_cid").ok().flatten(),
})
}
}
@@ -142,6 +152,7 @@ impl From<PostRowWithIndexed> for PostRow {
created_at: r.created_at,
like_count: r.like_count,
repost_count: r.repost_count,
avatar_cid: r.avatar_cid,
}
}
}
@@ -162,6 +173,22 @@ pub struct ProfileResponse {
pub posts: Vec<PostRow>,
pub followers: i64,
pub following: i64,
/// Denormalised profile metadata from the `profiles` cache.
/// Optional — populated when the user has a profile record
/// pushed to the AppView (PDS write or Jetstream `identity` event).
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_cid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner_cid: Option<String>,
/// Denormalised count of the user's posts (computed by
/// `upsert_profile` from the `posts` table). Lets the
/// ProfileView-Page render without an extra `COUNT(*)`.
#[serde(default)]
pub post_count: i64,
}
/// `GET /api/search` response. `q` echoes the search string so the
@@ -67,13 +67,14 @@ impl DidHandleResolver for StubResolver {
}
}
/// Build a worker whose PLC and web resolvers are both the same stub.
/// The integration tests in this file don't care which method the
/// DID uses — the stub answers for any prefix.
/// Build a worker whose PDS, PLC and web resolvers are all the same
/// stub. The integration tests in this file don't care which method
/// the DID uses — the stub answers for any prefix.
fn worker_with(db: sqlx::PgPool, stub: Arc<StubResolver>) -> HandleSyncWorker {
let r: Arc<dyn DidHandleResolver> = stub;
HandleSyncWorker {
db,
pds_resolver: Arc::clone(&r),
plc_resolver: Arc::clone(&r),
web_resolver: Arc::clone(&r),
interval_secs: 999,
@@ -389,6 +390,7 @@ async fn sync_resolves_did_web_via_web_resolver() {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
@@ -442,6 +444,7 @@ async fn sync_resolves_did_plc_via_plc_resolver() {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
+130
View File
@@ -7,6 +7,64 @@ use crate::cid::cid_for_cbor;
#[allow(unused_imports)]
use crate::did_key::verifying_key_to_multibase;
/// Deterministic `did:plc:<base32(sha256(dag-cbor(op)))>`.
///
/// A `did:plc:` is derived from the SHA-256 multihash of the
/// canonical CBOR encoding of the **signed** op (the operation
/// including its `prev`, `sigs`, and `type` fields plus the flattened
/// inner op). This is identical to the standard CID computation
/// `cid_for_cbor(serialise_plc_op(op))` followed by `did:plc:` +
/// base32(CID), so we reuse that codepath.
///
/// Stable for a given (prev, sigs, op) triple. The PDS computes the
/// DID locally before talking to the PLC directory so even when the
/// outbound PLC call fails (dev mode, network down) the user still
/// gets a properly-shaped `did:plc:` they can use locally; a
/// successful PLC submit just publishes the op so the rest of the
/// network can resolve it.
///
/// See <https://github.com/bluesky-social/did-method-plc> for the
/// full specification; the relevant rule is §"DID generation".
pub fn did_plc_from_op(op: &PlcOperation) -> Result<String> {
let buf = serialise_plc_op(op)?;
let cid = cid_for_cbor(&buf)?;
Ok(format!("did:plc:{}", cid))
}
/// Canonical dag-cbor encoding of a PLC op. Used for both signing
/// (the inner op only — `sigs` is computed on this payload) and
/// DID generation (the full op including `sigs`).
///
/// Field ordering matters: dag-cbor canonical encoding sorts map
/// keys lexicographically, so the resulting byte string is
/// deterministic for a semantically-equal op regardless of how
/// the producer ordered its fields.
pub fn serialise_plc_op(op: &PlcOperation) -> Result<Vec<u8>> {
let value = match op {
PlcOperation::Tombstone { prev } => json!({
"prev": prev,
"type": "plc_tombstone",
}),
PlcOperation::Op {
prev,
sigs,
op: inner,
} => json!({
"type": inner.op_type,
"identifier": inner.identifier,
"rotationKeys": inner.rotation_keys,
"verificationMethods": inner.verification_methods,
"alsoKnownAs": inner.also_known_as,
"services": inner.services,
"prev": prev,
"sigs": sigs,
}),
};
let mut buf = Vec::new();
ciborium::into_writer(&value, &mut buf)?;
Ok(buf)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PlcOperation {
@@ -130,4 +188,76 @@ mod tests {
assert_eq!(identifier, "alice.maarcadetweet.local");
assert!(serialized.get("sigs").is_some());
}
/// `did_plc_from_op` must be deterministic for the same op.
/// Two calls with the same args return the same DID.
#[test]
fn did_plc_is_deterministic() {
let sk = SecretKey::from_slice(&[7u8; 32]).unwrap();
let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap();
let signing = SigningKey::from(sk);
let op1 = PlcOperation::create(
"alice.maarcadetweet.local",
&signing,
&rot_mb,
"https://pds.example",
)
.unwrap();
let op2 = PlcOperation::create(
"alice.maarcadetweet.local",
&signing,
&rot_mb,
"https://pds.example",
)
.unwrap();
let did1 = did_plc_from_op(&op1).unwrap();
let did2 = did_plc_from_op(&op2).unwrap();
assert_eq!(did1, did2, "DID must be deterministic for identical ops");
assert!(did1.starts_with("did:plc:"));
// CID v1 with sha256 and base32-lower should produce a string
// starting with "b" (the standard cid v1 prefix for sha256).
let suffix = did1.strip_prefix("did:plc:").unwrap();
assert!(suffix.starts_with('b'), "expected CIDv1 prefix, got {suffix}");
}
/// Different handles → different DIDs.
#[test]
fn did_plc_differs_per_handle() {
let sk = SecretKey::from_slice(&[11u8; 32]).unwrap();
let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap();
let signing = SigningKey::from(sk);
let op_alice = PlcOperation::create(
"alice.maarcadetweet.local",
&signing,
&rot_mb,
"https://pds.example",
)
.unwrap();
let op_bob = PlcOperation::create(
"bob.maarcadetweet.local",
&signing,
&rot_mb,
"https://pds.example",
)
.unwrap();
let did_alice = did_plc_from_op(&op_alice).unwrap();
let did_bob = did_plc_from_op(&op_bob).unwrap();
assert_ne!(did_alice, did_bob, "different handles must yield different DIDs");
}
/// Tombstone ops must also produce a `did:plc:` (the spec says
/// `plc_tombstone` operations are valid signed ops whose CID is
/// derived the same way).
#[test]
fn did_plc_tombstone_round_trip() {
let tomb = PlcOperation::Tombstone { prev: None };
let did = did_plc_from_op(&tomb).unwrap();
assert!(did.starts_with("did:plc:"));
}
}
+2
View File
@@ -1,7 +1,9 @@
pub mod handle;
pub mod pds_handle;
pub mod plc;
pub mod web;
pub use handle::{resolve_handle, DidHandleResolver, HandleResolver};
pub use pds_handle::PdsHandleResolver;
pub use plc::{submit_op, PlcClient};
pub use web::WebResolver;
+184
View File
@@ -0,0 +1,184 @@
//! PDS-first handle resolver.
//!
//! The local PDS is the authoritative source for `did:key:` users (and
//! for any DID the operator hosts on this PDS). The `AppView`'s
//! `handle_sync` worker consults this resolver *before* falling through
//! to the public PLC directory / `did:web:` HTTPS resolver, so local
//! users get their handle without ever dialing out to plc.directory.
//!
//! ### Wire shape
//!
//! PDS endpoint: `POST /xrpc/com.atproto.identity.resolveHandle`
//! with body `{ "handle": "<did>" }`. The PDS's handler is
//! polymorphic on the `handle` field: if it starts with `did:`
//! the PDS looks the row up by `did` (PK), otherwise by `handle`.
//! On success the PDS returns `{ "did": "...", "handle": "..." }`
//! — we read the `handle` field, which is what the worker
//! actually needs to fill `posts.handle`. A 404 means the DID
//! isn't hosted here, and the worker falls through to PLC/Web.
//!
//! Network: `reqwest::Client` with a 10 s timeout. The same client
//! is reused across requests — handle with `Arc<PdsHandleResolver>`
//! in the worker.
use crate::handle::DidHandleResolver;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
pub struct PdsHandleResolver {
pub base_url: String,
pub client: reqwest::Client,
}
impl PdsHandleResolver {
pub fn new(base_url: impl Into<String>) -> Self {
// 2 s is plenty for a colocated PDS (typical round-trip
// < 100 ms in dev) but bounds the per-DID cost when the
// PDS is unreachable — at 100 DIDs/pass that caps a
// single pass at ~2 s with parallel dispatch, vs the
// ~17 min worst-case the old 10 s timeout allowed with
// serial dispatch.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("reqwest client build should never fail");
Self {
base_url: base_url.into(),
client,
}
}
}
#[async_trait]
impl DidHandleResolver for PdsHandleResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
// POST /xrpc/com.atproto.identity.resolveHandle with
// { "handle": "<did>" } in the body. The PDS's handler
// recognises a `did:` prefix and does a PK lookup on
// `users.did`, returning `{ did, handle }` on match.
let url = format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
self.base_url
);
let r = self
.client
.post(&url)
.json(&json!({ "handle": did }))
.send()
.await?;
if r.status().as_u16() == 404 {
return Ok(None);
}
if !r.status().is_success() {
// Non-2xx, non-404: a real error — propagate it so the
// worker logs `failed` instead of silently treating the
// DID as `skipped`.
anyhow::bail!(
"pds handle resolver returned {}",
r.status()
);
}
let v: serde_json::Value = r.json().await?;
// The PDS returns `{ "did": "...", "handle": "..." }` on
// success. We read `handle` (what we actually want for
// `posts.handle`) and ignore the echoed `did`.
Ok(v.get("handle").and_then(|x| x.as_str()).map(String::from))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::net::SocketAddr;
use tokio::net::TcpListener;
/// Spawn a one-shot HTTP listener that responds to the
/// resolveHandle XRPC call with the configured body + status.
/// Returns the bound address (so the resolver under test can hit
/// `http://127.0.0.1:<port>`).
async fn spawn_stub(
status: u16,
body: serde_json::Value,
) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
let (mut sock, _) = match listener.accept().await {
Ok(p) => p,
Err(_) => break,
};
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
if n == 0 {
continue;
}
let body_s = body.to_string();
let resp = format!(
"HTTP/1.1 {status} {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n{body_s}",
status_text(status),
body_s.len(),
);
let _ = sock.write_all(resp.as_bytes()).await;
}
});
addr
}
fn status_text(s: u16) -> &'static str {
match s {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Status",
}
}
#[tokio::test]
async fn returns_handle_on_match() {
let addr = spawn_stub(
200,
json!({ "did": "did:plc:abc", "handle": "alice.bsky" }),
)
.await;
let r = PdsHandleResolver::new(format!("http://{addr}"));
let h = r.resolve_handle("did:plc:abc").await.unwrap();
assert_eq!(h.as_deref(), Some("alice.bsky"));
}
#[tokio::test]
async fn returns_none_on_404() {
let addr = spawn_stub(404, json!({ "error": "NotFound" })).await;
let r = PdsHandleResolver::new(format!("http://{addr}"));
let h = r.resolve_handle("did:plc:unknown").await.unwrap();
assert!(h.is_none());
}
#[tokio::test]
async fn returns_err_on_5xx() {
let addr = spawn_stub(500, json!({ "error": "oops" })).await;
let r = PdsHandleResolver::new(format!("http://{addr}"));
let result = r.resolve_handle("did:plc:abc").await;
assert!(
result.is_err(),
"5xx must propagate as Err so the worker logs `failed`, not `skipped`"
);
}
#[tokio::test]
async fn returns_handle_even_when_did_field_missing() {
// Some PDS implementations might return `{ "handle": "x" }`
// without echoing the DID. We must still extract the handle.
let addr = spawn_stub(200, json!({ "handle": "bob.bsky" })).await;
let r = PdsHandleResolver::new(format!("http://{addr}"));
let h = r.resolve_handle("did:plc:bob").await.unwrap();
assert_eq!(h.as_deref(), Some("bob.bsky"));
}
}
+15 -8
View File
@@ -66,18 +66,25 @@ impl MstNode {
// -- CBOR wire format ----------------------------------------------------
//
// The MST node wire format is a plain (non-optimised) DAG-CBOR object:
// Per the atproto MST spec (datamodel-repo#node-data), each MST
// node is a DAG-CBOR object:
//
// {
// "l": <CID> | null,
// "e": [ { "k": "...", "v": <CID>, "t": <CID> | null }, ... ]
// "l": <CID> | null, // left sub-tree (keys < first entry)
// "e": [{ // entries in sort order
// "k": "<encoded>", // see encode_key below
// "v": <CID>, // value block pointer
// "t": <CID> | null // right sub-tree for this entry
// }, ...]
// }
//
// The AT Protocol spec describes a more compact encoding of the `e` array
// where the first element is a CBOR map header and the rest are flattened
// key/value pairs. For this implementation we use the plain array-of-objects
// encoding. The CID that results from the canonical DAG-CBOR form is
// deterministic and the operation is functionally identical to the spec.
// Optional fields (`t`) are CBOR-omitted via `serde(skip_serializing_if)`.
// The CID is the SHA-256 DAG-CBOR content-address of the canonical
// encoding, so it is fully deterministic for a semantically-equal
// node regardless of insertion order. (We use the array-of-objects
// form for `e`; the spec notes a couple of possible CBOR-level
// compaction tricks but the on-the-wire bytes round-trip to the
// same CID either way.)
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct WireNode {
+215 -104
View File
@@ -107,13 +107,18 @@ impl Mst {
// -- core reads ------------------------------------------------------
/// Returns the value CID associated with `raw_key`, or `None` if the key
/// is not present in the tree.
/// Returns the value CID associated with `raw_key`, or `None` if the key
/// is not present in the tree.
pub fn get(&self, raw_key: &str) -> Result<Option<Cid>> {
let Some(root) = self.root else {
return Ok(None);
};
self.get_in_tree(root, raw_key.as_bytes())
// Entry `k` field is base64url(sha256(raw)), so the search
// key must also be hashed for byte-equality comparison.
let key_hash = crate::util::hash_key(raw_key);
self.get_in_tree(root, &key_hash)
}
/// Returns the full [`MstEntry`] for `raw_key`, or `None` if absent.
@@ -121,67 +126,85 @@ impl Mst {
let Some(root) = self.root else {
return Ok(None);
};
self.get_entry_in_tree(root, raw_key.as_bytes())
let key_hash = crate::util::hash_key(raw_key);
self.get_entry_in_tree(root, &key_hash)
}
fn get_in_tree(&self, cid: Cid, key: &[u8]) -> Result<Option<Cid>> {
fn get_in_tree(&self, cid: Cid, key_hash: &[u8]) -> Result<Option<Cid>> {
let (left, entries) = self.load_node(cid)?;
eprintln!("GET cid={} entries={} left={}", &cid.to_string()[..8], entries.len(), left.is_some());
if entries.is_empty() {
return match left {
Some(sub) => self.get_in_tree(sub, key),
None => Ok(None),
Some(sub) => self.get_in_tree(sub, key_hash),
None => {
eprintln!(" -> entries empty, no left, None");
Ok(None)
}
};
}
let first_key = decode_key(&entries[0].key)?;
match key.cmp(first_key.as_slice()) {
Ordering::Less => match left {
Some(sub) => self.get_in_tree(sub, key),
None => Ok(None),
},
Ordering::Equal => Ok(Some(entries[0].value)),
let ord = key_hash.cmp(first_key.as_slice());
eprintln!(" cmp={:?} (search bytes fxs={:?})", ord, &key_hash[..4]);
match ord {
Ordering::Less => {
eprintln!(" Less → descend left");
match left {
Some(sub) => self.get_in_tree(sub, key_hash),
None => Ok(None),
}
}
Ordering::Equal => {
eprintln!(" Equal → return entries[0].value");
Ok(Some(entries[0].value))
}
Ordering::Greater => {
eprintln!(" Greater → scan remaining entries");
for i in 1..entries.len() {
let ek = decode_key(&entries[i].key)?;
match key.cmp(ek.as_slice()) {
Ordering::Less => match entries[i - 1].tree {
Some(sub) => return self.get_in_tree(sub, key),
None => return Ok(None),
},
Ordering::Equal => return Ok(Some(entries[i].value)),
match key_hash.cmp(ek.as_slice()) {
Ordering::Less => {
eprintln!(" Less at i={} → descend entries[{}].tree", i, i - 1);
match entries[i - 1].tree {
Some(sub) => return self.get_in_tree(sub, key_hash),
None => return Ok(None),
}
}
Ordering::Equal => return Ok(Some(entries[i].value.clone())),
Ordering::Greater => continue,
}
}
eprintln!(" past last → last.tree={:?}", entries.last().and_then(|e| e.tree));
match entries.last().and_then(|e| e.tree) {
Some(sub) => self.get_in_tree(sub, key),
Some(sub) => self.get_in_tree(sub, key_hash),
None => Ok(None),
}
}
}
}
fn get_entry_in_tree(&self, cid: Cid, key: &[u8]) -> Result<Option<MstEntry>> {
fn get_entry_in_tree(&self, cid: Cid, key_hash: &[u8]) -> Result<Option<MstEntry>> {
let (left, entries) = self.load_node(cid)?;
if entries.is_empty() {
return match left {
Some(sub) => self.get_entry_in_tree(sub, key),
Some(sub) => self.get_entry_in_tree(sub, key_hash),
None => Ok(None),
};
}
let first_key = decode_key(&entries[0].key)?;
match key.cmp(first_key.as_slice()) {
match key_hash.cmp(first_key.as_slice()) {
Ordering::Less => match left {
Some(sub) => self.get_entry_in_tree(sub, key),
Some(sub) => self.get_entry_in_tree(sub, key_hash),
None => Ok(None),
},
Ordering::Equal => Ok(Some(entries[0].clone())),
Ordering::Greater => {
for i in 1..entries.len() {
let ek = decode_key(&entries[i].key)?;
match key.cmp(ek.as_slice()) {
match key_hash.cmp(ek.as_slice()) {
Ordering::Less => match entries[i - 1].tree {
Some(sub) => return self.get_entry_in_tree(sub, key),
Some(sub) => return self.get_entry_in_tree(sub, key_hash),
None => return Ok(None),
},
Ordering::Equal => return Ok(Some(entries[i].clone())),
@@ -290,7 +313,8 @@ impl Mst {
for k in keys {
let raw_key = k.as_ref();
let path = self.collect_proof_path(root, raw_key.as_bytes())?;
let key_hash = crate::util::hash_key(raw_key);
let path = self.collect_proof_path(root, &key_hash)?;
for cid in path.blocks {
block_cids.insert(cid);
}
@@ -404,11 +428,12 @@ impl Mst {
}
}
for e in &entries {
// Decode the base64url-encoded key back to its raw form so the
// caller sees the key they inserted.
let raw = String::from_utf8(decode_key(&e.key)?)
.unwrap_or_else(|_| e.key.clone());
out.push((raw, e.value, e.tree));
// `entry.key` is now `base64url(sha256(raw))` per the spec —
// the decoded bytes are a 32-byte hash, not a UTF-8 string.
// Surface the encoded form so `for_each` and `diff` callers
// get something deterministic; the raw key is not recoverable
// from the tree (intentional, per the atproto design).
out.push((e.key.clone(), e.value, e.tree));
}
Ok(())
}
@@ -492,6 +517,7 @@ impl Mst {
let (left, entries) = Self::load_node_any(original_blocks, new_blocks, current)?;
let layer = known_zeros.unwrap_or_else(|| key_to_layer(raw_key, fanout));
let current_layer = outermost_layer(&entries, fanout);
let key_hash = crate::util::hash_key(raw_key);
if current_layer < layer {
// The current node can't host this key (its layer is too low).
@@ -508,20 +534,22 @@ impl Mst {
);
}
// Check for an existing entry to update.
let key_bytes = raw_key.as_bytes();
// Check for an existing entry to update. Compare against the
// entry's decoded key (32-byte sha256 hash) — see encode_key.
for (i, entry) in entries.iter().enumerate() {
let entry_key = decode_key(&entry.key)?;
if entry_key == key_bytes {
if entry_key == key_hash {
eprintln!("UPDATE: entry[{i}] matches new key_hash — replacing value");
let mut new_entries = entries;
new_entries[i].value = value;
new_entries[i].tree = attached_tree.or(new_entries[i].tree);
return Self::write_node(new_blocks, left.as_ref(), &new_entries);
}
}
eprintln!("no match in {} entries, continuing", entries.len());
// Find insertion position and descend.
let pos = find_position(&entries, key_bytes)?;
let pos = find_position(&entries, &key_hash)?;
let (new_left, new_entries) = match pos {
Pos::BeforeFirst => {
@@ -624,22 +652,49 @@ impl Mst {
attached_tree: Option<Cid>,
fanout: usize,
) -> Result<Cid> {
let (sub_left, sub_right) =
Self::split_around(original_blocks, new_blocks, left, &entries, raw_key, fanout)?;
// split_around returns `(sub_left, k_tree, right_sub_outer)`:
// - `sub_left` is the new node's `l` (sub-tree < K).
// - `k_tree` is the new key's `.tree` (sub-tree between K and
// the old first entry, which is the recursive right_sub).
// - `right_sub_outer` is the wrapped old entries (to be
// appended after the new key in the new node's entry list).
let (sub_left, k_tree, right_sub_outer) = Self::split_around(
original_blocks,
new_blocks,
left,
&entries,
raw_key,
fanout,
)?;
let k_entry = MstEntry::new(
encode_key(raw_key),
value,
attached_tree.or(sub_right),
);
Self::write_node(new_blocks, sub_left.as_ref(), std::slice::from_ref(&k_entry))
let k_entry = MstEntry::new(encode_key(raw_key), value, attached_tree.or(k_tree));
// New node's entry list = [k_entry, ...old_entries].
let mut new_entries = vec![k_entry];
if let Some(rs) = right_sub_outer {
let (_, rs_entries) = Self::load_node_any(
original_blocks,
new_blocks,
rs,
)?;
new_entries.extend(rs_entries);
}
Self::write_node(new_blocks, sub_left.as_ref(), &new_entries)
}
/// Split the current node around `raw_key`. Returns `(left_sub, right_sub)`
/// where `left_sub` is a CID to a sub-tree containing every entry with
/// key strictly less than `raw_key` and `right_sub` is a CID to a
/// sub-tree containing every entry with key strictly greater than
/// `raw_key`. Either may be `None` if there are no such entries.
/// Split the current node around `raw_key`. Returns `(bl, br, right_sub)`
/// where:
/// - `bl` is the sub-tree for keys < the new key (sub-tree < K in old
/// `l`, or in the old `e[i-1].tree` for the Between case).
/// - `br` is the sub-tree for keys > the new key (sub-tree > K in old
/// `l`, or in old `e[i].tree` for Between, or in old `e[last].tree`
/// for AfterLast). This goes into the new key's `.tree` in the
/// wrapping node.
/// - `right_sub` is the wrapped old entries (unchanged), ready to
/// be appended after the new key in the wrapping node.
/// Any of these may be `None` (e.g. `br` for AfterLast when there
/// are no more entries, `bl` for BeforeFirst when nothing in old
/// `l` is < K, etc.).
fn split_around(
original_blocks: &HashMap<Cid, Vec<u8>>,
new_blocks: &mut HashMap<Cid, Vec<u8>>,
@@ -647,31 +702,36 @@ impl Mst {
entries: &[MstEntry],
raw_key: &str,
fanout: usize,
) -> Result<(Option<Cid>, Option<Cid>)> {
let key_bytes = raw_key.as_bytes();
let pos = find_position(entries, key_bytes)?;
) -> Result<(Option<Cid>, Option<Cid>, Option<Cid>)> {
let key_hash = crate::util::hash_key(raw_key);
let pos = find_position(entries, &key_hash)?;
match pos {
Pos::BeforeFirst => {
let (bl, br) =
// k_tree (the new key's .tree) = the recursive
// call's right_sub. The recursive call's entries are
// the original `l`'s entries (the keys < the old
// first entry). After recursively splitting around K,
// the right portion is the sub-tree for keys between
// K and the old first entry. That's exactly what we
// want as k_tree.
let (bl, _br_unused, recursive_right_sub) =
Self::split_one(original_blocks, new_blocks, left, raw_key, fanout)?;
let k_tree = recursive_right_sub;
let right_sub = if entries.is_empty() {
br
None
} else {
let mut right_entries = entries.to_vec();
if let Some(first) = right_entries.first_mut() {
first.tree = br;
}
let right_entries = entries.to_vec();
Some(Self::write_node(new_blocks, None, &right_entries)?)
};
Ok((bl, right_sub))
Ok((bl, k_tree, right_sub))
}
Pos::Between(i) => {
let boundary = entries.get(i - 1).and_then(|e| e.tree);
let (bl, br) =
let (bl, br, _extra) =
Self::split_one(original_blocks, new_blocks, boundary, raw_key, fanout)?;
let left_sub = if entries[..i].is_empty() && left.is_none() {
bl
None
} else {
let mut left_entries = entries[..i].to_vec();
if let Some(last) = left_entries.last_mut() {
@@ -680,7 +740,7 @@ impl Mst {
Some(Self::write_node(new_blocks, left.as_ref(), &left_entries)?)
};
let right_sub = if entries[i..].is_empty() {
br
None
} else {
let mut right_entries = entries[i..].to_vec();
if let Some(first) = right_entries.first_mut() {
@@ -688,7 +748,7 @@ impl Mst {
}
Some(Self::write_node(new_blocks, None, &right_entries)?)
};
Ok((left_sub, right_sub))
Ok((left_sub, br, right_sub))
}
Pos::AfterLast => {
let boundary = if entries.is_empty() {
@@ -696,10 +756,10 @@ impl Mst {
} else {
entries.last().and_then(|e| e.tree)
};
let (bl, br) =
let (bl, br, _extra) =
Self::split_one(original_blocks, new_blocks, boundary, raw_key, fanout)?;
let left_sub = if entries.is_empty() {
bl
None
} else {
let mut left_entries = entries.to_vec();
if let Some(last) = left_entries.last_mut() {
@@ -707,7 +767,13 @@ impl Mst {
}
Some(Self::write_node(new_blocks, left.as_ref(), &left_entries)?)
};
Ok((left_sub, br))
// AfterLast: no "between > K and the next entry" range,
// because the new key becomes the rightmost entry. So
// `br` is unused for the new key's `.tree`; it would
// hold keys > old-last (which now sits at e[last] in
// the new node), i.e. > K and < nothing. The new key's
// `.tree` should be None in this case.
Ok((left_sub, None, br))
}
}
}
@@ -719,9 +785,9 @@ impl Mst {
boundary: Option<Cid>,
raw_key: &str,
fanout: usize,
) -> Result<(Option<Cid>, Option<Cid>)> {
) -> Result<(Option<Cid>, Option<Cid>, Option<Cid>)> {
let Some(cid) = boundary else {
return Ok((None, None));
return Ok((None, None, None));
};
let (b_left, b_entries) = Self::load_node_any(original_blocks, new_blocks, cid)?;
Self::split_around(original_blocks, new_blocks, b_left, &b_entries, raw_key, fanout)
@@ -736,12 +802,12 @@ impl Mst {
current: Cid,
) -> Result<Option<Cid>> {
let (left, entries) = Self::load_node_any(original_blocks, new_blocks, current)?;
let key_bytes = raw_key.as_bytes();
let key_hash = crate::util::hash_key(raw_key);
// 1. Key present at this level?
for (i, entry) in entries.iter().enumerate() {
let entry_key = decode_key(&entry.key)?;
if entry_key == key_bytes {
if entry_key == key_hash {
// We are about to remove entry i. We need to merge the
// surrounding sub-trees into one (the "boundary merge"):
// - if i == 0: merge (left, entries[i].t) → new leading tree
@@ -778,7 +844,7 @@ impl Mst {
}
let first_key = decode_key(&entries[0].key)?;
if key_bytes < first_key.as_slice() {
if key_hash.as_slice() < first_key.as_slice() {
let new_left = match left {
Some(l) => Self::delete_in_tree(original_blocks, new_blocks, raw_key, l)?,
None => return Ok(Some(current)),
@@ -788,7 +854,7 @@ impl Mst {
for i in 1..entries.len() {
let ek = decode_key(&entries[i].key)?;
if key_bytes < ek.as_slice() {
if key_hash.as_slice() < ek.as_slice() {
let prev_tree = entries[i - 1].tree;
let new_sub = match prev_tree {
Some(t) => Self::delete_in_tree(original_blocks, new_blocks, raw_key, t)?,
@@ -966,40 +1032,38 @@ enum Pos {
/// Locate the position where `key_bytes` would be inserted into `entries`,
/// expressed relative to existing entries.
fn find_position(entries: &[MstEntry], key_bytes: &[u8]) -> Result<Pos> {
if entries.is_empty() {
return Ok(Pos::AfterLast);
}
let first_key = decode_key(&entries[0].key)?;
if key_bytes < first_key.as_slice() {
return Ok(Pos::BeforeFirst);
}
for i in 1..entries.len() {
let ek = decode_key(&entries[i].key)?;
if key_bytes < ek.as_slice() {
return Ok(Pos::Between(i));
fn find_position(entries: &[MstEntry], key_hash: &[u8]) -> Result<Pos> {
if entries.is_empty() {
return Ok(Pos::AfterLast);
}
}
Ok(Pos::AfterLast)
let first_key = decode_key(&entries[0].key)?;
if key_hash < first_key.as_slice() {
return Ok(Pos::BeforeFirst);
}
for i in 1..entries.len() {
let ek = decode_key(&entries[i].key)?;
if key_hash < ek.as_slice() {
return Ok(Pos::Between(i));
}
}
Ok(Pos::AfterLast)
}
/// Outermost (i.e. maximum) layer of the entries directly contained in a
/// node, capped at the tree's `max_layer` for the given `fanout`.
///
/// Per the spec, `decode_key(&e.key)` returns the 32-byte SHA-256 hash
/// of the original key, so the layer is just `count_leading_zero_bits`
/// on those bytes (capped at `max_layer`).
fn outermost_layer(entries: &[MstEntry], fanout: usize) -> usize {
let max_layer = max_layer_for_fanout(fanout);
let mut best = 0usize;
for e in entries {
let raw = match decode_key(&e.key) {
Ok(b) => b,
let hash = match decode_key(&e.key) {
Ok(h) => h,
Err(_) => continue,
};
let raw_str = match std::str::from_utf8(&raw) {
Ok(s) => s,
Err(_) => continue,
};
let zeros = at_crypto::cid::sha256(raw_str.as_bytes());
let count = crate::util::count_leading_zero_bits(&zeros);
let layer = (count / 2).min(max_layer);
let layer = crate::util::hash_to_layer(&hash, fanout);
if layer > best {
best = layer;
}
@@ -1097,7 +1161,47 @@ mod tests {
})
.collect();
for (k, v) in &pairs {
if k == "com.example.foo/005" {
let prev_keys: std::collections::HashSet<_> = t
.collect_all()
.unwrap()
.into_iter()
.map(|(k, _, _)| k)
.collect();
eprintln!("--- BEFORE put 005, prev={:?}", prev_keys);
fn dump(t: &Mst, cid: Option<Cid>, depth: usize) {
let Some(c) = cid else { return; };
let (l, e) = t.load_node(c).unwrap();
eprintln!("{}{}", " ".repeat(depth), c);
for entry in &e {
eprintln!("{} k={}", " ".repeat(depth), entry.key);
dump(t, entry.tree, depth + 1);
}
dump(t, l, depth + 1);
}
dump(&t, t.root_cid(), 0);
}
t = t.put(k.clone(), *v, None).unwrap();
if k == "com.example.foo/005" {
let new_keys: std::collections::HashSet<_> = t
.collect_all()
.unwrap()
.into_iter()
.map(|(k, _, _)| k)
.collect();
eprintln!("--- AFTER put 005, new={:?}", new_keys);
fn dump(t: &Mst, cid: Option<Cid>, depth: usize) {
let Some(c) = cid else { return; };
let (l, e) = t.load_node(c).unwrap();
eprintln!("{}{}", " ".repeat(depth), c);
for entry in &e {
eprintln!("{} k={}", " ".repeat(depth), entry.key);
dump(t, entry.tree, depth + 1);
}
dump(t, l, depth + 1);
}
dump(&t, t.root_cid(), 0);
}
}
for (k, v) in &pairs {
assert_eq!(t.get(k).unwrap().as_ref(), Some(v), "key {k}");
@@ -1279,6 +1383,12 @@ mod tests {
#[test]
fn diff_detects_add_update_delete() {
use base64::Engine;
// With the spec-conformant key encoding, diff entries carry
// `base64url(sha256(raw_key))` rather than the raw key string.
// Decode the assertions against the encoded form.
let enc = |s: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(at_crypto::cid::sha256(s.as_bytes()));
let mut a = empty_mst();
for i in 0..5 {
a = a
@@ -1302,12 +1412,12 @@ mod tests {
let diff = a.diff(&b).unwrap();
let ops: Vec<_> = diff.iter().map(|d| (d.op, d.key.as_str())).collect();
assert!(ops.contains(&(DiffOp::Delete, "k/2")), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Update, "k/3")), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Add, "k/5")), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Add, "k/6")), "ops: {:?}", ops);
assert!(!ops.iter().any(|(_, k)| *k == "k/0"), "ops: {:?}", ops);
assert!(!ops.iter().any(|(_, k)| *k == "k/1"), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Delete, enc("k/2").as_str())), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Update, enc("k/3").as_str())), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Add, enc("k/5").as_str())), "ops: {:?}", ops);
assert!(ops.contains(&(DiffOp::Add, enc("k/6").as_str())), "ops: {:?}", ops);
assert!(!ops.iter().any(|(_, k)| *k == enc("k/0").as_str()), "ops: {:?}", ops);
assert!(!ops.iter().any(|(_, k)| *k == enc("k/1").as_str()), "ops: {:?}", ops);
}
#[test]
@@ -1316,7 +1426,11 @@ mod tests {
let raw = "did:plc:abc/xyz";
let t = empty_mst().put(raw, cid_for_str("v"), None).unwrap();
let entry = t.get_entry(raw).unwrap().expect("entry");
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes());
// Per the atproto MST spec, the `k` field is
// `base64url(sha256(raw_key_utf8))`.
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
at_crypto::cid::sha256(raw.as_bytes()),
);
assert_eq!(entry.key, expected);
}
@@ -1379,14 +1493,11 @@ mod tests {
#[test]
fn debug_10_entries_with_padded_keys() {
let mut t = empty_mst();
for i in 0..10 {
let key = format!("com.example.foo/{i:03}");
let value = cid_for_str(&format!("v{i}"));
let mut t = empty_mst();
t = t.put(key.clone(), value, None).unwrap();
}
for i in 0..10 {
let key = format!("com.example.foo/{i:03}");
assert!(
t.get(&key).unwrap().is_some(),
"key {key} should be retrievable"
+51 -8
View File
@@ -24,23 +24,66 @@ pub fn count_leading_zero_bits(hash: &[u8]) -> usize {
count
}
pub fn key_to_layer(raw_key: &str, fanout: usize) -> usize {
let hash = sha256(raw_key.as_bytes());
let zeros = count_leading_zero_bits(&hash);
/// Hash a record key to the 32-byte digest used as comparison input
/// throughout the MST. Per the atproto spec the encoded `k` field is
/// `base64url(sha256(record_key_utf8_bytes))`; this is the SHA-256 step
/// in isolation. Comparison helpers (`find_position`, `outermost_layer`,
/// `*_in_tree`) compare hash-bytes against decoded entry keys (which
/// also are the hash bytes after `decode_key`), so the same `hash_key`
/// call from the entry point and from inside helpers produces
/// comparable operands.
pub fn hash_key(raw_key: &str) -> [u8; 32] {
sha256(raw_key.as_bytes())
}
/// Layer that an already-hashed key occupies in the tree of `fanout`.
/// Use after `hash_key` to avoid hashing twice.
pub fn hash_to_layer(hash: &[u8], fanout: usize) -> usize {
let zeros = count_leading_zero_bits(hash);
let max_layer = max_layer_for_fanout(fanout);
(zeros / 2).min(max_layer)
}
pub fn encode_key(raw_key: &str) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw_key.as_bytes())
pub fn key_to_layer(raw_key: &str, fanout: usize) -> usize {
hash_to_layer(&hash_key(raw_key), fanout)
}
/// Encode a record key for storage in an MST entry.
///
/// Per the atproto MST spec
/// (<https://atproto.com/specs/data-model-repo#node-data>) the `k`
/// field is `base64url(sha256(record_key_utf8_bytes))`. Hashing
/// first ties the layer distribution to the cryptographic digest
/// of the key — under pre-image resistance, an attacker can't
/// craft keys that all land at the maximum layer by sorting their
/// bytes a certain way.
///
/// Decoding returns the raw 32-byte hash bytes; callers that need
/// the original key string have to keep it alongside. Cross-crate
/// callers passing `vec::Vec<u8>` vs `[u8; 32]` will need a trivial
/// .as_slice() conversion at the comparison site.
pub fn encode_key(raw_key: &str) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash_key(raw_key))
}
/// Inverse of [`encode_key`]: round-trip the base64url string back
/// to the 32-byte SHA-256 hash. Rejects anything that doesn't decode
/// to exactly 32 bytes — i.e. catches the old `base64url(raw_key)`
/// encoding that predates this commit, which makes it easy to spot
/// incompatibilities during migration.
pub fn decode_key(encoded: &str) -> Result<Vec<u8>> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded.as_bytes())
.map_err(|e| anyhow!("invalid base64url key `{encoded}`: {e}"))
.map_err(|e| anyhow!("invalid base64url key `{encoded}`: {e}"))?;
if bytes.len() != 32 {
return Err(anyhow!(
"decoded key `{encoded}` is {} bytes; expected 32 (sha256 hash per the atproto MST spec)",
bytes.len()
));
}
Ok(bytes)
}
#[cfg(test)]
+9
View File
@@ -28,6 +28,14 @@ pub struct AppConfig {
pub s3_bucket_pds: String,
pub s3_bucket_appview: String,
pub plc_directory_url: String,
/// Cluster-internal URL the AppView uses to reach the PDS (e.g.
/// `http://pds-server:3000`). Falls back to `pds_public_url` when
/// unset. Splitting this from `pds_public_url` lets a single
/// deployment point the AppView at the in-cluster PDS hostname
/// (which may not be reachable from outside) while clients
/// still see the public URL.
#[serde(default)]
pub pds_internal_url: Option<String>,
/// Optional shared secret for `POST /internal/ingest-commit`. If unset,
/// the endpoint accepts anonymous requests (dev mode). If set, callers
/// must send `X-Ingest-Secret: <value>`.
@@ -68,6 +76,7 @@ impl AppConfig {
s3_bucket_pds: env("S3_BUCKET_PDS")?,
s3_bucket_appview: env("S3_BUCKET_APPVIEW")?,
plc_directory_url: env("PLC_DIRECTORY_URL")?,
pds_internal_url: std::env::var("PDS_INTERNAL_URL").ok(),
appview_ingest_secret: std::env::var("APPVIEW_INGEST_SECRET").ok(),
appview_handle_sync_interval_secs: std::env::var("APPVIEW_HANDLE_SYNC_INTERVAL_SECS")
.ok()
+44 -1
View File
@@ -32,6 +32,11 @@ use std::time::Duration;
#[derive(Debug, Serialize)]
struct IngestCommitBody<'a> {
did: &'a str,
/// The poster's current handle. Optional in the wire payload —
/// the AppView's indexer treats an empty/missing handle as the
/// existing empty-string placeholder, which the Jetstream
/// `identity` event path will eventually backfill.
handle: Option<&'a str>,
collection: &'a str,
action: &'a str,
rkey: &'a str,
@@ -63,6 +68,14 @@ impl AppViewPushClient {
/// AT-Protocol record value as JSON — the AppView's indexer reads
/// `embed` / `reply` off it, which is why we can't just send the CID.
///
/// `handle` is the poster's current handle. Pass `Some(handle)` for
/// local-PDS users so the AppView's `posts.handle` column is
/// populated immediately (otherwise the timeline renders handles as
/// `@did:plc:…` snippets and the profile endpoint can't resolve
/// `handle → did`). For the `app.bsky.feed.like` / `app.bsky.feed.repost`
/// collections the AppView also needs it so the liker's handle
/// lands on the `likes.liker_handle` column.
///
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if
/// the request itself failed. The caller should treat any non-Ok as
@@ -70,6 +83,7 @@ impl AppViewPushClient {
pub async fn push_create(
&self,
did: &str,
handle: Option<&str>,
collection: &str,
rkey: &str,
cid: &str,
@@ -77,6 +91,7 @@ impl AppViewPushClient {
) -> Result<bool> {
self.push(
did,
handle,
collection,
"create",
rkey,
@@ -93,19 +108,21 @@ impl AppViewPushClient {
collection: &str,
rkey: &str,
) -> Result<bool> {
self.push(did, collection, "delete", rkey, None, None, None)
self.push(did, None, collection, "delete", rkey, None, None, None)
.await
}
pub async fn push_follow_create(
&self,
did: &str,
handle: Option<&str>,
rkey: &str,
subject_did: &str,
record: &Value,
) -> Result<bool> {
self.push(
did,
handle,
"app.bsky.graph.follow",
"create",
rkey,
@@ -124,6 +141,7 @@ impl AppViewPushClient {
) -> Result<bool> {
self.push(
did,
None,
"app.bsky.graph.follow",
"delete",
rkey,
@@ -134,9 +152,33 @@ impl AppViewPushClient {
.await
}
/// Push an `app.bsky.actor.profile` create event to the AppView
/// so the `profiles` cache stays in sync with the user's own PDS.
/// Best-effort — if the AppView is unreachable, the Jetstream
/// replay path eventually picks it up.
pub async fn push_profile(
&self,
did: &str,
handle: &str,
record: &serde_json::Value,
) -> Result<bool> {
self.push(
did,
Some(handle),
"app.bsky.actor.profile",
"create",
"self",
None,
Some(record),
None,
)
.await
}
async fn push(
&self,
did: &str,
handle: Option<&str>,
collection: &str,
action: &str,
rkey: &str,
@@ -147,6 +189,7 @@ impl AppViewPushClient {
let url = format!("{}/internal/ingest-commit", self.base_url);
let body = IngestCommitBody {
did,
handle,
collection,
action,
rkey,
+8
View File
@@ -123,6 +123,14 @@ pub fn router(state: AppState) -> Router {
"/xrpc/com.atproto.sync.getRecord",
get(routes::sync::get_record),
)
.route(
"/xrpc/app.bsky.actor.profile.get",
get(routes::profile::get_profile),
)
.route(
"/xrpc/app.bsky.actor.profile.set",
post(routes::profile::set_profile),
)
.route(
"/xrpc/com.atproto.sync.listRepos",
get(routes::sync::list_repos),
+32 -12
View File
@@ -1,12 +1,12 @@
use crate::jwt_issuer;
use crate::keys::{derive_did_from_signing, generate_user_keys};
use crate::keys::{generate_user_keys};
use crate::password::hash_password;
use crate::routes::types::{
CreateAccountReq, CreateAccountResp, CreateSessionReq, CreateSessionResp, RefreshSessionReq,
RefreshSessionResp,
};
use crate::state::AppState;
use at_crypto::plc_op::{create_unsigned_op, sign_op, PlcOperation};
use at_crypto::plc_op::{did_plc_from_op, PlcOperation};
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
@@ -69,7 +69,20 @@ pub async fn create_account(
}
let keys = generate_user_keys().map_err(|e| internal(e))?;
let did = derive_did_from_signing(&keys.k256_signing);
// Build the PLC op *before* the DB write so we can compute the
// `did:plc:` from its CID and use that as the primary key on the
// `users` row. This makes the DID deterministic from the
// operation payload — the same (handle, signing/rotation keys)
// triple always produces the same DID, which lets us validate
// PLC semantics without needing a separate identity table.
let plc_op = PlcOperation::create(
&req.handle,
&keys.k256_signing.secret_key().unwrap(),
&keys.k256_rotation.public_multibase,
&state.cfg.pds_public_url,
)
.map_err(|e| internal(e))?;
let did = did_plc_from_op(&plc_op).map_err(|e| internal(e))?;
let pwd_hash = match &req.password {
Some(pw) => Some(hash_password(pw).map_err(|e| internal(e))?),
None => None,
@@ -107,20 +120,27 @@ pub async fn create_account(
tx.commit().await.map_err(|e| internal(e))?;
let plc_op = PlcOperation::create(
&req.handle,
&keys.k256_signing.secret_key().unwrap(),
&keys.k256_rotation.public_multibase,
&state.cfg.pds_public_url,
)
.map_err(|e| internal(e))?;
// Submit the op to the PLC directory. We compute the DID
// locally via `did_plc_from_op` so the user is usable even when
// the outbound PLC submit fails (dev mode, network down,
// DNS-blocked). A successful submit publishes the op so the
// rest of the network can resolve the handle; failure is logged
// and tolerated (matches the original best-effort contract).
let plc_cid = match state.plc.submit(&did, &plc_op).await {
Ok(c) => {
info!("plc op submitted: cid={}", c);
info!(
did = %did,
cid = %c,
"plc op submitted; DID registered globally"
);
Some(c)
}
Err(e) => {
warn!("plc submit failed (dev ok): {e:#}");
warn!(
did = %did,
error = %e,
"plc submit failed (dev ok): DID stays local; recompute via did_plc_from_op"
);
None
}
};
+3 -1
View File
@@ -17,7 +17,7 @@
//! removed from the MST, a new commit is signed, the AppView is
//! told to drop the row, and we return the new commit CID + rev.
use crate::routes::helpers::{apply_repo_write, err, to_sqlx_error, RepoWriteOutcome};
use crate::routes::helpers::{apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome};
use at_repo::blockstore::Blockstore;
use crate::routes::types::ErrorBody;
use crate::state::AppState;
@@ -273,6 +273,7 @@ pub async fn create_like(
let value_cid_str = value_cid.to_string();
let push_record = record.clone();
let push_rkey = rkey.clone();
let push_handle_str: Option<String> = lookup_handle(&state, &did).await;
let commit = apply_and_commit(&state, &did, move |repo| {
let value_cid = value_cid;
@@ -317,6 +318,7 @@ pub async fn create_like(
if let Err(e) = push_handle
.push_create(
&push_did,
push_handle_str.as_deref(),
LIKE_COLLECTION,
&push_rkey_owned,
&push_cid_owned,
+22 -1
View File
@@ -453,4 +453,25 @@ async fn persist_user_blocks_in_tx(
})?;
}
Ok(())
}
}
/// Look up the current handle for `did` from the `users` table.
///
/// Returned as `Option<String>` (rather than an empty default) so the
/// caller can decide what to do when the row hasn't been found yet —
/// in practice the row is always present for an authenticated route,
/// but we'd rather log a warning than silently emit a bogus empty
/// handle into the AppView's `posts.handle` column.
///
/// Called once per `createRecord` / `feed.like.create` write, just
/// before the AppView push, so the liker's/poster's display handle
/// lands on the AppView row at write time — otherwise the AppView has
/// no source for `did:key:` handles and the timeline renders them as
/// `@did:key:z16D…` snippets.
pub async fn lookup_handle(state: &AppState, did: &str) -> Option<String> {
sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
.bind(did)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
}
+35 -7
View File
@@ -9,17 +9,42 @@ pub async fn resolve_handle(
State(state): State<AppState>,
Json(req): Json<ResolveHandleReq>,
) -> Result<Json<ResolveHandleResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
// Polymorphic input: `handle` may be a bare handle OR a DID
// (`did:plc:…`, `did:web:…`, `did:key:…`). When it's a DID we
// look the row up by `did` — the AppView's `PdsHandleResolver`
// uses this path to fill `posts.handle` for local-PDS users
// (including `did:key:`) without a second round-trip to plc.directory.
if req.handle.starts_with("did:") {
let row: Option<(String,)> =
sqlx::query_as("SELECT handle FROM users WHERE did = $1")
.bind(&req.handle)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
if let Some((handle,)) = row {
return Ok(Json(ResolveHandleResp {
did: req.handle.clone(),
handle: Some(handle),
}));
}
// DID not hosted here — fall through to the handle lookup
// (returns 404 below).
}
if let Some(zone) = state.cfg.pds_handle_dns_zone.strip_prefix(".") {
if let Some(stripped) = req.handle.strip_suffix(zone) {
let user = stripped.trim_end_matches('.');
let full = format!("{}{}", user, state.cfg.pds_handle_dns_zone);
let row: Option<(String,)> = sqlx::query_as("SELECT did FROM users WHERE handle = $1")
.bind(&full)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let row: Option<(String,)> =
sqlx::query_as("SELECT did FROM users WHERE handle = $1")
.bind(&full)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
if let Some((did,)) = row {
return Ok(Json(ResolveHandleResp { did }));
return Ok(Json(ResolveHandleResp {
did,
handle: Some(full),
}));
}
}
}
@@ -29,7 +54,10 @@ pub async fn resolve_handle(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
match row {
Some((did,)) => Ok(Json(ResolveHandleResp { did })),
Some((did,)) => Ok(Json(ResolveHandleResp {
did,
handle: Some(req.handle.clone()),
})),
None => {
warn!(handle = %req.handle, "handle not found");
Err(err(
+1
View File
@@ -3,6 +3,7 @@ pub mod blob;
pub mod feed;
pub mod helpers;
pub mod identity;
pub mod profile;
pub mod repo;
pub mod sync;
pub mod types;
+499
View File
@@ -0,0 +1,499 @@
//! `app.bsky.actor.profile.get` and `app.bsky.actor.profile.set`.
//!
//! ### `get`
//!
//! Read the profile record (CBOR-decoded `app.bsky.actor.profile/self`
//! value block) for the authenticated user. The handle/did come from
//! the JWT; the record is parsed into a JSON object. Returns `null`
//! for the `profile` field when the user has no profile yet (a brand
//! new account).
//!
//! ### `set`
//!
//! Read-modify-write of the user's `app.bsky.actor.profile/self`
//! record. The body carries only the fields the caller wants to
//! change; the existing record is fetched and the supplied fields
//! overwrite the corresponding fields. Best-effort push to the
//! AppView follows so the `profiles` cache reflects the new avatar /
//! display name / bio without waiting for the Jetstream replay.
use crate::jwt_issuer;
use crate::routes::helpers::{
apply_repo_write, err, load_head_commit, load_signing_key, load_user_blockstore,
to_sqlx_error, RepoWriteOutcome,
};
use crate::routes::types::ErrorBody;
use crate::state::AppState;
use at_crypto::cid::cid_for_cbor;
use at_repo::blockstore::Blockstore as _;
use at_repo::repo::Repo;
use axum::extract::State;
use axum::http::StatusCode;
use bytes::Bytes;
use cid::Cid;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{info, warn};
/// Blob metadata looked up from the `blobs` table. Used to write
/// the `mimeType` / `size` fields of a profile avatar/banner ref —
/// these must reflect what the user actually uploaded, not a
/// hardcoded constant.
#[derive(Debug, Clone)]
pub(crate) struct ResolvedBlob {
pub mime_type: String,
pub size: i64,
}
#[derive(Debug, Serialize)]
pub struct GetProfileResp {
pub did: String,
pub handle: String,
/// `null` when the user has no profile record yet.
pub profile: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetProfileReq {
/// Optional new display name. `null`/missing preserves the
/// existing record's `displayName`.
pub display_name: Option<String>,
/// Optional new bio / description. `null`/missing preserves.
pub description: Option<String>,
/// CID of the avatar blob, already uploaded via uploadBlob.
/// `null`/missing preserves.
pub avatar_blob_cid: Option<String>,
/// CID of the banner blob. `null`/missing preserves.
pub banner_blob_cid: Option<String>,
}
/// Read the authenticated user's profile record.
pub async fn get_profile(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
let did = authenticate(&headers, &state)?;
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
.bind(&did)
.fetch_one(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
let profile = read_profile_record(&state, &did).await?;
Ok(axum::Json(GetProfileResp { did, handle, profile }))
}
/// Read-modify-write the authenticated user's profile record.
pub async fn set_profile(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
axum::Json(req): axum::Json<SetProfileReq>,
) -> Result<axum::Json<GetProfileResp>, (StatusCode, axum::Json<ErrorBody>)> {
let did = authenticate(&headers, &state)?;
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
.bind(&did)
.fetch_one(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
// Fetch the existing record, if any.
let existing = read_profile_record(&state, &did).await?;
// For any blob CIDs in the request, look up the real
// `mime_type` / `size` from the `blobs` table — and verify
// ownership (the blob must belong to the authenticated DID).
// Without the ownership check a session for DID A could
// reference DID B's blob in their profile.
let avatar = match req.avatar_blob_cid.as_deref() {
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
None => None,
};
let banner = match req.banner_blob_cid.as_deref() {
Some(cid) => Some(resolve_owned_blob(&state, &did, cid).await?),
None => None,
};
// Merge: start from the existing record (or empty object), then
// overlay the supplied fields. We use the atproto standard
// `app.bsky.actor.profile` schema: displayName (string),
// description (string), avatar (blob ref), banner (blob ref).
let next = merge_profile_fields(existing, &req, avatar.as_ref(), banner.as_ref());
// Validate against the lexicon so the user can't push an
// arbitrary JSON shape that wouldn't round-trip through a real
// atproto client.
if let Err(e) = state.lex.validate("app.bsky.actor.profile", &next) {
return Err(err(
StatusCode::BAD_REQUEST,
"InvalidRequest",
format!("lex validation failed: {e}"),
));
}
// Encode the new record as CBOR and write it to the user's
// `app.bsky.actor.profile/self` MST via the canonical repo-write
// path (the same one createRecord uses).
let value_cid: Cid = {
let mut buf = Vec::new();
ciborium::into_writer(&next, &mut buf).map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("cbor: {e}"),
)
})?;
cid_for_cbor(&buf).map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
e.to_string(),
)
})?
};
let next_for_block = next.clone();
let outcome = apply_repo_write(&state, &did, move |repo| {
let value_cid = value_cid;
let next_for_block = next_for_block;
Box::pin(async move {
repo.blockstore
.put(&value_cid, Bytes::from({
let mut buf = Vec::new();
ciborium::into_writer(&next_for_block, &mut buf).unwrap();
buf
}))
.await
.map_err(to_sqlx_error)?;
let (_uri, _returned_cid) = repo
.put_record("app.bsky.actor.profile", "self", value_cid)
.await
.map_err(to_sqlx_error)?;
let commit = repo.commit().await.map_err(to_sqlx_error)?;
let head_cid_bytes = commit.cid.to_bytes().to_vec();
let head_commit_bytes = commit.signed_bytes.clone();
Ok(RepoWriteOutcome {
commit,
head_cid_bytes,
head_commit_bytes,
})
})
})
.await?;
info!(
did = %did,
cid = %outcome.commit.cid,
"profile record created"
);
// Best-effort push to the AppView so the profile cache reflects
// the new avatar / display name / bio without waiting for the
// Jetstream `identity` event to plumb through.
if let Err(e) = state
.appview
.push_profile(&did, &handle, &next)
.await
{
warn!(error = %e, "profile push to AppView failed; Jetstream will catch up");
}
Ok(axum::Json(GetProfileResp {
did,
handle,
profile: Some(next),
}))
}
// -- internals ----------------------------------------------------------------
/// Verify a bearer token and return the authenticated DID.
fn authenticate(
headers: &axum::http::HeaderMap,
state: &AppState,
) -> Result<String, (StatusCode, axum::Json<ErrorBody>)> {
let token = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| {
err(
StatusCode::UNAUTHORIZED,
"Unauthenticated",
"missing Authorization: Bearer header",
)
})?;
let server_pk = jwt_issuer::server_p256_public_multibase(&state.cfg)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
let claims = at_crypto::jwt::verify_jwt(token, &server_pk).map_err(|e| {
err(
StatusCode::UNAUTHORIZED,
"TokenInvalid",
format!("invalid token: {e}"),
)
})?;
Ok(claims.sub)
}
/// Decode the `app.bsky.actor.profile/self` record for `did`, if any.
/// Returns `Ok(None)` when the record doesn't exist.
///
/// Walk the head commit down to the profile/self leaf, fetch the
/// value block, CBOR-decode it into JSON. Mirrors `get_record`'s
/// walk in routes/sync.rs.
async fn read_profile_record(
state: &AppState,
did: &str,
) -> Result<Option<Value>, (StatusCode, axum::Json<ErrorBody>)> {
let (head_cid, head_commit_bytes) = match load_head_commit(state, did).await? {
Some(t) => t,
None => return Ok(None),
};
let signing_key_bytes: Vec<u8> =
sqlx::query_scalar("SELECT signing_key FROM users WHERE did = $1")
.bind(did)
.fetch_one(&state.db)
.await
.map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("users.signing_key read: {e}"),
)
})?;
let signing_key = load_signing_key(&signing_key_bytes)?;
let blockstore = load_user_blockstore(state, did).await?;
blockstore
.put(&head_cid, Bytes::from(head_commit_bytes.clone()))
.await
.map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("blockstore put head: {e:#}"),
)
})?;
let repo: Repo<_> = Repo::load(did.to_string(), signing_key, blockstore.clone(), head_cid)
.await
.map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("repo load: {e:#}"),
)
})?;
let value_cid = match repo.get_record("app.bsky.actor.profile", "self").await {
Ok(Some(c)) => c,
Ok(None) => return Ok(None),
Err(e) => {
return Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("repo.get_record: {e:#}"),
));
}
};
let value_bytes = match blockstore.get(&value_cid).await.map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("blockstore get value: {e:#}"),
)
})? {
Some(b) => b,
None => return Ok(None),
};
let v: Value = ciborium::from_reader(&value_bytes[..]).map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("cbor decode: {e}"),
)
})?;
Ok(Some(v))
}
/// Overlay `req` onto `existing`, returning the merged profile
/// record. Each `Some(_)` field in `req` overwrites the matching
/// field; `None` fields are preserved.
///
/// `avatar` / `banner` are the resolved blobs (looked up from the
/// `blobs` table in `set_profile` so we can write the real
/// `mimeType` / `size`). When `None`, the avatar/banner fields are
/// preserved from `existing`.
///
/// Extracted from `set_profile` so the merge semantics are testable
/// without a running PDS / DB.
pub(crate) fn merge_profile_fields(
existing: Option<Value>,
req: &SetProfileReq,
avatar: Option<&ResolvedBlob>,
banner: Option<&ResolvedBlob>,
) -> Value {
let mut next: Value = existing.unwrap_or_else(|| json!({}));
if let Some(s) = &req.display_name {
next["displayName"] = json!(s);
}
if let Some(s) = &req.description {
next["description"] = json!(s);
}
if let Some(cid) = &req.avatar_blob_cid {
// Caller is responsible for the DB lookup; default to a
// png/0 placeholder only if the lookup somehow returned
// `None` despite a CID being supplied (shouldn't happen
// — `resolve_owned_blob` rejects missing blobs earlier).
let (mime_type, size) = avatar
.map(|b| (b.mime_type.as_str(), b.size))
.unwrap_or(("image/png", 0));
next["avatar"] = json!({
"$type": "blob",
"ref": { "$link": cid },
"mimeType": mime_type,
"size": size,
});
}
if let Some(cid) = &req.banner_blob_cid {
let (mime_type, size) = banner
.map(|b| (b.mime_type.as_str(), b.size))
.unwrap_or(("image/png", 0));
next["banner"] = json!({
"$type": "blob",
"ref": { "$link": cid },
"mimeType": mime_type,
"size": size,
});
}
next
}
/// Look up a blob by CID and verify it's owned by `did`. The
/// ownership check is a security requirement: without it a
/// session for DID A could reference DID B's blob in their own
/// profile (the value would still resolve at fetch time because
/// `com.atproto.sync.getBlob` doesn't check ownership, but the
/// invariant "a profile's avatar belongs to that user" would be
/// broken).
async fn resolve_owned_blob(
state: &AppState,
did: &str,
cid: &str,
) -> Result<ResolvedBlob, (StatusCode, axum::Json<ErrorBody>)> {
let row: Option<(String, i64)> = sqlx::query_as(
"SELECT mime_type, size FROM blobs WHERE cid = $1 AND did = $2",
)
.bind(cid)
.bind(did)
.fetch_optional(&state.db)
.await
.map_err(|e| {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"InternalServerError",
format!("blobs lookup: {e}"),
)
})?;
match row {
Some((mime_type, size)) => Ok(ResolvedBlob { mime_type, size }),
None => Err(err(
StatusCode::BAD_REQUEST,
"InvalidRequest",
format!("blob {cid} not found or not owned by {did}"),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Round-trip the camelCase JSON the Tauri client sends and
/// confirm every field lands on its `snake_case` Rust
/// counterpart. Catches regressions of the BLOCKER-3 bug
/// (silent camelCase → snake_case mismatch that made every
/// set_profile write an empty record).
#[test]
fn set_profile_req_deserializes_camel_case() {
let raw = json!({
"displayName": "Alice",
"description": "hello",
"avatarBlobCid": "bafyavatar",
"bannerBlobCid": "bafybanner",
});
let req: SetProfileReq = serde_json::from_value(raw).unwrap();
assert_eq!(req.display_name.as_deref(), Some("Alice"));
assert_eq!(req.description.as_deref(), Some("hello"));
assert_eq!(req.avatar_blob_cid.as_deref(), Some("bafyavatar"));
assert_eq!(req.banner_blob_cid.as_deref(), Some("bafybanner"));
}
/// `displayName` only — preserves existing description / avatar.
#[test]
fn merge_preserves_fields_not_in_req() {
let existing = json!({
"displayName": "Old",
"description": "old bio",
"avatar": { "$type": "blob", "ref": { "$link": "old_avatar" } },
});
let req = SetProfileReq {
display_name: Some("New".into()),
description: None,
avatar_blob_cid: None,
banner_blob_cid: None,
};
let next = merge_profile_fields(Some(existing), &req, None, None);
assert_eq!(next["displayName"], "New");
assert_eq!(next["description"], "old bio");
assert_eq!(
next["avatar"]["ref"]["$link"], "old_avatar",
"avatar must be preserved when req.avatar_blob_cid is None"
);
}
/// All fields set on an empty record — the typical first-write
/// path for a brand-new account.
#[test]
fn merge_into_empty_record() {
let req = SetProfileReq {
display_name: Some("Alice".into()),
description: Some("first bio".into()),
avatar_blob_cid: Some("bafyavatar".into()),
banner_blob_cid: None,
};
let avatar = ResolvedBlob {
mime_type: "image/png".into(),
size: 1234,
};
let next = merge_profile_fields(None, &req, Some(&avatar), None);
assert_eq!(next["displayName"], "Alice");
assert_eq!(next["description"], "first bio");
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
assert_eq!(next["avatar"]["mimeType"], "image/png");
assert_eq!(next["avatar"]["size"], 1234);
assert!(next.get("banner").is_none(), "banner must be absent when not set");
}
/// Blob refs must use the modern `{ $type, ref.$link, mimeType,
/// size }` shape so the AppView's `blob_link_of` helper can
/// parse them back. Locks the wire contract in place.
#[test]
fn merge_writes_blob_refs_in_modern_shape() {
let req = SetProfileReq {
display_name: None,
description: None,
avatar_blob_cid: Some("bafyavatar".into()),
banner_blob_cid: None,
};
let avatar = ResolvedBlob {
mime_type: "image/webp".into(),
size: 999,
};
let next = merge_profile_fields(None, &req, Some(&avatar), None);
assert_eq!(next["avatar"]["$type"], "blob");
assert_eq!(next["avatar"]["ref"]["$link"], "bafyavatar");
// Real mime_type from the blobs table — not hardcoded.
assert_eq!(next["avatar"]["mimeType"], "image/webp");
assert_eq!(next["avatar"]["size"], 999);
}
}
+19 -2
View File
@@ -1,5 +1,5 @@
use crate::routes::helpers::{
apply_repo_write, err, to_sqlx_error, RepoWriteOutcome,
apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome,
};
use crate::routes::types::{CreateRecordReq, CreateRecordResp};
use crate::state::AppState;
@@ -92,6 +92,16 @@ pub async fn create_record(
let push_rkey = rkey.clone();
let push_cid = value_cid.to_string();
let push_record = req.record.clone();
// Resolve the poster's current handle from the local users
// table *before* the spawn — the closure can't easily borrow
// `&state` after we hand ownership to the spawned task.
let push_handle_str: Option<String> = match lookup_handle(&state, &did).await {
h @ Some(_) => h,
None => {
tracing::warn!(did = %did, "appview push: no handle in users table; timeline will show @did-prefix");
None
}
};
let collection = req.collection.clone();
let outcome = apply_repo_write(&state, &did, move |repo| {
@@ -136,7 +146,14 @@ pub async fn create_record(
// the AppView.
tokio::spawn(async move {
if let Err(e) = push_handle
.push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record)
.push_create(
&push_did,
push_handle_str.as_deref(),
&push_coll,
&push_rkey,
&push_cid,
&push_record,
)
.await
{
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
+7
View File
@@ -62,6 +62,13 @@ pub struct ResolveHandleReq {
#[derive(Debug, Serialize)]
pub struct ResolveHandleResp {
pub did: String,
/// Always populated when the lookup succeeds. For
/// handle → DID calls this is just the input echo; for
/// DID → handle calls this is the resolved local handle
/// (used by the AppView's `PdsHandleResolver` to fill
/// `posts.handle` without a second round-trip).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
}
#[derive(Debug, Deserialize)]
+8
View File
@@ -38,6 +38,14 @@ impl AppState {
"app.bsky.feed.repost".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/repost.json")).unwrap(),
);
// Profile record — avatar/banner/display name/description.
// Validates the createRecord body when the Tauri client calls
// its setProfile command. Other fields stay optional so a
// brand-new account with an empty profile is legal.
lex.lexicons.insert(
"app.bsky.actor.profile".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/actor/profile.json")).unwrap(),
);
let plc_url = cfg.plc_directory_url.clone();
// The PDS speaks to the AppView via the cluster-internal URL —
// never the public one, because the ingest endpoint is unauth'd
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the maarcadetweet main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:webview:allow-internal-toggle-devtools",
"core:window:default",
"notification:default",
"shell:default",
"dialog:default",
"updater:default",
"window-state:default"
]
}
+192 -2
View File
@@ -113,6 +113,7 @@ async fn post_create(
state: tauri::State<'_, AppState>,
text: String,
embed: Option<serde_json::Value>,
reply: Option<pds_client::ReplyRef>,
) -> Result<serde_json::Value, String> {
let sess = state
.store
@@ -130,6 +131,16 @@ async fn post_create(
record["embed"] = emb;
}
}
// The `reply` field on a post record (see
// `app.bsky.feed.post`) is `{root, parent}` strongRefs. We only
// attach it when the caller passes a non-null object; missing
// means "top-level post" which is the default.
if let Some(rp) = reply {
record["reply"] = serde_json::json!({
"root": { "uri": rp.root.uri, "cid": rp.root.cid },
"parent": { "uri": rp.parent.uri, "cid": rp.parent.cid },
});
}
let resp = state
.pds
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
@@ -273,6 +284,76 @@ async fn unrepost_post(
}))
}
/// `follow_user(target_did)` — create an `app.bsky.graph.follow`
/// record on the user's PDS pointing at `target_did`. Returns the
/// new record's URI (the client caches this in localStorage so it
/// can be deleted by `unfollow_user` without an extra round-trip).
///
/// `subject` in the follow record is just a DID string, not a
/// strong-ref — the PDS is the source of truth for which follow
/// record belongs to which subject.
#[tauri::command]
async fn follow_user(
state: tauri::State<'_, AppState>,
target_did: String,
) -> Result<serde_json::Value, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
if target_did == sess.did {
return Err("can't follow yourself".into());
}
let record = serde_json::json!({
"$type": "app.bsky.graph.follow",
"subject": target_did,
"createdAt": chrono::Utc::now().to_rfc3339(),
});
let resp = state
.pds
.create_record(
&sess.did,
"app.bsky.graph.follow",
record,
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"uri": resp.uri,
"cid": resp.cid,
}))
}
/// `unfollow_user(follow_uri)` — delete the previously-created
/// follow record. The client passes the cached URI from its
/// `localStorage` so we don't need a separate "list my follows"
/// endpoint to find the right rkey.
#[tauri::command]
async fn unfollow_user(
state: tauri::State<'_, AppState>,
follow_uri: String,
) -> Result<serde_json::Value, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
let rkey = rkey_from_uri(&follow_uri)?;
let resp = state
.pds
.delete_record(
&sess.did,
"app.bsky.graph.follow",
&rkey,
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"commit": resp.commit,
}))
}
#[tauri::command]
async fn timeline_home(
state: tauri::State<'_, AppState>,
@@ -543,8 +624,9 @@ pub fn run() {
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
let state = AppState {
pds: PdsHttpClient::new(pds_url),
appview: AppViewClient::new(appview_url),
pds: PdsHttpClient::new(pds_url.clone()),
appview: AppViewClient::new(appview_url.clone()),
appview_url,
store: store::SessionStore::new(),
};
@@ -559,6 +641,15 @@ pub fn run() {
use tauri::Manager;
tracing::info!("maarcadetweet starting up");
// Open the webview devtools on startup in debug builds so
// we can see the console + DOM inspector without reaching
// for the macOS menu. The production build has no debug
// assertions so this branch is a no-op there.
#[cfg(debug_assertions)]
if let Some(win) = app.get_webview_window("main") {
let _ = win.open_devtools();
}
// Embed the tray icon at compile time. `include_image!`
// resolves paths relative to `CARGO_MANIFEST_DIR` and
// bakes the raw RGBA pixels into the binary, so the
@@ -606,6 +697,14 @@ pub fn run() {
None::<&str>,
)
.map_err(|e| format!("failed to build search menu item: {e}"))?;
let settings_item = tauri::menu::MenuItem::with_id(
app,
"tray_settings",
"Settings",
true,
None::<&str>,
)
.map_err(|e| format!("failed to build settings menu item: {e}"))?;
let quit_item = tauri::menu::MenuItem::with_id(
app,
"tray_quit",
@@ -625,6 +724,7 @@ pub fn run() {
&compose_item,
&profile_item,
&search_item,
&settings_item,
&separator,
&quit_item,
],
@@ -653,6 +753,9 @@ pub fn run() {
"tray_search" => {
let _ = tauri::Emitter::emit(app, "app://navigate", "search");
}
"tray_settings" => {
let _ = tauri::Emitter::emit(app, "app://navigate", "settings");
}
"tray_quit" => {
app.exit(0);
}
@@ -694,12 +797,99 @@ pub fn run() {
unlike_post,
repost_post,
unrepost_post,
follow_user,
unfollow_user,
status_pds,
fetch_blob,
pick_and_upload_image,
show_notification,
open_external_url,
profile_get_record,
profile_set,
get_api_urls,
])
.run(tauri::generate_context!())
.expect("error while running maarcadetweet");
}
#[tauri::command]
async fn profile_get_record(
state: tauri::State<'_, AppState>,
) -> Result<Option<serde_json::Value>, String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
state
.pds
.get_profile_record(&sess.did, &sess.access_jwt)
.await
.map_err(|e| e.to_string())
}
/// Frontend-side base URLs the Tauri shell was started with. Used by
/// the Svelte components to build absolute fetch URLs — a relative
/// `/api/...` resolves against the Vite dev origin (port 1430), not
/// the AppView (port 2584), and the Vite server has no proxy
/// configured, so the fetch lands on a 404 HTML page and
/// `response.json()` throws `SyntaxError`.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiUrls {
pds_url: String,
appview_url: String,
}
#[tauri::command]
fn get_api_urls(state: tauri::State<'_, AppState>) -> ApiUrls {
// Sync command — the URLs are immutable for the lifetime of the
// Tauri shell (read from MAARCADETWEET_*_URL at startup), so no
// async machinery is needed. Returns the AppView URL the
// frontend needs; PDS URL is exposed too so future fetch-based
// XRPC calls don't have to add their own command.
ApiUrls {
pds_url: state.pds.base_url.clone(),
appview_url: state.appview_url.clone(),
}
}
#[tauri::command]
async fn profile_set(
state: tauri::State<'_, AppState>,
fields: serde_json::Value,
) -> Result<(), String> {
let sess = state
.store
.load()
.ok_or_else(|| "not logged in".to_string())?;
let display_name = fields
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string);
let description = fields
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string);
let avatar_blob_cid = fields
.get("avatarBlobCid")
.and_then(|v| v.as_str())
.map(str::to_string);
let banner_blob_cid = fields
.get("bannerBlobCid")
.and_then(|v| v.as_str())
.map(str::to_string);
state
.pds
.set_profile(
&sess.did,
&serde_json::json!({
"displayName": display_name,
"description": description,
"avatarBlobCid": avatar_blob_cid,
"bannerBlobCid": banner_blob_cid,
}),
&sess.access_jwt,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -43,6 +43,26 @@ pub struct CreateRecordReq {
pub record: serde_json::Value,
}
/// Strong reference as defined by
/// `com.atproto.repo.strongRef` — `{uri, cid}`. Used inside
/// `app.bsky.feed.post#reply` (root + parent) and inside
/// `app.bsky.embed.record` (the quoted post).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StrongRef {
pub uri: String,
pub cid: String,
}
/// `app.bsky.feed.post#reply` — the `reply` field on a post
/// record. `root` is the topmost ancestor of the thread,
/// `parent` is the post being directly replied to. For a
/// top-level reply they point at the same `strongRef`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ReplyRef {
pub root: StrongRef,
pub parent: StrongRef,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordResp {
pub uri: String,
@@ -385,3 +405,67 @@ pub struct UploadedBlobRef {
#[serde(rename = "$link")]
pub link: String,
}
impl PdsHttpClient {
/// `POST /xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self`
/// Returns the record's CBOR-decoded value as JSON, or `None` if no
/// record exists for that path. The server replies with a
/// `{ "value": {...} | null }` envelope; we unwrap and return the
/// inner value (which is the `app.bsky.actor.profile` JSON object
/// keyed by the deserialized CBOR field names: `displayName`,
/// `description`, `avatar`/{ ref, mimeType, size }, `banner`/...).
pub async fn get_profile_record(
&self,
repo: &str,
jwt: &str,
) -> Result<Option<serde_json::Value>> {
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord",
self.base_url
);
let r = self
.client
.get(&url)
.query(&[("repo", repo), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.bearer_auth(jwt)
.send()
.await?;
if r.status().as_u16() == 404 {
return Ok(None);
}
if !r.status().is_success() {
let s = r.status();
let body = r.text().await.unwrap_or_default();
anyhow::bail!("getRecord returned {s}: {body}");
}
let v: serde_json::Value = r.json().await?;
Ok(v.get("value").cloned().and_then(|x| if x.is_null() { None } else { Some(x) }))
}
/// `POST /xrpc/app.bsky.actor.profile.set` — PDS-only convenience
/// endpoint that does a read-modify-write of the profile record. The
/// request body has the same shape as `app.bsky.actor.profile` minus
/// the `$type` (added server-side).
pub async fn set_profile(
&self,
repo: &str,
profile: &serde_json::Value,
jwt: &str,
) -> Result<serde_json::Value> {
let url = format!("{}/xrpc/app.bsky.actor.profile.set", self.base_url);
let r = self
.client
.post(&url)
.bearer_auth(jwt)
.json(profile)
.send()
.await?;
if !r.status().is_success() {
let s = r.status();
let body = r.text().await.unwrap_or_default();
anyhow::bail!("setProfile returned {s}: {body}");
}
let v: serde_json::Value = r.json().await?;
Ok(v.get("profile").cloned().unwrap_or(serde_json::Value::Null))
}
}
+5
View File
@@ -1,5 +1,10 @@
pub struct AppState {
pub pds: crate::pds_client::PdsHttpClient,
pub appview: crate::appview_client::AppViewClient,
/// Base URL of the AppView service (`http://host:port`, no
/// trailing slash). Stored verbatim so the frontend can build
/// absolute URLs for fetch calls — a relative `/api/profile/…`
/// would resolve against the Vite dev origin, not the AppView.
pub appview_url: String,
pub store: crate::store::SessionStore,
}
+6 -4
View File
@@ -28,17 +28,19 @@
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost"
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost",
"capabilities": ["default"]
}
},
"plugins": {
"updater": {
"active": true,
"dialog": true,
"active": false,
"dialog": false,
"endpoints": [
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": ""
"pubkey": "",
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled."
}
},
"bundle": {
+487 -165
View File
@@ -4,24 +4,31 @@
session,
pdsStatus,
fetchTimeline,
fetchProfile,
fetchSearch,
fetchPost,
openExternalUrl,
showError,
type Session,
type Post,
type ProfileResponse,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
import ProfileView from "./lib/components/ProfileView.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "search";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The
// "profile" view remains the current-user view (the NavRail icon
// goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state("");
let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -32,12 +39,17 @@
let timelineError: string | null = $state(null);
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
let _timelinePollTimer: number | undefined;
// Profile state.
let profile: ProfileResponse | null = $state(null);
let profileLoading: boolean = $state(false);
let profileError: string | null = $state(null);
// Home tab strip — "for you" is a placeholder (no real algo yet),
// "following" is the live behavior. Mirrors the X-style "For you /
// Following" tabs.
type HomeTab = "for-you" | "following";
let homeTab: HomeTab = $state("following");
// Search tab strip — only "top" is wired (matches the current
// search endpoint). The rest are visually present but disabled.
type SearchTab = "top" | "latest" | "people" | "photos";
let searchTab: SearchTab = $state("top");
// Search state.
let searchQuery: string = $state("");
@@ -52,6 +64,28 @@
let threadLoading: boolean = $state(false);
let threadError: string | null = $state(null);
// Reply state — when the user clicks the reply button on a
// PostCard, the parent fires `on_reply` with strongRefs. We
// stash them here and switch to the compose view; the ComposeBox
// reads `replyTo` to render the "Replying to @handle" bar and
// attach the reply block on submit.
type ReplyTarget = {
handle: string;
root: { uri: string; cid: string };
parent: { uri: string; cid: string };
};
let replyTo: ReplyTarget | null = $state(null);
/// Called by PostCard's reply button. Stores the strongRefs and
/// routes the user to the compose view.
function onReply(target: ReplyTarget) {
replyTo = target;
view = "compose";
}
function clearReply() {
replyTo = null;
}
// Toasts surfaced by child components via the `maarcadetweet:toast`
// window event. We keep the last few so a slow render doesn't
// wipe the message before the user reads it.
@@ -80,6 +114,17 @@
threadLoading = false;
}
}
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<ProfileView>` on mount.
function openUserProfile(handle: string) {
selectedHandle = handle;
view = "user";
threadRoot = null;
threadParent = null;
}
function closeThread() {
threadRoot = null;
threadParent = null;
@@ -91,7 +136,7 @@
// — `main.ts` is the only place that wires to the Tauri event bus).
function onNavigateToView(e: Event) {
const detail = (e as CustomEvent<{ view: View }>).detail;
if (detail?.view) view = detail.view;
if (detail?.view) setView(detail.view);
}
function onNotification(e: Event) {
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
@@ -131,7 +176,7 @@
}
} else {
// unknown scheme — just open home
view = "home";
setView("home");
}
}
@@ -184,7 +229,7 @@
registerCleanup(() => {
if (_sessionUnsub) _sessionUnsub();
if (_statusTimer) clearInterval(_statusTimer);
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
if (_pollTimer != null) clearInterval(_pollTimer);
if (_searchDebounce) clearTimeout(_searchDebounce);
if (typeof window !== "undefined") {
window.removeEventListener("maarcadetweet:toast", _toastHandler);
@@ -199,6 +244,11 @@
_sessionUnsub = session.subscribe((s) => {
currentUser = s;
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
// Drive the 5s poll off the session lifecycle instead of a
// reactive effect — the effect form kept tripping Svelte 5's
// depth guard.
if (s) startPoll();
else stopPoll();
});
if (typeof window !== "undefined") {
@@ -219,29 +269,48 @@
})();
});
// Re-fetch the home timeline whenever we navigate to "home" or
// when the logged-in user changes. We also poll every 5s while
// the home view is active so new posts trickle in.
$effect(() => {
if (view === "home" && currentUser) {
// Imperative view-switching. We dispatch from a single function
// (called by NavRail on_select, the LoginScreen onLogin path, and
// the tray-event bridge) so every view transition runs the same
// side effects in one place. Previously this was four separate
// `$effect` blocks that read `view` / `currentUser` and called
// `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte
// 5's depth tracker kept aborting with `effect_update_depth_exceeded`
// because the sync portions of those refresh functions (`timelineLoading
// = true`, `profileLoading = true`) wrote $state that the effect's
// proxy-tracking had flagged as a self-write. Driving everything
// imperatively from a setter sidesteps the reactive cycle.
function setView(next: View) {
const prev = view;
view = next;
if (!currentUser) return;
// Entering home from elsewhere — pull a fresh timeline and
// (re)start the poll timer. Leaving home clears it.
if (next === "home" && prev !== "home") {
void refreshTimeline(true);
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
_timelinePollTimer = window.setInterval(() => {
void refreshTimeline(false); // poll = prepend new posts, don't wipe
}, 5000);
} else if (_timelinePollTimer) {
clearInterval(_timelinePollTimer);
_timelinePollTimer = undefined;
}
if (view === "profile" && currentUser) {
void refreshProfile(currentUser.handle);
if (next === "profile") {
// ProfileView fetches its own data on mount; nothing to
// preload here.
}
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch();
}
});
}
let _pollTimer: number | undefined;
function startPoll() {
if (_pollTimer != null) return;
_pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false);
}, 5000);
}
function stopPoll() {
if (_pollTimer == null) return;
window.clearInterval(_pollTimer);
_pollTimer = undefined;
}
async function refreshTimeline(reset: boolean) {
if (!currentUser) return;
@@ -292,19 +361,6 @@
}
}
async function refreshProfile(handle: string) {
profileLoading = true;
profileError = null;
try {
profile = await fetchProfile(handle);
} catch (e) {
profileError = String(e);
profile = null;
} finally {
profileLoading = false;
}
}
function scheduleSearch() {
if (_searchDebounce) clearTimeout(_searchDebounce);
_searchDebounce = window.setTimeout(() => {
@@ -347,18 +403,57 @@
}
async function handlePosted() {
// After the user posts, reset to page 1 so they see their own post.
// After the user posts, reset to page 1 so they see their own
// post, and clear any active reply target so the next compose
// doesn't re-attach the reply block.
replyTo = null;
await refreshTimeline(true);
}
/// Wired into the right-rail Sidebar. Fills the search query and
/// switches to the search view. If the query is empty we just
/// switch to the search view (the input there will keep focus).
function onSidebarSearch(query: string) {
searchQuery = query;
view = "search";
if (query.trim().length > 0) {
// Run the search immediately so the Sidebar click feels
// responsive (no debounce delay).
scheduleSearch();
}
}
async function handleLogout() {
try {
await session.logout();
setView("home");
searchResults = [];
threadRoot = null;
threadParent = null;
userPosts = [];
} catch (e) {
showError(`logout failed: ${String(e)}`);
}
}
// Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
// posts that have empty handles it falls back to a synthetic
// "@did:plc:abcd…" form, so the fallback here matches that.
function displayHandle(h: string | null | undefined): string {
if (!h) return "@unknown";
if (h.startsWith("@")) return h;
return `@${h}`;
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is
// talking to. Kept as plain helpers so they can be swapped for a
// `pds_describe`/`appview_describe` Tauri command later.
function pdsBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
return (import.meta as any).env.VITE_PDS_URL as string;
}
return "http://127.0.0.1:2583";
}
function appviewBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
return (import.meta as any).env.VITE_APPVIEW_URL as string;
}
return "http://127.0.0.1:2584";
}
</script>
@@ -367,7 +462,7 @@
<LoginScreen
onLogin={(s) => {
currentUser = s;
view = "home";
setView("home");
}}
/>
</div>
@@ -375,11 +470,10 @@
<div class="shell">
<NavRail
{view}
on_select={(v) => {
view = v;
}}
on_select={(v) => setView(v)}
/>
<div class="main">
<div class="main-inner">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
{#if view === "home"}
<div class="head">
@@ -388,6 +482,20 @@
<span class="as">@{currentUser.handle}</span>
<span class="meta">{userPosts.length} posts · polling every 5s</span>
</div>
<nav class="tabs" aria-label="Timeline">
<button
class="tab"
type="button"
disabled
title="for you — algo coming soon"
>for you</button>
<button
class="tab"
class:tab--active={homeTab === "following"}
type="button"
onclick={() => (homeTab = "following")}
>following</button>
</nav>
{#if timelineError}
<div class="toast toast--err">err: {timelineError}</div>
{/if}
@@ -408,14 +516,14 @@
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} /></div>
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} />
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
{#if timelineCursor}
<div class="loadmore">
@@ -432,73 +540,165 @@
<span class="as">@{currentUser.handle}</span>
<span class="meta">⌘↵ to post</span>
</div>
<ComposeBox onPosted={handlePosted} />
{:else if view === "profile"}
<ComposeBox
onPosted={handlePosted}
replyTo={replyTo}
onClearReply={clearReply}
/>
{:else if view === "user"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
<span class="as">@{selectedHandle}</span>
</div>
{#if profileLoading && !profile}
<Skeleton rows={4} />
{:else if profileError}
<div class="toast toast--err">err: {profileError}</div>
{:else if profile}
<section class="profile">
<header class="profile__head">
<span class="profile__handle">{displayHandle(profile.handle)}</span>
<span class="profile__did" title={profile.did}>{profile.did}</span>
</header>
<div class="profile__actions">
<button
class="btn btn--ghost"
type="button"
title="Copy DID to clipboard"
onclick={() => copyToClipboard(profile!.did)}
>copy did</button>
<button
class="btn btn--ghost"
type="button"
title="Copy AT URI to clipboard"
onclick={() =>
copyToClipboard(`at://${profile!.did}/app.twi.post`)}
>copy at-uri</button>
</div>
<dl class="counts">
<div>
<dt>followers</dt>
<dd>{profile.followers}</dd>
</div>
<div>
<dt>following</dt>
<dd>{profile.following}</dd>
</div>
<div>
<dt>posts</dt>
<dd>{profile.posts.length}</dd>
</div>
</dl>
{#if profile.posts.length === 0}
<div class="empty">// no posts yet</div>
{:else}
{#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
{/each}
{/if}
</section>
<ProfileView
handle={selectedHandle}
on_thread_click={openThread}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
{#if currentUser}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
<ProfileView
handle={currentUser.handle}
on_thread_click={openThread}
current_user_did={currentUser.did}
/>
{/if}
{:else if view === "settings"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// settings</span>
<span class="meta">@{currentUser?.handle ?? "?"}</span>
</div>
<section class="settings">
<!-- Account — X-style rows: label left, value right, full-width clickable -->
<div class="settings__group">
<h3 class="settings__h3">// account</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">handle</span>
<span class="settings__value">@{currentUser?.handle ?? "?"}</span>
</div>
<div class="settings__row">
<span class="settings__label">did</span>
<code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
</div>
<div class="settings__row">
<span class="settings__label">posts cached</span>
<span class="settings__value">{userPosts.length}</span>
</div>
</div>
<div class="settings__actions">
<button
class="settings__action"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>
<span>copy did</span>
<span class="settings__action-hint">atproto</span>
</button>
<button
class="settings__action"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>
<span>open profile in browser</span>
<span class="settings__action-hint">↗ bsky.app</span>
</button>
<button
class="settings__action"
type="button"
onclick={() => setView("home")}
>
<span>← back to timeline</span>
</button>
</div>
</div>
<!-- Backend / connection info — same row pattern -->
<div class="settings__group">
<h3 class="settings__h3">// backend</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">app</span>
<span class="settings__value">maarcadetweet</span>
</div>
<div class="settings__row">
<span class="settings__label">version</span>
<span class="settings__value">0.1.0</span>
</div>
<div class="settings__row">
<span class="settings__label">pds</span>
<code class="settings__value settings__value--mono">{pdsBase()}</code>
</div>
<div class="settings__row">
<span class="settings__label">appview</span>
<code class="settings__value settings__value--mono">{appviewBase()}</code>
</div>
</div>
</div>
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
<div class="settings__group settings__group--danger">
<div class="settings__list">
<button
class="settings__action settings__action--danger"
type="button"
onclick={handleLogout}
>
<span>sign out</span>
<span class="settings__action-hint"></span>
</button>
</div>
</div>
</section>
{:else if view === "search"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// search</span>
<span class="title">// search</span>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
</div>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
<nav class="tabs" aria-label="Search sections">
<button
class="tab"
class:tab--active={searchTab === "top"}
type="button"
onclick={() => (searchTab = "top")}
>top</button>
<button
class="tab"
type="button"
disabled
title="latest — coming soon"
>latest</button>
<button
class="tab"
type="button"
disabled
title="people — coming soon"
>people</button>
<button
class="tab"
type="button"
disabled
title="photos — coming soon"
>photos</button>
</nav>
{#if searchError}
<div class="toast toast--err">err: {searchError}</div>
{/if}
@@ -511,11 +711,15 @@
{:else}
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
{#each searchResults as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
{/if}
{/if}
</Terminal>
{#if view === "home"}
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
{/if}
</div>
</div>
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
</div>
@@ -595,19 +799,61 @@
"rail status";
min-height: 0;
}
.shell > :global(nav.rail) { grid-area: rail; }
.shell > :global(.statusbar) { grid-area: status; }
/* NavRail and StatusBar self-assign their own grid-area
(`grid-area: rail` / `grid-area: status`) in their component
styles, so the parent doesn't need any :global() child
selectors. The `.main` slot is just the next sibling; we set
its grid-area explicitly below. */
.main {
grid-area: main;
overflow: auto;
padding: var(--s-3);
}
.profile__actions {
.main-inner {
display: flex;
gap: var(--s-2);
margin: var(--s-3) 0;
gap: var(--s-3);
align-items: flex-start;
min-width: 0;
}
.main-inner > :global(.terminal) {
flex: 1 1 auto;
min-width: 0;
}
/* Tab strip — mirrors the ProfileView's `.tab` pattern so the
home + search tabs read as siblings of the profile tabs. */
.tabs {
display: flex;
border-bottom: 1px solid var(--line);
margin: 0 0 var(--s-3);
}
.tab {
flex: 1;
background: none;
border: 0;
padding: var(--s-3);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.tab:hover:not(:disabled) {
color: var(--text);
}
.tab:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab--active {
color: var(--orange);
border-bottom-color: var(--orange);
font-weight: 700;
}
.head {
font-family: var(--font-mono);
font-size: var(--fs-50);
@@ -680,51 +926,6 @@
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.profile {
padding: 0 var(--s-3);
}
.profile__head {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-3) 0 var(--s-4);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s-3);
}
.profile__handle {
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--fs-200);
color: var(--orange);
}
.profile__did {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
word-break: break-all;
}
.counts {
display: flex;
gap: var(--s-6);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
.counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
.toasts {
position: fixed;
right: var(--s-4);
@@ -753,4 +954,125 @@
border-color: var(--red);
background: rgba(255, 59, 48, 0.08);
}
/* (the legacy .btn--danger class used to be applied to the
"sign out" button — that's now styled via
`.settings__group--danger .settings__action` which is its own
selector tree in the settings section below) */
/* X-style settings page: sectioned cards with label-left /
value-right rows, then a list of clickable action rows, then
a danger zone at the bottom. Stays monospace + terminal-
commented, but the structure is the same as X's. */
.settings {
padding: 0 var(--s-3) var(--s-6);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.settings__group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.settings__h3 {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
margin: 0;
font-weight: 700;
}
.settings__list {
display: flex;
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
/* Each row is a label-left / value-right flex line, separated
by a hairline (X uses a single border on each row except the
last). */
.settings__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.settings__list .settings__row:last-child {
border-bottom: 0;
}
.settings__label {
color: var(--text-dim);
letter-spacing: var(--tracking-label);
flex: 0 0 auto;
}
.settings__value {
color: var(--text);
text-align: right;
word-break: break-all;
min-width: 0;
}
.settings__value--mono {
font-size: var(--fs-50);
}
/* Actions live in their own list — same border-radius but each
item is a full-width clickable button. The hint on the right
(e.g. "atproto", "↗ bsky.app") is a dim secondary label, the
same way X shows the destination on follow / open-in-app
rows. */
.settings__actions {
display: flex;
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.settings__action {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
text-align: left;
cursor: pointer;
transition: background-color var(--dur) var(--ease),
color var(--dur) var(--ease);
}
.settings__actions .settings__action:last-child {
border-bottom: 0;
}
.settings__action:hover {
background: var(--orange-8);
color: var(--orange);
}
.settings__action-hint {
color: var(--text-dim);
font-size: var(--fs-50);
}
.settings__action:hover .settings__action-hint {
color: var(--orange);
}
.settings__group--danger .settings__action {
color: var(--red);
}
.settings__group--danger .settings__action:hover {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
}
.settings__group--danger {
margin-top: var(--s-3);
}
</style>
+164 -7
View File
@@ -24,18 +24,73 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
return invoke<T>(cmd, args);
}
/// Base URLs the Tauri shell was started with. Exposed via the
/// `get_api_urls` command so the Svelte components can build
/// absolute fetch URLs — a relative `/api/...` resolves against
/// the Vite dev origin (port 1430), not the AppView (port 2584),
/// and `response.json()` then throws `SyntaxError` on the 404
/// HTML page. Cached after the first successful call.
let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null;
export type ApiUrls = { pdsUrl: string; appviewUrl: string };
/// Fetch the AppView + PDS base URLs from the Rust shell. Returns
/// the cached value on subsequent calls.
export async function getApiUrls(): Promise<ApiUrls> {
if (_apiUrlsCache) return _apiUrlsCache;
const urls = await safeInvoke<ApiUrls>("get_api_urls");
_apiUrlsCache = urls;
return urls;
}
/// Convenience: just the AppView base URL (the only one the UI
/// currently needs for direct fetch calls). Same caching as
/// `getApiUrls`.
export async function getAppviewUrl(): Promise<string> {
const { appviewUrl } = await getApiUrls();
return appviewUrl;
}
/**
* Strict variant of `tauriCall` for actions that MUST hit the
* Tauri runtime (login, register, logout, post, like, etc.). In
* the browser preview this throws a friendly Error so the UI can
* show a "running in browser preview" notice. In the Tauri
* webview it falls through to a normal `invoke` call.
*
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
* the PDS rejects our token with `TokenInvalid` (the rusty
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
* we ask the Rust shell for a fresh access JWT via the
* `auth_refresh` Tauri command. The Rust side reads the stored
* refresh JWT (valid for 90 days) and rotates both. We retry
* exactly once on the same `cmd` + `args`. The `auth_*` commands
* themselves are skipped so a failing login doesn't trigger an
* infinite refresh loop.
*/
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauri()) {
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
}
return invoke<T>(cmd, args);
try {
return await invoke<T>(cmd, args);
} catch (e: unknown) {
if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e;
const fresh = await session.refresh();
if (!fresh) throw e;
return await invoke<T>(cmd, args);
}
}
/// Sniff out a `TokenInvalid` response from the Rust error string.
/// Returns true when the error message looks like an expired/
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
function isTokenInvalid(e: unknown): boolean {
if (typeof e !== "object" || e === null) return false;
const msg = (e as { message?: string }).message ?? String(e);
if (!msg) return false;
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
}
export type Session = {
@@ -47,9 +102,35 @@ export type Session = {
function createSessionStore() {
const { subscribe, set } = writable<Session | null>(null);
// Coalesce concurrent refresh requests into one — every safeInvoke
// call that hits a 401 would otherwise race to call auth_refresh in
// parallel. The pending promise is reset to `null` exactly once in
// the finally block; subsequent callers await the same one.
let pendingRefresh: Promise<Session | null> | null = null;
return {
subscribe,
/// Mint a fresh access JWT from the stored refresh JWT. Called
/// automatically by [`safeInvoke`] on `TokenInvalid` responses.
/// Returns the new session, or `null` if the refresh itself failed
/// (e.g. refresh JWT expired; at that point the user has to log
/// in again).
async refresh(): Promise<Session | null> {
if (pendingRefresh) return pendingRefresh;
pendingRefresh = (async () => {
try {
const s = await invoke<Session>("auth_refresh");
set(s);
return s;
} catch (e) {
console.warn("session refresh failed", e);
return null;
} finally {
pendingRefresh = null;
}
})();
return pendingRefresh;
},
async load() {
const s = await tauriCall<Session | null>("current_session", null);
set(s);
@@ -137,6 +218,11 @@ export type Post = {
embed?: Embed | null;
langs: string[];
created_at: string;
like_count?: number;
repost_count?: number;
/// Resolved author-avatar CID from the AppView's `profiles`
/// cache. NULL when the user has no profile record yet.
avatar_cid?: string | null;
};
export type TimelineResponse = {
@@ -150,6 +236,11 @@ export type ProfileResponse = {
posts: Post[];
followers: number;
following: number;
display_name?: string | null;
description?: string | null;
avatar_cid?: string | null;
banner_cid?: string | null;
post_count: number;
};
export type SearchResponse = {
@@ -174,9 +265,20 @@ export type ThreadResponse = {
repost_count?: number;
};
/// Reply block for `app.bsky.feed.post#reply`. Both `root` and
/// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a
/// top-level reply to a single post, `root` and `parent` point at
/// the same strongRef. The Rust `post_create` command wires this
/// onto the record's `reply` field.
export type ReplyRef = {
root: { uri: string; cid: string };
parent: { uri: string; cid: string };
};
export async function createPost(
text: string,
embed?: unknown | null,
reply?: ReplyRef | null,
): Promise<Post> {
// The Rust post_create command returns a different shape (uri+cid
// only), but we keep the call simple: it gives us the cid we need
@@ -184,9 +286,12 @@ export async function createPost(
// `embed` is forwarded verbatim; the caller is responsible for
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
// record. Pass `null` or `undefined` to omit.
// `reply` is the reply block (root + parent strongRefs); `null` or
// `undefined` means "top-level post" (no reply block on the record).
return await safeInvoke<any>("post_create", {
text,
embed: embed ?? null,
reply: reply ?? null,
});
}
@@ -316,10 +421,28 @@ export async function unrepostPost(
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
}
/// Fire-and-forget user-visible error toast. Implemented as a
/// `window` `CustomEvent` so any component can show errors without
/// pulling in a global store. `App.svelte` listens for the event
/// and renders the toast UI.
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
/// record without needing a "list my follows" round-trip.
export async function followUser(
targetDid: string,
): Promise<RepoWriteResult> {
return await safeInvoke<RepoWriteResult>("follow_user", { targetDid });
}
export async function unfollowUser(
followUri: string,
): Promise<DeleteRecordResult> {
return await safeInvoke<DeleteRecordResult>("unfollow_user", {
followUri,
});
}
/// Fire-and-forget user-visible toast. Implemented as a `window`
/// `CustomEvent` so any component can show toasts without pulling
/// in a global store. `App.svelte` listens for the event and
/// renders the toast UI.
export function showError(text: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
@@ -327,6 +450,13 @@ export function showError(text: string): void {
);
}
export function showInfo(text: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", text } }),
);
}
/// Show a native OS notification. Thin wrapper around the
/// `show_notification` Tauri command. The Rust side also emits an
/// `app://notification` event with the same payload, so the click
@@ -355,13 +485,13 @@ export async function showNotification(
/// The handler receives the event payload (empty object for these
/// cases). Returns an unsubscribe function.
export async function listenTrayEvents(
handler: (event: "show" | "home" | "compose" | "profile" | "search") => void,
handler: (event: "show" | "home" | "compose" | "profile" | "search" | "settings") => void,
): Promise<() => void> {
const { listen } = await import("@tauri-apps/api/event");
const unlisteners: Array<() => void> = [];
const u1 = await listen("app://show", () => handler("show"));
const u2 = await listen("app://navigate", (e) => {
handler(e.payload as "home" | "profile" | "search");
handler(e.payload as "home" | "profile" | "search" | "settings");
});
const u3 = await listen("app://compose", () => handler("compose"));
unlisteners.push(u1, u2, u3);
@@ -375,6 +505,33 @@ export async function listenTrayEvents(
/// browser preview where no Tauri runtime is present, fall back
/// to `window.open` and treat a popup-blocker denial as
/// "fine, user can copy the URL themselves".
export type ProfileRecord = {
displayName?: string;
description?: string;
avatar?: { ref: { $link: string }; mimeType?: string; size?: number };
banner?: { ref: { $link: string }; mimeType?: string; size?: number };
};
/// Read the authenticated user's `app.bsky.actor.profile` record.
/// Returns `null` if no profile record exists yet (a brand-new
/// account, or a user whose PDS hasn't pushed one).
export async function getMyProfile(): Promise<ProfileRecord | null> {
return await safeInvoke<ProfileRecord | null>("profile_get_record");
}
/// Read-modify-write the authenticated user's profile. The Rust
/// `profile_set` command fetches the existing record, overlays
/// the supplied fields, and writes a new commit. `undefined` fields
/// are preserved.
export async function setMyProfile(fields: {
displayName?: string;
description?: string;
avatarBlobCid?: string;
bannerBlobCid?: string;
}): Promise<ProfileRecord | null> {
return await safeInvoke<ProfileRecord | null>("profile_set", fields);
}
export async function openExternalUrl(url: string): Promise<void> {
try {
if (isTauri()) {
@@ -0,0 +1,92 @@
<script lang="ts">
import { fetchBlob } from "../api/client";
import { onDestroy } from "svelte";
type Props = {
did: string;
/** Blob-ref `$link` from a posts/post record or a profile
* record. The Avatar component resolves the (did, cid) pair via
* the existing PDS `getBlob` route through `fetchBlob`. NULL
* falls back to the deterministic initial-letter SVG. */
cid?: string | null;
/** Human-readable name used for the initial-letter fallback and
* the alt text. */
name?: string | null;
/** Pixel size. The same component is used at 24 px (PostCard),
* 32 px (Header current-user avatar) and 88 px (Profile-View
* header). */
size?: number;
};
let { did, cid = null, name = "", size = 32 }: Props = $props();
const initial = $derived(
((name || "").trim()[0] || "?").toUpperCase(),
);
let blobUrl: string | null = $state(null);
let lastCid: string | null = null;
$effect(() => {
// Drop the previous blob URL when the CID changes — keeps the
// in-memory cache (managed by `fetchBlob`) lean and avoids leaking
// object URLs across navigations.
if (lastCid !== cid) {
if (blobUrl) URL.revokeObjectURL(blobUrl);
blobUrl = null;
lastCid = cid;
}
if (!cid) return;
let cancelled = false;
fetchBlob(did, cid)
.then((u) => {
if (!cancelled) blobUrl = u;
else URL.revokeObjectURL(u);
})
.catch(() => {
/* Fall back to the initial letter on fetch error. */
});
return () => {
cancelled = true;
};
});
onDestroy(() => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
});
</script>
{#if blobUrl}
<img
class="avatar"
style:width="{size}px"
style:height="{size}px"
src={blobUrl}
alt={name ? `${name}'s avatar` : "avatar"}
/>
{:else}
<span
class="avatar avatar--fallback"
style:width="{size}px"
style:height="{size}px"
style:font-size="{Math.max(10, Math.floor(size * 0.45))}px"
>
{initial}
</span>
{/if}
<style>
.avatar {
display: inline-block;
border-radius: 50%;
object-fit: cover;
background: var(--bg-elev, #1a1a1a);
flex-shrink: 0;
}
.avatar--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono, monospace);
color: var(--text-dim, #888);
border: 1px solid var(--line-2, #3a3a3a);
}
</style>
@@ -8,27 +8,37 @@
releaseBlob,
session,
type Post,
type Session,
type ReplyRef,
} from "../api/client";
import Avatar from "./Avatar.svelte";
/// Reply target. The parent/root strongRefs are required so the
/// resulting post can carry the `reply` block on its record.
type ReplyTarget = {
handle: string;
root: { uri: string; cid: string };
parent: { uri: string; cid: string };
};
type Props = {
onPosted?: () => void;
replyTo?: ReplyTarget | null;
onClearReply?: () => void;
};
const MAX = 160;
let { onPosted }: { onPosted?: () => void } = $props();
let text: string = $state("");
let isPosting: boolean = $state(false);
let isAttaching: boolean = $state(false);
let { onPosted, replyTo = null, onClearReply }: Props = $props();
let text = $state("");
let isPosting = $state(false);
let isAttaching = $state(false);
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
let currentUser: Session | null = $state(null);
// Currently logged-in user. We need the DID for `fetchBlob` (the
// PDS endpoint keys blobs by `(did, cid)`), so the compose box
// subscribes to the session store rather than taking a prop.
let did: string = $state("");
$effect(() => {
const u = $session;
did = u?.did ?? "";
currentUser = $session;
});
// The currently-attached image. `null` = no attachment. We hold
// the blob reference + a local object URL for the preview so the
// user sees the image before they post.
let attachment: {
cid: string;
mimeType: string;
@@ -36,267 +46,417 @@
previewUrl: string;
} | null = $state(null);
let remaining = $derived(MAX - text.length);
let counterClass = $derived(
remaining < 0 ? "counter counter--err" :
remaining < 40 ? "counter counter--warn" : "counter"
// Count graphemes, not UTF-16 code units. atproto enforces
// `maxLength: 160` as graphemes, so a single 🚀 (surrogate pair)
// must count as 1, not 2. `Intl.Segmenter` is built into the
// runtime — no dependency needed.
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
const count = $derived(text.trim().length === 0 ? 0 : [...seg.segment(text)].length);
const isTooLong = $derived(count > MAX);
const isAtMax = $derived(count === MAX);
// atproto's `maxLength: 160` is inclusive of the 160th grapheme
// — the spec rejects any record whose text length exceeds 160.
// So we treat `count === MAX` as "exactly at the cap, still
// shippable" and only flag as an error on strict overflow.
const counterClass = $derived(
isTooLong
? "counter counter--err"
: isAtMax
? "counter counter--warn"
: count >= MAX - 30
? "counter counter--warn"
: "counter",
);
const canPost = $derived(!!text.trim() && !isTooLong && !isPosting);
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
post();
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void post();
}
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
function fmtBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
async function attach() {
if (isAttaching || attachment) return;
if (!did) {
status = { kind: "err", msg: "> log in first" };
if (!currentUser?.did) {
status = { kind: "err", msg: "log in to add an image" };
return;
}
isAttaching = true;
status = { kind: "info", msg: "> picking…" };
status = null;
try {
const blob = await pickAndUploadImage();
if (!blob) {
// User cancelled — restore the previous status rather than
// leaving the "picking…" message on screen.
status = null;
return;
}
// Fetch the bytes back from the PDS so we can render the
// preview. `fetchBlob` caches by CID, so re-rendering the
// preview after a re-attach is cheap.
const previewUrl = await fetchBlob(did, blob.cid);
if (!blob) return;
const previewUrl = await fetchBlob(currentUser.did, blob.cid);
attachment = { ...blob, previewUrl };
status = { kind: "info", msg: `> attached (${fmtBytes(blob.size)})` };
} catch (e) {
status = { kind: "err", msg: `> ${String(e)}` };
} catch (error) {
status = { kind: "err", msg: String(error) };
} finally {
isAttaching = false;
}
}
function removeAttachment() {
if (attachment) {
// Revoke the object URL. `fetchBlob` may have evicted the
// cache entry for a different reason, so tolerate a no-op.
// The user can re-attach — the next fetch will allocate a
// fresh URL.
releaseBlob(did, attachment.cid);
attachment = null;
}
if (!attachment || !currentUser?.did) return;
releaseBlob(currentUser.did, attachment.cid);
attachment = null;
}
async function post() {
if (!text.trim() || remaining < 0 || isPosting) return;
if (!canPost) return;
isPosting = true;
status = { kind: "info", msg: "> posting…" };
status = { kind: "info", msg: "posting…" };
try {
const embed = attachment ? makeImagesEmbed(attachment) : null;
const r: Post = await createPost(text, embed);
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"})` };
const reply: ReplyRef | null = replyTo
? { root: replyTo.root, parent: replyTo.parent }
: null;
const response: Post = await createPost(text.trim(), embed, reply);
status = {
kind: "ok",
msg: `posted · cid ${(response as any).cid?.slice?.(0, 8) ?? "?"}…`,
};
text = "";
removeAttachment();
onPosted?.();
} catch (e) {
status = { kind: "err", msg: `> ${String(e)}` };
showError(`post failed: ${e}`);
} catch (error) {
status = { kind: "err", msg: String(error) };
showError(`post failed: ${error}`);
} finally {
isPosting = false;
}
}
</script>
<div class="compose">
<div class="compose__head">
<span class="title">// compose</span>
<span class="handle">@you</span>
<span class={counterClass}>{remaining}</span>
<section class="compose" aria-label={replyTo ? `Reply to @${replyTo.handle}` : "Compose a post"}>
<div class="compose__avatar">
<Avatar
did={currentUser?.did ?? ""}
name={currentUser?.handle ?? "you"}
size={40}
/>
</div>
<div class="compose__body">
<span class="prompt">$</span>
<div class="compose__content">
{#if replyTo}
<div class="replying">
<span>Replying to <b>@{replyTo.handle}</b></span>
<button type="button" onclick={onClearReply} title="cancel reply" aria-label="Cancel reply">×</button>
</div>
{/if}
<textarea
bind:value={text}
onkeydown={handleKeydown}
placeholder="// what's happening in 160 chars?"
placeholder={replyTo ? `Reply to @${replyTo.handle}` : "What's happening?"}
rows="3"
maxlength="500"
maxlength={MAX}
aria-label="Post text"
></textarea>
</div>
{#if attachment}
<div class="compose__attach">
<img
class="compose__preview"
src={attachment.previewUrl}
alt="attachment preview"
/>
<div class="compose__attach-meta">
<span class="compose__attach-cid" title={attachment.cid}>cid: {attachment.cid.slice(0, 10)}…</span>
<span class="compose__attach-mime">{attachment.mimeType}</span>
<span class="compose__attach-size">{fmtBytes(attachment.size)}</span>
{#if attachment}
<div class="attachment">
<img src={attachment.previewUrl} alt="Attachment preview" />
<div class="attachment__meta">
<span>{attachment.mimeType}</span>
<span>{fmtBytes(attachment.size)}</span>
</div>
<button
type="button"
class="attachment__remove"
onclick={removeAttachment}
disabled={isPosting}
title="remove image"
aria-label="Remove image"
>×</button>
</div>
{/if}
<div class="compose__footer">
<button
type="button"
class="compose__attach-remove"
onclick={removeAttachment}
disabled={isPosting}
title="remove attachment"
>×</button>
</div>
{/if}
<div class="compose__foot">
<span class="hint">⌘↵ to post</span>
<div class="actions">
<button
type="button"
class="btn btn--ghost"
class="media-button"
onclick={attach}
disabled={isAttaching || !!attachment || isPosting}
title={attachment ? "image already attached" : "attach image"}
title={attachment ? "one image already attached" : "add image"}
>
{isAttaching ? "picking…" : "📎"}
</button>
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
{isPosting ? "posting…" : "post"}
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9" r="1.5" />
<path d="m4 17 5-5 4 4 3-3 4 4" />
</svg>
<span>{isAttaching ? "adding…" : "image"}</span>
</button>
<div class="compose__submit">
<span class={counterClass}>{count}/{MAX}</span>
<span class="divider" aria-hidden="true"></span>
<button
class="post-button"
type="button"
onclick={post}
disabled={!canPost}
>
{isPosting ? "Posting…" : replyTo ? "Reply" : "Post"}
</button>
</div>
</div>
{#if status}
<div class="status status--{status.kind}" role="status">{status.msg}</div>
{/if}
</div>
{#if status}
<div class="status status--{status.kind}">{status.msg}</div>
{/if}
</div>
</section>
<style>
.compose {
background: var(--bg-elev);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
margin: var(--s-4) var(--s-5);
}
.compose__head {
display: flex;
align-items: center;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: var(--bg-deep);
padding: var(--s-4);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.title { color: var(--orange); }
.handle { color: var(--text-dim); flex: 1; }
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
.counter--warn { color: var(--orange); }
.counter--err { color: var(--red); letter-spacing: 0.05em; }
.compose__body {
display: flex;
align-items: flex-start;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
}
.prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
line-height: 1.7;
}
textarea {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-100);
line-height: 1.6;
resize: vertical;
outline: none;
padding: 0;
}
textarea::placeholder { color: var(--text-dim); }
.compose__attach {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--bg-deep);
border-top: 1px dashed var(--line);
}
.compose__preview {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg);
.compose__avatar {
padding-top: 2px;
}
.compose__attach-meta {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
.compose__content {
min-width: 0;
}
.compose__attach-cid { color: var(--cid-fg); }
.compose__attach-mime,
.compose__attach-size { font-variant-numeric: tabular-nums; }
.compose__attach-remove {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
width: 28px;
height: 28px;
border-radius: var(--r-sm);
cursor: pointer;
line-height: 1;
}
.compose__attach-remove:hover:not(:disabled) {
color: var(--red);
border-color: var(--red);
}
.compose__attach-remove:disabled { opacity: 0.4; cursor: not-allowed; }
.compose__foot {
.replying {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-2) var(--s-4);
border-top: 1px solid var(--line);
}
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
.actions { display: flex; gap: var(--s-2); }
.btn {
margin-bottom: var(--s-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid transparent;
}
.replying b {
color: var(--orange);
font-weight: 400;
}
.replying button {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--text-dim);
font: inherit;
cursor: pointer;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.status {
.replying button:hover {
background: var(--orange-8);
color: var(--orange);
}
textarea {
display: block;
width: 100%;
min-height: 88px;
padding: var(--s-1) 0 var(--s-3);
resize: vertical;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-200);
line-height: 1.45;
}
textarea::placeholder {
color: var(--text-dim);
}
.attachment {
position: relative;
margin-bottom: var(--s-3);
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--bg);
}
.attachment img {
width: 100%;
max-height: 320px;
object-fit: cover;
}
.attachment__meta {
display: flex;
justify-content: space-between;
padding: var(--s-2) var(--s-3);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-4);
}
.attachment__remove {
position: absolute;
top: var(--s-2);
right: var(--s-2);
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--line-2);
border-radius: var(--r-pill);
background: var(--bg-elev);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
cursor: pointer;
}
.attachment__remove:hover:not(:disabled) {
border-color: var(--orange);
color: var(--orange);
}
.compose__footer,
.compose__submit {
display: flex;
align-items: center;
}
.compose__footer {
min-height: 40px;
justify-content: space-between;
gap: var(--s-3);
padding-top: var(--s-2);
border-top: 1px solid var(--line);
}
.status--ok { color: var(--green); }
.status--err { color: var(--red); }
.status--info { color: var(--orange); }
</style>
.compose__submit {
gap: var(--s-3);
}
.media-button {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-2);
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-50);
cursor: pointer;
}
.media-button:hover:not(:disabled) {
background: var(--orange-8);
}
.media-button svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.media-button:disabled,
.post-button:disabled,
.attachment__remove:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.counter {
min-width: 5.5rem;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
text-align: right;
}
.counter--warn {
color: var(--orange);
}
.counter--err {
color: var(--red);
}
.divider {
width: 1px;
height: 24px;
background: var(--line-2);
}
.post-button {
min-width: 76px;
padding: 0.55rem 1rem;
border: 0;
border-radius: var(--r-pill);
background: var(--orange);
color: var(--bg-deep);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
cursor: pointer;
transition: background var(--dur) var(--ease);
}
.post-button:hover:not(:disabled) {
background: var(--orange-bright);
}
.status {
margin-top: var(--s-2);
padding-top: var(--s-2);
border-top: 1px dashed var(--line);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.status--ok {
color: var(--green);
}
.status--err {
color: var(--red);
}
.status--info {
color: var(--orange);
}
@media (max-width: 560px) {
.compose {
padding-inline: var(--s-3);
}
textarea {
font-size: var(--fs-100);
}
.media-button span,
.divider {
display: none;
}
}
</style>
@@ -4,7 +4,10 @@
let { onLogin }: { onLogin: (s: Session) => void } = $props();
let mode: "login" | "register" = $state("register");
// Default to "login" — most users opening the app already have an
// account, and the empty-autocomplete form now matches the
// action-label pair they expect. "register" is one click away.
let mode: "login" | "register" = $state("login");
let handle: string = $state("");
let password: string = $state("");
let busy = $state(false);
@@ -42,47 +45,46 @@
<div class="t">maarcadetweet — {mode}</div>
</div>
<div class="terminal-body">
<div class="line">
<span class="prompt">$</span> maarcadetweet {mode}
</div>
<div class="line muted">// the timeline that fits in 160 chars.</div>
<div class="line">&nbsp;</div>
<h1 class="brand">maarcadetweet</h1>
<p class="tagline">// the timeline that fits in 160 chars.</p>
{#if serverInfo}
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
<div class="meta">
<span>// pds: {serverInfo.did ?? "?"}</span>
<span>// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</span>
</div>
{/if}
<div class="line">&nbsp;</div>
<div class="form">
<label>
<span class="key">handle:</span>
<form class="form" onsubmit={(e) => { e.preventDefault(); submit(); }}>
<label class="field">
<span class="key">handle</span>
<input
type="text"
bind:value={handle}
placeholder="alice.maarcadetweet.local"
disabled={busy}
autocomplete="username"
/>
</label>
<label>
<span class="key">password:</span>
<label class="field">
<span class="key">password</span>
<input
type="password"
bind:value={password}
placeholder="≥ 8 chars"
disabled={busy}
onkeydown={(e) => e.key === "Enter" && submit()}
autocomplete={mode === "register" ? "new-password" : "current-password"}
/>
</label>
</div>
</form>
{#if error}
<div class="line err">error: {error}</div>
<div class="err">err: {error}</div>
{/if}
<div class="line">&nbsp;</div>
<div class="line">
<div class="actions">
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
{busy ? "..." : mode === "register" ? "create account" : "login"}
{busy ? "..." : mode === "register" ? "create account" : "log in"}
</button>
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
{mode === "register" ? "have an account? login" : "no account? register"}
{mode === "register" ? "have an account? log in" : "no account? register"}
</button>
</div>
</div>
@@ -94,7 +96,8 @@
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
overflow: hidden;
width: min(560px, 92vw);
width: min(480px, 92vw);
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
}
.terminal-head {
display: flex;
@@ -121,49 +124,109 @@
}
.terminal-body {
font-family: var(--font-mono);
font-size: 0.95rem;
line-height: 1.85;
padding: var(--s-5);
padding: var(--s-6) var(--s-5);
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.brand {
margin: 0;
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-400);
font-weight: 700;
letter-spacing: var(--tracking-tight);
line-height: var(--lh-tight);
text-align: center;
}
.tagline {
margin: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
text-align: center;
}
.meta {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
text-align: center;
}
.line { white-space: pre-wrap; }
.muted { color: var(--text-dim); }
.prompt { color: var(--orange); }
.err { color: var(--red); }
.form {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin: var(--s-4) 0;
margin-top: var(--s-3);
}
.form label {
.field {
display: flex;
align-items: center;
gap: var(--s-3);
flex-direction: column;
gap: var(--s-1);
}
.key {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
}
.key { color: var(--orange); width: 90px; flex-shrink: 0; }
.form input {
flex: 1;
background: var(--bg);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
outline: none;
transition: border-color var(--dur) var(--ease);
}
.form input:focus { border-color: var(--orange); }
.btn {
.form input::placeholder { color: var(--text-dim); }
.err {
color: var(--red);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.5rem 0.8rem;
padding: var(--s-2) var(--s-3);
border-left: 3px solid var(--red);
background: rgba(255, 59, 48, 0.08);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.actions {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-top: var(--s-3);
}
.btn {
font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-3) var(--s-4);
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
margin-right: var(--s-2);
text-align: center;
transition:
background var(--dur) var(--ease),
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.btn--primary {
background: var(--orange);
color: #1a0d00;
font-weight: 700;
}
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
.btn--ghost {
background: transparent;
color: var(--text-dim);
border-color: var(--line-2);
}
.btn--ghost:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
@@ -4,7 +4,7 @@
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "search";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let {
view = "home",
@@ -19,6 +19,7 @@
{ id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" },
{ id: "settings", label: "settings", key: ",", icon: "settings" },
];
</script>
@@ -44,6 +45,11 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
</svg>
{:else if item.icon === "settings"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
@@ -56,7 +62,15 @@
</nav>
<style>
/* Self-assign the grid area so the parent App.svelte doesn't
need a `:global(nav.rail)` selector. Doing it via a parent
child-selector is fragile under Svelte 5's scoping — the
parent's `.shell.s-XXX > nav.rail` doesn't reliably match
`<nav class="rail s-YYY">` in the Tauri webview, which leaves
the buttons invisible to clicks. Owning the grid placement
here avoids the cross-component selector entirely. */
.rail {
grid-area: rail;
width: 88px;
background: var(--bg);
border-right: 1px solid var(--line);
@@ -29,10 +29,10 @@ async function mountHarness() {
}
describe("NavRail (callback-prop pattern)", () => {
it("renders 4 buttons with home active", async () => {
it("renders 5 buttons with home active", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(4);
expect(btns.length).toBe(5);
expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
});
@@ -56,5 +56,10 @@ describe("NavRail (callback-prop pattern)", () => {
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true);
});
});
@@ -5,7 +5,7 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "search";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let view: View = $state("home");
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,811 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import PostCard from "./PostCard.svelte";
import {
setMyProfile,
pickAndUploadImage,
fetchBlob,
releaseBlob,
getAppviewUrl,
followUser,
unfollowUser,
showInfo,
showError,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
type Props = {
handle: string;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable).
current_user_did?: string | null;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
type State =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: AppViewProfile };
type AppViewProfile = {
did: string;
handle: string;
posts: AppViewPost[];
followers: number;
following: number;
display_name?: string;
description?: string;
avatar_cid?: string;
banner_cid?: string;
post_count: number;
};
type AppViewPost = {
uri: string;
did: string;
handle: string;
rkey: string;
collection: string;
text: string;
cid: string;
parent_uri?: string | null;
root_uri?: string | null;
embed?: null;
langs: string[];
created_at: string;
avatar_cid?: string | null;
};
let editing: boolean = $state(false);
// Type-annotated so TypeScript keeps the discriminated-union narrowing
// when we do `viewModel.kind === "ready"` — otherwise $state infers
// the literal `"loading"` from the initial value and the `===`
// checks become "no overlap" errors.
let viewModel: State = $state({ kind: "loading" } as State);
let editName: string = $state("");
let editDesc: string = $state("");
let editAvatarCid: string | null = $state(null);
let saving: boolean = $state(false);
// Follow state — the AppView has no `viewer_followed` field yet, so
// we persist per-viewer follow state in localStorage (keyed by
// viewer-did + target-did). `followUri` is the URI of the
// `app.bsky.graph.follow` record on the viewer's PDS — the unfollow
// command needs it because atproto requires the rkey to delete a
// record, and we don't have a "list my follows" endpoint to look
// it up server-side.
let isFollowing: boolean = $state(false);
let followUri: string | null = $state(null);
let followBusy: boolean = $state(false);
// Banner blob URL — fetched via the same path as Avatar (Tauri
// getBlob via fetchBlob). Released on unmount or when banner
// changes.
let bannerUrl: string | null = $state(null);
let bannerCidLoaded: string | null = null;
async function load() {
viewModel = { kind: "loading" };
try {
// Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a
// 404 HTML page, and `r.json()` would throw `SyntaxError`.
const base = await getAppviewUrl();
const r = await fetch(`${base}/api/profile/${encodeURIComponent(handle)}`);
if (!r.ok) {
viewModel = {
kind: "error",
message: `profile fetch failed: ${r.status}`,
};
return;
}
const data: AppViewProfile = await r.json();
viewModel = { kind: "ready", data };
} catch (e) {
viewModel = { kind: "error", message: String(e) };
}
}
$effect(() => {
const bannerCid =
viewModel.kind === "ready" ? viewModel.data.banner_cid ?? null : null;
// Release the previous URL whenever the banner CID changes
// (including to/from null). We read the previous-loaded value
// through `untrack` because reading + writing `bannerCidLoaded`
// inside the same effect would trip Svelte 5's depth guard
// (`effect_update_depth_exceeded`).
const previous = untrack(() => bannerCidLoaded);
if (previous === bannerCid) return;
if (bannerUrl) {
if (viewModel.kind === "ready" && viewModel.data.did) {
releaseBlob(viewModel.data.did, previous ?? "");
}
URL.revokeObjectURL(bannerUrl);
bannerUrl = null;
}
bannerCidLoaded = bannerCid;
if (!bannerCid || viewModel.kind !== "ready") return;
const did = viewModel.data.did;
let cancelled = false;
fetchBlob(did, bannerCid)
.then((u) => {
if (!cancelled) bannerUrl = u;
else URL.revokeObjectURL(u);
})
.catch(() => {
/* fall back to CSS gradient placeholder */
});
return () => {
cancelled = true;
};
});
onMount(() => {
void load();
});
onDestroy(() => {
if (bannerUrl) URL.revokeObjectURL(bannerUrl);
});
const isOwn = $derived(
!!current_user_did &&
viewModel.kind === "ready" &&
viewModel.data.did === current_user_did,
);
const isEmptyProfile = $derived(
viewModel.kind === "ready" &&
!viewModel.data.display_name &&
!viewModel.data.description &&
!viewModel.data.avatar_cid &&
!viewModel.data.banner_cid,
);
// Restore follow state from localStorage whenever the profile
// (DID) changes. Writes are inside `untrack` so the effect's
// reactive dep set is just `[viewModel.kind, viewModel.data.did]`
// — without untrack, every write to `isFollowing` / `followUri`
// would re-enter the effect and trip Svelte's depth guard.
$effect(() => {
if (viewModel.kind !== "ready" || !current_user_did) return;
const did = viewModel.data.did;
const key = localStorageKey(`follow:${current_user_did}:${did}`);
untrack(() => {
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw) as {
following: boolean;
uri: string | null;
};
isFollowing = !!parsed.following;
followUri = parsed.uri ?? null;
} else {
isFollowing = false;
followUri = null;
}
} catch {
isFollowing = false;
followUri = null;
}
});
});
function persistFollow(following: boolean, uri: string | null) {
if (viewModel.kind !== "ready" || !current_user_did) return;
const did = viewModel.data.did;
const key = localStorageKey(`follow:${current_user_did}:${did}`);
try {
if (following) {
localStorage.setItem(
key,
JSON.stringify({ following: true, uri }),
);
} else {
localStorage.removeItem(key);
}
} catch {
/* quota / private mode — fall through */
}
}
async function onFollowClick() {
if (viewModel.kind !== "ready" || !current_user_did) return;
if (followBusy) return;
const targetDid = viewModel.data.did;
if (targetDid === current_user_did) return;
followBusy = true;
const wasFollowing = isFollowing;
const previousUri = followUri;
isFollowing = true;
try {
const resp = await followUser(targetDid);
followUri = resp.uri;
persistFollow(true, resp.uri);
showInfo("followed");
} catch (e) {
isFollowing = wasFollowing;
followUri = previousUri;
persistFollow(wasFollowing, previousUri);
showError(`follow failed: ${e}`);
} finally {
followBusy = false;
}
}
async function onUnfollowClick() {
if (viewModel.kind !== "ready") return;
if (followBusy) return;
if (!followUri) {
// Nothing to unfollow — clear the flag and bail.
isFollowing = false;
return;
}
followBusy = true;
const wasFollowing = isFollowing;
const previousUri = followUri;
isFollowing = false;
followUri = null;
persistFollow(false, null);
try {
await unfollowUser(previousUri!);
showInfo("unfollowed");
} catch (e) {
isFollowing = wasFollowing;
followUri = previousUri;
persistFollow(wasFollowing, previousUri);
showError(`unfollow failed: ${e}`);
} finally {
followBusy = false;
}
}
function openEdit() {
if (viewModel.kind !== "ready") return;
editName = viewModel.data.display_name ?? "";
editDesc = viewModel.data.description ?? "";
editAvatarCid = viewModel.data.avatar_cid ?? null;
editing = true;
}
async function saveProfile() {
if (viewModel.kind !== "ready") return;
saving = true;
try {
await setMyProfile({
displayName: editName || undefined,
description: editDesc || undefined,
avatarBlobCid: editAvatarCid || undefined,
});
editing = false;
await load();
} catch (e) {
console.warn("profile save failed", e);
} finally {
saving = false;
}
}
async function pickAndUploadAvatar() {
const blob = await pickAndUploadImage();
if (!blob) return;
editAvatarCid = blob.cid;
}
type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts");
</script>
<section class="profile">
<!-- ─── banner ────────────────────────────────────────────────── -->
<div
class="profile__banner"
style:background-image={bannerUrl ? `url(${bannerUrl})` : "none"}
>
{#if !bannerUrl}
<!--
Placeholder shown when the profile has no banner blob.
Subtle orange-tinted terminal grid — keeps the page from
looking bare without competing with the avatar.
-->
<div class="profile__banner-grid" aria-hidden="true"></div>
{/if}
</div>
<!-- ─── avatar + actions ──────────────────────────────────────── -->
<div class="profile__topbar">
<div class="profile__avatar-overlap">
{#if viewModel.kind === "ready"}
<Avatar
did={viewModel.data.did}
cid={viewModel.data.avatar_cid ?? null}
name={viewModel.data.display_name ?? viewModel.data.handle}
size={96}
/>
{:else}
<span class="profile__avatar-skeleton"></span>
{/if}
</div>
<div class="profile__actions">
{#if viewModel.kind === "ready"}
{#if isOwn}
{#if editing}
<button
class="btn btn--ghost"
type="button"
onclick={() => (editing = false)}
>cancel</button>
{:else}
<button
class="btn btn--primary"
type="button"
onclick={openEdit}
>edit profile</button>
{/if}
{:else}
<!--
Follow toggle. Text + class flip with `isFollowing`:
"follow" / `.btn--primary` (outlined-emphasis) when not
following, "following" / `.btn--ghost` (subdued) when
already following. The "following" click becomes an
unfollow via the same handler — X shows the relationship
state in the label, not a separate "unfollow" button.
-->
{#if isFollowing}
<button
class="btn btn--ghost profile__follow-btn profile__follow-btn--active"
type="button"
disabled={followBusy}
onclick={onUnfollowClick}
>following</button>
{:else}
<button
class="btn btn--primary profile__follow-btn"
type="button"
disabled={followBusy}
onclick={onFollowClick}
>follow</button>
{/if}
{/if}
{/if}
</div>
</div>
<!-- ─── identity ──────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<div class="profile__identity">
<h2 class="profile__name">
{viewModel.data.display_name ?? viewModel.data.handle}
</h2>
<div class="profile__handle">@{viewModel.data.handle}</div>
</div>
{:else if viewModel.kind === "loading"}
<div class="profile__identity">
<h2 class="profile__name profile__name--skeleton"></h2>
<div class="profile__handle">@{handle}</div>
</div>
{:else}
<div class="profile__identity profile__identity--err">
err: {viewModel.message}
</div>
{/if}
<!-- ─── bio ───────────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
{#if viewModel.data.description}
<p class="profile__bio">{viewModel.data.description}</p>
{:else if isEmptyProfile}
<p class="profile__bio profile__bio--empty">
{#if isOwn}
// no profile yet — click "edit profile" to set one up.
{:else}
// no profile yet.
{/if}
</p>
{/if}
{/if}
<!-- ─── meta (did) ───────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<div class="profile__meta">
<span class="profile__meta-item" title={viewModel.data.did}>
did: <code>{shortDid(viewModel.data.did)}</code>
</span>
</div>
{/if}
<!-- ─── counts ────────────────────────────────────────────────── -->
{#if viewModel.kind === "ready"}
<dl class="profile__counts">
<div>
<dt>posts</dt>
<dd>{viewModel.data.post_count}</dd>
</div>
<div>
<dt>followers</dt>
<dd>{viewModel.data.followers}</dd>
</div>
<div>
<dt>following</dt>
<dd>{viewModel.data.following}</dd>
</div>
</dl>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections">
<button
class="tab"
class:tab--active={activeTab === "posts"}
type="button"
onclick={() => (activeTab = "posts")}
>posts</button>
<button
class="tab"
class:tab--active={activeTab === "replies"}
type="button"
disabled
title="replies — coming soon"
>replies</button>
<button
class="tab"
class:tab--active={activeTab === "likes"}
type="button"
disabled
title="likes — coming soon"
>likes</button>
</nav>
<!-- ─── feed ──────────────────────────────────────────────────── -->
<div class="profile__feed">
{#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
{/each}
{#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div>
{/if}
{/if}
</div>
<!-- ─── edit form (own profile only) ─────────────────────────── -->
{#if editing && viewModel.kind === "ready"}
<div class="profile__edit">
<h3 class="profile__edit-title">// edit profile</h3>
<label class="profile__edit-field">
<span class="key">display name</span>
<input type="text" bind:value={editName} maxlength="64" />
</label>
<label class="profile__edit-field">
<span class="key">description</span>
<textarea
bind:value={editDesc}
rows="3"
maxlength="300"
></textarea>
</label>
<div class="profile__edit-field">
<span class="key">avatar</span>
<div class="profile__edit-row">
{#if editAvatarCid}
<span class="meta">cid: {editAvatarCid.slice(0, 10)}</span>
<button
class="btn btn--ghost"
type="button"
onclick={() => (editAvatarCid = null)}
>clear</button>
{:else}
<span class="meta">none</span>
{/if}
<button
class="btn btn--ghost"
type="button"
onclick={pickAndUploadAvatar}
>upload…</button>
</div>
</div>
<div class="profile__edit-actions">
<button
class="btn btn--primary"
type="button"
disabled={saving}
onclick={saveProfile}
>
{saving ? "saving…" : "save"}
</button>
</div>
</div>
{/if}
</section>
<script lang="ts" module>
/// Compact DID renderer for the profile meta line — keeps the
/// `did:plc:bafyreiczj…` from spilling past the column.
export function shortDid(did: string): string {
if (did.length <= 24) return did;
const head = did.slice(0, 18);
const tail = did.slice(-6);
return `${head}…${tail}`;
}
</script>
<style>
/* No outer padding — banner + avatar overhang make the section
fill the column edge to edge on mobile. */
/* ─── banner ─────────────────────────────────────────────── */
.profile__banner {
position: relative;
height: 140px;
overflow: hidden;
background-color: var(--bg-elev);
background-size: cover;
background-position: center;
}
.profile__banner-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(
135deg,
var(--bg-elev) 0%,
rgba(255, 102, 0, 0.12) 60%,
rgba(255, 102, 0, 0.04) 100%
),
repeating-linear-gradient(
0deg,
transparent 0,
transparent 27px,
rgba(255, 102, 0, 0.06) 27px,
rgba(255, 102, 0, 0.06) 28px
),
repeating-linear-gradient(
90deg,
transparent 0,
transparent 27px,
rgba(255, 102, 0, 0.06) 27px,
rgba(255, 102, 0, 0.06) 28px
);
}
/* ─── avatar + actions ──────────────────────────────────── */
.profile__topbar {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 0 var(--s-4);
margin-top: -44px;
min-height: 52px;
}
.profile__avatar-overlap {
border: 4px solid var(--bg);
border-radius: 50%;
background: var(--bg);
line-height: 0;
}
.profile__avatar-skeleton {
display: inline-block;
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--bg-elev);
}
.profile__actions {
padding-bottom: var(--s-3);
}
/* Follow button — X-style with two states. "follow" is the
full orange emphasis (btn--primary); "following" flips to a
ghost button that turns red on hover (mirroring X's
"unfollow on hover" affordance). */
.profile__follow-btn {
min-width: 6.5rem;
font-weight: 700;
}
.profile__follow-btn--active {
color: var(--text);
border-color: var(--line-2);
background: transparent;
}
.profile__follow-btn--active:hover:not(:disabled) {
/* X's "unfollow on hover" — replace label + colour with the
destructive cue, but only while actually hovering. */
color: var(--red);
border-color: var(--red);
background: rgba(255, 59, 48, 0.08);
}
/* ─── identity ──────────────────────────────────────────── */
.profile__identity {
padding: var(--s-3) var(--s-4) 0;
}
.profile__name {
font-family: var(--font-mono);
font-size: var(--fs-300);
font-weight: 700;
color: var(--text);
line-height: var(--lh-tight);
margin: 0;
word-break: break-word;
}
.profile__name--skeleton {
color: var(--text-dim);
}
.profile__handle {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text-dim);
margin-top: 2px;
}
.profile__identity--err {
color: var(--red);
font-family: var(--font-mono);
font-size: var(--fs-100);
}
/* ─── bio ───────────────────────────────────────────────── */
.profile__bio {
padding: var(--s-3) var(--s-4) 0;
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--text);
line-height: var(--lh-body);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.profile__bio--empty {
color: var(--text-dim);
font-style: italic;
}
/* ─── meta ──────────────────────────────────────────────── */
.profile__meta {
padding: var(--s-3) var(--s-4) 0;
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
display: flex;
gap: var(--s-4);
flex-wrap: wrap;
}
.profile__meta code {
font-family: var(--font-mono);
color: var(--text-dim);
}
/* ─── counts ────────────────────────────────────────────── */
.profile__counts {
display: flex;
gap: var(--s-6);
padding: var(--s-3) var(--s-4);
margin: 0;
font-family: var(--font-mono);
}
.profile__counts > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.profile__counts dt {
color: var(--text-dim);
letter-spacing: var(--tracking-label);
font-size: var(--fs-50);
}
.profile__counts dd {
margin: 0;
color: var(--text);
font-weight: 700;
font-size: var(--fs-200);
font-variant-numeric: tabular-nums;
}
/* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs {
display: flex;
border-bottom: 1px solid var(--line);
margin-top: var(--s-2);
}
.tab {
flex: 1;
background: none;
border: 0;
padding: var(--s-3);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.tab:hover:not(:disabled) {
color: var(--text);
}
.tab:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab--active {
color: var(--orange);
border-bottom-color: var(--orange);
font-weight: 700;
}
/* ─── feed ──────────────────────────────────────────────── */
.profile__feed {
padding: var(--s-2) 0 var(--s-6);
}
.profile__empty {
font-family: var(--font-mono);
color: var(--text-dim);
font-style: italic;
padding: var(--s-4);
text-align: center;
}
/* ─── edit form ─────────────────────────────────────────── */
.profile__edit {
border-top: 1px solid var(--line);
margin: var(--s-4) var(--s-4) 0;
padding: var(--s-4) 0;
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.profile__edit-title {
font-family: var(--font-mono);
font-size: var(--fs-100);
color: var(--orange);
margin: 0 0 var(--s-2);
font-weight: 700;
letter-spacing: var(--tracking-label);
}
.profile__edit-field {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.profile__edit-field .key {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
}
.profile__edit-field input,
.profile__edit-field textarea {
background: var(--bg);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
resize: vertical;
}
.profile__edit-field input:focus,
.profile__edit-field textarea:focus {
outline: none;
border-color: var(--orange);
}
.profile__edit-row {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.profile__edit-row .meta {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.profile__edit-actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,269 @@
<script lang="ts">
import type { Post } from "../api/client";
type Props = {
/// Posts currently in the home timeline. The Sidebar derives
/// the trends list client-side from these (top 3 DIDs by post
/// count), so no new backend endpoint is required.
posts: Post[];
/// Switches the App's view to "search" and populates the
/// search query. Wired by the parent App.
onSearch: (query: string) => void;
};
let { posts, onSearch }: Props = $props();
let query: string = $state("");
function submit() {
onSearch(query.trim());
}
/// Aggregate by DID; cheapest possible counter (no fetchProfile).
/// We surface the handles for display, but the actual handle
/// resolution still comes from the `handle` field baked into
/// each post by the AppView. Skips empty DIDs defensively.
const trends = $derived.by(() => {
const counts = new Map<string, { did: string; handle: string; count: number }>();
for (const p of posts) {
if (!p.did) continue;
const existing = counts.get(p.did);
if (existing) {
existing.count += 1;
} else {
counts.set(p.did, { did: p.did, handle: p.handle, count: 1 });
}
}
const sorted = Array.from(counts.values()).sort((a, b) => b.count - a.count);
return sorted.slice(0, 3);
});
const placeholders = [
{ handle: "alice.bsky.social", why: "shared network" },
{ handle: "bob.bsky.social", why: "popular in feed" },
{ handle: "carol.bsky.social", why: "trending" },
];
</script>
<aside class="sidebar" aria-label="Discover">
<section class="panel">
<label class="panel__search">
<span class="prompt">$</span>
<input
type="text"
bind:value={query}
onfocus={() => onSearch("")}
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
placeholder="grep posts…"
aria-label="Search posts"
/>
</label>
</section>
<section class="panel">
<h3 class="panel__title">// trends</h3>
{#if trends.length === 0}
<p class="panel__empty">// no posts yet — start the timeline.</p>
{:else}
<ul class="trends">
{#each trends as t (t.did)}
<li>
<button
type="button"
class="trend"
title={`${t.count} post${t.count === 1 ? "" : "s"} in current timeline`}
onclick={() => onSearch(t.handle)}
>
<span class="trend__handle">@{t.handle}</span>
<span class="trend__count">{t.count} post{t.count === 1 ? "" : "s"}</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section class="panel">
<h3 class="panel__title">// who to follow</h3>
<ul class="who">
{#each placeholders as p (p.handle)}
<li class="who__row">
<span class="who__handle">@{p.handle}</span>
<span class="who__why">{p.why}</span>
</li>
{/each}
</ul>
<p class="panel__hint">// coming soon — follow graph not wired yet</p>
</section>
</aside>
<style>
.sidebar {
width: 280px;
flex: 0 0 280px;
align-self: flex-start;
position: sticky;
top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-2) var(--s-3) var(--s-5);
max-height: calc(100vh - 24px);
overflow-y: auto;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-deep);
padding: var(--s-3);
}
.panel__title {
margin: 0 0 var(--s-3);
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.panel__search {
display: flex;
align-items: center;
gap: var(--s-2);
font-family: var(--font-mono);
}
.panel__search .prompt {
color: var(--orange);
font-family: var(--font-mono);
font-size: var(--fs-100);
}
.panel__search input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--line-2);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-sm);
outline: none;
}
.panel__search input:focus {
border-color: var(--orange);
}
.panel__search input::placeholder {
color: var(--text-dim);
}
.panel__empty {
margin: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-style: italic;
}
.panel__hint {
margin: var(--s-3) 0 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-style: italic;
}
.trends,
.who {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.trend {
width: 100%;
text-align: left;
background: transparent;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: var(--s-2) var(--s-3);
color: var(--text);
font-family: var(--font-mono);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.trend:hover {
color: var(--orange);
border-color: var(--orange);
}
.trend__handle {
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trend:hover .trend__handle {
color: var(--orange);
}
.trend__count {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
}
.who__row {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s-2) var(--s-1);
border-bottom: 1px dashed var(--line);
}
.who__row:last-child {
border-bottom: 0;
}
.who__handle {
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who__why {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
@media (max-width: 900px) {
.sidebar {
display: none;
}
}
</style>
@@ -53,7 +53,11 @@
</div>
<style>
/* Self-assign the grid area — see NavRail.svelte for why we don't
rely on the parent's `:global(.statusbar)` child selector
(Svelte 5 scoping makes it unreliable in the Tauri webview). */
.statusbar {
grid-area: status;
height: 24px;
background: var(--bg-elev);
border-top: 1px solid var(--line);
+25
View File
@@ -0,0 +1,25 @@
{
"lexicon": 1,
"id": "app.bsky.actor.profile",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"properties": {
"displayName": { "type": "string", "maxLength": 64, "maxGraphemes": 64 },
"description": { "type": "string", "maxLength": 300, "maxGraphemes": 300 },
"avatar": {
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp", "image/gif"]
},
"banner": {
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"]
}
}
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
-- AppView database schema 0005: profile metadata + per-post avatar refs.
--
-- Why
-- The Profile-View-Page and PostCard both render a user avatar.
-- Fetching the live `app.bsky.actor.profile/self` record from every
-- user's PDS on every render doesn't scale, and isn't always reachable
-- (e.g. a did:web: user whose PDS is offline). We cache the
-- denormalised profile fields the UI shows keyed by did, indexed by
-- handle so the `/api/profile/<handle>` lookup is index-driven.
--
-- Source of truth: the user's own PDS. The PDS pushes profile records
-- via the existing `/internal/ingest-commit` path; this migration
-- adds the `(collection, action, rkey) == ('app.bsky.actor.profile',
-- 'create', 'self')` arm to the AppView's indexer to populate this
-- table.
--
-- All fields nullable: a profile record can omit displayName,
-- description, avatar, banner independently.
--
-- post_count / follower_count / following_count are denormalised
-- counts populated only when the row is created/replaced; the
-- Profile-View-Page reads them here so it doesn't have to issue a
-- separate COUNT(*) over posts/follows.
--
-- The avatar_cid on posts is the resolved profile-avatar blob ref
-- (or NULL) for the post's author. The AppView fills it in at
-- upsert_post-time from the profiles table; the PostCard reads it
-- to inline an <Avatar cid={post.avatar_cid}/> without a per-row
-- PDS round-trip.
CREATE TABLE profiles (
did TEXT PRIMARY KEY,
handle TEXT NOT NULL,
display_name TEXT,
description TEXT,
avatar_cid TEXT,
banner_cid TEXT,
post_count BIGINT NOT NULL DEFAULT 0,
follower_count BIGINT NOT NULL DEFAULT 0,
following_count BIGINT NOT NULL DEFAULT 0,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX profiles_handle_idx ON profiles (LOWER(handle));
CREATE INDEX profiles_indexed_at_idx ON profiles (indexed_at DESC);
-- Backfill: seed a profile row for every handle we've already
-- resolved through the posts.did → posts.handle mapping. The display
-- fields stay NULL — they need the live PDS profile record.
INSERT INTO profiles (did, handle)
SELECT DISTINCT ON (did) did, handle
FROM posts
WHERE handle <> ''
ORDER BY did, indexed_at DESC
ON CONFLICT (did) DO NOTHING;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS avatar_cid TEXT;
@@ -0,0 +1,25 @@
-- AppView database schema 0006: handle-sync attempt tracking.
--
-- Why
-- The `handle_sync` worker SELECTs DIDs whose `posts.handle` is empty
-- and tries to resolve them via the local PDS → PLC directory →
-- `did:web:` resolver. Some DIDs are *unresolvable* (e.g. a `did:key:`
-- user not hosted on the local PDS, or any `did:foo:` method that
-- neither PLC nor Web understands). Without tracking these, every
-- pass re-selects them and they dominate the 100-row batch — and
-- since `did:key:` sorts lexicographically before `did:plc:` /
-- `did:web:`, the worker would process the same 100 unresolvable
-- `did:key:` rows forever and never reach any resolvable DID.
--
-- With this column the worker marks each empty-handle row with the
-- time of its last attempt. The SELECT filter excludes rows
-- attempted within the last hour, so an unresolvable DID gets at
-- most one attempt per hour and stops blocking forward progress.
-- Rows whose `handle` later gets filled (by another code path) are
-- naturally no longer in the candidate set.
--
-- The column is per-post (not per-DID) because the candidate set is
-- already per-post and the update is cheap (the empty-handle slice
-- is small in steady state).
ALTER TABLE posts ADD COLUMN IF NOT EXISTS handle_sync_attempted_at TIMESTAMPTZ;
@@ -0,0 +1,12 @@
-- AppView database schema 0007: drop unused `profiles_handle_idx`.
--
-- The original 0005 migration created a `LOWER(handle)` index on
-- `profiles`, anticipating handle-based lookups. In practice every
-- caller derives a DID first (via `posts.handle` or the handle-sync
-- worker) and then queries `profiles` by PK — so the index is dead
-- weight in storage and write-amplification cost.
--
-- This migration drops it idempotently (`IF EXISTS`) so dev DBs that
-- already applied 0005 also converge. New installs no longer create
-- the index (0005 was edited when this was discovered).
DROP INDEX IF EXISTS profiles_handle_idx;