215 lines
7.6 KiB
TypeScript
215 lines
7.6 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 = typeof m.durationSeconds === "number" ? m.durationSeconds : typeof m.duration === "number" ? m.duration : 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;
|
|
}
|
|
|
|
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;
|
|
}
|