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()}>