From af41c56391906d7d129a98d10b4411fcac1fc3a0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 1 Jun 2026 20:16:35 +0200 Subject: [PATCH] wavelake and fountain rendering --- src/PageManager.tsx | 2 +- src/components/Content/index.tsx | 90 ++++++ src/components/Embedded/HttpNostrAwareUrl.tsx | 24 ++ src/components/EmojiPickerDialog/index.tsx | 2 +- .../FountainEmbeddedPlayer/index.tsx | 141 ++++++++++ src/components/GifPicker/index.tsx | 175 ++++++------ src/components/LiveActivitiesStrip.tsx | 46 ++- src/components/MemePicker/index.tsx | 73 ++++- .../Note/MarkdownArticle/MarkdownArticle.tsx | 265 +++++++++++++++++- .../Note/MarkdownArticle/preprocessMarkup.ts | 10 + .../WavlakeEmbeddedPlayer/index.tsx | 60 ++++ src/components/ui/scroll-area.tsx | 5 +- src/constants.ts | 8 + src/lib/content-parser.ts | 7 + src/lib/fountain-url.test.ts | 36 +++ src/lib/fountain-url.ts | 37 +++ src/lib/vite-proxy-url.ts | 6 + src/lib/wavlake-url.test.ts | 41 +++ src/lib/wavlake-url.ts | 46 +++ src/providers/ScreenSizeProvider.tsx | 40 ++- src/services/web.service.ts | 54 +++- src/types/index.d.ts | 2 + 22 files changed, 1033 insertions(+), 137 deletions(-) create mode 100644 src/components/FountainEmbeddedPlayer/index.tsx create mode 100644 src/components/WavlakeEmbeddedPlayer/index.tsx create mode 100644 src/lib/fountain-url.test.ts create mode 100644 src/lib/fountain-url.ts create mode 100644 src/lib/wavlake-url.test.ts create mode 100644 src/lib/wavlake-url.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 748eb271..d9db12e1 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -2507,7 +2507,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { {secondaryStack.length > 0 ? ( ) : (
diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index fe3601b3..25c5f956 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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({ !isHlsPlaylistUrl(url) && !isYouTubeUrl(url) && !isSpotifyOpenUrl(url) && + !isWavlakeOpenUrl(url) && + !isFountainOpenUrl(url) && !isZapStreamWatchUrl(url) ) { const cleaned = cleanUrl(url) @@ -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() + 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() + 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() const s = new Set() @@ -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({ /> ))} + {wavlakeUrlsFromTags.map((url) => ( + + ))} + + {fountainUrlsFromTags.map((url) => ( + + ))} + {zapstreamUrlsFromTags.map((url) => ( ) } + if (node.type === 'wavlake') { + return ( + + ) + } + if (node.type === 'fountain') { + return ( + + ) + } if (node.type === 'zapstream') { return ( + ) + } + + if (isFountainOpenUrl(cleaned)) { + return ( + + ) + } + if (sameOriginTarget) { if (sameOriginTarget.kind === 'event') { return ( diff --git a/src/components/EmojiPickerDialog/index.tsx b/src/components/EmojiPickerDialog/index.tsx index a337696f..b5339155 100644 --- a/src/components/EmojiPickerDialog/index.tsx +++ b/src/components/EmojiPickerDialog/index.tsx @@ -24,7 +24,7 @@ export default function EmojiPickerDialog({ if (isSmallScreen) { return ( - + {children} + +
+ ) +} + +function FountainMeta({ + displayTitle, + cleanedUrl, + compact = false +}: { + displayTitle?: string | null + cleanedUrl: string + compact?: boolean +}) { + return ( +
+ {displayTitle ? ( +

{displayTitle}

+ ) : ( +

fountain.fm

+ )} + e.stopPropagation()} + > + Open on Fountain + + +
+ ) +} + +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 + } + + if (!showPlayer) { + return ( + setUserClickedLoad(true)} + className={cn('w-full max-w-[400px]', minHeightClass, className)} + /> + ) + } + + if (ogLoading) { + return ( +
+ + + +
+ ) + } + + if (!audio) { + return ( +
+ {image ? : null} + +
+ ) + } + + return ( +
+ {image ? : null} + + +
+ ) +} diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 31dce471..7e2ed46d 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -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 ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {gifs.map((gif) => { + const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn + return ( +
+ + + {gifSourceKindShortLabel(gif)} + + {showArchive && ( + + )} +
+ ) + })} +
+ ) + const content = (
{error}

)}
- - {loading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : ( -
- {gifs.map((gif) => { - const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn - return ( -
- - - {gifSourceKindShortLabel(gif)} - - {showArchive && ( - - )} -
- ) - })} -
- )} -
+ {isDrawer ? ( +
+ {gifGrid} +
+ ) : ( + {gifGrid} + )}
@@ -521,13 +524,19 @@ export default function GifPicker({ if (isSmallScreen) { return ( - + {children} - + {t('Choose a GIF')} - {content} +
+ {content} +
) diff --git a/src/components/LiveActivitiesStrip.tsx b/src/components/LiveActivitiesStrip.tsx index c0266c19..838b8359 100644 --- a/src/components/LiveActivitiesStrip.tsx +++ b/src/components/LiveActivitiesStrip.tsx @@ -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 {t('liveActivities.swipeToBrowse')} ) : null} -
- {t('liveActivities.heading')} -
+ {placement === 'sidebar' ? ( +
+ {t('liveActivities.heading')} +
+ ) : null}
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' ? ( + + {t('liveActivities.heading')} + + ) : null} {current.imageUrl ? ( ) : null}
-
{current.title}
- {current.summary ? ( +
+ {current.title} +
+ {placement === 'sidebar' && current.summary ? (

{current.summary}

) : null} - {current.fromFollowedHost ? ( + {placement === 'sidebar' && current.fromFollowedHost ? (

{t('liveActivities.fromFollow')}

) : null}
@@ -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()} > - +
{items.length > 1 ? ( placement === 'mobile' ? ( -
+
{items.map((item, i) => (
{error &&

{error}

}
- + {isDrawer ? ( +
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {memes.map((meme) => ( + + ))} +
+ )} +
+ ) : ( + {loading ? (
)} + )}
@@ -456,13 +499,19 @@ export default function MemePicker({ if (isSmallScreen) { return ( - + {children} - + {t('Choose a meme')} - {content} +
+ {content} +
) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 187f609a..950c69dc 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -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 { 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 { 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( } }) + 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( 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( 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(
) + } else if (pattern.type === 'wavlake-url') { + const { url } = pattern.data + parts.push( +
+ +
+ ) + } else if (pattern.type === 'fountain-url') { + const { url } = pattern.data + parts.push( +
+ +
+ ) } else if (pattern.type === 'zapstream-url') { const { url } = pattern.data parts.push( @@ -3776,6 +3861,20 @@ function parseMarkdownContentMarked(
) } + if (isWavlakeUrl(cleaned)) { + return ( +
+ +
+ ) + } + if (isFountainUrl(cleaned)) { + return ( +
+ +
+ ) + } if (isZapStreamUrl(cleaned)) { return (
@@ -3949,6 +4048,20 @@ function parseMarkdownContentMarked(
) } + if (isWavlakeUrl(cleaned)) { + return ( +
+ +
+ ) + } + if (isFountainUrl(cleaned)) { + return ( +
+ +
+ ) + } if (isZapStreamUrl(cleaned)) { return (
@@ -4023,6 +4136,20 @@ function parseMarkdownContentMarked(
) } + if (soleHref && isWavlakeUrl(soleHref)) { + return ( +
+ +
+ ) + } + if (soleHref && isFountainUrl(soleHref)) { + return ( +
+ +
+ ) + } if (soleHref && isZapStreamUrl(soleHref)) { return (
@@ -4112,6 +4239,24 @@ function parseMarkdownContentMarked( ) return } + if (cleaned && isWavlakeUrl(cleaned)) { + flushInlineSegment(segmentIdx++) + nodes.push( +
+ +
+ ) + return + } + if (cleaned && isFountainUrl(cleaned)) { + flushInlineSegment(segmentIdx++) + nodes.push( +
+ +
+ ) + return + } if (cleaned && isZapStreamUrl(cleaned)) { flushInlineSegment(segmentIdx++) nodes.push( @@ -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() + + 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() + + 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() @@ -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({ seenUrls.add(cleaned) } }) - + return links }, [event.id, JSON.stringify(event.tags)]) @@ -5659,6 +5848,34 @@ export default function MarkdownArticle({ return urls }, [event.content]) + const wavlakeUrlsInContent = useMemo(() => { + const urls = new Set() + 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() + 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() const urlRegex = /https?:\/\/[^\s<>"']+/g @@ -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({ }) }, [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({
)} + {leftoverTagWavlakeUrls.length > 0 && ( +
+ {leftoverTagWavlakeUrls.map((url) => { + const cleaned = cleanUrl(url) + return ( +
+ +
+ ) + })} +
+ )} + + {leftoverTagFountainUrls.length > 0 && ( +
+ {leftoverTagFountainUrls.map((url) => { + const cleaned = cleanUrl(url) + return ( +
+ +
+ ) + })} +
+ )} + {leftoverTagZapStreamUrls.length > 0 && (
{leftoverTagZapStreamUrls.map((url) => ( diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts index 821d0950..368c56cb 100644 --- a/src/components/Note/MarkdownArticle/preprocessMarkup.ts +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -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 { continue } + if (isWavlakeOpenUrl(url)) { + continue + } + + if (isFountainOpenUrl(url)) { + continue + } + if (isZapStreamWatchUrl(url)) { continue } diff --git a/src/components/WavlakeEmbeddedPlayer/index.tsx b/src/components/WavlakeEmbeddedPlayer/index.tsx new file mode 100644 index 00000000..41b69d5c --- /dev/null +++ b/src/components/WavlakeEmbeddedPlayer/index.tsx @@ -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 + } + + if (!mustLoad && !showEmbed) { + return ( + setUserClickedLoad(true)} + className={cn('w-full max-w-[400px]', minHeightClass, className)} + /> + ) + } + + return ( +