Compare commits

..
18 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
43 changed files with 5297 additions and 1104 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 }
+89 -17
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:") {
@@ -113,21 +138,21 @@ impl HandleSyncWorker {
/// posts have an empty handle, resolve them, and update the rows
/// where the handle is still empty (race-safe).
///
/// **SQL-level filter**: we exclude `did:key:` entirely because
/// there's no resolver path for them — the PLC directory and the
/// `did:web:` HTTPS resolver both reject non-`did:plc:` /
/// non-`did:web:` DIDs with `Ok(None)`. Previously the worker
/// picked via `ORDER BY did LIMIT 100`, but lexicographically
/// `did:key:` sorts before `did:plc:` / `did:web:`, so the worker
/// would process the same 100 `did:key:` rows every 300 s and
/// never reach any resolvable DID. Filtering at SQL time makes
/// every batch contribute real work.
/// 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 (did LIKE 'did:plc:%' OR did LIKE 'did:web:%')
AND (handle_sync_attempted_at IS NULL
OR handle_sync_attempted_at < now() - interval '1 hour')
ORDER BY did
LIMIT $1"#,
)
@@ -140,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)
@@ -165,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");
}
}
}
}
@@ -176,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::*;
@@ -219,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,
@@ -383,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,
@@ -440,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,
@@ -496,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,
@@ -554,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,
+410 -16
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 {
@@ -276,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)
@@ -313,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(())
@@ -592,7 +625,7 @@ pub async fn apply_commit(
// handle — leave it empty so the upsert
// COALESCE guard preserves the row's existing
// (or backfilled-from-identity) handle.
let row = PostRow::from_record(
let mut row = PostRow::from_record(
&ev.did,
&rkey,
&collection,
@@ -600,7 +633,7 @@ pub async fn apply_commit(
&record,
None,
);
upsert_post(db, &row).await?;
upsert_post(db, &mut row).await?;
applied = true;
} else if op.action == "delete" {
let rkey = op
@@ -720,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).
@@ -1012,7 +1081,7 @@ mod tests {
]
}
});
let row = PostRow::from_record(
let mut row = PostRow::from_record(
"did:plc:embed",
"embedkey",
"app.twi.post",
@@ -1020,7 +1089,7 @@ mod tests {
&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",
@@ -1139,3 +1208,328 @@ pub async fn backfill_handle(
.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;
}
}
+35 -2
View File
@@ -128,7 +128,7 @@ 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,
@@ -136,7 +136,7 @@ async fn apply(
&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") => {
@@ -219,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,
+84 -3
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,13 +360,31 @@ 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
};
@@ -372,6 +407,11 @@ async fn resolve_profile(
posts: Vec::new(),
followers: 0,
following: 0,
display_name: None,
description: None,
avatar_cid: None,
banner_cid: None,
post_count: 0,
}));
};
@@ -427,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,
}))
}
@@ -830,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(),
@@ -846,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()
+23
View File
@@ -152,6 +152,29 @@ 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,
+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
}
};
+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);
}
}
+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
+171 -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(),
};
@@ -715,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,
}
+4 -3
View File
@@ -34,12 +34,13 @@
},
"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": {
+371 -263
View File
@@ -4,26 +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" | "settings";
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 });
@@ -35,10 +40,16 @@
let seenUris: Set<string> = new Set();
let _statusTimer: 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("");
@@ -53,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.
@@ -81,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;
@@ -247,8 +291,8 @@
void refreshTimeline(true);
}
if (next === "profile") {
const handle = currentUser.handle;
void refreshProfile(handle);
// ProfileView fetches its own data on mount; nothing to
// preload here.
}
if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch();
@@ -317,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(() => {
@@ -372,15 +403,30 @@
}
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");
profile = null;
searchResults = [];
threadRoot = null;
threadParent = null;
@@ -392,13 +438,6 @@
// 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
@@ -434,6 +473,7 @@
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">
@@ -442,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}
@@ -462,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">
@@ -486,165 +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">
<div class="profile__handle">{displayHandle(profile.handle)}</div>
<div class="profile__did" title={profile.did}>{profile.did}</div>
</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>
<button
class="btn btn--ghost"
type="button"
title="Open profile in your default browser"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${profile!.handle}`,
)}
>open in browser</button>
<button
class="btn btn--ghost"
type="button"
title="Sign out of this app"
onclick={handleLogout}
>sign out</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 — compose your first one</div>
{:else}
<h3 class="profile__h3">// recent posts</h3>
{#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">
<h3 class="settings__h3">// account</h3>
<dl class="settings__rows">
<div>
<dt>handle</dt>
<dd>@{currentUser?.handle ?? "?"}</dd>
<!-- 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>
<dt>did</dt>
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
<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>
<dt>posts in cache</dt>
<dd>{userPosts.length}</dd>
</div>
</dl>
<h3 class="settings__h3">// actions</h3>
<div class="settings__actions">
<button
class="btn btn--ghost"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>copy my did</button>
<button
class="btn btn--ghost"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>open profile in browser</button>
<button
class="btn btn--ghost"
type="button"
onclick={() => setView("home")}
>← back to timeline</button>
</div>
<h3 class="settings__h3">// about</h3>
<dl class="settings__rows">
<div>
<dt>app</dt>
<dd>maarcadetweet</dd>
<!-- 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>
<dt>version</dt>
<dd>0.1.0</dd>
</div>
<div>
<dt>backend</dt>
<dd>{pdsBase()}</dd>
</div>
<div>
<dt>appview</dt>
<dd>{appviewBase()}</dd>
</div>
</dl>
</div>
<div class="settings__signout">
<button
class="btn btn--ghost btn--danger"
type="button"
onclick={handleLogout}
>sign out</button>
<!-- 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}
@@ -657,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>
@@ -751,12 +809,51 @@
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);
@@ -829,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);
@@ -903,68 +955,124 @@
background: rgba(255, 59, 48, 0.08);
}
.btn--danger {
color: var(--red);
border-color: var(--red);
}
.btn--danger:hover:not(:disabled) {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
border-color: var(--red);
}
/* (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) */
.profile__h3,
/* 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(--text-dim);
letter-spacing: 0.04em;
margin: var(--s-4) 0 var(--s-2);
font-weight: 400;
color: var(--orange);
letter-spacing: var(--tracking-label);
margin: 0;
font-weight: 700;
}
.did-cell {
word-break: break-all;
font-size: var(--fs-50);
}
.settings {
padding: 0 var(--s-3);
.settings__list {
display: flex;
flex-direction: column;
gap: var(--s-2);
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.settings__rows {
/* 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;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
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__rows > div {
display: flex;
gap: var(--s-3);
.settings__list .settings__row:last-child {
border-bottom: 0;
}
.settings__rows dt {
.settings__label {
color: var(--text-dim);
letter-spacing: 0.04em;
min-width: 9rem;
letter-spacing: var(--tracking-label);
flex: 0 0 auto;
}
.settings__rows dd {
margin: 0;
.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-wrap: wrap;
gap: var(--s-2);
margin: 0 0 var(--s-4);
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.settings__signout {
margin-top: var(--s-4);
padding-top: var(--s-4);
border-top: 1px dashed var(--line);
.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>
+107 -4
View File
@@ -24,6 +24,33 @@ 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
@@ -191,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 = {
@@ -204,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 = {
@@ -228,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
@@ -238,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,
});
}
@@ -370,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(
@@ -381,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
@@ -429,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" | "settings";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let {
view = "home",
@@ -5,7 +5,7 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "search" | "settings";
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>
+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;