/** * 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 = { success: boolean; data?: T; error?: { code: number; message: string }; meta?: { page: number; limit: number; total: number; total_pages: number }; }; async function call(path: string, init?: RequestInit): Promise { 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; 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; }; /** 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; 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("/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(`/posts/mention-suggest?${params}`); }, /** Users tagged on a post (insertion order). Public. */ mentions(id: string) { return call(`/posts/${id}/mentions`); }, /** Tag a user on the post (author only, idempotent). */ addMention(id: string, userId: string) { return call(`/posts/${id}/mentions`, { method: "POST", body: JSON.stringify({ user_id: userId }), }); }, /** Untag a user (author only). */ removeMention(id: string, userId: string) { return call(`/posts/${id}/mentions/${userId}`, { method: "DELETE" }); }, get(id: string) { return call(`/posts/${id}`); }, delete(id: string) { return call(`/posts/${id}`, { method: "DELETE" }); }, /** Hide the post from timelines + public collections (owner-only). */ hide(id: string) { return call(`/posts/${id}/hide`, { method: "POST" }); }, /** Restore a hidden post to timelines (owner-only). */ unhide(id: string) { return call(`/posts/${id}/hide`, { method: "DELETE" }); }, /** Swap the post's image set. Empty array removes all images. */ replaceImages(id: string, image_urls: string[]) { return call(`/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(`/posts/mine${feedPageQuery(opts)}`); }, /** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */ hidden() { return call("/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(`/posts/${id}/reshare`, { method: "POST" }); }, /** Remove a previously recorded reshare. Idempotent. */ unreshare(id: string) { return call(`/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(`/posts/${id}/like`, { method: "POST" }); }, /** Unlike a post (idempotent). Same shape as `like`. */ unlike(id: string) { return call(`/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( `/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(`/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 { 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( `/posts/${postId}/comments?${commentPageQuery(params)}`, ); }, /** A thread's replies, oldest first. Public. */ replies(commentId: string, params: CommentPageParams = {}) { return call( `/comments/${commentId}/replies?${commentPageQuery(params)}`, ); }, /** Post a comment, or a reply when `parentId` is set. */ create(postId: string, body: string, parentId?: string) { return call(`/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(`/comments/${commentId}/pin`, { method: "POST" }); }, unpin(commentId: string) { return call(`/comments/${commentId}/pin`, { method: "DELETE" }); }, /** Delete a comment (author or post owner). Removes the whole thread. */ delete(commentId: string) { return call(`/comments/${commentId}`, { method: "DELETE" }); }, /** Report a comment. */ report(commentId: string, category: PostReportCategory, reason: string) { return call(`/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("/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("/bookmarks"); }, delete(id: string) { return call(`/bookmarks/${id}`, { method: "DELETE" }); }, }; // ─── Collections ───────────────────────────────────────────────────────── export const collections = { create(input: { title: string; description?: string; cover_url?: string; is_public?: boolean; }) { return call("/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(`/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(`/collections/${id}`, { method: "DELETE" }); }, mine() { return call("/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(`/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 { 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(`/collections/${collectionID}/items/order`, { method: "PUT", body: JSON.stringify({ post_ids }), }); }, /** R2 presigned PUT for the collection cover. */ async uploadCover(file: File): Promise { 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", { 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/mine"); }, }; // ─── Public profile ────────────────────────────────────────────────────── export const profile = { get(handle: string) { return call(`/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( `/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(`/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(`/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(`/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(`/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(`/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("/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" | "community"; export type PrefChannel = "in_app" | "email" | "push"; /** event → channel → enabled, as resolved by the BFF (defaults on). */ export type NotificationPrefs = Record>; 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(`/u/${encodeURIComponent(handle)}/follow`, { method: "POST", }); }, /** Unfollow, or cancel a still-pending request. Idempotent. */ unfollow(handle: string) { return call(`/u/${encodeURIComponent(handle)}/follow`, { method: "DELETE", }); }, /** Accept the target's pending request to follow you. */ accept(handle: string) { return call( `/u/${encodeURIComponent(handle)}/follow/accept`, { method: "POST" }, ); }, /** Reject the target's pending request to follow you. */ reject(handle: string) { return call( `/u/${encodeURIComponent(handle)}/follow/reject`, { method: "POST" }, ); }, /** Remove the target from your followers (they currently follow you). */ removeFollower(handle: string) { return call( `/u/${encodeURIComponent(handle)}/follower`, { method: "DELETE" }, ); }, /** Block the target. Severs follows both ways. */ block(handle: string) { return call(`/u/${encodeURIComponent(handle)}/block`, { method: "POST", }); }, /** Remove your block on the target. */ unblock(handle: string) { return call(`/u/${encodeURIComponent(handle)}/block`, { method: "DELETE", }); }, /** File a report against the target. Returns nothing (204). */ report(handle: string, category: ReportCategory, reason: string) { return call(`/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( `/u/${encodeURIComponent(handle)}/followers?${followListQuery(opts)}`, ); }, /** One page of the users this profile follows (accepted), same shape. */ following(handle: string, opts: FollowListParams = {}) { return call( `/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(`/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(); }