Browse Source

wavelake and fountain rendering

imwald
Silberengel 2 weeks ago
parent
commit
af41c56391
  1. 2
      src/PageManager.tsx
  2. 90
      src/components/Content/index.tsx
  3. 24
      src/components/Embedded/HttpNostrAwareUrl.tsx
  4. 2
      src/components/EmojiPickerDialog/index.tsx
  5. 141
      src/components/FountainEmbeddedPlayer/index.tsx
  6. 175
      src/components/GifPicker/index.tsx
  7. 46
      src/components/LiveActivitiesStrip.tsx
  8. 73
      src/components/MemePicker/index.tsx
  9. 265
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  10. 10
      src/components/Note/MarkdownArticle/preprocessMarkup.ts
  11. 60
      src/components/WavlakeEmbeddedPlayer/index.tsx
  12. 5
      src/components/ui/scroll-area.tsx
  13. 8
      src/constants.ts
  14. 7
      src/lib/content-parser.ts
  15. 36
      src/lib/fountain-url.test.ts
  16. 37
      src/lib/fountain-url.ts
  17. 6
      src/lib/vite-proxy-url.ts
  18. 41
      src/lib/wavlake-url.test.ts
  19. 46
      src/lib/wavlake-url.ts
  20. 40
      src/providers/ScreenSizeProvider.tsx
  21. 54
      src/services/web.service.ts
  22. 2
      src/types/index.d.ts

2
src/PageManager.tsx

