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.
This commit is contained in:
tomdebone
2026-07-26 21:25:35 +02:00
parent eb62fd5654
commit 48ee25f217
4 changed files with 419 additions and 18 deletions
+29 -4
View File
@@ -421,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(
@@ -432,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