Files
grimoire/src/lib/live-activity.ts
2025-12-17 11:44:12 +01:00

143 lines
3.9 KiB
TypeScript

import type { NostrEvent } from "@/types/nostr";
import type {
ParsedLiveActivity,
LiveParticipant,
LiveStatus,
} from "@/types/live-activity";
import { getTagValue } from "applesauce-core/helpers";
/**
* Helper to get all values for a given tag name
*/
function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags.filter((t) => t[0] === tagName).map((t) => t[1] || "");
}
/**
* Parse a kind:30311 live activity event
*/
export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity {
// Parse participants (p tags: [pubkey, relay?, role?, proof?])
const participants: LiveParticipant[] = event.tags
.filter((t) => t[0] === "p")
.map((t) => ({
pubkey: t[1],
relay: t[2] || undefined,
role: t[3] || "Participant",
proof: t[4] || undefined,
}));
// Parse numeric fields
const parseNum = (val?: string): number | undefined => {
return val ? parseInt(val, 10) : undefined;
};
return {
event,
identifier: getTagValue(event, "d") || "",
title: getTagValue(event, "title"),
summary: getTagValue(event, "summary"),
image: getTagValue(event, "image"),
streaming: getTagValue(event, "streaming"),
recording: getTagValue(event, "recording"),
starts: parseNum(getTagValue(event, "starts")),
ends: parseNum(getTagValue(event, "ends")),
status: getTagValue(event, "status") as LiveStatus | undefined,
currentParticipants: parseNum(getTagValue(event, "current_participants")),
totalParticipants: parseNum(getTagValue(event, "total_participants")),
participants,
hashtags: getTagValues(event, "t"),
relays: getTagValues(event, "relays"),
lastUpdate: event.created_at || Date.now() / 1000,
};
}
/**
* Get live status with optional timeout detection
* Events without updates for 1hr may be considered ended
*/
export function getLiveStatus(
event: NostrEvent,
considerTimeout = true,
): LiveStatus {
const parsed = parseLiveActivity(event);
// Explicit status from tags
if (parsed.status) {
// If status is 'live' but hasn't been updated in 1hr, consider ended
if (parsed.status === "live" && considerTimeout) {
const now = Date.now() / 1000;
const oneHourAgo = now - 3600;
if (parsed.lastUpdate < oneHourAgo) {
return "ended";
}
}
return parsed.status;
}
// Infer status from timestamps
const now = Date.now() / 1000;
if (parsed.ends && now > parsed.ends) {
return "ended";
}
if (parsed.starts && now > parsed.starts) {
return "live";
}
return "planned";
}
/**
* Get the host of a live activity
* Returns the first participant with "Host" role, or event author as fallback
*/
export function getLiveHost(event: NostrEvent): string {
const parsed = parseLiveActivity(event);
const host = parsed.participants.find((p) => p.role.toLowerCase() === "host");
return host?.pubkey || event.pubkey;
}
/**
* Get streaming URL (if available)
*/
export function getStreamingUrl(event: NostrEvent): string | undefined {
return parseLiveActivity(event).streaming;
}
/**
* Get recording URL (if available)
*/
export function getRecordingUrl(event: NostrEvent): string | undefined {
return parseLiveActivity(event).recording;
}
/**
* Format start time as relative or absolute
*/
export function formatStartTime(
starts?: number,
status?: LiveStatus,
): string | undefined {
if (!starts) return undefined;
const now = Date.now() / 1000;
const diff = starts - now;
if (status === "planned" && diff > 0) {
// Future event - show countdown
const hours = Math.floor(diff / 3600);
const days = Math.floor(hours / 24);
if (days > 0) {
return `in ${days}d`;
} else if (hours > 0) {
return `in ${hours}h`;
} else {
const minutes = Math.floor(diff / 60);
return `in ${minutes}m`;
}
}
// Past event - show date
return new Date(starts * 1000).toLocaleDateString();
}