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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user