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 @@ |
|||||||
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 @@ |
|||||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' |
|
||||||
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' |
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' |
||||||
import { ReplyProvider } from '@/providers/ReplyProvider' |
import { ReplyProvider } from '@/providers/ReplyProvider' |
||||||
|
|
||||||
/** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */ |
/** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */ |
||||||
export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) { |
export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) { |
||||||
return ( |
return ( |
||||||
<ContentPolicyProvider> |
|
||||||
<DeletedEventProvider> |
<DeletedEventProvider> |
||||||
<ReplyProvider>{children}</ReplyProvider> |
<ReplyProvider>{children}</ReplyProvider> |
||||||
</DeletedEventProvider> |
</DeletedEventProvider> |
||||||
</ContentPolicyProvider> |
|
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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