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,
+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
@@ -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,14 +273,64 @@
}
}
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();
@@ -353,6 +448,30 @@
<time class="time" datetime={post.created_at} title={post.created_at}>
{timeAgo(post.created_at)}
</time>
<!--
Follow button — only rendered for posts by other users (the
own profile flow is handled in ProfileView). X places the
"Follow" affordance on each post card so you can subscribe
without leaving the timeline. The click is propagated to
`onFollowAuthor` which goes through the same Rust
`follow_user` command as the ProfileView button, so the
state stays in sync.
-->
{#if $session && post.did !== $session.did}
<button
class="follow-btn"
class:follow-btn--active={isFollowingAuthor}
type="button"
disabled={followAuthorBusy}
title={isFollowingAuthor ? "unfollow" : "follow"}
onclick={(event) => {
event.stopPropagation();
void onFollowAuthor();
}}
>
{isFollowingAuthor ? "following" : "follow"}
</button>
{/if}
<details class="post-menu" onclick={(event) => event.stopPropagation()}>
<summary aria-label="Post details" title="post details">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -812,6 +931,41 @@
is an a11y regression. The `aria-disabled` attribute on the
button announces the state to assistive tech. */
.action--disabled,
/* Follow button in the post header — same label-flip pattern as
the ProfileView follow button. Inactive "follow" is a filled
orange pill, active "following" is a ghost button that turns
red on hover (X's "unfollow on hover" affordance). */
.follow-btn {
margin-left: auto;
padding: var(--s-1) var(--s-3);
border: 1px solid var(--line-2);
border-radius: var(--r-pill);
background: transparent;
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
cursor: pointer;
transition: background-color var(--dur) var(--ease),
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.follow-btn:hover:not(:disabled) {
background: var(--orange-8);
color: var(--orange);
border-color: var(--orange);
}
.follow-btn--active {
color: var(--text);
background: transparent;
border-color: var(--line-2);
}
.follow-btn--active:hover:not(:disabled) {
color: var(--red);
background: rgba(255, 59, 48, 0.08);
border-color: var(--red);
}
.post:hover .action--disabled {
opacity: 0.45;
cursor: not-allowed;
@@ -7,7 +7,12 @@
fetchBlob,
releaseBlob,
getAppviewUrl,
followUser,
unfollowUser,
showInfo,
showError,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
type Props = {
@@ -66,6 +71,17 @@
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.
@@ -150,6 +166,105 @@
!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 ?? "";
@@ -234,13 +349,28 @@
{/if}
{:else}
<!--
Follow is a placeholder UI — actual follow wiring lives
on a future PR. Disabled so users can't trigger a no-op
network call.
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.
-->
<button class="btn btn--primary" type="button" disabled
>follow</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>
@@ -469,6 +599,26 @@
.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 {