Compare commits
14 Commits
ee98ab9dab
...
da4b2cb1db
| Author | SHA1 | Date |
|---|---|---|
|
|
da4b2cb1db | 2 weeks ago |
|
|
0928ad78be | 2 weeks ago |
|
|
5a7535a8a1 | 2 weeks ago |
|
|
fb7f469ea9 | 2 weeks ago |
|
|
d2a9af19d2 | 2 weeks ago |
|
|
1fe68c2dac | 2 weeks ago |
|
|
af41c56391 | 2 weeks ago |
|
|
6caff4767d | 2 weeks ago |
|
|
846f220ac2 | 2 weeks ago |
|
|
c85b63ef18 | 2 weeks ago |
|
|
e3d974e2b9 | 2 weeks ago |
|
|
d12e599d5b | 2 weeks ago |
|
|
e8bded89e6 | 2 weeks ago |
|
|
79709ed502 | 2 weeks ago |
92 changed files with 3011 additions and 677 deletions
@ -0,0 +1,21 @@ |
|||||||
|
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,11 +1,14 @@ |
|||||||
|
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> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,141 @@ |
|||||||
|
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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
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] ?? '' |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
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 } |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
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('') |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
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}` : '' |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
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) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
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 |
||||||
|
} |
||||||
@ -0,0 +1,243 @@ |
|||||||
|
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' |
||||||
|
) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
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(' · ') |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
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 |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,171 @@ |
|||||||
|
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 } |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
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 |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
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) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
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) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
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