Files
gosangamandClaude Opus 5.5 52cd48b12e Feedback form: load categories from the BFF
The form offers the admin-managed categories, falling back to the original
four if they can't be loaded. "My feedback" shows the server's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30

842 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Browser-side BFF client for the social features: posts, bookmarks,
* collections, public profiles.
*
* All requests run with `credentials: "include"` so the auth cookie set
* by /auth/login is sent along automatically. Errors come back as
* `SocialError` instances carrying the HTTP status code and the message
* from the BFF envelope.
*
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
* for these in the current setup (BFF is on the same registrable domain
* in prod, and localhost is same-site in dev).
*/
const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SocialError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SocialError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
meta?: { page: number; limit: number; total: number; total_pages: number };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SocialError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
export type PostImage = { id: string; url: string; position: number };
export type Post = {
id: string;
user_id: string;
caption: string;
source_url: string;
source_title: string;
images: PostImage[];
created_at: string;
updated_at: string;
author_name?: string;
author_handle?: string;
author_avatar_url?: string | null;
/** True when the calling viewer has reshared the canonical version
* of this post. Populated by endpoints that go through OptionalAuth
* (single post, profile feed, collection contents). Always false /
* absent for anonymous viewers. Drives the post-card "Reshare ↔ Send"
* swap. */
viewer_has_reshared?: boolean;
/** Non-null only when this row is itself a reshare — points at the
* canonical original. The four `original_author_*` fields carry the
* canonical author's profile info for rendering the
* "Reshared from @handle" link in the card. Authored canonical posts
* leave all of these null/absent. */
reshared_from_post_id?: string | null;
original_author_name?: string | null;
original_author_handle?: string | null;
original_author_avatar_url?: string | null;
/** Effective like total = denormalized count + pending (un-flushed)
* delta. Always present on hydrated reads (defaults to 0). */
likes_count?: number;
/** Whether the calling viewer has liked this post. Always false / absent
* for anonymous viewers. */
viewer_has_liked?: boolean;
/** How many users are tagged on this post (drives the mentions icon). */
mentions_count?: number;
/** The post's pinned comment, if any — hydrated so the card footer can show
* it without a separate fetch. Null/absent when nothing is pinned. */
pinned_comment?: Comment | null;
/** Total comments + replies on the post (drives the footer comment count). */
comments_count?: number;
/** Home-feed only: does the viewer follow this post's author (accepted)? */
viewer_follows_author?: boolean;
/** Home-feed only: does the author follow the viewer (drives "Follow back")? */
author_follows_viewer?: boolean;
/** Home-feed only: viewer has a pending (unaccepted) follow request to the
* author — drives the "Requested" state on the feed follow button. */
viewer_follow_requested?: boolean;
/** Owner-only: post is hidden from timelines + public collections. Still
* shown to the owner on their own profile and in private collections. */
is_hidden?: boolean;
};
/** A comment or reply on a post. Threading is one level deep: `parent_id` is
* null for a top-level comment and otherwise the thread root. `reply_to_handle`
* is who a reply was addressed to (for the "Replying to @handle" prefix). */
export type Comment = {
id: string;
post_id: string;
user_id: string;
parent_id?: string | null;
body: string;
likes_count: number;
/** Non-null when this comment is the post's pinned comment. */
pinned_at?: string | null;
created_at: string;
updated_at: string;
author_name: string;
author_handle: string;
author_avatar_url?: string | null;
reply_to_handle?: string | null;
viewer_has_liked: boolean;
/** Number of replies under a top-level comment (0 on replies). */
reply_count: number;
};
/** Result of a like / unlike toggle: the new viewer state + effective total. */
export type LikeResult = {
liked: boolean;
likes_count: number;
};
export type Bookmark = {
id: string;
user_id: string;
source_url: string;
source_title: string;
excerpt: string | null;
note: string | null;
created_at: string;
};
export type Collection = {
id: string;
user_id: string;
title: string;
description: string | null;
cover_url: string | null;
is_public: boolean;
item_count: number;
created_at: string;
updated_at: string;
};
/** The viewer's relationship to a profile, all "viewer → target". Null
* when the viewer is anonymous or looking at their own profile. */
export type RelationshipState = {
/** viewer → target follow is accepted (you follow them). */
following: boolean;
/** viewer → target follow is pending (you've requested). */
follow_requested: boolean;
/** target → viewer follow is accepted (they follow you). */
follows_you: boolean;
/** target → viewer follow is pending (they've asked to follow you). */
requests_you: boolean;
/** you have blocked them. */
blocked: boolean;
/** they have blocked you. */
blocked_by: boolean;
/** Whether the viewer may START a conversation with this profile. Reflects
* the target's "who can message you" setting and any block, so the Message
* button is only offered when tapping it would actually work. */
can_message: boolean;
/** The target's rule when `can_message` is false — one of "blocked",
* "friends", "account_age", "same_gender" — so the UI can explain why. */
message_restriction?: string;
};
/** Returned by every relationship mutation: the recomputed state plus
* the target's live follower/following counts. */
export type RelationshipResult = {
relationship: RelationshipState;
follower_count: number;
following_count: number;
};
export type PublicProfile = {
handle: string;
name: string;
avatar_url: string | null;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
joined_at: string;
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null;
post_count: number;
/** Owner-only: number of the caller's hidden posts (0 for other viewers).
* Drives the /u/me "Hidden" tab's visibility. */
hidden_count?: number;
follower_count: number;
following_count: number;
relationship: RelationshipState | null;
/** True when an admin has linked this account to the disciples directory —
* i.e. one of Śrīla Prabhupāda's own initiated disciples. */
is_direct_disciple?: boolean;
public_collections: Collection[];
};
export type Feedback = {
id: string;
user_id?: string;
category: string;
/** The category's current display name (renames apply to past feedback). */
category_label?: string;
message: string;
contact_email?: string;
created_at: string;
/** Set when the team has seen the submission (one-way). */
acknowledged_at?: string;
/** Set when the submission has been addressed (one-way). */
resolved_at?: string;
/** The team's written reply, if any, and when it was sent. */
reply_message?: string;
replied_at?: string;
};
// ─── Posts ───────────────────────────────────────────────────────────────
/** Keyset cursor for the scroll feed — the last seen post's sort key. */
export type FeedCursor = { created_at: string; id: string };
/** Paging options for a scroll feed: a keyset `before` cursor (preferred) or
* a plain `limit` for the first/grid page. */
export type FeedPageParams = { limit?: number; before?: FeedCursor };
function feedPageQuery({ limit = 20, before }: FeedPageParams): string {
const qs = new URLSearchParams({ limit: String(limit) });
if (before) {
qs.set("before", before.created_at);
qs.set("before_id", before.id);
}
return `?${qs}`;
}
export const posts = {
create(input: {
caption: string;
source_url: string;
source_title?: string;
image_urls?: string[];
mention_user_ids?: string[];
}) {
return call<Post>("/posts", {
method: "POST",
body: JSON.stringify({
caption: input.caption,
source_url: input.source_url,
source_title: input.source_title ?? "",
image_urls: input.image_urls ?? [],
mention_user_ids: input.mention_user_ids ?? [],
}),
});
},
/** Prioritised mention suggestions (following → followers → others). Returns
* one page; the popover loads more by `offset` until a short page. */
mentionSuggest(q: string, opts: { limit?: number; offset?: number } = {}) {
const params = new URLSearchParams({
limit: String(opts.limit ?? 20),
offset: String(opts.offset ?? 0),
});
if (q.trim()) params.set("q", q.trim());
return call<UserSummary[]>(`/posts/mention-suggest?${params}`);
},
/** Users tagged on a post (insertion order). Public. */
mentions(id: string) {
return call<UserSummary[]>(`/posts/${id}/mentions`);
},
/** Tag a user on the post (author only, idempotent). */
addMention(id: string, userId: string) {
return call<void>(`/posts/${id}/mentions`, {
method: "POST",
body: JSON.stringify({ user_id: userId }),
});
},
/** Untag a user (author only). */
removeMention(id: string, userId: string) {
return call<void>(`/posts/${id}/mentions/${userId}`, { method: "DELETE" });
},
get(id: string) {
return call<Post>(`/posts/${id}`);
},
delete(id: string) {
return call<void>(`/posts/${id}`, { method: "DELETE" });
},
/** Hide the post from timelines + public collections (owner-only). */
hide(id: string) {
return call<void>(`/posts/${id}/hide`, { method: "POST" });
},
/** Restore a hidden post to timelines (owner-only). */
unhide(id: string) {
return call<void>(`/posts/${id}/hide`, { method: "DELETE" });
},
/** Swap the post's image set. Empty array removes all images. */
replaceImages(id: string, image_urls: string[]) {
return call<Post>(`/posts/${id}/images`, {
method: "PUT",
body: JSON.stringify({ image_urls }),
});
},
/** The caller's own posts (scroll feed). With `before` it keyset-pages
* posts older than that cursor; without it, the newest page. */
mine(opts: FeedPageParams = {}) {
return call<Post[]>(`/posts/mine${feedPageQuery(opts)}`);
},
/** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */
hidden() {
return call<Post[]>("/posts/hidden");
},
/** Record a reshare. Idempotent — calling twice is a no-op server
* side. Resharing your own post is rejected with 400 (not currently
* surfaced as a distinct error type because the UI only ever offers
* Reshare on non-owned posts). */
/** Materialise a reshare. Returns the new post (or the existing
* reshare row if the caller already reshared this canonical), so
* callers can drop it straight into local feed state. */
reshare(id: string) {
return call<Post>(`/posts/${id}/reshare`, { method: "POST" });
},
/** Remove a previously recorded reshare. Idempotent. */
unreshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
},
/** Like a post (idempotent). Returns the new viewer state + effective
* total (DB count + pending Redis delta). Auth-required. */
like(id: string) {
return call<LikeResult>(`/posts/${id}/like`, { method: "POST" });
},
/** Unlike a post (idempotent). Same shape as `like`. */
unlike(id: string) {
return call<LikeResult>(`/posts/${id}/like`, { method: "DELETE" });
},
/** One page of users who liked the post (newest first), optional
* name/handle search. `total` is the full matching count. Public. */
likers(id: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/posts/${id}/likes?${followListQuery(opts)}`,
);
},
/** Report a post. category from PostReportCategory; reason optional.
* Auth-required (401 for anonymous callers). Returns nothing (204). */
report(id: string, category: PostReportCategory, reason: string) {
return call<void>(`/posts/${id}/report`, {
method: "POST",
body: JSON.stringify({ category, reason }),
});
},
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
async uploadImage(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/posts/image-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "image upload failed");
return public_url;
},
};
// ─── Comments ────────────────────────────────────────────────────────────
export type CommentPageParams = { limit?: number; offset?: number };
function commentPageQuery({ limit = 20, offset = 0 }: CommentPageParams) {
return new URLSearchParams({
limit: String(limit),
offset: String(offset),
}).toString();
}
export const comments = {
/** A post's top-level comments — pinned first, then newest. Public. */
list(postId: string, params: CommentPageParams = {}) {
return call<Comment[]>(
`/posts/${postId}/comments?${commentPageQuery(params)}`,
);
},
/** A thread's replies, oldest first. Public. */
replies(commentId: string, params: CommentPageParams = {}) {
return call<Comment[]>(
`/comments/${commentId}/replies?${commentPageQuery(params)}`,
);
},
/** Post a comment, or a reply when `parentId` is set. */
create(postId: string, body: string, parentId?: string) {
return call<Comment>(`/posts/${postId}/comments`, {
method: "POST",
body: JSON.stringify({ body, parent_id: parentId ?? null }),
});
},
/** Like / unlike a comment; returns the new like count. */
like(commentId: string) {
return call<{ likes_count: number }>(`/comments/${commentId}/like`, {
method: "POST",
});
},
unlike(commentId: string) {
return call<{ likes_count: number }>(`/comments/${commentId}/like`, {
method: "DELETE",
});
},
/** Pin / unpin a comment (post author only; top-level only). */
pin(commentId: string) {
return call<void>(`/comments/${commentId}/pin`, { method: "POST" });
},
unpin(commentId: string) {
return call<void>(`/comments/${commentId}/pin`, { method: "DELETE" });
},
/** Delete a comment (author or post owner). Removes the whole thread. */
delete(commentId: string) {
return call<void>(`/comments/${commentId}`, { method: "DELETE" });
},
/** Report a comment. */
report(commentId: string, category: PostReportCategory, reason: string) {
return call<void>(`/comments/${commentId}/report`, {
method: "POST",
body: JSON.stringify({ category, reason }),
});
},
};
// ─── Bookmarks ───────────────────────────────────────────────────────────
export const bookmarks = {
create(input: {
source_url: string;
source_title?: string;
excerpt?: string;
note?: string;
}) {
return call<Bookmark>("/bookmarks", {
method: "POST",
body: JSON.stringify({
source_url: input.source_url,
source_title: input.source_title ?? "",
excerpt: input.excerpt ?? null,
note: input.note ?? null,
}),
});
},
list() {
return call<Bookmark[]>("/bookmarks");
},
delete(id: string) {
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
},
};
// ─── Collections ─────────────────────────────────────────────────────────
export const collections = {
create(input: {
title: string;
description?: string;
cover_url?: string;
is_public?: boolean;
}) {
return call<Collection>("/collections", {
method: "POST",
body: JSON.stringify({
title: input.title,
description: input.description ?? null,
cover_url: input.cover_url ?? null,
is_public: !!input.is_public,
}),
});
},
update(
id: string,
input: Partial<{
title: string;
description: string;
cover_url: string;
is_public: boolean;
}>,
) {
return call<Collection>(`/collections/${id}`, {
method: "PATCH",
body: JSON.stringify({
title: input.title ?? undefined,
description: input.description ?? undefined,
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
is_public: input.is_public ?? undefined,
}),
});
},
delete(id: string) {
return call<void>(`/collections/${id}`, { method: "DELETE" });
},
mine() {
return call<Collection[]>("/collections");
},
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
get(id: string) {
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
},
addItem(collectionID: string, postID: string) {
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
method: "POST",
body: JSON.stringify({ post_id: postID }),
});
},
removeItem(collectionID: string, postID: string) {
return call<void>(`/collections/${collectionID}/items/${postID}`, {
method: "DELETE",
});
},
/** Returns the ids of the caller's collections that already contain
* the given post. The save-to-collection drawer uses this to disable
* rows so the post can't be added a second time. */
async containingPost(postID: string): Promise<string[]> {
const r = await call<{ collection_ids: string[] }>(
`/collections/containing/${postID}`,
);
return r.collection_ids ?? [];
},
/** Rewrite the position of every post in `post_ids` to match the array
* order. The BFF runs the updates in a single tx — readers see either
* the old order or the new one, never a half-applied state. */
reorderItems(collectionID: string, post_ids: string[]) {
return call<void>(`/collections/${collectionID}/items/order`, {
method: "PUT",
body: JSON.stringify({ post_ids }),
});
},
/** R2 presigned PUT for the collection cover. */
async uploadCover(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/collections/cover-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
return public_url;
},
};
// ─── Feedback ────────────────────────────────────────────────────────────
/** One option on the /feedback form, managed from the admin panel. */
export type FeedbackCategory = {
key: string;
label: string;
description: string;
};
export const feedback = {
/** The form's categories, in order. Public. */
categories() {
return call<FeedbackCategory[]>("/feedback/categories");
},
/** Submit a /feedback page entry. When the viewer is signed in the
* BFF captures their user_id automatically via the auth cookie —
* the form doesn't ask for it. `contact_email` is required only
* for anonymous submissions; the BFF re-enforces that on its end. */
submit(input: {
/** A FeedbackCategory key. */
category: string;
message: string;
contact_email?: string;
}) {
return call<Feedback>("/feedback", {
method: "POST",
body: JSON.stringify({
category: input.category,
message: input.message,
contact_email: input.contact_email || undefined,
}),
});
},
/** The signed-in user's own submissions, newest first — each with its
* status (acknowledged / resolved) and any admin reply. Auth-only. */
mine() {
return call<Feedback[]>("/feedback/mine");
},
};
// ─── Public profile ──────────────────────────────────────────────────────
export const profile = {
get(handle: string) {
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
},
/** A user's public scroll feed. With `before` it keyset-pages posts older
* than that cursor (infinite scroll); otherwise the newest page. The grid
* still passes plain `limit`. */
feed(handle: string, opts: FeedPageParams = {}) {
return call<Post[]>(
`/u/${encodeURIComponent(handle)}/feed${feedPageQuery(opts)}`,
);
},
/** Posts that tag this user (the Mentions tab), newest first. */
mentionsFeed(handle: string, limit = 30, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<Post[]>(`/u/${encodeURIComponent(handle)}/mentions?${qs}`);
},
};
// ─── Home feed (global, personalised) ──────────────────────────────────────
export const feed = {
/** One page of the global home feed (auth required). Returns at most
* `limit` posts; callers infer "more" from a full page. */
home(limit = 10, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<Post[]>(`/feed?${qs}`);
},
/** Up to 5 accounts that joined in the last 24h — the feed's discovery
* strip. Empty array when there are none. */
newUsers() {
return call<UserSummary[]>(`/feed/new-users`);
},
/** Clear the server-side "seen" set so the feed starts fresh. Called on
* logout and on page unload; `keepalive` lets the request outlive the
* unloading page (like a beacon). */
reset() {
return call<void>(`/feed/reset`, { method: "POST", keepalive: true });
},
};
// ─── Relationships (follow / block / report) ───────────────────────────────
/** Allowed user-report categories — mirrors the BFF allow-list. */
export type ReportCategory =
| "spam"
| "harassment"
| "impersonation"
| "inappropriate"
| "other";
/** Allowed post-report categories — mirrors the BFF allow-list. */
export type PostReportCategory =
| "spam"
| "harassment"
| "misinformation"
| "inappropriate"
| "other";
// ─── Notifications ─────────────────────────────────────────────────────────
/** One in-app notification for the signed-in user. `read_at` is absent
* (undefined) while unread. Actor fields describe who triggered it. */
export type AppNotification = {
id: string;
kind: string;
created_at: string;
read_at?: string | null;
actor_id?: string | null;
actor_name?: string | null;
actor_handle?: string | null;
actor_avatar_url?: string | null;
/** For aggregated `post_like` rows: the liked post + how many likers the
* row represents (renders "X and N-1 others liked your post"). */
post_id?: string | null;
actor_count?: number;
/** Handle of the post's author (when the row references a post), so comment
* / reply notifications can deep-link to the post even though the actor
* isn't its owner. */
post_author_handle?: string | null;
/** Free text for the `admin_message` kind (an admin → user direct message).
* Absent on every event-driven kind. */
body?: string | null;
};
export const notifications = {
/** The caller's notifications, newest first. */
list(limit = 30, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<AppNotification[]>(`/notifications?${qs}`);
},
/** Unread count for the header bell badge. */
unreadCount() {
return call<{ count: number }>("/notifications/unread-count");
},
/** Mark every notification read (called when the page opens). */
markAllRead() {
return call<void>("/notifications/read", { method: "POST" });
},
/** The event × channel notification matrix (Settings → Notifications). */
prefs() {
return call<{ prefs: NotificationPrefs }>("/notifications/prefs");
},
/** Write one matrix cell; returns the full re-resolved matrix. */
setPref(event: PrefEvent, channel: PrefChannel, enabled: boolean) {
return call<{ prefs: NotificationPrefs }>("/notifications/prefs", {
method: "PUT",
body: JSON.stringify({ event, channel, enabled }),
});
},
};
// ─── Notification preferences ──────────────────────────────────────────
export type PrefEvent =
/** Chat push. Delivered by the chat service directly, not the notification
* tray — a new message highlights its inbox row instead. */
| "messages"
| "follows"
| "post_interactions"
| "sadhna_reminders"
| "community";
export type PrefChannel = "in_app" | "email" | "push";
/** event → channel → enabled, as resolved by the BFF (defaults on). */
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
export const relationships = {
/** Send a follow REQUEST. The target must accept before it counts as a
* follow — the returned relationship reflects `follow_requested: true`
* until then. Requires the viewer to be signed in (401 otherwise). */
follow(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/follow`, {
method: "POST",
});
},
/** Unfollow, or cancel a still-pending request. Idempotent. */
unfollow(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/follow`, {
method: "DELETE",
});
},
/** Accept the target's pending request to follow you. */
accept(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follow/accept`,
{ method: "POST" },
);
},
/** Reject the target's pending request to follow you. */
reject(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follow/reject`,
{ method: "POST" },
);
},
/** Remove the target from your followers (they currently follow you). */
removeFollower(handle: string) {
return call<RelationshipResult>(
`/u/${encodeURIComponent(handle)}/follower`,
{ method: "DELETE" },
);
},
/** Block the target. Severs follows both ways. */
block(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/block`, {
method: "POST",
});
},
/** Remove your block on the target. */
unblock(handle: string) {
return call<RelationshipResult>(`/u/${encodeURIComponent(handle)}/block`, {
method: "DELETE",
});
},
/** File a report against the target. Returns nothing (204). */
report(handle: string, category: ReportCategory, reason: string) {
return call<void>(`/u/${encodeURIComponent(handle)}/report`, {
method: "POST",
body: JSON.stringify({ category, reason }),
});
},
/** One page of the profile's accepted followers, optionally filtered by a
* name/handle search. `total` is the full matching count for pagination. */
followers(handle: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/u/${encodeURIComponent(handle)}/followers?${followListQuery(opts)}`,
);
},
/** One page of the users this profile follows (accepted), same shape. */
following(handle: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`,
);
},
/** The caller's pending incoming follow requests (people who asked to follow
* you) — the notifications "Friend Requests" tab. Same shape as followers. */
incomingRequests(opts: FollowListParams = {}) {
return call<FollowList>(`/follow-requests?${followListQuery(opts)}`);
},
};
/** Lightweight user row in a followers / following list. */
export type UserSummary = {
/** Present where the caller needs it (mentions); absent in follower/like
* lists that link by handle. */
id?: string;
handle: string;
name: string;
avatar_url: string | null;
};
/** A paginated followers/following page: the rows + total matching count. */
export type FollowList = {
users: UserSummary[];
total: number;
};
export type FollowListParams = { q?: string; limit?: number; offset?: number };
function followListQuery({ q, limit = 30, offset = 0 }: FollowListParams) {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (q && q.trim()) params.set("q", q.trim());
return params.toString();
}