Compare commits
No commits in common. 'da4b2cb1db95fe84d548d6d3994c1fbc71a3f885' and 'ee98ab9dabaa7cc21e0c580a8ee3cca91b59fabc' have entirely different histories.
da4b2cb1db
...
ee98ab9dab
92 changed files with 677 additions and 3011 deletions
@ -1,21 +0,0 @@
@@ -1,21 +0,0 @@
|
||||
import { musicTrackPreviewText } from '@/components/Note/MusicTrackNote' |
||||
import { cn } from '@/lib/utils' |
||||
import { Event } from 'nostr-tools' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function MusicTrackNotePreview({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const line = musicTrackPreviewText(event).trim() |
||||
|
||||
return ( |
||||
<div className={cn('pointer-events-none min-w-0 truncate text-sm italic', className)}> |
||||
{line || t('Music track', { defaultValue: 'Music track' })} |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,14 +1,11 @@
@@ -1,14 +1,11 @@
|
||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' |
||||
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' |
||||
import { ReplyProvider } from '@/providers/ReplyProvider' |
||||
|
||||
/** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */ |
||||
export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) { |
||||
return ( |
||||
<ContentPolicyProvider> |
||||
<DeletedEventProvider> |
||||
<ReplyProvider>{children}</ReplyProvider> |
||||
</DeletedEventProvider> |
||||
</ContentPolicyProvider> |
||||
<DeletedEventProvider> |
||||
<ReplyProvider>{children}</ReplyProvider> |
||||
</DeletedEventProvider> |
||||
) |
||||
} |
||||
|
||||
@ -1,141 +0,0 @@
@@ -1,141 +0,0 @@
|
||||
import ExternalLink from '../ExternalLink' |
||||
import MediaPlayer from '../MediaPlayer' |
||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' |
||||
import { |
||||
fountainDisplayTitleFromOgTitle, |
||||
fountainEmbedMinHeight, |
||||
isFountainOpenUrl |
||||
} from '@/lib/fountain-url' |
||||
import { cleanUrl } from '@/lib/url' |
||||
import { cn } from '@/lib/utils' |
||||
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { useLayoutEffect, useMemo, useState } from 'react' |
||||
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder' |
||||
import { ExternalLink as ExternalLinkIcon } from 'lucide-react' |
||||
|
||||
function FountainCover({ url, className }: { url: string; className?: string }) { |
||||
return ( |
||||
<div className={cn('w-full overflow-hidden bg-muted', className)}> |
||||
<img |
||||
src={url} |
||||
alt="" |
||||
className="aspect-[2/1] max-h-36 w-full object-cover object-center" |
||||
loading="lazy" |
||||
referrerPolicy="no-referrer" |
||||
draggable={false} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function FountainMeta({ |
||||
displayTitle, |
||||
cleanedUrl, |
||||
compact = false |
||||
}: { |
||||
displayTitle?: string | null |
||||
cleanedUrl: string |
||||
compact?: boolean |
||||
}) { |
||||
return ( |
||||
<div className={cn('min-w-0 px-3', compact ? 'py-2' : 'pb-2 pt-2.5')}> |
||||
{displayTitle ? ( |
||||
<p className="line-clamp-2 text-sm font-medium leading-snug">{displayTitle}</p> |
||||
) : ( |
||||
<p className="text-sm font-medium">fountain.fm</p> |
||||
)} |
||||
<a |
||||
href={cleanedUrl} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="mt-1 inline-flex max-w-full items-center gap-1 text-xs text-primary hover:underline" |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
<span className="truncate">Open on Fountain</span> |
||||
<ExternalLinkIcon className="size-3 shrink-0" aria-hidden /> |
||||
</a> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const cardShell = (className?: string) => |
||||
cn( |
||||
'not-prose w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 shadow-sm', |
||||
className |
||||
) |
||||
|
||||
export default function FountainEmbeddedPlayer({ |
||||
url, |
||||
className, |
||||
mustLoad = false |
||||
}: { |
||||
url: string |
||||
className?: string |
||||
mustLoad?: boolean |
||||
}) { |
||||
const contentPolicy = useContentPolicyOptional() |
||||
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true |
||||
const [userClickedLoad, setUserClickedLoad] = useState(false) |
||||
const cleanedUrl = useMemo(() => cleanUrl(url) || url, [url]) |
||||
const minHeight = useMemo(() => fountainEmbedMinHeight(cleanedUrl), [cleanedUrl]) |
||||
const minHeightClass = minHeight === 200 ? 'min-h-[120px]' : 'min-h-[88px]' |
||||
const showPlayer = mustLoad || autoLoadMedia || userClickedLoad |
||||
|
||||
const { title, image, audio, ogLoading } = useFetchWebMetadata(cleanedUrl, { |
||||
fetchEnabled: showPlayer |
||||
}) |
||||
|
||||
const displayTitle = useMemo(() => fountainDisplayTitleFromOgTitle(title) ?? title, [title]) |
||||
|
||||
useLayoutEffect(() => { |
||||
if (!autoLoadMedia) setUserClickedLoad(false) |
||||
}, [autoLoadMedia]) |
||||
|
||||
if (!isFountainOpenUrl(cleanedUrl)) { |
||||
return <ExternalLink url={url} /> |
||||
} |
||||
|
||||
if (!showPlayer) { |
||||
return ( |
||||
<LazyMediaTapPlaceholder |
||||
src={cleanedUrl} |
||||
posterUrl={image ?? undefined} |
||||
mediaKind="audio" |
||||
onActivate={() => setUserClickedLoad(true)} |
||||
className={cn('w-full max-w-[400px]', minHeightClass, className)} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
if (ogLoading) { |
||||
return ( |
||||
<div className={cn(cardShell(className), minHeightClass)}> |
||||
<Skeleton className="aspect-[2/1] max-h-36 w-full rounded-none" /> |
||||
<Skeleton className="mx-3 mt-2 h-4 w-3/4" /> |
||||
<Skeleton className="mx-3 mt-1 h-8 w-full" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!audio) { |
||||
return ( |
||||
<div className={cn(cardShell(className))}> |
||||
{image ? <FountainCover url={image} /> : null} |
||||
<FountainMeta displayTitle={displayTitle} cleanedUrl={cleanedUrl} /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={cardShell(className)}> |
||||
{image ? <FountainCover url={image} /> : null} |
||||
<FountainMeta displayTitle={displayTitle} cleanedUrl={cleanedUrl} compact /> |
||||
<MediaPlayer |
||||
src={audio} |
||||
className="w-full max-w-none shrink-0 border-0 border-t border-border px-2 pb-2 pt-1" |
||||
mustLoad={showPlayer} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,99 +0,0 @@
@@ -1,99 +0,0 @@
|
||||
import AudioPlayer from '@/components/AudioPlayer' |
||||
import { |
||||
getMusicTrackFromEvent, |
||||
musicTrackCaptionContent, |
||||
musicTrackDisplayLine, |
||||
musicTrackMetaLine |
||||
} from '@/lib/music-track' |
||||
import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url' |
||||
import { cn } from '@/lib/utils' |
||||
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import MediaPlayer from '../MediaPlayer' |
||||
|
||||
export default function MusicTrackNote({ |
||||
event, |
||||
className, |
||||
loadMedia = false |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
loadMedia?: boolean |
||||
}) { |
||||
const contentPolicy = useContentPolicyOptional() |
||||
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true |
||||
const mustLoad = loadMedia || autoLoadMedia |
||||
const { t } = useTranslation() |
||||
|
||||
const track = useMemo(() => getMusicTrackFromEvent(event), [event]) |
||||
const metaLine = useMemo(() => (track ? musicTrackMetaLine(track) : ''), [track]) |
||||
const caption = useMemo( |
||||
() => (track ? musicTrackCaptionContent(event.content, track) : null), |
||||
[event.content, track] |
||||
) |
||||
const audioFallbackSrc = useMemo( |
||||
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined), |
||||
[track] |
||||
) |
||||
|
||||
if (!track) { |
||||
return ( |
||||
<p className={cn('text-sm text-muted-foreground', className)}> |
||||
{t('Invalid music track event', { defaultValue: 'Invalid music track event' })} |
||||
</p> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={cn('min-w-0', className)}> |
||||
<div className="not-prose w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-card shadow-sm"> |
||||
<div className="flex gap-3 p-3"> |
||||
{track.imageUrl ? ( |
||||
<img |
||||
src={track.imageUrl} |
||||
alt="" |
||||
className="size-16 shrink-0 rounded-md object-cover shadow-sm" |
||||
loading="lazy" |
||||
referrerPolicy="no-referrer" |
||||
draggable={false} |
||||
/> |
||||
) : null} |
||||
<div className="min-w-0 flex-1"> |
||||
<p className="line-clamp-2 text-sm font-semibold leading-snug">{track.title}</p> |
||||
{track.artist ? ( |
||||
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{track.artist}</p> |
||||
) : null} |
||||
{metaLine ? ( |
||||
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground">{metaLine}</p> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
<div className="border-t border-border px-2 pb-2.5 pt-1.5"> |
||||
<AudioPlayer |
||||
src={track.audioUrl} |
||||
fallbackSrc={audioFallbackSrc} |
||||
className="w-full max-w-none" |
||||
/> |
||||
</div> |
||||
{track.videoUrl ? ( |
||||
<div className="border-t border-border px-2 pb-2 pt-1"> |
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> |
||||
{t('Music video', { defaultValue: 'Music video' })} |
||||
</p> |
||||
<MediaPlayer src={track.videoUrl} className="w-full max-w-none" mustLoad={mustLoad} /> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
{caption ? ( |
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{caption}</p> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export function musicTrackPreviewText(event: Event): string { |
||||
const track = getMusicTrackFromEvent(event) |
||||
return track ? musicTrackDisplayLine(track) : event.tags.find((t) => t[0] === 'title')?.[1] ?? '' |
||||
} |
||||
@ -1,60 +0,0 @@
@@ -1,60 +0,0 @@
|
||||
import { cn } from '@/lib/utils' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export type RelaySettingsKindNoticeVariant = 'edit' | 'view' | 'session' |
||||
|
||||
type Props = { |
||||
kinds: readonly number[] |
||||
variant?: RelaySettingsKindNoticeVariant |
||||
className?: string |
||||
} |
||||
|
||||
function formatKindList(kinds: readonly number[]): string { |
||||
return kinds.join(', ') |
||||
} |
||||
|
||||
export default function RelaySettingsKindNotice({ |
||||
kinds, |
||||
variant = 'edit', |
||||
className |
||||
}: Props) { |
||||
const { t } = useTranslation() |
||||
|
||||
if (variant === 'session') { |
||||
return ( |
||||
<p |
||||
className={cn( |
||||
'rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground', |
||||
className |
||||
)} |
||||
role="note" |
||||
> |
||||
{t('relaySettingsEventKindsSession')} |
||||
</p> |
||||
) |
||||
} |
||||
|
||||
if (kinds.length === 0) return null |
||||
|
||||
const kindsLabel = formatKindList(kinds) |
||||
const body = |
||||
variant === 'view' |
||||
? t('relaySettingsEventKindsView', { kinds: kindsLabel }) |
||||
: kinds.length === 1 |
||||
? t('relaySettingsEventKindsEditOne', { kind: kindsLabel }) |
||||
: t('relaySettingsEventKindsEditMany', { kinds: kindsLabel }) |
||||
|
||||
return ( |
||||
<p |
||||
className={cn( |
||||
'rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground', |
||||
className |
||||
)} |
||||
role="note" |
||||
> |
||||
<span className="font-medium text-foreground">{t('relaySettingsEventKindsLabel')}:</span>{' '} |
||||
<span className="font-mono tabular-nums">{kindsLabel}</span> |
||||
<span className="block mt-1">{body}</span> |
||||
</p> |
||||
) |
||||
} |
||||
@ -1,60 +0,0 @@
@@ -1,60 +0,0 @@
|
||||
import { |
||||
isWavlakeOpenUrl, |
||||
wavlakeEmbedMinHeight, |
||||
wavlakeOpenUrlToEmbedSrc |
||||
} from '@/lib/wavlake-url' |
||||
import { cn } from '@/lib/utils' |
||||
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
||||
import { useLayoutEffect, useMemo, useState } from 'react' |
||||
import ExternalLink from '../ExternalLink' |
||||
import LazyMediaTapPlaceholder from '../MediaPlayer/LazyMediaTapPlaceholder' |
||||
|
||||
export default function WavlakeEmbeddedPlayer({ |
||||
url, |
||||
className, |
||||
mustLoad = false |
||||
}: { |
||||
url: string |
||||
className?: string |
||||
mustLoad?: boolean |
||||
}) { |
||||
const contentPolicy = useContentPolicyOptional() |
||||
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true |
||||
const [userClickedLoad, setUserClickedLoad] = useState(false) |
||||
const embedSrc = useMemo(() => wavlakeOpenUrlToEmbedSrc(url), [url]) |
||||
const minHeight = useMemo(() => wavlakeEmbedMinHeight(url), [url]) |
||||
const minHeightClass = minHeight === 200 ? 'min-h-[200px]' : 'min-h-[380px]' |
||||
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad |
||||
|
||||
useLayoutEffect(() => { |
||||
if (!autoLoadMedia) setUserClickedLoad(false) |
||||
}, [autoLoadMedia]) |
||||
|
||||
if (!embedSrc) { |
||||
return <ExternalLink url={url} /> |
||||
} |
||||
|
||||
if (!mustLoad && !showEmbed) { |
||||
return ( |
||||
<LazyMediaTapPlaceholder |
||||
src={url} |
||||
mediaKind="audio" |
||||
onActivate={() => setUserClickedLoad(true)} |
||||
className={cn('w-full max-w-[400px]', minHeightClass, className)} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<iframe |
||||
title="Wavlake" |
||||
src={embedSrc} |
||||
className={cn('w-full max-w-[400px] rounded-lg border', minHeightClass, className)} |
||||
style={{ height: minHeight }} |
||||
allow="autoplay; encrypted-media; clipboard-write" |
||||
loading="lazy" |
||||
/> |
||||
) |
||||
} |
||||
|
||||
export { isWavlakeOpenUrl } |
||||
@ -1,105 +0,0 @@
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
calendarEventHexId, |
||||
calendarRsvpMatchesCalendarEvent, |
||||
calendarRsvpParentKeyFromEventId, |
||||
parseCalendarRsvpStatus |
||||
} from '@/lib/calendar-rsvp-match' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const ORG = 'b'.repeat(63) + 'c' |
||||
const D = 'purple-prague' |
||||
const CAL_ID = 'a'.repeat(64) |
||||
|
||||
function calendarEvent(overrides: Partial<Event> = {}): Event { |
||||
return { |
||||
id: CAL_ID, |
||||
pubkey: ORG, |
||||
created_at: 1_700_000_000, |
||||
kind: ExtendedKind.CALENDAR_EVENT_TIME, |
||||
tags: [['d', D]], |
||||
content: '', |
||||
sig: 'sig', |
||||
...overrides |
||||
} |
||||
} |
||||
|
||||
function rsvp(tags: string[][], pubkey = 'c'.repeat(63) + 'd'): Event { |
||||
return { |
||||
id: 'f'.repeat(64), |
||||
pubkey, |
||||
created_at: 1_700_000_100, |
||||
kind: ExtendedKind.CALENDAR_EVENT_RSVP, |
||||
tags, |
||||
content: '', |
||||
sig: 'sig' |
||||
} |
||||
} |
||||
|
||||
describe('calendarRsvpMatchesCalendarEvent', () => { |
||||
const cal = calendarEvent() |
||||
const coord = `${ExtendedKind.CALENDAR_EVENT_TIME}:${ORG}:${D}` |
||||
|
||||
it('matches via normalized a tag', () => { |
||||
expect( |
||||
calendarRsvpMatchesCalendarEvent( |
||||
cal, |
||||
rsvp([ |
||||
['a', coord], |
||||
['status', 'accepted'] |
||||
]) |
||||
) |
||||
).toBe(true) |
||||
}) |
||||
|
||||
it('matches via e tag when a is absent', () => { |
||||
expect( |
||||
calendarRsvpMatchesCalendarEvent( |
||||
cal, |
||||
rsvp([ |
||||
['e', CAL_ID.toUpperCase()], |
||||
['status', 'tentative'] |
||||
]) |
||||
) |
||||
).toBe(true) |
||||
}) |
||||
|
||||
it('rejects wrong coordinate and wrong event id', () => { |
||||
expect( |
||||
calendarRsvpMatchesCalendarEvent( |
||||
cal, |
||||
rsvp([ |
||||
['a', `${ExtendedKind.CALENDAR_EVENT_TIME}:${ORG}:other`], |
||||
['e', 'b'.repeat(64)] |
||||
]) |
||||
) |
||||
).toBe(false) |
||||
}) |
||||
}) |
||||
|
||||
describe('parseCalendarRsvpStatus', () => { |
||||
it('parses accepted, tentative, declined', () => { |
||||
expect(parseCalendarRsvpStatus(rsvp([['status', 'Accepted']]))).toBe('accepted') |
||||
expect(parseCalendarRsvpStatus(rsvp([['status', 'TENTATIVE']]))).toBe('tentative') |
||||
expect(parseCalendarRsvpStatus(rsvp([['status', 'declined']]))).toBe('declined') |
||||
}) |
||||
|
||||
it('returns undefined for missing or invalid status', () => { |
||||
expect(parseCalendarRsvpStatus(rsvp([]))).toBeUndefined() |
||||
expect(parseCalendarRsvpStatus(rsvp([['status', 'maybe']]))).toBeUndefined() |
||||
}) |
||||
}) |
||||
|
||||
describe('calendarEventHexId', () => { |
||||
it('lowercases 64-char hex ids', () => { |
||||
expect(calendarEventHexId({ ...calendarEvent(), id: CAL_ID.toUpperCase() })).toBe(CAL_ID) |
||||
}) |
||||
}) |
||||
|
||||
describe('calendarRsvpParentKeyFromEventId', () => { |
||||
it('builds e: prefix key', () => { |
||||
expect(calendarRsvpParentKeyFromEventId(CAL_ID)).toBe(`e:${CAL_ID}`) |
||||
expect(calendarRsvpParentKeyFromEventId('not-hex')).toBe('') |
||||
}) |
||||
}) |
||||
@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
getReplaceableCoordinateFromEvent, |
||||
normalizeReplaceableCoordinateString |
||||
} from '@/lib/event' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { Event } from 'nostr-tools' |
||||
|
||||
export type CalendarRsvpStatus = 'accepted' | 'tentative' | 'declined' |
||||
|
||||
export function calendarEventHexId(event: Event): string { |
||||
return /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id |
||||
} |
||||
|
||||
/** Whether kind 31925 references this calendar note (31922 / 31923) via `a` and/or `e`. */ |
||||
export function calendarRsvpMatchesCalendarEvent(calendarEvent: Event, rsvp: Event): boolean { |
||||
if (rsvp.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return false |
||||
const coordNorm = normalizeReplaceableCoordinateString( |
||||
getReplaceableCoordinateFromEvent(calendarEvent) |
||||
) |
||||
const calId = calendarEventHexId(calendarEvent) |
||||
const rawA = rsvp.tags.find(tagNameEquals('a'))?.[1]?.trim() |
||||
if (rawA && normalizeReplaceableCoordinateString(rawA) === coordNorm) return true |
||||
const eTag = rsvp.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase() |
||||
return Boolean(eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId) |
||||
} |
||||
|
||||
export function parseCalendarRsvpStatus(rsvp: Event): CalendarRsvpStatus | undefined { |
||||
const status = rsvp.tags.find(tagNameEquals('status'))?.[1]?.trim().toLowerCase() |
||||
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status |
||||
return undefined |
||||
} |
||||
|
||||
/** IndexedDB parent key for RSVPs that only tag the calendar event id (`e`). */ |
||||
export function calendarRsvpParentKeyFromEventId(hexId: string): string { |
||||
const id = hexId.trim().toLowerCase() |
||||
return /^[0-9a-f]{64}$/.test(id) ? `e:${id}` : '' |
||||
} |
||||
@ -1,37 +0,0 @@
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { |
||||
fountainDisplayTitleFromOgTitle, |
||||
fountainEmbedMinHeight, |
||||
fountainOpenUrlKind, |
||||
isFountainOpenUrl |
||||
} from './fountain-url' |
||||
|
||||
describe('fountain-url', () => { |
||||
it('recognizes episode URLs', () => { |
||||
const url = 'https://fountain.fm/episode/iZHflqr7FsRmZXk4RH3i' |
||||
expect(isFountainOpenUrl(url)).toBe(true) |
||||
expect(fountainOpenUrlKind(url)).toBe('episode') |
||||
expect(fountainEmbedMinHeight(url)).toBe(200) |
||||
}) |
||||
|
||||
it('recognizes show URLs', () => { |
||||
const url = 'https://fountain.fm/show/68gcLZFDRxOzgGeZmXq6' |
||||
expect(fountainOpenUrlKind(url)).toBe('show') |
||||
expect(fountainEmbedMinHeight(url)).toBe(120) |
||||
}) |
||||
|
||||
it('shortens og titles', () => { |
||||
expect( |
||||
fountainDisplayTitleFromOgTitle( |
||||
'Bitcoin And | Bitcoin & Economic News • Bombing Strategy | Bitcoin News • Listen on Fountain' |
||||
) |
||||
).toBe('Bitcoin And | Bitcoin & Economic News • Bombing Strategy | Bitcoin News') |
||||
}) |
||||
|
||||
it('rejects non-fountain hosts and invalid paths', () => { |
||||
expect(isFountainOpenUrl('https://example.com/episode/x')).toBe(false) |
||||
expect(isFountainOpenUrl('https://fountain.fm/')).toBe(false) |
||||
expect(isFountainOpenUrl('https://fountain.fm/episode/')).toBe(false) |
||||
expect(isFountainOpenUrl('https://fountain.fm/foo/bar')).toBe(false) |
||||
}) |
||||
}) |
||||
@ -1,37 +0,0 @@
@@ -1,37 +0,0 @@
|
||||
const FOUNTAIN_HOSTS = new Set(['fountain.fm', 'www.fountain.fm']) |
||||
|
||||
export type FountainEmbedKind = 'episode' | 'show' |
||||
|
||||
export function fountainOpenUrlKind(url: string): FountainEmbedKind | null { |
||||
try { |
||||
const u = new URL(url.trim()) |
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null |
||||
if (!FOUNTAIN_HOSTS.has(u.hostname.toLowerCase())) return null |
||||
const parts = u.pathname.split('/').filter(Boolean) |
||||
if (parts.length !== 2) return null |
||||
const head = parts[0].toLowerCase() |
||||
if (head !== 'episode' && head !== 'show') return null |
||||
return /^[A-Za-z0-9]+$/.test(parts[1]) ? head : null |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** Card min height (episode player vs show link card). */ |
||||
export function fountainEmbedMinHeight(url: string): number { |
||||
return fountainOpenUrlKind(url) === 'episode' ? 200 : 120 |
||||
} |
||||
|
||||
export function isFountainOpenUrl(url: string): boolean { |
||||
return fountainOpenUrlKind(url) != null |
||||
} |
||||
|
||||
/** Shorten Fountain og:title for display in embed cards. */ |
||||
export function fountainDisplayTitleFromOgTitle(ogTitle: string | null | undefined): string | undefined { |
||||
if (!ogTitle) return undefined |
||||
const trimmed = ogTitle |
||||
.replace(/\s*•\s*Listen on Fountain\s*$/i, '') |
||||
.replace(/\s*•\s*$/g, '') |
||||
.trim() |
||||
return trimmed || undefined |
||||
} |
||||
@ -1,243 +0,0 @@
@@ -1,243 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
formatMusicTrackDuration, |
||||
getMusicTrackFromEvent, |
||||
musicTrackCaptionContent, |
||||
musicTrackDisplayLine, |
||||
musicTrackMetaLine |
||||
} from './music-track' |
||||
import { describe, expect, it } from 'vitest' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** Minimal kind-36787 event for unit tests. */ |
||||
function musicTrackEvent( |
||||
tags: string[][], |
||||
content = '' |
||||
): Event { |
||||
return { |
||||
id: 'a'.repeat(64), |
||||
pubkey: 'b'.repeat(64), |
||||
created_at: 1779818315, |
||||
kind: ExtendedKind.MUSIC_TRACK, |
||||
tags, |
||||
content, |
||||
sig: 'c'.repeat(128) |
||||
} |
||||
} |
||||
|
||||
const BASE_TAGS: string[][] = [ |
||||
['d', 'track-8rqwm2jwg'], |
||||
['title', 'Scatman (Ski-Ba-Bop-Ba-Dop-Bop)'], |
||||
['url', 'https://blossom.primal.net/cc0c235629f80ef4e98cee3475dc0dcc2ba2eea53730a3448d6d4dcb37c30078.mp3'], |
||||
['artist', 'Scatman John'], |
||||
['album', "Scatman's World"], |
||||
['duration', '218'], |
||||
['format', 'mp3'], |
||||
['track_number', '5'], |
||||
['genre', 'Disco Pop'], |
||||
['t', 'music'], |
||||
['t', 'gruuv'], |
||||
['t', 'grooveblossom'], |
||||
['t', 'disco pop'] |
||||
] |
||||
|
||||
describe('getMusicTrackFromEvent', () => { |
||||
it('parses a realistic blossom music-track event', () => { |
||||
const track = getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS)) |
||||
expect(track).toEqual({ |
||||
title: 'Scatman (Ski-Ba-Bop-Ba-Dop-Bop)', |
||||
artist: 'Scatman John', |
||||
audioUrl: |
||||
'https://blossom.primal.net/cc0c235629f80ef4e98cee3475dc0dcc2ba2eea53730a3448d6d4dcb37c30078.mp3', |
||||
imageUrl: undefined, |
||||
videoUrl: undefined, |
||||
album: "Scatman's World", |
||||
trackNumber: '5', |
||||
released: undefined, |
||||
durationSec: 218, |
||||
format: 'mp3', |
||||
explicit: false, |
||||
alt: undefined, |
||||
genres: ['gruuv', 'grooveblossom', 'disco pop'], |
||||
language: undefined |
||||
}) |
||||
}) |
||||
|
||||
it('parses optional tags', () => { |
||||
const track = getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'summer-nights-2024'], |
||||
['title', 'Summer Nights'], |
||||
['url', 'https://cdn.example/audio.mp3'], |
||||
['image', 'https://cdn.example/art.jpg'], |
||||
['video', 'https://cdn.example/video.mp4'], |
||||
['artist', 'The Midnight Collective'], |
||||
['album', 'Endless Summer'], |
||||
['track_number', '3'], |
||||
['released', '2024-06-15'], |
||||
['duration', '245'], |
||||
['format', 'mp3'], |
||||
['explicit', 'true'], |
||||
['alt', 'Cover art'], |
||||
['language', 'en'], |
||||
['t', 'music'], |
||||
['t', 'electronic'] |
||||
]) |
||||
) |
||||
expect(track).toMatchObject({ |
||||
imageUrl: 'https://cdn.example/art.jpg', |
||||
videoUrl: 'https://cdn.example/video.mp4', |
||||
released: '2024-06-15', |
||||
durationSec: 245, |
||||
explicit: true, |
||||
alt: 'Cover art', |
||||
language: 'en', |
||||
genres: ['electronic'] |
||||
}) |
||||
expect(musicTrackDisplayLine(track!)).toBe('The Midnight Collective — Summer Nights') |
||||
}) |
||||
|
||||
it('returns null without title, url, or t=music', () => { |
||||
expect( |
||||
getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'] |
||||
]) |
||||
) |
||||
).toBeNull() |
||||
expect( |
||||
getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'music'] |
||||
]) |
||||
) |
||||
).toBeNull() |
||||
expect( |
||||
getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['t', 'music'] |
||||
]) |
||||
) |
||||
).toBeNull() |
||||
expect( |
||||
getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'rock'] |
||||
]) |
||||
) |
||||
).toBeNull() |
||||
}) |
||||
|
||||
it('ignores invalid or zero duration', () => { |
||||
const noDuration = getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'music'] |
||||
]) |
||||
) |
||||
expect(noDuration?.durationSec).toBeUndefined() |
||||
|
||||
const badDuration = getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'music'], |
||||
['duration', 'nope'], |
||||
['duration', '0'] |
||||
]) |
||||
) |
||||
expect(badDuration?.durationSec).toBeUndefined() |
||||
}) |
||||
|
||||
it('prepends genre tag when not duplicated by a t tag', () => { |
||||
const track = getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'music'], |
||||
['genre', 'Disco Pop'], |
||||
['t', 'synthwave'] |
||||
]) |
||||
) |
||||
expect(track?.genres).toEqual(['Disco Pop', 'synthwave']) |
||||
}) |
||||
|
||||
it('dedupes genre tag against t tags case-insensitively', () => { |
||||
const track = getMusicTrackFromEvent( |
||||
musicTrackEvent([ |
||||
['d', 'x'], |
||||
['title', 'T'], |
||||
['url', 'https://a.mp3'], |
||||
['t', 'music'], |
||||
['genre', 'Disco Pop'], |
||||
['t', 'disco pop'] |
||||
]) |
||||
) |
||||
expect(track?.genres).toEqual(['disco pop']) |
||||
}) |
||||
}) |
||||
|
||||
describe('formatMusicTrackDuration', () => { |
||||
it('formats mm:ss and h:mm:ss', () => { |
||||
expect(formatMusicTrackDuration(245)).toBe('4:05') |
||||
expect(formatMusicTrackDuration(218)).toBe('3:38') |
||||
expect(formatMusicTrackDuration(3665)).toBe('1:01:05') |
||||
}) |
||||
|
||||
it('returns empty string for invalid input', () => { |
||||
expect(formatMusicTrackDuration(NaN)).toBe('') |
||||
expect(formatMusicTrackDuration(-1)).toBe('') |
||||
expect(formatMusicTrackDuration(Infinity)).toBe('') |
||||
}) |
||||
}) |
||||
|
||||
describe('musicTrackMetaLine', () => { |
||||
it('builds a metadata line from track fields', () => { |
||||
const track = getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS))! |
||||
expect(musicTrackMetaLine(track)).toBe( |
||||
"Scatman's World #5 · 3:38 · MP3 · gruuv, grooveblossom, disco pop" |
||||
) |
||||
}) |
||||
}) |
||||
|
||||
describe('musicTrackCaptionContent', () => { |
||||
const scatmanTrack = () => getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS))! |
||||
|
||||
it('drops promotional content that repeats title and artist', () => { |
||||
const track = scatmanTrack() |
||||
expect( |
||||
musicTrackCaptionContent( |
||||
'Listen to my song - Scatman (Ski-Ba-Bop-Ba-Dop-Bop) by Scatman John', |
||||
track |
||||
) |
||||
).toBeNull() |
||||
}) |
||||
|
||||
it('keeps lyrics or notes that are not just title/artist promotion', () => { |
||||
const track = scatmanTrack() |
||||
expect(musicTrackCaptionContent('Verse one…', track)).toBe('Verse one…') |
||||
expect(musicTrackCaptionContent('Listen to my song', track)).toBe('Listen to my song') |
||||
expect(musicTrackCaptionContent('', track)).toBeNull() |
||||
expect(musicTrackCaptionContent(' ', track)).toBeNull() |
||||
}) |
||||
|
||||
it('keeps content that mentions the title but not the artist', () => { |
||||
const track = scatmanTrack() |
||||
expect(musicTrackCaptionContent('Scatman (Ski-Ba-Bop-Ba-Dop-Bop) — live version', track)).toBe( |
||||
'Scatman (Ski-Ba-Bop-Ba-Dop-Bop) — live version' |
||||
) |
||||
}) |
||||
}) |
||||
@ -1,119 +0,0 @@
@@ -1,119 +0,0 @@
|
||||
import { isMusicTrackKind } from '@/constants' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
export type TMusicTrack = { |
||||
title: string |
||||
artist?: string |
||||
audioUrl: string |
||||
imageUrl?: string |
||||
videoUrl?: string |
||||
album?: string |
||||
trackNumber?: string |
||||
released?: string |
||||
durationSec?: number |
||||
format?: string |
||||
explicit?: boolean |
||||
alt?: string |
||||
genres: string[] |
||||
language?: string |
||||
} |
||||
|
||||
function firstTagValue(event: Event, name: string): string | undefined { |
||||
const v = event.tags.find(tagNameEquals(name))?.[1]?.trim() |
||||
return v || undefined |
||||
} |
||||
|
||||
function tagValues(event: Event, name: string): string[] { |
||||
return event.tags |
||||
.filter((t) => t[0] === name && t[1]?.trim()) |
||||
.map((t) => t[1]!.trim()) |
||||
} |
||||
|
||||
function mergeMusicTrackGenres(event: Event): string[] { |
||||
const fromT = tagValues(event, 't').filter((t) => t !== 'music') |
||||
const genre = firstTagValue(event, 'genre') |
||||
if (!genre) return fromT |
||||
const key = genre.toLowerCase() |
||||
if (fromT.some((g) => g.toLowerCase() === key)) return fromT |
||||
return [genre, ...fromT] |
||||
} |
||||
|
||||
/** Promotional note text that only repeats title/artist should not render below the card. */ |
||||
export function musicTrackCaptionContent( |
||||
content: string | undefined, |
||||
track: TMusicTrack |
||||
): string | null { |
||||
const c = content?.trim() |
||||
if (!c) return null |
||||
const norm = c.toLowerCase() |
||||
const title = track.title.toLowerCase() |
||||
if (!norm.includes(title)) return c |
||||
const artist = track.artist?.toLowerCase() |
||||
if (artist && !norm.includes(artist)) return c |
||||
return null |
||||
} |
||||
|
||||
export function formatMusicTrackDuration(seconds: number): string { |
||||
if (!Number.isFinite(seconds) || seconds < 0) return '' |
||||
const total = Math.floor(seconds) |
||||
const h = Math.floor(total / 3600) |
||||
const m = Math.floor((total % 3600) / 60) |
||||
const s = total % 60 |
||||
if (h > 0) { |
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` |
||||
} |
||||
return `${m}:${s.toString().padStart(2, '0')}` |
||||
} |
||||
|
||||
/** Parse kind 36787 music track metadata from event tags. */ |
||||
export function getMusicTrackFromEvent(event: Event): TMusicTrack | null { |
||||
if (!isMusicTrackKind(event.kind)) return null |
||||
|
||||
const title = firstTagValue(event, 'title') |
||||
const audioUrl = firstTagValue(event, 'url') |
||||
if (!title || !audioUrl) return null |
||||
|
||||
const hasMusicTag = event.tags.some((t) => t[0] === 't' && t[1] === 'music') |
||||
if (!hasMusicTag) return null |
||||
|
||||
const durationRaw = firstTagValue(event, 'duration') |
||||
const durationSec = durationRaw != null ? Number.parseInt(durationRaw, 10) : NaN |
||||
|
||||
return { |
||||
title, |
||||
artist: firstTagValue(event, 'artist'), |
||||
audioUrl, |
||||
imageUrl: firstTagValue(event, 'image'), |
||||
videoUrl: firstTagValue(event, 'video'), |
||||
album: firstTagValue(event, 'album'), |
||||
trackNumber: firstTagValue(event, 'track_number'), |
||||
released: firstTagValue(event, 'released'), |
||||
durationSec: Number.isFinite(durationSec) && durationSec > 0 ? durationSec : undefined, |
||||
format: firstTagValue(event, 'format'), |
||||
explicit: firstTagValue(event, 'explicit')?.toLowerCase() === 'true', |
||||
alt: firstTagValue(event, 'alt'), |
||||
genres: mergeMusicTrackGenres(event), |
||||
language: firstTagValue(event, 'language') |
||||
} |
||||
} |
||||
|
||||
export function musicTrackDisplayLine(track: TMusicTrack): string { |
||||
if (track.artist) return `${track.artist} — ${track.title}` |
||||
return track.title |
||||
} |
||||
|
||||
export function musicTrackMetaLine(track: TMusicTrack): string { |
||||
const parts: string[] = [] |
||||
if (track.album) { |
||||
parts.push(track.trackNumber ? `${track.album} #${track.trackNumber}` : track.album) |
||||
} else if (track.trackNumber) { |
||||
parts.push(`#${track.trackNumber}`) |
||||
} |
||||
if (track.released) parts.push(track.released) |
||||
if (track.durationSec) parts.push(formatMusicTrackDuration(track.durationSec)) |
||||
if (track.format) parts.push(track.format.toUpperCase()) |
||||
if (track.explicit) parts.push('Explicit') |
||||
if (track.genres.length) parts.push(track.genres.slice(0, 3).join(', ')) |
||||
return parts.join(' · ') |
||||
} |
||||
@ -1,70 +0,0 @@
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { |
||||
htmlLooksLikeImwaldAppShell, |
||||
isImwaldDefaultOpenGraphDescription, |
||||
isImwaldDefaultOpenGraphTitle, |
||||
parseOpenGraphFromHtml |
||||
} from './open-graph' |
||||
|
||||
const IMWALD_INDEX_SNIPPET = `<!doctype html>
|
||||
<html><head> |
||||
<title>Imwald</title> |
||||
<meta property="og:title" content="Imwald" /> |
||||
<meta property="og:description" content="Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery." /> |
||||
<meta property="og:image" content="https://jumble.imwald.eu/og-image.png" /> |
||||
</head><body><div id="root"><div id="imwald-boot-splash"></div></div></body></html>` |
||||
|
||||
const FOUNTAIN_SNIPPET = `<!doctype html>
|
||||
<html><head> |
||||
<meta property="og:title" content="Episode Title | Fountain" /> |
||||
<meta property="og:description" content="A podcast episode" /> |
||||
<meta property="og:image" content="https://fountain.fm/cover.jpg" /> |
||||
<meta property="og:audio" content="https://fountain.fm/audio.mp3" /> |
||||
</head><body></body></html>` |
||||
|
||||
describe('open-graph', () => { |
||||
it('detects Imwald app shell HTML', () => { |
||||
expect(htmlLooksLikeImwaldAppShell(IMWALD_INDEX_SNIPPET)).toBe(true) |
||||
expect(htmlLooksLikeImwaldAppShell(FOUNTAIN_SNIPPET)).toBe(false) |
||||
}) |
||||
|
||||
it('returns empty metadata for app shell on external URLs', () => { |
||||
expect(parseOpenGraphFromHtml(IMWALD_INDEX_SNIPPET, 'https://fountain.fm/episode/x')).toEqual({}) |
||||
}) |
||||
|
||||
it('parses og and twitter tags from a normal page', () => { |
||||
expect(parseOpenGraphFromHtml(FOUNTAIN_SNIPPET, 'https://fountain.fm/episode/x')).toEqual({ |
||||
title: 'Episode Title | Fountain', |
||||
description: 'A podcast episode', |
||||
image: 'https://fountain.fm/cover.jpg', |
||||
audio: 'https://fountain.fm/audio.mp3' |
||||
}) |
||||
}) |
||||
|
||||
it('strips Imwald default title even without trailing space', () => { |
||||
expect(isImwaldDefaultOpenGraphTitle('Imwald')).toBe(true) |
||||
expect(isImwaldDefaultOpenGraphTitle('Episode Title')).toBe(false) |
||||
}) |
||||
|
||||
it('strips Imwald default description case-insensitively', () => { |
||||
expect( |
||||
isImwaldDefaultOpenGraphDescription( |
||||
'Imwald — a user-friendly Nostr client focused on relay feed browsing.' |
||||
) |
||||
).toBe(true) |
||||
}) |
||||
|
||||
it('filters jumble og-image on external hosts while keeping other fields', () => { |
||||
const html = `<html><head>
|
||||
<meta property="og:title" content="Real Site" /> |
||||
<meta property="og:description" content="About the site" /> |
||||
<meta property="og:image" content="https://jumble.imwald.eu/og-image.png" /> |
||||
</head></html>` |
||||
expect(parseOpenGraphFromHtml(html, 'https://example.com/page')).toEqual({ |
||||
title: 'Real Site', |
||||
description: 'About the site', |
||||
image: undefined, |
||||
audio: undefined |
||||
}) |
||||
}) |
||||
}) |
||||
@ -1,171 +0,0 @@
@@ -1,171 +0,0 @@
|
||||
import { TWebMetadata } from '@/types' |
||||
import logger from '@/lib/logger' |
||||
|
||||
/** True when HTML is the Vite/React dev shell or another SPA stub, not the target page. */ |
||||
export function htmlLooksLikeLocalDevAppShell(html: string): boolean { |
||||
const head = html.slice(0, 8000) |
||||
return ( |
||||
head.includes('injectIntoGlobalHook') || |
||||
head.includes('/@vite/') || |
||||
head.includes('@vite/client') || |
||||
head.includes('@react-refresh') |
||||
) |
||||
} |
||||
|
||||
/** True when HTML is Imwald's SPA index (served when OG proxy is missing or misrouted). */ |
||||
export function htmlLooksLikeImwaldAppShell(html: string): boolean { |
||||
if (htmlLooksLikeLocalDevAppShell(html)) return true |
||||
const head = html.slice(0, 16_000) |
||||
if (head.includes('imwald-boot-splash') && head.includes('<title>Imwald</title>')) return true |
||||
if (head.includes('jumble.imwald.eu/og-image') && /property="og:title"[^>]*content="Imwald"/i.test(head)) { |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
export function isImwaldDefaultOpenGraphTitle(title: string | null | undefined): boolean { |
||||
if (!title) return false |
||||
const t = title.trim() |
||||
return ( |
||||
/^imwald$/i.test(t) || |
||||
t.includes('Imwald ') || |
||||
/jumble\s*-\s*imwald edition/i.test(t) || |
||||
/jumble imwald edition/i.test(t) |
||||
) |
||||
} |
||||
|
||||
export function isImwaldDefaultOpenGraphDescription(description: string | null | undefined): boolean { |
||||
if (!description) return false |
||||
return /user-friendly nostr client focused on relay feed browsing/i.test(description) |
||||
} |
||||
|
||||
function metaContent(doc: Document, selectors: string[]): string | undefined { |
||||
for (const sel of selectors) { |
||||
const el = doc.querySelector(sel) |
||||
const v = el?.getAttribute('content') ?? (el as HTMLMetaElement | null)?.content |
||||
if (v?.trim()) return v.trim() |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
function resolveMaybeRelativeUrl(value: string, pageUrl: string): string { |
||||
try { |
||||
const urlObj = new URL(pageUrl) |
||||
if (value.startsWith('/')) { |
||||
return `${urlObj.protocol}//${urlObj.host}${value}` |
||||
} |
||||
if (!value.match(/^https?:\/\//)) { |
||||
const basePath = urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1) |
||||
return `${urlObj.protocol}//${urlObj.host}${basePath}${value}` |
||||
} |
||||
return value |
||||
} catch { |
||||
return value |
||||
} |
||||
} |
||||
|
||||
function isFaviconOgImage(image: string): boolean { |
||||
const imageLower = image.toLowerCase() |
||||
return ( |
||||
imageLower.includes('/favicon') || |
||||
imageLower.endsWith('/favicon.ico') || |
||||
imageLower.endsWith('/favicon.svg') |
||||
) |
||||
} |
||||
|
||||
/** Parse Open Graph / Twitter / description meta tags from fetched HTML. */ |
||||
export function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata { |
||||
if (htmlLooksLikeImwaldAppShell(html)) { |
||||
logger.debug('[OpenGraph] Ignoring Imwald app shell HTML', { pageUrl }) |
||||
return {} |
||||
} |
||||
|
||||
const parser = new DOMParser() |
||||
const doc = parser.parseFromString(html, 'text/html') |
||||
|
||||
let title = metaContent(doc, [ |
||||
'meta[property="og:title"]', |
||||
'meta[name="og:title"]', |
||||
'meta[name="twitter:title"]', |
||||
'meta[property="twitter:title"]' |
||||
]) |
||||
if (!title) { |
||||
const titleTag = doc.querySelector('title')?.textContent?.trim() |
||||
if (titleTag) title = titleTag |
||||
} |
||||
if (title) { |
||||
if ( |
||||
/^(Redirecting|Loading|Please wait|Redirect)(\.\.\.|…)?$/i.test(title) || |
||||
title === '...' || |
||||
title === '…' |
||||
) { |
||||
title = undefined |
||||
} |
||||
} |
||||
|
||||
let description = metaContent(doc, [ |
||||
'meta[property="og:description"]', |
||||
'meta[name="og:description"]', |
||||
'meta[name="twitter:description"]', |
||||
'meta[property="twitter:description"]', |
||||
'meta[name="description"]' |
||||
]) |
||||
|
||||
let image = metaContent(doc, [ |
||||
'meta[property="og:image"]', |
||||
'meta[name="og:image"]', |
||||
'meta[property="og:image:url"]', |
||||
'meta[property="og:image:secure_url"]', |
||||
'meta[name="twitter:image"]', |
||||
'meta[property="twitter:image"]' |
||||
]) |
||||
|
||||
let audio = metaContent(doc, [ |
||||
'meta[property="og:audio"]', |
||||
'meta[property="og:audio:url"]', |
||||
'meta[property="og:audio:secure_url"]', |
||||
'meta[name="og:audio"]' |
||||
]) |
||||
|
||||
if (image) { |
||||
try { |
||||
image = resolveMaybeRelativeUrl(image, pageUrl) |
||||
if (isFaviconOgImage(image)) { |
||||
logger.warn('[OpenGraph] Filtered favicon from OG image', { pageUrl, image }) |
||||
image = undefined |
||||
} |
||||
} catch (error) { |
||||
logger.warn('[OpenGraph] Failed to resolve image URL', { image, pageUrl, error }) |
||||
image = undefined |
||||
} |
||||
} |
||||
|
||||
if (audio && !audio.match(/^https?:\/\//)) { |
||||
try { |
||||
audio = resolveMaybeRelativeUrl(audio, pageUrl) |
||||
if (!audio.match(/^https?:\/\//)) audio = undefined |
||||
} catch { |
||||
audio = undefined |
||||
} |
||||
} |
||||
|
||||
try { |
||||
const urlObj = new URL(pageUrl) |
||||
const isAppCanonicalHost = urlObj.hostname === 'jumble.imwald.eu' |
||||
if (!isAppCanonicalHost) { |
||||
if (isImwaldDefaultOpenGraphTitle(title)) title = undefined |
||||
if (isImwaldDefaultOpenGraphDescription(description)) description = undefined |
||||
if (image?.includes('jumble.imwald.eu/og-image')) image = undefined |
||||
if (!title && !description && !image && !audio) { |
||||
logger.debug('[OpenGraph] Stripped Imwald default tags for external URL', { |
||||
url: pageUrl, |
||||
hostname: urlObj.hostname |
||||
}) |
||||
} |
||||
} |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
|
||||
return { title, description, image, audio } |
||||
} |
||||
@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
|
||||
import { cleanUrl } from '@/lib/url' |
||||
|
||||
/** URLs the user chose to load this session (tap-to-reveal); survives Image remounts when feeds re-parse. */ |
||||
const revealed = new Set<string>() |
||||
|
||||
export function markMediaUrlRevealed(url: string): void { |
||||
const key = cleanUrl(url.trim()) |
||||
if (key) revealed.add(key) |
||||
} |
||||
|
||||
export function wasMediaUrlRevealed(url: string): boolean { |
||||
const key = cleanUrl(url.trim()) |
||||
return key ? revealed.has(key) : false |
||||
} |
||||
@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { |
||||
computeSpellSubRequestsIdentityKey, |
||||
isSpellSubRequestsFilterSuperset |
||||
} from './spell-feed-request-identity' |
||||
import type { TFeedSubRequest } from '@/types' |
||||
|
||||
describe('isSpellSubRequestsFilterSuperset', () => { |
||||
it('detects when new shards add thread-watch filters', () => { |
||||
const base: TFeedSubRequest[] = [ |
||||
{ |
||||
urls: ['wss://relay.example/'], |
||||
filter: { limit: 200, '#p': ['abc'.repeat(32)] } |
||||
} |
||||
] |
||||
const expanded: TFeedSubRequest[] = [ |
||||
...base, |
||||
{ |
||||
urls: ['wss://relay.example/'], |
||||
filter: { kinds: [1], limit: 200, '#e': ['d'.repeat(64)] } |
||||
} |
||||
] |
||||
const prevKey = computeSpellSubRequestsIdentityKey(base) |
||||
const nextKey = computeSpellSubRequestsIdentityKey(expanded) |
||||
expect(isSpellSubRequestsFilterSuperset(prevKey, nextKey)).toBe(true) |
||||
expect(isSpellSubRequestsFilterSuperset(nextKey, prevKey)).toBe(false) |
||||
}) |
||||
}) |
||||
@ -1,41 +0,0 @@
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { |
||||
isWavlakeOpenUrl, |
||||
wavlakeEmbedMinHeight, |
||||
wavlakeOpenUrlKind, |
||||
wavlakeOpenUrlToEmbedSrc |
||||
} from './wavlake-url' |
||||
|
||||
describe('wavlake-url', () => { |
||||
it('embeds album URLs', () => { |
||||
const url = 'https://wavlake.com/album/b95132b8-a655-4b47-8394-96d0ea8260d2' |
||||
expect(isWavlakeOpenUrl(url)).toBe(true) |
||||
expect(wavlakeOpenUrlKind(url)).toBe('album') |
||||
expect(wavlakeOpenUrlToEmbedSrc(url)).toBe( |
||||
'https://embed.wavlake.com/album/b95132b8-a655-4b47-8394-96d0ea8260d2' |
||||
) |
||||
expect(wavlakeEmbedMinHeight(url)).toBe(380) |
||||
}) |
||||
|
||||
it('embeds track URLs', () => { |
||||
const url = 'https://wavlake.com/track/2b8f5095-a57c-46ea-9731-18911afee136' |
||||
expect(wavlakeOpenUrlKind(url)).toBe('track') |
||||
expect(wavlakeOpenUrlToEmbedSrc(url)).toBe( |
||||
'https://embed.wavlake.com/track/2b8f5095-a57c-46ea-9731-18911afee136' |
||||
) |
||||
expect(wavlakeEmbedMinHeight(url)).toBe(200) |
||||
}) |
||||
|
||||
it('embeds artist profile slugs', () => { |
||||
const url = 'https://wavlake.com/dj-bitcoin' |
||||
expect(wavlakeOpenUrlKind(url)).toBe('profile') |
||||
expect(wavlakeOpenUrlToEmbedSrc(url)).toBe('https://embed.wavlake.com/dj-bitcoin') |
||||
}) |
||||
|
||||
it('rejects non-wavlake hosts and invalid paths', () => { |
||||
expect(isWavlakeOpenUrl('https://example.com/album/x')).toBe(false) |
||||
expect(isWavlakeOpenUrl('https://wavlake.com/')).toBe(false) |
||||
expect(isWavlakeOpenUrl('https://wavlake.com/album/')).toBe(false) |
||||
expect(isWavlakeOpenUrl('https://wavlake.com/foo/bar/baz')).toBe(false) |
||||
}) |
||||
}) |
||||
@ -1,46 +0,0 @@
@@ -1,46 +0,0 @@
|
||||
const WAVLAKE_HOSTS = new Set(['wavlake.com', 'www.wavlake.com']) |
||||
|
||||
export type WavlakeEmbedKind = 'track' | 'album' | 'profile' |
||||
|
||||
/** |
||||
* Build Wavlake embed iframe `src` from a wavlake.com link, or null if not embeddable. |
||||
* @see https://github.com/wavlake/embed — same URL paths on embed.wavlake.com
|
||||
*/ |
||||
export function wavlakeOpenUrlToEmbedSrc(url: string): string | null { |
||||
try { |
||||
const u = new URL(url.trim()) |
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null |
||||
if (!WAVLAKE_HOSTS.has(u.hostname.toLowerCase())) return null |
||||
if (wavlakeOpenUrlKind(url) == null) return null |
||||
return `https://embed.wavlake.com${u.pathname}${u.search}` |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
export function wavlakeOpenUrlKind(url: string): WavlakeEmbedKind | null { |
||||
try { |
||||
const u = new URL(url.trim()) |
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null |
||||
if (!WAVLAKE_HOSTS.has(u.hostname.toLowerCase())) return null |
||||
const parts = u.pathname.split('/').filter(Boolean) |
||||
if (parts.length === 0) return null |
||||
const head = parts[0].toLowerCase() |
||||
if (head === 'track' || head === 'album') { |
||||
return parts.length === 2 && parts[1] ? head : null |
||||
} |
||||
if (parts.length === 1) return 'profile' |
||||
return null |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** Suggested min iframe height (album/artist pages need more chrome than a single track). */ |
||||
export function wavlakeEmbedMinHeight(url: string): number { |
||||
return wavlakeOpenUrlKind(url) === 'track' ? 200 : 380 |
||||
} |
||||
|
||||
export function isWavlakeOpenUrl(url: string): boolean { |
||||
return wavlakeOpenUrlToEmbedSrc(url) != null |
||||
} |
||||
Loading…
Reference in new issue