748 lines
26 KiB
TypeScript
748 lines
26 KiB
TypeScript
/**
|
||
* 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;
|
||
};
|
||
|
||
/** 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;
|
||
};
|
||
|
||
/** 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;
|
||
joined_at: string;
|
||
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
|
||
country: string | null;
|
||
post_count: number;
|
||
follower_count: number;
|
||
following_count: number;
|
||
relationship: RelationshipState | null;
|
||
public_collections: Collection[];
|
||
};
|
||
|
||
export type Feedback = {
|
||
id: string;
|
||
user_id?: string;
|
||
category: string;
|
||
message: string;
|
||
contact_email?: string;
|
||
created_at: string;
|
||
};
|
||
|
||
// ─── Posts ───────────────────────────────────────────────────────────────
|
||
|
||
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" });
|
||
},
|
||
/** 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 }),
|
||
});
|
||
},
|
||
mine() {
|
||
return call<Post[]>("/posts/mine");
|
||
},
|
||
/** 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 ────────────────────────────────────────────────────────────
|
||
|
||
export const feedback = {
|
||
/** 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: {
|
||
category: "bug" | "feature" | "general" | "other";
|
||
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,
|
||
}),
|
||
});
|
||
},
|
||
};
|
||
|
||
// ─── Public profile ──────────────────────────────────────────────────────
|
||
|
||
export const profile = {
|
||
get(handle: string) {
|
||
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
||
},
|
||
feed(handle: string, limit = 20, offset = 0) {
|
||
const qs = new URLSearchParams({
|
||
limit: String(limit),
|
||
offset: String(offset),
|
||
});
|
||
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
||
},
|
||
/** 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;
|
||
};
|
||
|
||
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 = "follows" | "post_interactions" | "sadhna_reminders";
|
||
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)}`,
|
||
);
|
||
},
|
||
};
|
||
|
||
/** 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();
|
||
}
|