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
+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 }),
});
},
};