Files
frontend-helper/src/lib/feed-model.ts
T

225 lines
8.1 KiB
TypeScript

// 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;
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
hlsUrl?: string | null;
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
mp4Url?: string | null;
/** Video length in whole seconds, when the feed reports it. */
durationSeconds?: number | null;
}
/** "m:ss" for a duration in seconds, or null when it is missing or not positive. */
export function formatDuration(seconds: number | null | undefined): string | null {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null;
const total = Math.round(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
export interface CardPost {
key: string;
id: string | null;
/** The author's id on the feed, for matching against the signed-in feed user id. */
authorId: string | null;
authorName: string;
authorPictureUrl: string | null;
publishedAt: string | null;
time: number;
text: string;
likesCount: number;
/** Whether the signed-in person has liked the post. */
isLiked: boolean;
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";
}
/** The HLS URL only when it is https on stream.mux.com. */
export function muxHlsUrl(v: unknown): string | null {
const s = str(v);
if (!s) return null;
try {
const u = new URL(s);
return u.protocol === "https:" && u.hostname === "stream.mux.com" ? s : null;
} catch {
return null;
}
}
/** The MP4 URL only when it is https on the feed CDN. */
export function feedMp4Url(v: unknown): string | null {
const s = str(v);
if (!s) return null;
try {
const u = new URL(s);
return u.protocol === "https:" && u.hostname === "cdn.feed.sharedservices.vt.com" ? s : null;
} catch {
return null;
}
}
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") {
const thumb = str(m.thumbnailUrl);
const hls = muxHlsUrl(m.hlsUrl);
const mp4 = feedMp4Url(m.mp4Url);
// Keep a video even with no thumbnail or playable URL yet (just published).
const dur = [m.videoDuration, m.durationSeconds, m.duration].find((v): v is number => typeof v === "number" && v > 0) ?? null;
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4, ...(dur ? { durationSeconds: dur } : {}) });
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,
authorId:
typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null,
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),
isLiked: post.isLiked === true || item.isLiked === true,
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);
}
/** A video is playable once it has an mp4Url or an hlsUrl. */
export const isPlayableVideo = (m: CardMedia) => m.kind === "video" && !!(m.mp4Url || m.hlsUrl);
const hasPlayable = (p: CardPost) => p.media.some(isPlayableVideo);
/**
* Swap in the media of shown posts whose video gained an mp4Url or hlsUrl on a refresh
* (transcoding finished), so the thumbnail upgrades to the player in place.
* Returns the same array when nothing changed.
*/
export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[] {
const byKey = new Map(fresh.map((p) => [p.key, p]));
let changed = false;
const next = current.map((p) => {
const f = byKey.get(p.key);
if (f && !hasPlayable(p) && hasPlayable(f)) {
changed = true;
return { ...p, media: f.media };
}
return p;
});
return changed ? next : current;
}
/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */
export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } {
return { fullError: !refresh, refreshFailed: refresh };
}
/** "1 comment", "0 comments", "2 comments". */
export function commentsLabel(count: number): string {
return count === 1 ? "1 comment" : `${count} comments`;
}
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" });
}
/** Delete is offered only on the signed-in person's own post. */
export function canDelete(post: CardPost, feedUserId: string | null | undefined): boolean {
return !!feedUserId && !!post.id && post.authorId !== null && post.authorId === feedUserId;
}