feat: social graph features

This commit is contained in:
gosangam
2026-06-04 19:30:50 +05:30
parent 0f433c5708
commit 684ea2ae97
16 changed files with 2504 additions and 119 deletions
+35 -10
View File
@@ -33,6 +33,22 @@ export type User = {
* the "Change password" modal and the "Set a password" flow.
*/
has_password: boolean;
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
* when unknown. Captured best-effort at signup; user-editable. */
country: string | null;
/** Devotional profile. */
/** Spiritual lineage code (e.g. "gaudiya"); new accounts default to
* "gaudiya" but it's optional, so null means "not specified". */
sampradaya: string | null;
gender: string | null;
initiated: boolean;
/** ISO date "YYYY-MM-DD" (or full timestamp from the DB), null if unset. */
initiated_at: string | null;
daily_chanting_rounds: number | null;
/** System-managed notification scheduling ("HH:MM"), set by the backend
* later — surfaced here for completeness, no UI edits them today. */
preferred_chanting_time: string | null;
peak_active_time: string | null;
created_at: string;
updated_at: string;
};
@@ -189,18 +205,27 @@ export const auth = {
}),
}),
updateProfile: (
name?: string,
avatarURL?: string | null,
handle?: string,
) =>
/**
* Partial profile patch. Only the keys present in `patch` are sent — an
* omitted key leaves that field unchanged; an explicit `""`/`null` (for
* the nullable fields) or `0` (for daily_chanting_rounds) clears it.
*/
updateProfile: (patch: {
name?: string;
avatar_url?: string | null;
handle?: string;
country?: string | null;
sampradaya?: string;
gender?: string | null;
initiated?: boolean;
initiated_at?: string | null;
daily_chanting_rounds?: number | null;
preferred_chanting_time?: string | null;
peak_active_time?: string | null;
}) =>
authFetch<User>("/users/me", {
method: "PATCH",
body: JSON.stringify({
name: name ?? undefined,
avatar_url: avatarURL === undefined ? undefined : avatarURL,
handle: handle ?? undefined,
}),
body: JSON.stringify(patch),
}),
/**
+51
View File
@@ -0,0 +1,51 @@
/**
* Country helpers for the profile country field.
*
* We store ISO-3166-1 alpha-2 codes (e.g. "IN") on the account and render
* human names with the platform's `Intl.DisplayNames` so labels localise
* for free and we don't ship a 250-entry name table. The code list below
* is the static ISO alpha-2 set; names are derived at module load.
*/
// ISO-3166-1 alpha-2 codes (officially assigned).
const CODES: string[] = [
"AD","AE","AF","AG","AI","AL","AM","AO","AQ","AR","AS","AT","AU","AW","AX","AZ",
"BA","BB","BD","BE","BF","BG","BH","BI","BJ","BL","BM","BN","BO","BQ","BR","BS",
"BT","BV","BW","BY","BZ","CA","CC","CD","CF","CG","CH","CI","CK","CL","CM","CN",
"CO","CR","CU","CV","CW","CX","CY","CZ","DE","DJ","DK","DM","DO","DZ","EC","EE",
"EG","EH","ER","ES","ET","FI","FJ","FK","FM","FO","FR","GA","GB","GD","GE","GF",
"GG","GH","GI","GL","GM","GN","GP","GQ","GR","GS","GT","GU","GW","GY","HK","HM",
"HN","HR","HT","HU","ID","IE","IL","IM","IN","IO","IQ","IR","IS","IT","JE","JM",
"JO","JP","KE","KG","KH","KI","KM","KN","KP","KR","KW","KY","KZ","LA","LB","LC",
"LI","LK","LR","LS","LT","LU","LV","LY","MA","MC","MD","ME","MF","MG","MH","MK",
"ML","MM","MN","MO","MP","MQ","MR","MS","MT","MU","MV","MW","MX","MY","MZ","NA",
"NC","NE","NF","NG","NI","NL","NO","NP","NR","NU","NZ","OM","PA","PE","PF","PG",
"PH","PK","PL","PM","PN","PR","PS","PT","PW","PY","QA","RE","RO","RS","RU","RW",
"SA","SB","SC","SD","SE","SG","SH","SI","SJ","SK","SL","SM","SN","SO","SR","SS",
"ST","SV","SX","SY","SZ","TC","TD","TF","TG","TH","TJ","TK","TL","TM","TN","TO",
"TR","TT","TV","TW","TZ","UA","UG","UM","US","UY","UZ","VA","VC","VE","VG","VI",
"VN","VU","WF","WS","YE","YT","ZA","ZM","ZW",
];
const regionNames =
typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["en"], { type: "region" })
: null;
/** Localized country name for an ISO-2 code, or null when the code is
* empty/unknown. Falls back to the raw code if the platform can't name it. */
export function countryName(code: string | null | undefined): string | null {
if (!code) return null;
const cc = code.toUpperCase();
try {
return regionNames?.of(cc) ?? cc;
} catch {
return cc;
}
}
/** `{ code, name }` for every country, sorted by name — feeds the picker. */
export const COUNTRIES: { code: string; name: string }[] = CODES.map((c) => ({
code: c,
name: countryName(c) || c,
})).sort((a, b) => a.name.localeCompare(b.name));
+26
View File
@@ -0,0 +1,26 @@
/**
* Sampradāya (spiritual lineage) options for the profile field.
*
* Stored as a lowercase `code` on the account; the `name` is the display
* label shown in the searchable picker. Only Gaudiya exists today —
* append more entries here as they're supported (the BFF allow-list in
* user_service.go must be extended in lockstep).
*/
export const SAMPRADAYAS: { code: string; name: string }[] = [
{ code: "gaudiya", name: "Gaudiya" },
];
/** Display label for a sampradāya code, or null when unknown. */
export function sampradayaName(code: string | null | undefined): string | null {
if (!code) return null;
return SAMPRADAYAS.find((s) => s.code === code)?.name ?? null;
}
/** Resolve a typed display name back to its code (case-insensitive), or
* null when it doesn't match a known lineage. */
export function sampradayaCode(name: string): string | null {
const n = name.trim().toLowerCase();
if (!n) return null;
return SAMPRADAYAS.find((s) => s.name.toLowerCase() === n)?.code ?? null;
}
+147
View File
@@ -101,12 +101,42 @@ export type Collection = {
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[];
};
@@ -168,6 +198,14 @@ export const posts = {
unreshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
},
/** 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<{
@@ -347,3 +385,112 @@ export const profile = {
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
},
};
// ─── 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;
};
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" });
},
};
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 }),
});
},
};