q12xo172i: Render post cards in the signed-in home feed
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Pure feed model: pick renderable posts out of raw feed items, normalise the
|
||||
// card fields, and keep one list sorted strictly by publishedAt, newest first.
|
||||
|
||||
export interface CardMedia {
|
||||
kind: "image" | "video";
|
||||
/** Image source (images) or poster (videos). May be null for a bare video. */
|
||||
src: string | null;
|
||||
}
|
||||
|
||||
export interface CardPost {
|
||||
key: string;
|
||||
id: string | null;
|
||||
authorName: string;
|
||||
authorPictureUrl: string | null;
|
||||
publishedAt: string | null;
|
||||
time: number;
|
||||
text: string;
|
||||
likesCount: number;
|
||||
commentsCount: number;
|
||||
media: CardMedia[];
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
function isObj(v: unknown): v is Obj {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
function num(v: unknown): number {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
const first = parts[0][0] ?? "";
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? "") : "";
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
function authorName(author: Obj): string {
|
||||
const display = str(author.displayName);
|
||||
if (display) return display.trim();
|
||||
const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim();
|
||||
return full || "Unknown";
|
||||
}
|
||||
|
||||
function toMedia(raw: unknown): CardMedia[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const items = raw
|
||||
.filter(isObj)
|
||||
.map((m, i) => ({ m, i, order: typeof m.order === "number" ? m.order : i }))
|
||||
.sort((a, b) => a.order - b.order || a.i - b.i);
|
||||
const out: CardMedia[] = [];
|
||||
for (const { m } of items) {
|
||||
if (m.mediaType === "video") {
|
||||
out.push({ kind: "video", src: str(m.thumbnailUrl) });
|
||||
continue;
|
||||
}
|
||||
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
|
||||
if (src) out.push({ kind: "image", src });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The card for one raw feed item, or null when it is not a post. */
|
||||
export function toCardPost(item: unknown, index = 0): CardPost | null {
|
||||
if (!isObj(item) || item.type !== "post" || !isObj(item.post)) return null;
|
||||
const post = item.post;
|
||||
const author = isObj(post.author) ? post.author : {};
|
||||
const publishedAt = str(item.publishedAt);
|
||||
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||
const id =
|
||||
typeof post.id === "string" || typeof post.id === "number" ? String(post.id) : null;
|
||||
return {
|
||||
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
|
||||
id,
|
||||
authorName: authorName(author),
|
||||
authorPictureUrl: str(author.profilePictureUrl),
|
||||
publishedAt,
|
||||
time: Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY,
|
||||
text: typeof post.content === "string" ? post.content : "",
|
||||
likesCount: num(item.likesCount ?? post.likesCount),
|
||||
commentsCount: num(item.commentsCount ?? post.commentsCount),
|
||||
media: toMedia(post.mediaItems),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCardPosts(items: unknown[]): CardPost[] {
|
||||
const out: CardPost[] = [];
|
||||
items.forEach((item, i) => {
|
||||
const p = toCardPost(item, i);
|
||||
if (p) out.push(p);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Newest first by publishedAt. Ties keep their given order. */
|
||||
export function sortByPublishedDesc(posts: CardPost[]): CardPost[] {
|
||||
return posts
|
||||
.map((p, i) => ({ p, i }))
|
||||
.sort((a, b) => (a.p.time === b.p.time ? a.i - b.i : b.p.time > a.p.time ? 1 : -1))
|
||||
.map((x) => x.p);
|
||||
}
|
||||
|
||||
/** Merge lists, drop duplicates (first wins), and sort newest first. */
|
||||
export function mergePosts(...lists: CardPost[][]): CardPost[] {
|
||||
const seen = new Set<string>();
|
||||
const all: CardPost[] = [];
|
||||
for (const list of lists) {
|
||||
for (const p of list) {
|
||||
if (seen.has(p.key)) continue;
|
||||
seen.add(p.key);
|
||||
all.push(p);
|
||||
}
|
||||
}
|
||||
return sortByPublishedDesc(all);
|
||||
}
|
||||
|
||||
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
|
||||
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||
if (!Number.isFinite(t)) return "";
|
||||
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (s < 60) return "just now";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
Reference in New Issue
Block a user