// 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; 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(); 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; }