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:
@@ -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]
|
#[tauri::command]
|
||||||
async fn timeline_home(
|
async fn timeline_home(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -727,6 +797,8 @@ pub fn run() {
|
|||||||
unlike_post,
|
unlike_post,
|
||||||
repost_post,
|
repost_post,
|
||||||
unrepost_post,
|
unrepost_post,
|
||||||
|
follow_user,
|
||||||
|
unfollow_user,
|
||||||
status_pds,
|
status_pds,
|
||||||
fetch_blob,
|
fetch_blob,
|
||||||
pick_and_upload_image,
|
pick_and_upload_image,
|
||||||
|
|||||||
@@ -421,10 +421,28 @@ export async function unrepostPost(
|
|||||||
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
/// `followUser(targetDid)` — create an `app.bsky.graph.follow` record
|
||||||
/// `window` `CustomEvent` so any component can show errors without
|
/// on the user's PDS. Returns `{ uri, cid }` — the client caches
|
||||||
/// pulling in a global store. `App.svelte` listens for the event
|
/// `uri` in localStorage so `unfollowUser(uri)` can delete the
|
||||||
/// and renders the toast UI.
|
/// 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 {
|
export function showError(text: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.dispatchEvent(
|
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 a native OS notification. Thin wrapper around the
|
||||||
/// `show_notification` Tauri command. The Rust side also emits an
|
/// `show_notification` Tauri command. The Rust side also emits an
|
||||||
/// `app://notification` event with the same payload, so the click
|
/// `app://notification` event with the same payload, so the click
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
unlikePost,
|
unlikePost,
|
||||||
repostPost,
|
repostPost,
|
||||||
unrepostPost,
|
unrepostPost,
|
||||||
session,
|
followUser,
|
||||||
|
unfollowUser,
|
||||||
|
showInfo,
|
||||||
showError,
|
showError,
|
||||||
|
session,
|
||||||
type Post,
|
type Post,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
|
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
|
||||||
@@ -96,6 +99,16 @@
|
|||||||
let repostUri: string | null = $state(null);
|
let repostUri: string | null = $state(null);
|
||||||
let likeBusy = $state(false);
|
let likeBusy = $state(false);
|
||||||
let repostBusy = $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
|
// Optimistic like/repost count deltas. The base count comes from
|
||||||
// the post prop (which the AppView's 5 s poll refreshes); we add
|
// 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
|
// the local delta for the user's pending action so the count
|
||||||
@@ -145,6 +158,38 @@
|
|||||||
reposted = storedRepost.reposted;
|
reposted = storedRepost.reposted;
|
||||||
repostUri = storedRepost.uri;
|
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) {
|
async function onLikeClick(event: MouseEvent) {
|
||||||
@@ -228,15 +273,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showInfo(text: string) {
|
async function onFollowAuthor() {
|
||||||
if (typeof window === "undefined") return;
|
if (!$session || followAuthorBusy) return;
|
||||||
window.dispatchEvent(
|
if (post.did === $session.did) return;
|
||||||
new CustomEvent("maarcadetweet:toast", {
|
const wasFollowing = isFollowingAuthor;
|
||||||
detail: { kind: "info", text },
|
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) {
|
async function onReplyClick(event: MouseEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
@@ -353,6 +448,30 @@
|
|||||||
<time class="time" datetime={post.created_at} title={post.created_at}>
|
<time class="time" datetime={post.created_at} title={post.created_at}>
|
||||||
{timeAgo(post.created_at)}
|
{timeAgo(post.created_at)}
|
||||||
</time>
|
</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()}>
|
<details class="post-menu" onclick={(event) => event.stopPropagation()}>
|
||||||
<summary aria-label="Post details" title="post details">
|
<summary aria-label="Post details" title="post details">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -812,6 +931,41 @@
|
|||||||
is an a11y regression. The `aria-disabled` attribute on the
|
is an a11y regression. The `aria-disabled` attribute on the
|
||||||
button announces the state to assistive tech. */
|
button announces the state to assistive tech. */
|
||||||
.action--disabled,
|
.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 {
|
.post:hover .action--disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
fetchBlob,
|
fetchBlob,
|
||||||
releaseBlob,
|
releaseBlob,
|
||||||
getAppviewUrl,
|
getAppviewUrl,
|
||||||
|
followUser,
|
||||||
|
unfollowUser,
|
||||||
|
showInfo,
|
||||||
|
showError,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
import { localStorageKey } from "../utils/localstorage";
|
||||||
import { onDestroy, onMount, untrack } from "svelte";
|
import { onDestroy, onMount, untrack } from "svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -66,6 +71,17 @@
|
|||||||
let editAvatarCid: string | null = $state(null);
|
let editAvatarCid: string | null = $state(null);
|
||||||
let saving: boolean = $state(false);
|
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
|
// Banner blob URL — fetched via the same path as Avatar (Tauri
|
||||||
// getBlob via fetchBlob). Released on unmount or when banner
|
// getBlob via fetchBlob). Released on unmount or when banner
|
||||||
// changes.
|
// changes.
|
||||||
@@ -150,6 +166,105 @@
|
|||||||
!viewModel.data.banner_cid,
|
!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() {
|
function openEdit() {
|
||||||
if (viewModel.kind !== "ready") return;
|
if (viewModel.kind !== "ready") return;
|
||||||
editName = viewModel.data.display_name ?? "";
|
editName = viewModel.data.display_name ?? "";
|
||||||
@@ -234,13 +349,28 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!--
|
<!--
|
||||||
Follow is a placeholder UI — actual follow wiring lives
|
Follow toggle. Text + class flip with `isFollowing`:
|
||||||
on a future PR. Disabled so users can't trigger a no-op
|
"follow" / `.btn--primary` (outlined-emphasis) when not
|
||||||
network call.
|
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
|
{#if isFollowing}
|
||||||
>follow</button
|
<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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -469,6 +599,26 @@
|
|||||||
.profile__actions {
|
.profile__actions {
|
||||||
padding-bottom: var(--s-3);
|
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 ──────────────────────────────────────────── */
|
/* ─── identity ──────────────────────────────────────────── */
|
||||||
.profile__identity {
|
.profile__identity {
|
||||||
|
|||||||
Reference in New Issue
Block a user