@ -2507,7 +2507,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2507,7 +2507,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{secondaryStack.length > 0 ? (
<TopSecondaryStackPane
item={secondaryStack[secondaryStack.length - 1]!}
className="flex h-full min-h-0 min-w-0 flex-col"
className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
/>
) : (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">

90
src/components/Content/index.tsx

@ -32,12 +32,16 @@ import Emoji from '../Emoji' @@ -32,12 +32,16 @@ import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import SpotifyEmbeddedPlayer from '../SpotifyEmbeddedPlayer'
import FountainEmbeddedPlayer from '../FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '../WavlakeEmbeddedPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '../ZapStreamLiveEventEmbed'
import WebPreview from '../WebPreview'
import { toNote } from '@/lib/link'
import { YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { isFountainOpenUrl } from '@/lib/fountain-url'
import { isWavlakeOpenUrl } from '@/lib/wavlake-url'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { shouldDeferLongVideoAutoload } from '@/lib/long-video-load-policy'
@ -180,6 +184,8 @@ export default function Content({ @@ -180,6 +184,8 @@ export default function Content({
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) &&
!isWavlakeOpenUrl(url) &&
!isFountainOpenUrl(url) &&
!isZapStreamWatchUrl(url)
) {
const cleaned = cleanUrl(url)
@ -247,6 +253,50 @@ export default function Content({ @@ -247,6 +253,50 @@ export default function Content({
return urls
}, [event, nodes])
const wavlakeUrlsFromTags = useMemo(() => {
if (!event) return []
const urls: string[] = []
const seenUrls = new Set<string>()
const hasWavlakeInContent = nodes?.some((node) => node.type === 'wavlake') || false
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (isWavlakeOpenUrl(url)) {
const cleaned = cleanUrl(url)
if (cleaned && !hasWavlakeInContent && !seenUrls.has(cleaned)) {
urls.push(cleaned)
seenUrls.add(cleaned)
}
}
})
return urls
}, [event, nodes])
const fountainUrlsFromTags = useMemo(() => {
if (!event) return []
const urls: string[] = []
const seenUrls = new Set<string>()
const hasFountainInContent = nodes?.some((node) => node.type === 'fountain') || false
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (isFountainOpenUrl(url)) {
const cleaned = cleanUrl(url)
if (cleaned && !hasFountainInContent && !seenUrls.has(cleaned)) {
urls.push(cleaned)
seenUrls.add(cleaned)
}
}
})
return urls
}, [event, nodes])
const zapStreamCanonicalInContent = useMemo(() => {
if (!nodes) return new Set<string>()
const s = new Set<string>()
@ -297,6 +347,8 @@ export default function Content({ @@ -297,6 +347,8 @@ export default function Content({
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) &&
!isWavlakeOpenUrl(url) &&
!isFountainOpenUrl(url) &&
!isZapStreamWatchUrl(url)
) {
const cleaned = cleanUrl(url)
@ -506,6 +558,24 @@ export default function Content({ @@ -506,6 +558,24 @@ export default function Content({
/>
))}
{wavlakeUrlsFromTags.map((url) => (
<WavlakeEmbeddedPlayer
key={`tag-wavlake-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{fountainUrlsFromTags.map((url) => (
<FountainEmbeddedPlayer
key={`tag-fountain-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{zapstreamUrlsFromTags.map((url) => (
<ZapStreamLiveEventEmbed
key={`tag-zapstream-${url}`}
@ -672,6 +742,26 @@ export default function Content({ @@ -672,6 +742,26 @@ export default function Content({
/>
)
}
if (node.type === 'wavlake') {
return (
<WavlakeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'fountain') {
return (
<FountainEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'zapstream') {
return (
<ZapStreamLiveEventEmbed

24
src/components/Embedded/HttpNostrAwareUrl.tsx

@ -15,7 +15,11 @@ import { EmbeddedNormalUrl } from './EmbeddedNormalUrl' @@ -15,7 +15,11 @@ import { EmbeddedNormalUrl } from './EmbeddedNormalUrl'
import { EmbeddedNote } from './EmbeddedNote'
import WebPreview from '@/components/WebPreview'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import FountainEmbeddedPlayer from '@/components/FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '@/components/WavlakeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed'
import { isFountainOpenUrl } from '@/lib/fountain-url'
import { isWavlakeOpenUrl } from '@/lib/wavlake-url'
import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url'
import { isZapStreamWatchUrl } from '@/lib/zap-stream-url'
@ -66,6 +70,26 @@ export function HttpNostrAwareUrl({ @@ -66,6 +70,26 @@ export function HttpNostrAwareUrl({
)
}
if (isWavlakeOpenUrl(cleaned)) {
return (
<WavlakeEmbeddedPlayer
url={cleaned}
className={cn('mt-2 max-w-[400px]', className)}
mustLoad={renderMode === 'article'}
/>
)
}
if (isFountainOpenUrl(cleaned)) {
return (
<FountainEmbeddedPlayer
url={cleaned}
className={cn('mt-2 max-w-[400px]', className)}
mustLoad={renderMode === 'article'}
/>
)
}
if (sameOriginTarget) {
if (sameOriginTarget.kind === 'event') {
return (

2
src/components/EmojiPickerDialog/index.tsx

@ -24,7 +24,7 @@ export default function EmojiPickerDialog({ @@ -24,7 +24,7 @@ export default function EmojiPickerDialog({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen} handleOnly>
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
dragHandle="vaul"

141
src/components/FountainEmbeddedPlayer/index.tsx

@ -0,0 +1,141 @@ @@ -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>
)
}

175
src/components/GifPicker/index.tsx

@ -332,9 +332,83 @@ export default function GifPicker({ @@ -332,9 +332,83 @@ export default function GifPicker({
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen
const gifGrid = loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<button
type="button"
className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
>
{gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)
const content = (
<div
className={`flex flex-col gap-2 p-2 ${isDrawer ? 'w-full h-[70vh] max-h-[70vh] overflow-hidden' : 'min-w-[280px] max-w-[360px]'}`}
className={cn(
'flex min-w-0 w-full flex-col gap-2 p-2',
isDrawer ? 'min-h-0 flex-1 overflow-hidden' : 'min-w-[280px] max-w-[360px]'
)}
>
<div className="flex items-center gap-1 shrink-0">
<Input
@ -358,87 +432,16 @@ export default function GifPicker({ @@ -358,87 +432,16 @@ export default function GifPicker({
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<div
className={isDrawer ? 'flex-1 min-h-0 flex flex-col' : undefined}
className={cn(isDrawer && 'flex min-h-0 flex-1 flex-col')}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[420px] w-full rounded-md border'
: 'h-[520px] w-full rounded-md border'
}
>
{loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<button
type="button"
className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
>
{gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)}
</ScrollArea>
{isDrawer ? (
<div className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border">
{gifGrid}
</div>
) : (
<ScrollArea className="h-[520px] w-full rounded-md border">{gifGrid}</ScrollArea>
)}
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5">
@ -521,13 +524,19 @@ export default function GifPicker({ @@ -521,13 +524,19 @@ export default function GifPicker({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen} handleOnly>
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent dragHandle="vaul" portalContainer={portalContainer}>
<DrawerContent
dragHandle="vaul"
portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2 pb-2"
>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader>
{content}
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content}
</div>
</DrawerContent>
</Drawer>
)

46
src/components/LiveActivitiesStrip.tsx

@ -135,7 +135,7 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -135,7 +135,7 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
'min-w-0 max-w-full overflow-hidden',
placement === 'sidebar' &&
'mb-2 rounded-lg border border-border/80 bg-muted/50 p-2 shadow-sm dark:bg-muted/30',
placement === 'mobile' && 'w-full shrink-0 border-b border-border/80 bg-muted/50 px-2 py-2 dark:bg-muted/30'
placement === 'mobile' && 'w-full shrink-0 border-b border-border/80 bg-muted/50 px-2 py-1 dark:bg-muted/30'
)}
role="region"
aria-label={t('liveActivities.regionLabel')}
@ -146,14 +146,16 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -146,14 +146,16 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
{t('liveActivities.swipeToBrowse')}
</span>
) : null}
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground xl:text-xs">
{t('liveActivities.heading')}
</div>
{placement === 'sidebar' ? (
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground xl:text-xs">
{t('liveActivities.heading')}
</div>
) : null}
<div
className={cn(
'flex min-w-0 gap-1.5 rounded-md',
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-stretch',
placement === 'mobile' && 'items-stretch touch-pan-y',
placement === 'mobile' && 'items-center touch-pan-y',
mobileSwipe && 'cursor-grab active:cursor-grabbing'
)}
onPointerDown={mobileSwipe ? onSwipePointerDown : undefined}
@ -165,28 +167,42 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -165,28 +167,42 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
onPointerDown={(e) => e.stopPropagation()}
onClick={openLiveNote}
className={cn(
'flex min-w-0 flex-1 gap-2 rounded-md text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-start',
placement === 'mobile' && 'items-center'
'flex min-w-0 flex-1 rounded-md text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
placement === 'sidebar' && 'flex-col gap-2 xl:flex-row xl:items-start',
placement === 'mobile' && 'flex-row items-center gap-1.5'
)}
title={t('liveActivities.viewNoteTitle')}
>
{placement === 'mobile' ? (
<span className="shrink-0 rounded bg-primary/15 px-1 py-px text-[9px] font-semibold uppercase tracking-wide text-primary">
{t('liveActivities.heading')}
</span>
) : null}
{current.imageUrl ? (
<img
src={current.imageUrl}
alt=""
className={cn(
'shrink-0 rounded object-cover',
placement === 'sidebar' ? 'h-14 w-full xl:h-12 xl:w-12' : 'h-12 w-12'
placement === 'sidebar' ? 'h-14 w-full xl:h-12 xl:w-12' : 'h-8 w-8'
)}
/>
) : null}
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-xs font-medium leading-snug xl:text-sm">{current.title}</div>
{current.summary ? (
<div
className={cn(
'font-medium',
placement === 'sidebar'
? 'line-clamp-2 text-xs leading-snug xl:text-sm'
: 'truncate text-xs leading-none'
)}
>
{current.title}
</div>
{placement === 'sidebar' && current.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground xl:text-xs">{current.summary}</p>
) : null}
{current.fromFollowedHost ? (
{placement === 'sidebar' && current.fromFollowedHost ? (
<p className="mt-1 text-[10px] text-green-600 dark:text-green-500">{t('liveActivities.fromFollow')}</p>
) : null}
</div>
@ -197,19 +213,19 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme @@ -197,19 +213,19 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
rel="noopener noreferrer"
className={cn(
'flex shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground',
placement === 'sidebar' ? 'h-9 w-full xl:h-auto xl:w-9 xl:self-start' : 'h-12 w-10'
placement === 'sidebar' ? 'h-9 w-full xl:h-auto xl:w-9 xl:self-start' : 'h-8 w-8'
)}
title={t('liveActivities.openJoinPageTitle')}
aria-label={t('liveActivities.openJoinPageTitle')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-4 shrink-0" aria-hidden />
<ExternalLink className={cn('shrink-0', placement === 'mobile' ? 'size-3.5' : 'size-4')} aria-hidden />
</a>
</div>
{items.length > 1 ? (
placement === 'mobile' ? (
<div className="mt-2 flex justify-center gap-1.5" aria-hidden>
<div className="mt-1 flex justify-center gap-1" aria-hidden>
{items.map((item, i) => (
<span
key={item.address}

73
src/components/MemePicker/index.tsx

@ -14,6 +14,7 @@ import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMai @@ -14,6 +14,7 @@ import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMai
import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import {
fetchMemes,
getCachedMemes,
@ -297,7 +298,10 @@ export default function MemePicker({ @@ -297,7 +298,10 @@ export default function MemePicker({
const isDrawer = isSmallScreen
const content = (
<div
className={`flex flex-col gap-2 p-2 ${isDrawer ? 'w-full h-[70vh] max-h-[70vh] overflow-hidden' : 'min-w-[280px] max-w-[360px]'}`}
className={cn(
'flex min-w-0 w-full flex-col gap-2 p-2',
isDrawer ? 'min-h-0 flex-1 overflow-hidden' : 'min-w-[280px] max-w-[360px]'
)}
>
<div className="flex items-center gap-1 shrink-0">
<Input
@ -319,16 +323,54 @@ export default function MemePicker({ @@ -319,16 +323,54 @@ export default function MemePicker({
</div>
{error && <p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>}
<div
className={isDrawer ? 'flex-1 min-h-0 flex flex-col' : undefined}
className={cn(isDrawer && 'flex min-h-0 flex-1 flex-col')}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
}
>
{isDrawer ? (
<div className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border">
{loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{memes.map((meme) => (
<button
key={meme.eventId}
type="button"
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square"
onClick={() => handleSelect(meme)}
>
<img
src={meme.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = meme.fallbackUrl?.trim()
if (fallback && el.dataset.memeFallbackTried !== '1') {
el.dataset.memeFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
))}
</div>
)}
</div>
) : (
<ScrollArea className="h-[280px] w-full rounded-md border">
{loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
@ -370,6 +412,7 @@ export default function MemePicker({ @@ -370,6 +412,7 @@ export default function MemePicker({
</div>
)}
</ScrollArea>
)}
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5">
@ -456,13 +499,19 @@ export default function MemePicker({ @@ -456,13 +499,19 @@ export default function MemePicker({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent portalContainer={portalContainer}>
<DrawerContent
dragHandle="vaul"
portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2 pb-2"
>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a meme')}</DrawerTitle>
</DrawerHeader>
{content}
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content}
</div>
</DrawerContent>
</Drawer>
)

265
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -6,6 +6,8 @@ import Wikilink from '@/components/UniversalContent/Wikilink' @@ -6,6 +6,8 @@ import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import WebPreview from '@/components/WebPreview'
import SpotifyEmbeddedPlayer from '@/components/SpotifyEmbeddedPlayer'
import FountainEmbeddedPlayer from '@/components/FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '@/components/WavlakeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
@ -33,11 +35,15 @@ import { @@ -33,11 +35,15 @@ import {
ExtendedKind,
isNip52CalendarCardKind,
SPOTIFY_OPEN_URL_REGEX,
FOUNTAIN_OPEN_URL_REGEX,
WAVLAKE_OPEN_URL_REGEX,
WS_URL_REGEX,
YOUTUBE_URL_REGEX,
ZAP_STREAM_WATCH_URL_REGEX
} from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { isFountainOpenUrl } from '@/lib/fountain-url'
import { isWavlakeOpenUrl } from '@/lib/wavlake-url'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
@ -431,6 +437,14 @@ function isSpotifyUrl(url: string): boolean { @@ -431,6 +437,14 @@ function isSpotifyUrl(url: string): boolean {
return regex.test(url)
}
function isWavlakeUrl(url: string): boolean {
return isWavlakeOpenUrl(url)
}
function isFountainUrl(url: string): boolean {
return isFountainOpenUrl(url)
}
function isZapStreamUrl(url: string): boolean {
const flags = ZAP_STREAM_WATCH_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(ZAP_STREAM_WATCH_URL_REGEX.source, flags)
@ -1309,6 +1323,61 @@ function parseMarkdownContentLegacy( @@ -1309,6 +1323,61 @@ function parseMarkdownContentLegacy(
}
})
const wavlakeUrlMatches = Array.from(content.matchAll(WAVLAKE_OPEN_URL_REGEX))
wavlakeUrlMatches.forEach((match) => {
if (match.index !== undefined) {
const url = match[0]
const start = match.index
const end = match.index + match[0].length
const isInMarkdown = patterns.some(
(p) =>
(p.type === 'markdown-link' ||
p.type === 'markdown-image-link' ||
p.type === 'markdown-image' ||
p.type === 'youtube-url' ||
p.type === 'spotify-url') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isWavlakeUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'wavlake-url',
data: { url }
})
}
}
})
const fountainUrlMatches = Array.from(content.matchAll(FOUNTAIN_OPEN_URL_REGEX))
fountainUrlMatches.forEach((match) => {
if (match.index !== undefined) {
const url = match[0]
const start = match.index
const end = match.index + match[0].length
const isInMarkdown = patterns.some(
(p) =>
(p.type === 'markdown-link' ||
p.type === 'markdown-image-link' ||
p.type === 'markdown-image' ||
p.type === 'youtube-url' ||
p.type === 'spotify-url' ||
p.type === 'wavlake-url') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isFountainUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'fountain-url',
data: { url }
})
}
}
})
const zapstreamUrlMatches = Array.from(content.matchAll(ZAP_STREAM_WATCH_URL_REGEX))
zapstreamUrlMatches.forEach((match) => {
if (match.index !== undefined) {
@ -1321,7 +1390,9 @@ function parseMarkdownContentLegacy( @@ -1321,7 +1390,9 @@ function parseMarkdownContentLegacy(
p.type === 'markdown-image-link' ||
p.type === 'markdown-image' ||
p.type === 'youtube-url' ||
p.type === 'spotify-url') &&
p.type === 'spotify-url' ||
p.type === 'wavlake-url' ||
p.type === 'fountain-url') &&
start >= p.index &&
start < p.end
)
@ -1345,7 +1416,7 @@ function parseMarkdownContentLegacy( @@ -1345,7 +1416,7 @@ function parseMarkdownContentLegacy(
const end = match.index + match[0].length
// Only add if not already covered by a markdown link/image-link/image or YouTube URL and not in block pattern
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'zapstream-url') &&
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url' || p.type === 'spotify-url' || p.type === 'wavlake-url' || p.type === 'fountain-url' || p.type === 'zapstream-url') &&
start >= p.index &&
start < p.end
)
@ -2328,6 +2399,20 @@ function parseMarkdownContentLegacy( @@ -2328,6 +2399,20 @@ function parseMarkdownContentLegacy(
<SpotifyEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
} else if (pattern.type === 'wavlake-url') {
const { url } = pattern.data
parts.push(
<div key={`wavlake-url-${patternIdx}`} className="my-2">
<WavlakeEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
} else if (pattern.type === 'fountain-url') {
const { url } = pattern.data
parts.push(
<div key={`fountain-url-${patternIdx}`} className="my-2">
<FountainEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
} else if (pattern.type === 'zapstream-url') {
const { url } = pattern.data
parts.push(
@ -3776,6 +3861,20 @@ function parseMarkdownContentMarked( @@ -3776,6 +3861,20 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isWavlakeUrl(cleaned)) {
return (
<div key={`${key}-line-wavlake-${lineIdx}`} className="my-2">
<WavlakeEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isFountainUrl(cleaned)) {
return (
<div key={`${key}-line-fountain-${lineIdx}`} className="my-2">
<FountainEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isZapStreamUrl(cleaned)) {
return (
<div key={`${key}-line-zapstream-${lineIdx}`} className="my-2">
@ -3949,6 +4048,20 @@ function parseMarkdownContentMarked( @@ -3949,6 +4048,20 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isWavlakeUrl(cleaned)) {
return (
<div key={`${key}-wavlake-url`} className="my-2">
<WavlakeEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isFountainUrl(cleaned)) {
return (
<div key={`${key}-fountain-url`} className="my-2">
<FountainEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (isZapStreamUrl(cleaned)) {
return (
<div key={`${key}-zapstream-url`} className="my-2">
@ -4023,6 +4136,20 @@ function parseMarkdownContentMarked( @@ -4023,6 +4136,20 @@ function parseMarkdownContentMarked(
</div>
)
}
if (soleHref && isWavlakeUrl(soleHref)) {
return (
<div key={`${key}-wavlake-sole-link`} className="my-2">
<WavlakeEmbeddedPlayer url={soleHref} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (soleHref && isFountainUrl(soleHref)) {
return (
<div key={`${key}-fountain-sole-link`} className="my-2">
<FountainEmbeddedPlayer url={soleHref} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
if (soleHref && isZapStreamUrl(soleHref)) {
return (
<div key={`${key}-zapstream-sole-link`} className="my-2">
@ -4112,6 +4239,24 @@ function parseMarkdownContentMarked( @@ -4112,6 +4239,24 @@ function parseMarkdownContentMarked(
)
return
}
if (cleaned && isWavlakeUrl(cleaned)) {
flushInlineSegment(segmentIdx++)
nodes.push(
<div key={`${key}-inline-wavlake-with-media-${idx}`} className="my-2">
<WavlakeEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
return
}
if (cleaned && isFountainUrl(cleaned)) {
flushInlineSegment(segmentIdx++)
nodes.push(
<div key={`${key}-inline-fountain-with-media-${idx}`} className="my-2">
<FountainEmbeddedPlayer url={cleaned} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
return
}
if (cleaned && isZapStreamUrl(cleaned)) {
flushInlineSegment(segmentIdx++)
nodes.push(
@ -5463,6 +5608,48 @@ export default function MarkdownArticle({ @@ -5463,6 +5608,48 @@ export default function MarkdownArticle({
return spotifyUrls
}, [event.id, JSON.stringify(event.tags)])
const tagWavlakeUrls = useMemo(() => {
const wavlakeUrls: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (!isWavlakeUrl(url)) return
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
wavlakeUrls.push(cleaned)
seenUrls.add(cleaned)
}
})
return wavlakeUrls
}, [event.id, JSON.stringify(event.tags)])
const tagFountainUrls = useMemo(() => {
const fountainUrls: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter((tag) => tag[0] === 'r' && tag[1])
.forEach((tag) => {
const url = tag[1]!
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (!isFountainUrl(url)) return
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
fountainUrls.push(cleaned)
seenUrls.add(cleaned)
}
})
return fountainUrls
}, [event.id, JSON.stringify(event.tags)])
const tagZapStreamUrls = useMemo(() => {
const zapUrls: string[] = []
const seenUrls = new Set<string>()
@ -5497,6 +5684,8 @@ export default function MarkdownArticle({ @@ -5497,6 +5684,8 @@ export default function MarkdownArticle({
if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url) || isBlossomBudBlobUrl(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return
if (isWavlakeUrl(url)) return
if (isFountainUrl(url)) return
if (isZapStreamWatchUrl(url)) return
const cleaned = cleanUrl(url)
@ -5505,7 +5694,7 @@ export default function MarkdownArticle({ @@ -5505,7 +5694,7 @@ export default function MarkdownArticle({
seenUrls.add(cleaned)
}
})
return links
}, [event.id, JSON.stringify(event.tags)])
@ -5659,6 +5848,34 @@ export default function MarkdownArticle({ @@ -5659,6 +5848,34 @@ export default function MarkdownArticle({
return urls
}, [event.content])
const wavlakeUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (cleaned && isWavlakeUrl(cleaned)) {
urls.add(cleaned)
}
}
return urls
}, [event.content])
const fountainUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (cleaned && isFountainUrl(cleaned)) {
urls.add(cleaned)
}
}
return urls
}, [event.content])
const zapstreamUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
@ -5688,6 +5905,8 @@ export default function MarkdownArticle({ @@ -5688,6 +5905,8 @@ export default function MarkdownArticle({
!isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) &&
!isSpotifyUrl(url) &&
!isWavlakeUrl(url) &&
!isFountainUrl(url) &&
!isZapStreamWatchUrl(url)
) {
const cleaned = cleanUrl(url)
@ -5755,6 +5974,20 @@ export default function MarkdownArticle({ @@ -5755,6 +5974,20 @@ export default function MarkdownArticle({
})
}, [tagSpotifyUrls, spotifyUrlsInContent])
const leftoverTagWavlakeUrls = useMemo(() => {
return tagWavlakeUrls.filter((url) => {
const cleaned = cleanUrl(url)
return cleaned && !wavlakeUrlsInContent.has(cleaned)
})
}, [tagWavlakeUrls, wavlakeUrlsInContent])
const leftoverTagFountainUrls = useMemo(() => {
return tagFountainUrls.filter((url) => {
const cleaned = cleanUrl(url)
return cleaned && !fountainUrlsInContent.has(cleaned)
})
}, [tagFountainUrls, fountainUrlsInContent])
const leftoverTagZapStreamUrls = useMemo(() => {
return tagZapStreamUrls.filter((canon) => !zapstreamUrlsInContent.has(canon))
}, [tagZapStreamUrls, zapstreamUrlsInContent])
@ -6193,6 +6426,32 @@ export default function MarkdownArticle({ @@ -6193,6 +6426,32 @@ export default function MarkdownArticle({
</div>
)}
{leftoverTagWavlakeUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagWavlakeUrls.map((url) => {
const cleaned = cleanUrl(url)
return (
<div key={`tag-wavlake-${cleaned}`} className="my-2">
<WavlakeEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
})}
</div>
)}
{leftoverTagFountainUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagFountainUrls.map((url) => {
const cleaned = cleanUrl(url)
return (
<div key={`tag-fountain-${cleaned}`} className="my-2">
<FountainEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
})}
</div>
)}
{leftoverTagZapStreamUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagZapStreamUrls.map((url) => (

10
src/components/Note/MarkdownArticle/preprocessMarkup.ts

@ -2,6 +2,8 @@ import { shouldLeaveDoubleBracketForAsciidoctor } from '@/lib/asciidoc-double-br @@ -2,6 +2,8 @@ import { shouldLeaveDoubleBracketForAsciidoctor } from '@/lib/asciidoc-double-br
import { isImage, isVideo, isAudio } from '@/lib/url'
import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { isFountainOpenUrl } from '@/lib/fountain-url'
import { isWavlakeOpenUrl } from '@/lib/wavlake-url'
import { isZapStreamWatchUrl } from '@/lib/zap-stream-url'
/**
@ -92,6 +94,14 @@ export function preprocessMarkdownMediaLinks(content: string): string { @@ -92,6 +94,14 @@ export function preprocessMarkdownMediaLinks(content: string): string {
continue
}
if (isWavlakeOpenUrl(url)) {
continue
}
if (isFountainOpenUrl(url)) {
continue
}
if (isZapStreamWatchUrl(url)) {
continue
}

60
src/components/WavlakeEmbeddedPlayer/index.tsx

@ -0,0 +1,60 @@ @@ -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 }

5
src/components/ui/scroll-area.tsx

@ -8,7 +8,10 @@ const ScrollArea = React.forwardRef< @@ -8,7 +8,10 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollBarClassName?: string }
>(({ className, scrollBarClassName, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport ref={ref} className="h-full min-h-0 w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport
ref={ref}
className="h-full min-h-0 w-full rounded-[inherit] [&>div]:!block"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className={scrollBarClassName} />

8
src/constants.ts

@ -1074,6 +1074,14 @@ export const YOUTUBE_URL_REGEX = @@ -1074,6 +1074,14 @@ export const YOUTUBE_URL_REGEX =
export const SPOTIFY_OPEN_URL_REGEX =
/https?:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(?:track|album|playlist|episode|show)\/[a-zA-Z0-9]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
/** wavlake.com track / album (UUID) or artist profile slug — embedded via embed.wavlake.com */
export const WAVLAKE_OPEN_URL_REGEX =
/https?:\/\/(?:www\.)?wavlake\.com\/(?:(?:track|album)\/[0-9a-f-]{36}|[a-z0-9][a-z0-9-]*)(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
/** fountain.fm podcast episode or show — played via og:audio from episode pages */
export const FOUNTAIN_OPEN_URL_REGEX =
/https?:\/\/(?:www\.)?fountain\.fm\/(?:episode|show)\/[A-Za-z0-9]+(?:\?[^\s#]*)?(?:#[^\s]*)?/gi
/** zap.stream live player: path must be a bare NIP-19 naddr (`/naddr1…`). */
export const ZAP_STREAM_WATCH_URL_REGEX =
/https?:\/\/(?:www\.)?zap\.stream\/(naddr1[02-9ac-hj-np-z]+)(?:\?[^\s#]*)?(?:#[^\s]*)?/gi

7
src/lib/content-parser.ts

@ -15,6 +15,8 @@ import { parseAboutContentWithCoinPayto } from '@/lib/payto-about-coin-lines' @@ -15,6 +15,8 @@ import { parseAboutContentWithCoinPayto } from '@/lib/payto-about-coin-lines'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { isImage, isMedia, isHlsPlaylistUrl, isBlossomBudBlobUrl } from './url'
import { isSpotifyOpenUrl } from './spotify-url'
import { isFountainOpenUrl } from './fountain-url'
import { isWavlakeOpenUrl } from './wavlake-url'
import { isZapStreamWatchUrl } from './zap-stream-url'
export type TEmbeddedNodeType =
@ -32,6 +34,7 @@ export type TEmbeddedNodeType = @@ -32,6 +34,7 @@ export type TEmbeddedNodeType =
| 'invoice'
| 'youtube'
| 'spotify'
| 'wavlake'
| 'zapstream'
| 'payto'
@ -125,6 +128,10 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => { @@ -125,6 +128,10 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
type = 'youtube'
} else if (isSpotifyOpenUrl(url)) {
type = 'spotify'
} else if (isWavlakeOpenUrl(url)) {
type = 'wavlake'
} else if (isFountainOpenUrl(url)) {
type = 'fountain'
} else if (isZapStreamWatchUrl(url)) {
type = 'zapstream'
}

36
src/lib/fountain-url.test.ts

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
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)
})
})

37
src/lib/fountain-url.ts

@ -0,0 +1,37 @@ @@ -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
}

6
src/lib/vite-proxy-url.ts

@ -15,3 +15,9 @@ export function buildViteProxySitesFetchUrl(originalUrl: string, proxyServer: st @@ -15,3 +15,9 @@ export function buildViteProxySitesFetchUrl(originalUrl: string, proxyServer: st
export function urlLooksLikeViteProxyRequest(url: string): boolean {
return url.includes('/sites/') || url.includes('/sites/?url=')
}
/** Same-origin Vite dev proxy (`vite.config.ts` → :8090 OG scraper). */
export function buildDevLocalSitesFetchUrl(originalUrl: string): string | null {
if (typeof window === 'undefined') return null
return `${window.location.origin}/sites/?url=${encodeURIComponent(originalUrl)}`
}

41
src/lib/wavlake-url.test.ts

@ -0,0 +1,41 @@ @@ -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)
})
})

46
src/lib/wavlake-url.ts

@ -0,0 +1,46 @@ @@ -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
}

40
src/providers/ScreenSizeProvider.tsx

@ -7,6 +7,17 @@ type TScreenSizeContext = { @@ -7,6 +7,17 @@ type TScreenSizeContext = {
const ScreenSizeContext = createContext<TScreenSizeContext | undefined>(undefined)
const SMALL_SCREEN_MQ = '(max-width: 768px)'
const LARGE_SCREEN_MQ = '(min-width: 1280px)'
/** Layout breakpoints follow the CSS viewport (matchMedia), not `window.innerWidth` — Firefox/Chrome responsive mode emulates width without changing innerWidth. */
function readScreenSizeFlags(): Pick<TScreenSizeContext, 'isSmallScreen' | 'isLargeScreen'> {
return {
isSmallScreen: window.matchMedia(SMALL_SCREEN_MQ).matches,
isLargeScreen: window.matchMedia(LARGE_SCREEN_MQ).matches
}
}
export const useScreenSize = () => {
const context = useContext(ScreenSizeContext)
if (!context) {
@ -21,24 +32,31 @@ export function useScreenSizeOptional(): TScreenSizeContext | undefined { @@ -21,24 +32,31 @@ export function useScreenSizeOptional(): TScreenSizeContext | undefined {
}
export function ScreenSizeProvider({ children }: { children: React.ReactNode }) {
const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768)
const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280)
const [flags, setFlags] = useState(readScreenSizeFlags)
useEffect(() => {
const handleResize = () => {
setIsSmallScreen(window.innerWidth <= 768)
setIsLargeScreen(window.innerWidth >= 1280)
const smallMq = window.matchMedia(SMALL_SCREEN_MQ)
const largeMq = window.matchMedia(LARGE_SCREEN_MQ)
const sync = () => setFlags(readScreenSizeFlags())
smallMq.addEventListener('change', sync)
largeMq.addEventListener('change', sync)
window.addEventListener('resize', sync)
window.visualViewport?.addEventListener('resize', sync)
return () => {
smallMq.removeEventListener('change', sync)
largeMq.removeEventListener('change', sync)
window.removeEventListener('resize', sync)
window.visualViewport?.removeEventListener('resize', sync)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<ScreenSizeContext.Provider
value={{
isSmallScreen,
isLargeScreen
isSmallScreen: flags.isSmallScreen,
isLargeScreen: flags.isLargeScreen
}}
>
{children}

54
src/services/web.service.ts

@ -4,7 +4,11 @@ import { @@ -4,7 +4,11 @@ import {
isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session'
import { buildViteProxySitesFetchUrl, urlLooksLikeViteProxyRequest } from '@/lib/vite-proxy-url'
import {
buildDevLocalSitesFetchUrl,
buildViteProxySitesFetchUrl,
urlLooksLikeViteProxyRequest
} from '@/lib/vite-proxy-url'
import { TWebMetadata } from '@/types'
import DataLoader from 'dataloader'
import logger from '@/lib/logger'
@ -25,16 +29,22 @@ const HTML_FETCH_HEADERS = { @@ -25,16 +29,22 @@ const HTML_FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; Imwald/1.0; +https://jumble.imwald.eu)'
}
/** Browser direct fetches: no custom User-Agent (many sites reject it in CORS preflight). */
const HTML_FETCH_HEADERS_DIRECT = {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
async function tryFetchHtml(
fetchUrl: string,
timeoutMs: number
timeoutMs: number,
options?: { direct?: boolean }
): Promise<{ html: string | null; status?: number }> {
try {
const res = await fetchWithTimeout(fetchUrl, {
timeoutMs,
mode: 'cors',
credentials: 'omit',
headers: HTML_FETCH_HEADERS
headers: options?.direct ? HTML_FETCH_HEADERS_DIRECT : HTML_FETCH_HEADERS
})
if (!res.ok) return { html: null, status: res.status }
const html = await res.text()
@ -71,16 +81,31 @@ async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: strin @@ -71,16 +81,31 @@ async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: strin
markSitesProxyUnavailableFromHttpStatus(proxyTry.status)
}
logger.debug('[WebService] OG proxy unavailable or bad response', { originalUrl, status: proxyTry.status })
// In production with a configured proxy, skip direct fetch: random sites rarely allow browser CORS,
// and the attempt spams DevTools with cross-origin errors without improving OG success.
if (!import.meta.env.PROD) {
const direct = await tryFetchHtml(originalUrl, 15_000)
return direct.html ? { html: direct.html, via: 'direct' } : null
}
if (import.meta.env.DEV) {
const devSitesUrl = buildDevLocalSitesFetchUrl(originalUrl)
if (devSitesUrl && !isSitesProxyUnavailableThisSession()) {
const devTry = await tryFetchHtml(devSitesUrl, 35_000)
if (devTry.html) {
clearSitesProxyUnavailableThisSession()
return { html: devTry.html, via: devSitesUrl }
}
if (typeof devTry.status === 'number') {
markSitesProxyUnavailableFromHttpStatus(devTry.status)
}
}
const direct = await tryFetchHtml(originalUrl, 15_000, { direct: true })
return direct.html ? { html: direct.html, via: 'direct' } : null
}
// In production with a configured proxy, skip direct fetch: random sites rarely allow browser CORS,
// and the attempt spams DevTools with cross-origin errors without improving OG success.
if (proxyServer) {
return null
}
const directOnly = await tryFetchHtml(originalUrl, 15_000)
const directOnly = await tryFetchHtml(originalUrl, 15_000, { direct: true })
return directOnly.html ? { html: directOnly.html, via: 'direct' } : null
}
@ -109,6 +134,15 @@ function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata { @@ -109,6 +134,15 @@ function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata {
let image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null)?.content
let audio =
doc.querySelector('meta[property="og:audio"]')?.getAttribute('content') ||
doc.querySelector('meta[property="og:audio:url"]')?.getAttribute('content') ||
doc.querySelector('meta[property="og:audio:secure_url"]')?.getAttribute('content') ||
null
if (audio && !audio.match(/^https?:\/\//)) {
audio = null
}
if (image) {
try {
const urlObj = new URL(pageUrl)
@ -154,7 +188,7 @@ function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata { @@ -154,7 +188,7 @@ function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata {
/* ignore */
}
return { title, description, image }
return { title, description, image, audio }
}
class WebService {

2
src/types/index.d.ts vendored

@ -124,6 +124,8 @@ export type TWebMetadata = { @@ -124,6 +124,8 @@ export type TWebMetadata = {
title?: string | null
description?: string | null
image?: string | null
/** Direct audio URL from og:audio (e.g. fountain.fm episodes). */
audio?: string | null
}
export type TRelaySet = {

Loading…
Cancel
Save