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
+72
View File
@@ -284,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>,
@@ -727,6 +797,8 @@ pub fn run() {
unlike_post,
repost_post,
unrepost_post,
follow_user,
unfollow_user,
status_pds,
fetch_blob,
pick_and_upload_image,