From 48ee25f21700912797d3d247e3ce91ee12b89782 Mon Sep 17 00:00:00 2001 From: tomdebone Date: Sun, 26 Jul 2026 21:25:35 +0200 Subject: [PATCH] feat(follow): end-to-end follow / unfollow with localStorage state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/tauri-app/src-tauri/src/lib.rs | 72 ++++++++ crates/tauri-app/src/lib/api/client.ts | 33 +++- .../src/lib/components/PostCard.svelte | 170 +++++++++++++++++- .../src/lib/components/ProfileView.svelte | 162 ++++++++++++++++- 4 files changed, 419 insertions(+), 18 deletions(-) diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index 6e4514c..6a31bb9 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -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 { + 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 { + 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, diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 77efc99..9397232 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -421,10 +421,28 @@ export async function unrepostPost( return await safeInvoke("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 { + return await safeInvoke("follow_user", { targetDid }); +} + +export async function unfollowUser( + followUri: string, +): Promise { + return await safeInvoke("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 diff --git a/crates/tauri-app/src/lib/components/PostCard.svelte b/crates/tauri-app/src/lib/components/PostCard.svelte index 937f48b..62a8daa 100644 --- a/crates/tauri-app/src/lib/components/PostCard.svelte +++ b/crates/tauri-app/src/lib/components/PostCard.svelte @@ -6,8 +6,11 @@ unlikePost, repostPost, unrepostPost, - session, + followUser, + unfollowUser, + showInfo, showError, + session, type Post, } from "../api/client"; import { localStorageKey, useLocalStorage } from "../utils/localstorage"; @@ -96,6 +99,16 @@ let repostUri: string | null = $state(null); let likeBusy = $state(false); let repostBusy = $state(false); + // Per-post-card follow state for the header "follow" button. + // `isFollowingAuthor` is read from `localStorage` keyed by + // (viewer-did, author-did) on mount/post-prop change, and the + // `onFollowAuthor` handler toggles the persistent state via the + // `follow_user` / `unfollow_user` Tauri commands. We track the + // follow record's URI so unfollow can target the right rkey + // (atproto's `deleteRecord` needs the rkey, not the subject). + let isFollowingAuthor: boolean = $state(false); + let followAuthorUri: string | null = $state(null); + let followAuthorBusy: boolean = $state(false); // Optimistic like/repost count deltas. The base count comes from // the post prop (which the AppView's 5 s poll refreshes); we add // the local delta for the user's pending action so the count @@ -145,6 +158,38 @@ reposted = storedRepost.reposted; repostUri = storedRepost.uri; }); + + // Same pattern for the per-author follow state — `isFollowingAuthor` + // is read from `localStorage` (keyed by viewer-did + author-did) + // and `followAuthorUri` is the cached URI of the follow record so + // unfollow can target the right rkey. + untrack(() => { + if (!$session) { + isFollowingAuthor = false; + followAuthorUri = null; + return; + } + const followKey = localStorageKey( + `follow:${$session.did}:${post.did}`, + ); + try { + const raw = localStorage.getItem(followKey); + if (raw) { + const parsed = JSON.parse(raw) as { + following: boolean; + uri: string | null; + }; + isFollowingAuthor = !!parsed.following; + followAuthorUri = parsed.uri ?? null; + } else { + isFollowingAuthor = false; + followAuthorUri = null; + } + } catch { + isFollowingAuthor = false; + followAuthorUri = null; + } + }); }); async function onLikeClick(event: MouseEvent) { @@ -228,15 +273,65 @@ } } - function showInfo(text: string) { - if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent("maarcadetweet:toast", { - detail: { kind: "info", text }, - }), - ); + async function onFollowAuthor() { + if (!$session || followAuthorBusy) return; + if (post.did === $session.did) return; + const wasFollowing = isFollowingAuthor; + const previousUri = followAuthorUri; + followAuthorBusy = true; + isFollowingAuthor = !wasFollowing; + followAuthorUri = null; + try { + if (wasFollowing) { + if (!previousUri) { + // Nothing to unfollow server-side — just clear the flag. + persistAuthorFollow(false, null); + showInfo("unfollowed"); + return; + } + await unfollowUser(previousUri); + persistAuthorFollow(false, null); + showInfo(`unfollowed @${shortHandle(post.handle)}`); + } else { + const resp = await followUser(post.did); + followAuthorUri = resp.uri; + persistAuthorFollow(true, resp.uri); + showInfo(`following @${shortHandle(post.handle)}`); + } + } catch (e) { + // Roll back the optimistic flip on any failure. + isFollowingAuthor = wasFollowing; + followAuthorUri = previousUri; + persistAuthorFollow(wasFollowing, previousUri); + showError( + `${wasFollowing ? "unfollow" : "follow"} failed: ${e}`, + ); + } finally { + followAuthorBusy = false; + } } + function persistAuthorFollow(following: boolean, uri: string | null) { + if (!$session) return; + const key = localStorageKey(`follow:${$session.did}:${post.did}`); + try { + if (following && uri) { + localStorage.setItem( + key, + JSON.stringify({ following: true, uri }), + ); + } else { + localStorage.removeItem(key); + } + } catch { + /* quota / private mode — best effort */ + } + } + + // `showInfo` is now imported from `../api/client` — used by + // `onFollowAuthor` and by the bookmark / share buttons further + // down. + async function onReplyClick(event: MouseEvent) { event.stopPropagation(); if (!authed) { @@ -353,6 +448,30 @@ + + {#if $session && post.did !== $session.did} + + {/if}
event.stopPropagation()}>