Compare commits

..

14 Commits

  1. 4
      package-lock.json
  2. 2
      package.json
  3. 2
      src/App.tsx
  4. 22
      src/PageManager.tsx
  5. 36
      src/components/AudioPlayer/index.tsx
  6. 13
      src/components/CalendarEventContent/index.tsx
  7. 90
      src/components/Content/index.tsx
  8. 21
      src/components/ContentPreview/MusicTrackNotePreview.tsx
  9. 15
      src/components/ContentPreview/index.tsx
  10. 3
      src/components/Embedded/EmbeddedNoteProviders.tsx
  11. 24
      src/components/Embedded/HttpNostrAwareUrl.tsx
  12. 46
      src/components/EmojiPicker/index.tsx
  13. 6
      src/components/EmojiPickerDialog/index.tsx
  14. 2
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  15. 2
      src/components/FavoriteRelaysSetting/RelayItem.tsx
  16. 141
      src/components/FountainEmbeddedPlayer/index.tsx
  17. 89
      src/components/GifPicker/index.tsx
  18. 45
      src/components/Image/index.tsx
  19. 40
      src/components/LiveActivitiesStrip.tsx
  20. 11
      src/components/MediaGridItem/index.tsx
  21. 69
      src/components/MemePicker/index.tsx
  22. 1
      src/components/Note/Highlight/index.tsx
  23. 263
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  24. 10
      src/components/Note/MarkdownArticle/preprocessMarkup.ts
  25. 99
      src/components/Note/MusicTrackNote.tsx
  26. 5
      src/components/Note/index.tsx
  27. 7
      src/components/NoteCard/RepostNoteCard.tsx
  28. 7
      src/components/NoteCard/index.tsx
  29. 76
      src/components/NoteList/index.tsx
  30. 5
      src/components/NoteStats/LikeButton.tsx
  31. 3
      src/components/OthersRelayList/index.tsx
  32. 4
      src/components/PaytoDialog/index.tsx
  33. 60
      src/components/RelaySettingsKindNotice/index.tsx
  34. 7
      src/components/ReplyNote/index.tsx
  35. 7
      src/components/ReplyNoteList/index.tsx
  36. 60
      src/components/WavlakeEmbeddedPlayer/index.tsx
  37. 2
      src/components/WebPreview/index.tsx
  38. 24
      src/components/ZapDialog/Nip57QuickZapButton.tsx
  39. 9
      src/components/ZapDialog/index.tsx
  40. 5
      src/components/ui/scroll-area.tsx
  41. 23
      src/constants.ts
  42. 98
      src/hooks/useFetchCalendarRsvps.tsx
  43. 29
      src/hooks/useNip57QuickZap.ts
  44. 9
      src/i18n/locales/de.ts
  45. 9
      src/i18n/locales/en.ts
  46. 42
      src/index.css
  47. 64
      src/layouts/PrimaryPageLayout/index.tsx
  48. 32
      src/layouts/SecondaryPageLayout/index.tsx
  49. 105
      src/lib/calendar-rsvp-match.test.ts
  50. 38
      src/lib/calendar-rsvp-match.ts
  51. 8
      src/lib/content-parser.ts
  52. 22
      src/lib/error-suppression.ts
  53. 1
      src/lib/feed-kind-filter.test.ts
  54. 3
      src/lib/feed-kind-filter.ts
  55. 37
      src/lib/fountain-url.test.ts
  56. 37
      src/lib/fountain-url.ts
  57. 2
      src/lib/kind-description.ts
  58. 17
      src/lib/mobile-primary-feed-scroll.ts
  59. 243
      src/lib/music-track.test.ts
  60. 119
      src/lib/music-track.ts
  61. 14
      src/lib/new-user-template.test.ts
  62. 9
      src/lib/new-user-template.ts
  63. 70
      src/lib/open-graph.test.ts
  64. 171
      src/lib/open-graph.ts
  65. 8
      src/lib/parent-reply-blurb.ts
  66. 14
      src/lib/revealed-media-session.ts
  67. 28
      src/lib/spell-feed-request-identity.test.ts
  68. 18
      src/lib/spell-feed-request-identity.ts
  69. 4
      src/lib/viewer-blocked-relays.ts
  70. 6
      src/lib/vite-proxy-url.ts
  71. 41
      src/lib/wavlake-url.test.ts
  72. 46
      src/lib/wavlake-url.ts
  73. 25
      src/lib/webln-payment.ts
  74. 81
      src/pages/primary/SpellsPage/index.tsx
  75. 3
      src/pages/secondary/GeneralSettingsPage/index.tsx
  76. 4
      src/pages/secondary/NotePage/index.tsx
  77. 41
      src/pages/secondary/RelaySettingsPage/index.tsx
  78. 3
      src/providers/FavoriteRelaysProvider.tsx
  79. 36
      src/providers/NostrProvider/index.tsx
  80. 38
      src/providers/ScreenSizeProvider.tsx
  81. 20
      src/services/client-events.service.ts
  82. 5
      src/services/client-query.service.ts
  83. 7
      src/services/client-replaceable-events.service.ts
  84. 50
      src/services/client.service.ts
  85. 39
      src/services/indexed-db.service.ts
  86. 17
      src/services/lightning.service.ts
  87. 15
      src/services/local-storage.service.ts
  88. 1
      src/services/mention-event-search.service.ts
  89. 1
      src/services/nip89.service.ts
  90. 9
      src/services/relay-info.service.ts
  91. 179
      src/services/web.service.ts
  92. 2
      src/types/index.d.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.2", "version": "23.17.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.17.2", "version": "23.17.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.17.2", "version": "23.17.3",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

2
src/App.tsx

@ -38,7 +38,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider> <DeletedEventProvider>
<NostrProvider> <NostrProvider>
<CacheBrowserProvider> <CacheBrowserProvider>
<div className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden max-md:h-auto max-md:max-h-none max-md:min-h-dvh max-md:overflow-visible"> <div className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden max-md:h-[var(--vh)] max-md:max-h-[var(--vh)] max-md:min-h-0">
<VersionUpdateBanner /> <VersionUpdateBanner />
<StartupSessionBanner /> <StartupSessionBanner />
<SlowConnectionHint /> <SlowConnectionHint />

22
src/PageManager.tsx

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
captureMobilePrimaryFeedScrollFromWindow, captureMobilePrimaryFeedScroll,
peekMobilePrimaryFeedScroll peekMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll' } from '@/lib/mobile-primary-feed-scroll'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back' import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
@ -2054,7 +2054,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if (isSmallScreen && currentPrimaryPage) { if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage) captureMobilePrimaryFeedScroll(currentPrimaryPage)
} }
// Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows. // Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows.
@ -2393,13 +2393,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}} }}
> >
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-content-canvas min-h-[var(--vh)]"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-content-canvas">
<LiveActivitiesStrip placement="mobile" /> <LiveActivitiesStrip placement="mobile" />
{primaryNoteView ? ( {primaryNoteView ? (
// Show primary note view with back button on mobile // Show primary note view with back button on mobile
<div <div
ref={setMobilePrimarySwipeRoot} ref={setMobilePrimarySwipeRoot}
className="flex min-h-0 flex-1 flex-col h-full w-full touch-pan-y" className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden touch-pan-y"
> >
<ImwaldBrandBar /> <ImwaldBrandBar />
<div className="flex gap-1 border-b border-border p-1 items-center justify-between font-semibold"> <div className="flex gap-1 border-b border-border p-1 items-center justify-between font-semibold">
@ -2423,15 +2423,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
<RefreshButton onClick={triggerPrimaryPanelRefresh} /> <RefreshButton onClick={triggerPrimaryPanelRefresh} />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="page-scroll-y min-h-0 flex-1 basis-0 overflow-y-scroll overscroll-y-contain touch-pan-y">
{primaryNoteView} {primaryNoteView}
</div> </div>
</div> </div>
) : ( ) : (
<> <div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div <div
className={cn( className={cn(
'block h-full min-h-0 min-w-0', 'flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden',
secondaryStack.length > 0 && 'hidden' secondaryStack.length > 0 && 'hidden'
)} )}
aria-hidden={secondaryStack.length > 0} aria-hidden={secondaryStack.length > 0}
@ -2441,12 +2441,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
<div <div
ref={setMobileSecondarySwipeRoot} ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y bg-background" className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden touch-pan-y bg-background"
> >
<TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} /> <TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
</div> </div>
) : null} ) : null}
</> </div>
)} )}
</div> </div>
<Suspense fallback={null}> <Suspense fallback={null}>
@ -2507,7 +2507,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
<TopSecondaryStackPane <TopSecondaryStackPane
item={secondaryStack[secondaryStack.length - 1]!} 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"> <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">
@ -2675,7 +2675,7 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean
/** Mount only the top secondary frame so Back unmounts feeds/relays under the previous page. */ /** Mount only the top secondary frame so Back unmounts feeds/relays under the previous page. */
function TopSecondaryStackPane({ function TopSecondaryStackPane({
item, item,
className = 'block h-full min-h-0 min-w-0' className = 'flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
}: { }: {
item: TStackItem item: TStackItem
className?: string className?: string

36
src/components/AudioPlayer/index.tsx

@ -13,12 +13,21 @@ interface AudioPlayerProps {
className?: string className?: string
/** Optional cover / still (e.g. NIP-53 `image` on live events). */ /** Optional cover / still (e.g. NIP-53 `image` on live events). */
poster?: string poster?: string
/** Tried when `src` fails to load (e.g. Primal r2a mirror for blossom URLs). */
fallbackSrc?: string
/** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */ /** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */
onReady?: () => void onReady?: () => void
} }
export default function AudioPlayer({ src, className, poster, onReady }: AudioPlayerProps) { export default function AudioPlayer({
src,
className,
poster,
fallbackSrc,
onReady
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const [activeSrc, setActiveSrc] = useState(src)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
@ -26,6 +35,14 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
const seekTimeoutRef = useRef<NodeJS.Timeout>() const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false) const isSeeking = useRef(false)
useEffect(() => {
setActiveSrc(src)
setError(false)
setIsPlaying(false)
setCurrentTime(0)
setDuration(0)
}, [src, fallbackSrc])
useEffect(() => { useEffect(() => {
if (!onReady) return if (!onReady) return
const audio = audioRef.current const audio = audioRef.current
@ -37,7 +54,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
} }
audio.addEventListener('canplay', notify, { once: true }) audio.addEventListener('canplay', notify, { once: true })
return () => audio.removeEventListener('canplay', notify) return () => audio.removeEventListener('canplay', notify)
}, [src, onReady]) }, [activeSrc, onReady])
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -122,6 +139,19 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
}, 300) }, 300)
} }
const handleLoadError = () => {
const fb = fallbackSrc?.trim()
if (fb && activeSrc !== fb) {
setActiveSrc(fb)
setError(false)
setIsPlaying(false)
setCurrentTime(0)
setDuration(0)
return
}
setError(true)
}
if (error) { if (error) {
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }
@ -157,7 +187,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
!cover && 'max-w-md' !cover && 'max-w-md'
)} )}
> >
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} /> <audio ref={audioRef} src={activeSrc} preload="metadata" onError={handleLoadError} />
<Button size="icon" className="shrink-0 rounded-full" onClick={togglePlay}> <Button size="icon" className="shrink-0 rounded-full" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />} {isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}

13
src/components/CalendarEventContent/index.tsx

@ -51,7 +51,7 @@ export default function CalendarEventContent({
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey: myPubkey, publish } = useNostr() const { pubkey: myPubkey, publish } = useNostr()
const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) const { rsvps, isFetching, getRsvpStatus: getStatus, applyRsvp } = useFetchCalendarRsvps(event)
const meta = useMemo(() => { const meta = useMemo(() => {
if (!isCalendarEventKind(event.kind)) return null if (!isCalendarEventKind(event.kind)) return null
@ -88,7 +88,8 @@ export default function CalendarEventContent({
return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])] return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])]
}, [meta, event]) }, [meta, event])
const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined const myPk = myPubkey?.toLowerCase()
const myRsvp = myPk ? rsvps.find((r) => r.pubkey.toLowerCase() === myPk) : undefined
const myStatus = myRsvp ? getStatus(myRsvp) : undefined const myStatus = myRsvp ? getStatus(myRsvp) : undefined
// Organizer + invitees (event p tags) + anyone who sent an RSVP. Each shows response: accepted/tentative/declined or no response. // Organizer + invitees (event p tags) + anyone who sent an RSVP. Each shows response: accepted/tentative/declined or no response.
@ -108,12 +109,13 @@ export default function CalendarEventContent({
new Set([organizerPubkey, ...participantPubkeys, ...rsvps.map((r) => r.pubkey)]) new Set([organizerPubkey, ...participantPubkeys, ...rsvps.map((r) => r.pubkey)])
) )
return allPubkeys.map((pubkey) => { return allPubkeys.map((pubkey) => {
const rsvp = rsvps.find((r) => r.pubkey === pubkey) const pk = pubkey.toLowerCase()
const rsvp = rsvps.find((r) => r.pubkey.toLowerCase() === pk)
return { return {
pubkey, pubkey,
role: roleByPubkey.get(pubkey), role: roleByPubkey.get(pubkey),
status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null, status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null,
isOrganizer: pubkey === organizerPubkey isOrganizer: pk === organizerPubkey.toLowerCase()
} }
}) })
}, [event.pubkey, event.tags, rsvps]) }, [event.pubkey, event.tags, rsvps])
@ -142,7 +144,8 @@ export default function CalendarEventContent({
} }
try { try {
const draft = createCalendarRsvpDraftEvent(event, status) const draft = createCalendarRsvpDraftEvent(event, status)
await publish(draft) const signed = await publish(draft)
applyRsvp(signed)
toast.success(t('RSVP updated')) toast.success(t('RSVP updated'))
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) toast.error(err instanceof Error ? err.message : t('Failed to update RSVP'))

90
src/components/Content/index.tsx

@ -32,12 +32,16 @@ import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import SpotifyEmbeddedPlayer from '../SpotifyEmbeddedPlayer' import SpotifyEmbeddedPlayer from '../SpotifyEmbeddedPlayer'
import FountainEmbeddedPlayer from '../FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '../WavlakeEmbeddedPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '../ZapStreamLiveEventEmbed' import ZapStreamLiveEventEmbed from '../ZapStreamLiveEventEmbed'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { YOUTUBE_URL_REGEX } from '@/constants' import { YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url' 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 { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { shouldDeferLongVideoAutoload } from '@/lib/long-video-load-policy' import { shouldDeferLongVideoAutoload } from '@/lib/long-video-load-policy'
@ -180,6 +184,8 @@ export default function Content({
!isHlsPlaylistUrl(url) && !isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) && !isSpotifyOpenUrl(url) &&
!isWavlakeOpenUrl(url) &&
!isFountainOpenUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
) { ) {
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
@ -247,6 +253,50 @@ export default function Content({
return urls return urls
}, [event, nodes]) }, [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(() => { const zapStreamCanonicalInContent = useMemo(() => {
if (!nodes) return new Set<string>() if (!nodes) return new Set<string>()
const s = new Set<string>() const s = new Set<string>()
@ -297,6 +347,8 @@ export default function Content({
!isHlsPlaylistUrl(url) && !isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyOpenUrl(url) && !isSpotifyOpenUrl(url) &&
!isWavlakeOpenUrl(url) &&
!isFountainOpenUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
) { ) {
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
@ -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) => ( {zapstreamUrlsFromTags.map((url) => (
<ZapStreamLiveEventEmbed <ZapStreamLiveEventEmbed
key={`tag-zapstream-${url}`} key={`tag-zapstream-${url}`}
@ -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') { if (node.type === 'zapstream') {
return ( return (
<ZapStreamLiveEventEmbed <ZapStreamLiveEventEmbed

21
src/components/ContentPreview/MusicTrackNotePreview.tsx

@ -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>
)
}

15
src/components/ContentPreview/index.tsx

@ -30,6 +30,7 @@ import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview' import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview' import PollPreview from './PollPreview'
import VideoNotePreview from './VideoNotePreview' import VideoNotePreview from './VideoNotePreview'
import MusicTrackNotePreview from './MusicTrackNotePreview'
import ZapPreview from './ZapPreview' import ZapPreview from './ZapPreview'
import DiscussionNote from '../DiscussionNote' import DiscussionNote from '../DiscussionNote'
import ApplicationHandlerInfo from '../ApplicationHandlerInfo' import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
@ -244,6 +245,20 @@ export default function ContentPreview({
return withKindRow(<VideoNotePreview event={previewEvent} />) return withKindRow(<VideoNotePreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.MUSIC_TRACK) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>
{line || t('Music track', { defaultValue: 'Music track' })}
</div>
</div>
)
}
return withKindRow(<MusicTrackNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.PICTURE) { if (event.kind === ExtendedKind.PICTURE) {
if (forParentReplyBlurb) { if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent) const line = getParentReplyBlurbDisplayText(previewEvent)

3
src/components/Embedded/EmbeddedNoteProviders.tsx

@ -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>
) )
} }

24
src/components/Embedded/HttpNostrAwareUrl.tsx

@ -15,7 +15,11 @@ import { EmbeddedNormalUrl } from './EmbeddedNormalUrl'
import { EmbeddedNote } from './EmbeddedNote' import { EmbeddedNote } from './EmbeddedNote'
import WebPreview from '@/components/WebPreview' import WebPreview from '@/components/WebPreview'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import FountainEmbeddedPlayer from '@/components/FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '@/components/WavlakeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed' import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed'
import { isFountainOpenUrl } from '@/lib/fountain-url'
import { isWavlakeOpenUrl } from '@/lib/wavlake-url'
import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url' import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url'
import { isZapStreamWatchUrl } from '@/lib/zap-stream-url' import { isZapStreamWatchUrl } from '@/lib/zap-stream-url'
@ -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) {
if (sameOriginTarget.kind === 'event') { if (sameOriginTarget.kind === 'event') {
return ( return (

46
src/components/EmojiPicker/index.tsx

@ -134,8 +134,30 @@ export default function EmojiPicker({
const reactionsList = reactions ?? [...DEFAULT_SUGGESTED_EMOJIS] const reactionsList = reactions ?? [...DEFAULT_SUGGESTED_EMOJIS]
const ownEmojisRow =
ownEmojis.length > 0 ? (
<div className="flex shrink-0 items-center gap-0.5 px-1 py-1 border-b overflow-x-auto scrollbar-hide">
{ownEmojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={`:${emoji.shortcode}:`}
className="shrink-0 w-8 h-8 rounded hover:bg-muted flex items-center justify-center"
onClick={(e) => {
recordEmojiUsed(emoji)
onEmojiClick(emoji, e.nativeEvent)
}}
>
<img src={emoji.url} alt={emoji.shortcode} className="w-6 h-6 object-contain" />
</button>
))}
</div>
) : null
if (mode === 'reactions') { if (mode === 'reactions') {
return ( return (
<div className="flex w-full min-w-0 flex-col">
{ownEmojisRow}
<div className="flex flex-wrap items-center gap-1 p-2"> <div className="flex flex-wrap items-center gap-1 p-2">
{reactionsList.map((emoji) => ( {reactionsList.map((emoji) => (
<button <button
@ -159,30 +181,14 @@ export default function EmojiPicker({
<Plus size={20} /> <Plus size={20} />
</button> </button>
</div> </div>
</div>
) )
} }
return ( return (
<div className="w-full flex flex-col"> <div className="flex w-full min-w-0 flex-col">
{ownEmojis.length > 0 && ( {ownEmojisRow}
<div className="flex items-center gap-0.5 px-1 py-1 border-b overflow-x-auto scrollbar-hide"> <div ref={containerRef} className="min-h-0 w-full flex-1 overflow-hidden" />
{ownEmojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={`:${emoji.shortcode}:`}
className="shrink-0 w-8 h-8 rounded hover:bg-muted flex items-center justify-center"
onClick={(e) => {
recordEmojiUsed(emoji)
onEmojiClick(emoji, e.nativeEvent)
}}
>
<img src={emoji.url} alt={emoji.shortcode} className="w-6 h-6 object-contain" />
</button>
))}
</div>
)}
<div ref={containerRef} />
</div> </div>
) )
} }

6
src/components/EmojiPickerDialog/index.tsx

@ -24,7 +24,7 @@ export default function EmojiPickerDialog({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={setOpen} handleOnly> <Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
@ -39,7 +39,8 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle> <DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex w-full max-w-[100vw] min-w-0 min-h-0 shrink flex-col items-stretch overflow-x-hidden pb-1"> <div className="flex w-full max-w-[100vw] min-w-0 min-h-0 max-h-[min(72dvh,calc(100dvh-6rem))] flex-col overflow-hidden pb-1">
{open ? (
<EmojiPicker <EmojiPicker
onEmojiClick={(emoji, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
@ -47,6 +48,7 @@ export default function EmojiPickerDialog({
onEmojiClick?.(emoji) onEmojiClick?.(emoji)
}} }}
/> />
) : null}
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

2
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -33,7 +33,7 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
onClick={() => push(toRelay(relay))} onClick={() => push(toRelay(relay))}
> >
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<RelayIcon url={relay} /> <RelayIcon url={relay} skipRelayInfoFetch />
<div className="flex-1 w-0 truncate font-semibold">{relay}</div> <div className="flex-1 w-0 truncate font-semibold">{relay}</div>
</div> </div>
<Button <Button

2
src/components/FavoriteRelaysSetting/RelayItem.tsx

@ -36,7 +36,7 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string;
<GripVertical className="size-4 text-muted-foreground" /> <GripVertical className="size-4 text-muted-foreground" />
</div> </div>
<div className="flex gap-2 items-center flex-1 min-w-0"> <div className="flex gap-2 items-center flex-1 min-w-0">
<RelayIcon url={relay} /> <RelayIcon url={relay} skipRelayInfoFetch={isBlocked} />
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-1 truncate font-semibold">{relay}</div> <div className="flex-1 truncate font-semibold">{relay}</div>
{isBlocked && ( {isBlocked && (

141
src/components/FountainEmbeddedPlayer/index.tsx

@ -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>
)
}

89
src/components/GifPicker/index.tsx

@ -332,43 +332,7 @@ export default function GifPicker({
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen const isDrawer = isSmallScreen
const content = ( const gifGrid = loading ? (
<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]'}`}
>
<div className="flex items-center gap-1 shrink-0">
<Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</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}
{...(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 <div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]" className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status" role="status"
@ -437,8 +401,47 @@ export default function GifPicker({
) )
})} })}
</div> </div>
)
const content = (
<div
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
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
{error && (
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<div
className={cn(isDrawer && 'flex min-h-0 flex-1 flex-col')}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
{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>
)} )}
</ScrollArea>
</div> </div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0"> <div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -521,13 +524,19 @@ export default function GifPicker({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={setOpen} handleOnly> <Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger> <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"> <DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle> <DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content} {content}
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) )

45
src/components/Image/index.tsx

@ -1,9 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { markMediaUrlRevealed, wasMediaUrlRevealed } from '@/lib/revealed-media-session'
import { import {
isRenderableMediaUrl, isRenderableMediaUrl,
isSafeMediaUrl, isSafeMediaUrl,
primalR2aMirrorForBlossomPrimalUrl, primalR2aMirrorForBlossomPrimalUrl,
primalR2aUploads2UrlFromSha256,
resolvePrimalBlossomPlayableUrl resolvePrimalBlossomPlayableUrl
} from '@/lib/url' } from '@/lib/url'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
@ -53,8 +55,17 @@ function formatFileSize(bytes: number): string {
return `${bytes} B` return `${bytes} B`
} }
function extensionWithDotFromUrl(url: string): string {
try {
const m = new URL(url).pathname.match(/(\.[a-z0-9]+)$/i)
return m?.[1]?.toLowerCase() ?? ''
} catch {
return ''
}
}
export default function Image({ export default function Image({
image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes }, image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes, x: imetaHash },
alt, alt,
className = '', className = '',
classNames = {}, classNames = {},
@ -112,6 +123,8 @@ export default function Image({
const loadWatchRef = useRef<number | null>(null) const loadWatchRef = useRef<number | null>(null)
/** After r2a + imeta fallbacks fail, try `url` on blossom.primal.net once (see handleError). */ /** After r2a + imeta fallbacks fail, try `url` on blossom.primal.net once (see handleError). */
const triedPrimaryBlossomDirectRef = useRef(false) const triedPrimaryBlossomDirectRef = useRef(false)
const triedR2aFromHashRef = useRef(false)
const userRevealedRef = useRef(false)
// Kept in sync in the reset effect; load-timeout runs only while tap-to-load is actually active. // Kept in sync in the reset effect; load-timeout runs only while tap-to-load is actually active.
const wasInitiallyHeldRef = useRef(effectiveHoldUntilClick) const wasInitiallyHeldRef = useRef(effectiveHoldUntilClick)
const imgRef = useRef<HTMLImageElement | null>(null) const imgRef = useRef<HTMLImageElement | null>(null)
@ -160,11 +173,14 @@ export default function Image({
loadSettledRef.current = false loadSettledRef.current = false
wasInitiallyHeldRef.current = effectiveHoldUntilClick wasInitiallyHeldRef.current = effectiveHoldUntilClick
const shouldHold = effectiveHoldUntilClick const shouldHold = effectiveHoldUntilClick
setRevealed(!shouldHold) const sessionRevealed = Boolean(url?.trim() && wasMediaUrlRevealed(url))
const showImmediately = !shouldHold || userRevealedRef.current || sessionRevealed
setRevealed(showImmediately)
setHasError(false) setHasError(false)
setDisplaySkeleton(true) setDisplaySkeleton(true)
setFallbackIndex(0) setFallbackIndex(0)
triedPrimaryBlossomDirectRef.current = false triedPrimaryBlossomDirectRef.current = false
triedR2aFromHashRef.current = false
clearLoadWatch() clearLoadWatch()
if (!url?.trim()) { if (!url?.trim()) {
setIsLoading(false) setIsLoading(false)
@ -172,7 +188,7 @@ export default function Image({
setDisplaySkeleton(false) setDisplaySkeleton(false)
return return
} }
setIsLoading(!shouldHold) setIsLoading(showImmediately)
}, [url, effectiveHoldUntilClick]) }, [url, effectiveHoldUntilClick])
const notifyLoaded = useCallback(() => { const notifyLoaded = useCallback(() => {
@ -195,7 +211,7 @@ export default function Image({
notifyLoaded() notifyLoaded()
return return
} }
if (!effectiveHoldUntilClick && typeof el.decode === 'function') { if (typeof el.decode === 'function') {
let cancelled = false let cancelled = false
el.decode().then(() => { el.decode().then(() => {
if (!cancelled && el.naturalWidth > 0) notifyLoaded() if (!cancelled && el.naturalWidth > 0) notifyLoaded()
@ -204,7 +220,7 @@ export default function Image({
cancelled = true cancelled = true
} }
} }
}, [revealed, badSrc, imageUrl, effectiveHoldUntilClick, notifyLoaded]) }, [revealed, badSrc, imageUrl, notifyLoaded])
useEffect(() => { useEffect(() => {
clearLoadWatch() clearLoadWatch()
@ -213,6 +229,11 @@ export default function Image({
if (!wasInitiallyHeldRef.current) return if (!wasInitiallyHeldRef.current) return
loadWatchRef.current = window.setTimeout(() => { loadWatchRef.current = window.setTimeout(() => {
loadWatchRef.current = null loadWatchRef.current = null
const el = imgRef.current
if (el?.complete && el.naturalWidth > 0) {
notifyLoaded()
return
}
setIsLoading(false) setIsLoading(false)
setDisplaySkeleton(false) setDisplaySkeleton(false)
setHasError(true) setHasError(true)
@ -244,6 +265,16 @@ export default function Image({
setImageUrl(primary) setImageUrl(primary)
return return
} }
const hash = imetaHash?.trim().toLowerCase()
if (hash && !triedR2aFromHashRef.current) {
const r2a = primalR2aUploads2UrlFromSha256(hash, extensionWithDotFromUrl(primary || imageUrl))
if (r2a && imageUrl !== r2a) {
triedR2aFromHashRef.current = true
loadSettledRef.current = false
setImageUrl(r2a)
return
}
}
setIsLoading(false) setIsLoading(false)
setDisplaySkeleton(false) setDisplaySkeleton(false)
setHasError(true) setHasError(true)
@ -265,6 +296,8 @@ export default function Image({
const handleReveal = () => { const handleReveal = () => {
if (revealed) return if (revealed) return
userRevealedRef.current = true
if (url?.trim()) markMediaUrlRevealed(url)
setRevealed(true) setRevealed(true)
setIsLoading(true) setIsLoading(true)
} }
@ -323,7 +356,7 @@ export default function Image({
ref={imgRef} ref={imgRef}
src={imageUrl} src={imageUrl}
alt={finalAlt} alt={finalAlt}
referrerPolicy="no-referrer" referrerPolicy="no-referrer-when-downgrade"
decoding={effectiveHoldUntilClick ? 'async' : 'sync'} decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager" loading="eager"

40
src/components/LiveActivitiesStrip.tsx

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

11
src/components/MediaGridItem/index.tsx

@ -1,4 +1,5 @@
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind } from '@/constants'
import { getMusicTrackFromEvent } from '@/lib/music-track'
import { isLongFormNip71VideoEventKind } from '@/lib/long-video-load-policy' import { isLongFormNip71VideoEventKind } from '@/lib/long-video-load-policy'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -21,7 +22,11 @@ export default function MediaGridItem({ event }: { event: Event }) {
const isVideo = const isVideo =
(!isPictureKind && first?.m?.startsWith('video/')) || (!isPictureKind && first?.m?.startsWith('video/')) ||
(!isPictureKind && isNip71StyleVideoKind(event.kind)) (!isPictureKind && isNip71StyleVideoKind(event.kind))
const isAudio = first?.m?.startsWith('audio/') || event.kind === ExtendedKind.VOICE const musicTrack = isMusicTrackKind(event.kind) ? getMusicTrackFromEvent(event) : null
const isAudio =
first?.m?.startsWith('audio/') ||
event.kind === ExtendedKind.VOICE ||
musicTrack != null
const hasMultiple = media.all.length > 1 const hasMultiple = media.all.length > 1
// For videos prefer the poster image; long-form feed tiles never prefetch the .mp4 (open note to play). // For videos prefer the poster image; long-form feed tiles never prefetch the .mp4 (open note to play).
@ -29,7 +34,7 @@ export default function MediaGridItem({ event }: { event: Event }) {
? isLongFormVideo ? isLongFormVideo
? (first?.image ?? first?.thumb) ? (first?.image ?? first?.thumb)
: (first?.image ?? first?.url) : (first?.image ?? first?.url)
: (first?.thumb ?? first?.url) : musicTrack?.imageUrl ?? first?.thumb ?? first?.url
const handleClick = () => { const handleClick = () => {
client.addEventToCache(event) client.addEventToCache(event)

69
src/components/MemePicker/index.tsx

@ -14,6 +14,7 @@ import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMai
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { import {
fetchMemes, fetchMemes,
getCachedMemes, getCachedMemes,
@ -297,7 +298,10 @@ export default function MemePicker({
const isDrawer = isSmallScreen const isDrawer = isSmallScreen
const content = ( const content = (
<div <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"> <div className="flex items-center gap-1 shrink-0">
<Input <Input
@ -319,16 +323,54 @@ export default function MemePicker({
</div> </div>
{error && <p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>} {error && <p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>}
<div <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 })} {...(isDrawer && { 'data-vaul-no-drag': true })}
> >
<ScrollArea {isDrawer ? (
className={ <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">
isDrawer {loading ? (
? 'flex-1 min-h-[200px] w-full rounded-md border' <div
: 'h-[280px] w-full rounded-md border' 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 ? ( {loading ? (
<div <div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]" className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
@ -370,6 +412,7 @@ export default function MemePicker({
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
)}
</div> </div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0"> <div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -456,13 +499,19 @@ export default function MemePicker({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<DrawerTrigger asChild>{children}</DrawerTrigger> <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"> <DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a meme')}</DrawerTitle> <DrawerTitle>{t('Choose a meme')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content} {content}
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) )

1
src/components/Note/Highlight/index.tsx

@ -287,6 +287,7 @@ export default function Highlight({
ExtendedKind.WIKI_ARTICLE, // Has special card ExtendedKind.WIKI_ARTICLE, // Has special card
ExtendedKind.NOSTR_SPECIFICATION, // Has special card ExtendedKind.NOSTR_SPECIFICATION, // Has special card
ExtendedKind.VOICE, // Has special card ExtendedKind.VOICE, // Has special card
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT, // Has special card ExtendedKind.VOICE_COMMENT, // Has special card
] ]

263
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -6,6 +6,8 @@ import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr' import { BookstrContent } from '@/components/Bookstr'
import WebPreview from '@/components/WebPreview' import WebPreview from '@/components/WebPreview'
import SpotifyEmbeddedPlayer from '@/components/SpotifyEmbeddedPlayer' import SpotifyEmbeddedPlayer from '@/components/SpotifyEmbeddedPlayer'
import FountainEmbeddedPlayer from '@/components/FountainEmbeddedPlayer'
import WavlakeEmbeddedPlayer from '@/components/WavlakeEmbeddedPlayer'
import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed' import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
@ -33,11 +35,15 @@ import {
ExtendedKind, ExtendedKind,
isNip52CalendarCardKind, isNip52CalendarCardKind,
SPOTIFY_OPEN_URL_REGEX, SPOTIFY_OPEN_URL_REGEX,
FOUNTAIN_OPEN_URL_REGEX,
WAVLAKE_OPEN_URL_REGEX,
WS_URL_REGEX, WS_URL_REGEX,
YOUTUBE_URL_REGEX, YOUTUBE_URL_REGEX,
ZAP_STREAM_WATCH_URL_REGEX ZAP_STREAM_WATCH_URL_REGEX
} from '@/constants' } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url' 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 { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url' import { isEmbeddableYoutubeUrl } from '@/lib/youtube-url'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns' 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) 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 { function isZapStreamUrl(url: string): boolean {
const flags = ZAP_STREAM_WATCH_URL_REGEX.flags.replace('g', '') const flags = ZAP_STREAM_WATCH_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(ZAP_STREAM_WATCH_URL_REGEX.source, flags) 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)) const zapstreamUrlMatches = Array.from(content.matchAll(ZAP_STREAM_WATCH_URL_REGEX))
zapstreamUrlMatches.forEach((match) => { zapstreamUrlMatches.forEach((match) => {
if (match.index !== undefined) { if (match.index !== undefined) {
@ -1321,7 +1390,9 @@ function parseMarkdownContentLegacy(
p.type === 'markdown-image-link' || p.type === 'markdown-image-link' ||
p.type === 'markdown-image' || p.type === 'markdown-image' ||
p.type === 'youtube-url' || 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.index &&
start < p.end start < p.end
) )
@ -1345,7 +1416,7 @@ function parseMarkdownContentLegacy(
const end = match.index + match[0].length 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 // 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 => 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.index &&
start < p.end start < p.end
) )
@ -2328,6 +2399,20 @@ function parseMarkdownContentLegacy(
<SpotifyEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} /> <SpotifyEmbeddedPlayer url={url} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div> </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') { } else if (pattern.type === 'zapstream-url') {
const { url } = pattern.data const { url } = pattern.data
parts.push( parts.push(
@ -3776,6 +3861,20 @@ function parseMarkdownContentMarked(
</div> </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)) { if (isZapStreamUrl(cleaned)) {
return ( return (
<div key={`${key}-line-zapstream-${lineIdx}`} className="my-2"> <div key={`${key}-line-zapstream-${lineIdx}`} className="my-2">
@ -3949,6 +4048,20 @@ function parseMarkdownContentMarked(
</div> </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)) { if (isZapStreamUrl(cleaned)) {
return ( return (
<div key={`${key}-zapstream-url`} className="my-2"> <div key={`${key}-zapstream-url`} className="my-2">
@ -4023,6 +4136,20 @@ function parseMarkdownContentMarked(
</div> </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)) { if (soleHref && isZapStreamUrl(soleHref)) {
return ( return (
<div key={`${key}-zapstream-sole-link`} className="my-2"> <div key={`${key}-zapstream-sole-link`} className="my-2">
@ -4112,6 +4239,24 @@ function parseMarkdownContentMarked(
) )
return 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)) { if (cleaned && isZapStreamUrl(cleaned)) {
flushInlineSegment(segmentIdx++) flushInlineSegment(segmentIdx++)
nodes.push( nodes.push(
@ -5463,6 +5608,48 @@ export default function MarkdownArticle({
return spotifyUrls return spotifyUrls
}, [event.id, JSON.stringify(event.tags)]) }, [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 tagZapStreamUrls = useMemo(() => {
const zapUrls: string[] = [] const zapUrls: string[] = []
const seenUrls = new Set<string>() const seenUrls = new Set<string>()
@ -5497,6 +5684,8 @@ export default function MarkdownArticle({
if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url) || isBlossomBudBlobUrl(url)) return if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url) || isBlossomBudBlobUrl(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs if (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return if (isSpotifyUrl(url)) return
if (isWavlakeUrl(url)) return
if (isFountainUrl(url)) return
if (isZapStreamWatchUrl(url)) return if (isZapStreamWatchUrl(url)) return
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
@ -5659,6 +5848,34 @@ export default function MarkdownArticle({
return urls return urls
}, [event.content]) }, [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 zapstreamUrlsInContent = useMemo(() => {
const urls = new Set<string>() const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g const urlRegex = /https?:\/\/[^\s<>"']+/g
@ -5688,6 +5905,8 @@ export default function MarkdownArticle({
!isHlsPlaylistUrl(url) && !isHlsPlaylistUrl(url) &&
!isYouTubeUrl(url) && !isYouTubeUrl(url) &&
!isSpotifyUrl(url) && !isSpotifyUrl(url) &&
!isWavlakeUrl(url) &&
!isFountainUrl(url) &&
!isZapStreamWatchUrl(url) !isZapStreamWatchUrl(url)
) { ) {
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
@ -5755,6 +5974,20 @@ export default function MarkdownArticle({
}) })
}, [tagSpotifyUrls, spotifyUrlsInContent]) }, [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(() => { const leftoverTagZapStreamUrls = useMemo(() => {
return tagZapStreamUrls.filter((canon) => !zapstreamUrlsInContent.has(canon)) return tagZapStreamUrls.filter((canon) => !zapstreamUrlsInContent.has(canon))
}, [tagZapStreamUrls, zapstreamUrlsInContent]) }, [tagZapStreamUrls, zapstreamUrlsInContent])
@ -6193,6 +6426,32 @@ export default function MarkdownArticle({
</div> </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 && ( {leftoverTagZapStreamUrls.length > 0 && (
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
{leftoverTagZapStreamUrls.map((url) => ( {leftoverTagZapStreamUrls.map((url) => (

10
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 { isImage, isVideo, isAudio } from '@/lib/url'
import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url' 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' import { isZapStreamWatchUrl } from '@/lib/zap-stream-url'
/** /**
@ -92,6 +94,14 @@ export function preprocessMarkdownMediaLinks(content: string): string {
continue continue
} }
if (isWavlakeOpenUrl(url)) {
continue
}
if (isFountainOpenUrl(url)) {
continue
}
if (isZapStreamWatchUrl(url)) { if (isZapStreamWatchUrl(url)) {
continue continue
} }

99
src/components/Note/MusicTrackNote.tsx

@ -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] ?? ''
}

5
src/components/Note/index.tsx

@ -1,5 +1,5 @@
import { useSmartNoteNavigationOptional } from '@/PageManager' import { useSmartNoteNavigationOptional } from '@/PageManager'
import { ExtendedKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants' import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { import {
getHttpUrlFromITags, getHttpUrlFromITags,
@ -76,6 +76,7 @@ import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import MusicTrackNote from './MusicTrackNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
import Superchat from './Superchat' import Superchat from './Superchat'
import Zap from './Zap' import Zap from './Zap'
@ -565,6 +566,8 @@ export default function Note({
) )
} else if (event.kind === ExtendedKind.PICTURE) { } else if (event.kind === ExtendedKind.PICTURE) {
content = <PictureNote className="mt-2" event={event} /> content = <PictureNote className="mt-2" event={event} />
} else if (isMusicTrackKind(event.kind)) {
content = <MusicTrackNote className="mt-2" event={event} loadMedia={showFull} />
} else if (isNip71StyleVideoKind(event.kind)) { } else if (isNip71StyleVideoKind(event.kind)) {
content = <VideoNote className="mt-2" event={event} loadMedia={showFull} /> content = <VideoNote className="mt-2" event={event} loadMedia={showFull} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) { } else if (event.kind === ExtendedKind.RELAY_REVIEW) {

7
src/components/NoteCard/RepostNoteCard.tsx

@ -1,7 +1,8 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -28,7 +29,9 @@ export default function RepostNoteCard({
seenOnAllowlist?: readonly string[] seenOnAllowlist?: readonly string[]
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const [targetEvent, setTargetEvent] = useState<Event | null>(null) const [targetEvent, setTargetEvent] = useState<Event | null>(null)
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (!targetEvent) return true if (!targetEvent) return true

7
src/components/NoteCard/index.tsx

@ -1,7 +1,8 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers, isNip18RepostKind, isNip56ReportEvent } from '@/lib/event' import { isMentioningMutedUsers, isNip18RepostKind, isNip56ReportEvent } from '@/lib/event'
import ReportCard from '@/components/ReportCard' import ReportCard from '@/components/ReportCard'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -37,7 +38,9 @@ const NoteCard = memo(function NoteCard({
showPaymentAttestationAction?: boolean showPaymentAttestationAction?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (filterMutedNotes && muteSetHas(mutePubkeySet, event.pubkey)) { if (filterMutedNotes && muteSetHas(mutePubkeySet, event.pubkey)) {
return true return true

76
src/components/NoteList/index.tsx

@ -24,6 +24,7 @@ import { prefetchAuthorNip30EmojisForPubkeys } from '@/lib/nip30-author-emojis'
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { import {
isRelayUrlStrictSupersetIdentityKey, isRelayUrlStrictSupersetIdentityKey,
isSpellSubRequestsFilterSuperset,
isSpellSubRequestsSameFiltersDifferentRelays isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -748,6 +749,12 @@ const NoteList = forwardRef(
* Use a larger value for slow feeds (e.g. notifications `#p` across many relays). * Use a larger value for slow feeds (e.g. notifications `#p` across many relays).
*/ */
timelineLoadingSafetyTimeoutMs, timelineLoadingSafetyTimeoutMs,
/**
* When true, live `onNew` events merge into the visible timeline immediately (home feed behavior).
* Default false on Spells faux feeds: new rows go to {@link NewNotesButton} until the user scrolls near the top.
* Enable for notifications so mentions/replies appear without tapping Show n new notes.
*/
mergeLiveEventsImmediately = false,
/** /**
* With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none. Kindless relay feeds * With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none. Kindless relay feeds
* merge the full batch; {@link withKindFilter} + {@link showAllKinds} control whether {@link showKinds} * merge the full batch; {@link withKindFilter} + {@link showAllKinds} control whether {@link showKinds}
@ -865,6 +872,7 @@ const NoteList = forwardRef(
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number timelineLoadingSafetyTimeoutMs?: number
mergeLiveEventsImmediately?: boolean
clientSideKindFilter?: boolean clientSideKindFilter?: boolean
oneShotFetch?: boolean oneShotFetch?: boolean
oneShotMergedCap?: number oneShotMergedCap?: number
@ -1333,6 +1341,8 @@ const NoteList = forwardRef(
withKindFilterRef.current = withKindFilter withKindFilterRef.current = withKindFilter
const hostPrimaryPageNameRef = useRef(hostPrimaryPageName) const hostPrimaryPageNameRef = useRef(hostPrimaryPageName)
hostPrimaryPageNameRef.current = hostPrimaryPageName hostPrimaryPageNameRef.current = hostPrimaryPageName
const mergeLiveEventsImmediatelyRef = useRef(mergeLiveEventsImmediately)
mergeLiveEventsImmediatelyRef.current = mergeLiveEventsImmediately
const gridLayoutRef = useRef(gridLayout) const gridLayoutRef = useRef(gridLayout)
gridLayoutRef.current = gridLayout gridLayoutRef.current = gridLayout
@ -1444,6 +1454,61 @@ const NoteList = forwardRef(
shouldHideEventRef.current = shouldHideEvent shouldHideEventRef.current = shouldHideEvent
}, [shouldHideEvent]) }, [shouldHideEvent])
/** Paint the author's own publishes into the open feed without waiting for relay echo or "new notes". */
useEffect(() => {
const onOwnPublish = (data: globalThis.Event) => {
const evt = (data as CustomEvent<Event>).detail
if (!evt?.id || !pubkey || evt.pubkey !== pubkey) return
if (shouldHideEventRef.current(evt)) return
const mapped = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey,
mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current)
).filter((req) => req.urls.length > 0)
if (mapped.length === 0) return
if (
!mapped.some(({ filter }) =>
eventMatchesSubRequestFilterWithWindow(evt, filter as Filter)
)
) {
return
}
const narrowed = narrowLiveBatchUsingRefs([evt])
if (narrowed.length === 0) return
if (eventsRef.current.some((e) => e.id === evt.id)) return
const cap = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === evt.id)) return oldEvents
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(oldEvents, narrowed, cap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
setNewEvents((pending) => pending.filter((e) => e.id !== evt.id))
setLoading(false)
client.prefetchEmbeddedEventsForParents(narrowed, {
relayHintsOnly: relayAuthoritativeFeedOnlyRef.current
})
}
client.addEventListener('newEvent', onOwnPublish)
return () => client.removeEventListener('newEvent', onOwnPublish)
}, [
pubkey,
feedSubscriptionKey,
allowKindlessRelayExplore,
areAlgoRelays,
shouldHideEvent
])
const { items: filteredEvents, bufferExhaustedForVisibleQuota } = useMemo(() => { const { items: filteredEvents, bufferExhaustedForVisibleQuota } = useMemo(() => {
const idSet = new Set<string>() const idSet = new Set<string>()
const out: Event[] = [] const out: Event[] = []
@ -2256,7 +2321,8 @@ const NoteList = forwardRef(
!feedScopeChanged && !feedScopeChanged &&
prevSubKey != null && prevSubKey != null &&
(isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || (isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey)) isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey) ||
isSpellSubRequestsFilterSuperset(prevSubKey, subRequestsKey))
const keepExistingTimelineEvents = const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange && preserveTimelineOnSubRequestsChange &&
@ -2266,7 +2332,8 @@ const NoteList = forwardRef(
(prevSubKey === subRequestsKey || (prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch && (mergeTimelineWhenSubRequestFiltersMatch &&
isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))) (isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey) ||
isSpellSubRequestsFilterSuperset(prevSubKey, subRequestsKey))))
prevSubRequestsKeyForTimelineRef.current = subRequestsKey prevSubRequestsKeyForTimelineRef.current = subRequestsKey
/** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */ /** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */
@ -3516,8 +3583,11 @@ const NoteList = forwardRef(
} }
} }
if (shouldHideEventRef.current(event)) return if (shouldHideEventRef.current(event)) return
const isOwnPublish = Boolean(pubkey && event.pubkey === pubkey)
const route: 'profile' | 'home' | 'pending' = const route: 'profile' | 'home' | 'pending' =
(pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event) mergeLiveEventsImmediatelyRef.current || isOwnPublish
? 'home'
: eventMatchesProfileTimelineRequest(event)
? 'profile' ? 'profile'
: hostPrimaryPageNameRef.current === 'feed' : hostPrimaryPageNameRef.current === 'feed'
? 'home' ? 'home'

5
src/components/NoteStats/LikeButton.tsx

@ -340,6 +340,7 @@ export function LikeButtonWithStats({
<Drawer handleOnly open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> <Drawer handleOnly open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
className="max-h-[min(88dvh,calc(100dvh-5rem))]"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return if (t?.closest?.('[data-vaul-overlay]')) return
@ -349,7 +350,9 @@ export function LikeButtonWithStats({
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>React</DrawerTitle> <DrawerTitle>React</DrawerTitle>
</DrawerHeader> </DrawerHeader>
{likeEmojiPicker} <div className="flex min-h-0 w-full max-h-[min(72dvh,calc(100dvh-6rem))] flex-col overflow-hidden px-1 pb-1">
{isEmojiReactionsOpen ? likeEmojiPicker : null}
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
</> </>

3
src/components/OthersRelayList/index.tsx

@ -1,5 +1,7 @@
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { kinds } from 'nostr-tools'
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks' import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
@ -19,6 +21,7 @@ export default function OthersRelayList({ userId }: { userId: string }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<RelaySettingsKindNotice kinds={[kinds.RelayList]} variant="view" />
{showingRelayListFallback && ( {showingRelayListFallback && (
<p <p
className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground" className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground"

4
src/components/PaytoDialog/index.tsx

@ -88,7 +88,11 @@ export default function PaytoDialog({
setCompletedPaymentDetails(null) setCompletedPaymentDetails(null)
closeModal() closeModal()
releaseBodyScrollLocks() releaseBodyScrollLocks()
return
} }
// Nested on ZapDialog: Radix scroll-lock can block clicks on this dialog.
const id = requestAnimationFrame(() => releaseBodyScrollLocks())
return () => cancelAnimationFrame(id)
}, [open]) }, [open])
useEffect(() => { useEffect(() => {

60
src/components/RelaySettingsKindNotice/index.tsx

@ -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>
)
}

7
src/components/ReplyNote/index.tsx

@ -18,7 +18,8 @@ import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -59,7 +60,9 @@ export default function ReplyNote({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
const reactionDisplay = useNotificationReactionDisplay(event) const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo( const webReactionParentUrl = useMemo(

7
src/components/ReplyNoteList/index.tsx

@ -20,7 +20,8 @@ import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
@ -109,7 +110,9 @@ function ReplyNoteList({
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const hideContentMentioningMutedUsers =
useContentPolicyOptional()?.hideContentMentioningMutedUsers ??
storage.getHideContentMentioningMutedUsers()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()

60
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 <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 }

2
src/components/WebPreview/index.tsx

@ -44,6 +44,8 @@ function getEventTypeName(kind: number): string {
return 'Comment' return 'Comment'
case ExtendedKind.VOICE: case ExtendedKind.VOICE:
return 'Voice Post' return 'Voice Post'
case ExtendedKind.MUSIC_TRACK:
return 'Music Track'
case ExtendedKind.VOICE_COMMENT: case ExtendedKind.VOICE_COMMENT:
return 'Voice Comment' return 'Voice Comment'
case kinds.Highlights: case kinds.Highlights:

24
src/components/ZapDialog/Nip57QuickZapButton.tsx

@ -3,30 +3,44 @@ import { Skeleton } from '@/components/ui/skeleton'
import { superchatLightningAccentClass } from '@/lib/superchat-ui' import { superchatLightningAccentClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function Nip57QuickZapButton({ export default function Nip57QuickZapButton({
label, label,
zapping, zapping,
onClick, onClick,
onCancel,
className className
}: { }: {
label: string label: string
zapping: boolean zapping: boolean
onClick: () => void onClick: () => void
onCancel: () => void
className?: string className?: string
}) { }) {
const { t } = useTranslation()
if (zapping) {
return (
<div className={cn('mb-3 flex gap-2', className)}>
<Button type="button" className="min-w-0 flex-1 justify-start gap-2" disabled>
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
<span className="min-w-0 truncate">{label}</span>
</Button>
<Button type="button" variant="outline" className="shrink-0" onClick={onCancel}>
{t('Cancel')}
</Button>
</div>
)
}
return ( return (
<Button <Button
type="button" type="button"
className={cn('mb-3 w-full justify-start gap-2', className)} className={cn('mb-3 w-full justify-start gap-2', className)}
onClick={onClick} onClick={onClick}
disabled={zapping}
> >
{zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap className={cn('size-4 shrink-0', superchatLightningAccentClass)} aria-hidden /> <Zap className={cn('size-4 shrink-0', superchatLightningAccentClass)} aria-hidden />
)}
<span className="min-w-0 truncate">{label}</span> <span className="min-w-0 truncate">{label}</span>
</Button> </Button>
) )

9
src/components/ZapDialog/index.tsx

@ -95,7 +95,7 @@ export default function ZapDialog({
return groupPaymentMethodsForDisplay(merged, senderPaytoTypes) return groupPaymentMethodsForDisplay(merged, senderPaytoTypes)
}, [recipientPayment, senderPaytoTypes]) }, [recipientPayment, senderPaytoTypes])
const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({ const { canQuickNip57Zap, quickZapLabel, sendQuickZap, cancelZap, zapping } = useNip57QuickZap({
enabled: open, enabled: open,
recipientPubkey: pubkey, recipientPubkey: pubkey,
referencedEvent: event, referencedEvent: event,
@ -108,7 +108,12 @@ export default function ZapDialog({
paymentGroups.length > 0 || canQuickNip57Zap ? ( paymentGroups.length > 0 || canQuickNip57Zap ? (
<> <>
{canQuickNip57Zap ? ( {canQuickNip57Zap ? (
<Nip57QuickZapButton label={quickZapLabel} zapping={zapping} onClick={sendQuickZap} /> <Nip57QuickZapButton
label={quickZapLabel}
zapping={zapping}
onClick={sendQuickZap}
onCancel={cancelZap}
/>
) : null} ) : null}
{paymentGroups.length > 0 ? ( {paymentGroups.length > 0 ? (
<PaymentMethodsSection <PaymentMethodsSection

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

@ -8,7 +8,10 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollBarClassName?: string } React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollBarClassName?: string }
>(({ className, scrollBarClassName, children, ...props }, ref) => ( >(({ className, scrollBarClassName, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}> <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} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar className={scrollBarClassName} /> <ScrollBar className={scrollBarClassName} />

23
src/constants.ts

@ -74,6 +74,7 @@ export const DESKTOP_APP_DOWNLOAD_URL_DEFAULT =
export const DEFAULT_FAVORITE_RELAYS = [ export const DEFAULT_FAVORITE_RELAYS = [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://nostr.land', 'wss://nostr.land',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today'
] ]
/** /**
@ -424,6 +425,8 @@ export const FONT_SIZE = {
LARGE: 'large' LARGE: 'large'
} as const } as const
export const DEFAULT_FONT_SIZE = FONT_SIZE.LARGE
/** /**
* Random public relays (from NIP-66 lively list; write-tested monitors preferred) merged into the * Random public relays (from NIP-66 lively list; write-tested monitors preferred) merged into the
* publish relay picker. More candidates improve odds some accept open writes. * publish relay picker. More candidates improve odds some accept open writes.
@ -554,7 +557,7 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://profiles.nostrver.se/', 'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://indexer.coracle.social/' 'wss://indexer.coracle.social/'
] ]
@ -569,6 +572,8 @@ export const ExtendedKind = {
SHORT_VIDEO: 22, SHORT_VIDEO: 22,
/** NIP-71: addressable normal video (same rendering as {@link ExtendedKind.VIDEO}). */ /** NIP-71: addressable normal video (same rendering as {@link ExtendedKind.VIDEO}). */
VIDEO_ADDRESSABLE: 34235, VIDEO_ADDRESSABLE: 34235,
/** Music track (addressable): audio URL + metadata in tags; kind 36787. */
MUSIC_TRACK: 36787,
POLL: 1068, POLL: 1068,
/** NIP-B9 zap poll (paid votes via zaps). */ /** NIP-B9 zap poll (paid votes via zaps). */
ZAP_POLL: 6969, ZAP_POLL: 6969,
@ -725,6 +730,10 @@ export function isNip71StyleVideoKind(kind: number): boolean {
return NIP71_VIDEO_KIND_SET.has(kind) return NIP71_VIDEO_KIND_SET.has(kind)
} }
export function isMusicTrackKind(kind: number): boolean {
return kind === ExtendedKind.MUSIC_TRACK
}
/** /**
* When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event * When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event
* author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm. * author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm.
@ -948,6 +957,7 @@ export const SUPPORTED_KINDS = [
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
// ExtendedKind.PUBLIC_MESSAGE, // Excluded - public messages should only appear in notifications // ExtendedKind.PUBLIC_MESSAGE, // Excluded - public messages should only appear in notifications
kinds.Highlights, kinds.Highlights,
@ -1001,7 +1011,8 @@ const PROFILE_PUBLICATIONS_TAB_KIND_SET = new Set<number>(PROFILE_PUBLICATIONS_T
export const PROFILE_MEDIA_TAB_KINDS: readonly number[] = [ export const PROFILE_MEDIA_TAB_KINDS: readonly number[] = [
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
...NIP71_VIDEO_KINDS, ...NIP71_VIDEO_KINDS,
ExtendedKind.VOICE ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK
] ]
/** Home feed Gallery tab: picture + NIP-71 video only (20, 21, 22, 34235). */ /** Home feed Gallery tab: picture + NIP-71 video only (20, 21, 22, 34235). */
@ -1071,6 +1082,14 @@ export const YOUTUBE_URL_REGEX =
export const SPOTIFY_OPEN_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 /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…`). */ /** zap.stream live player: path must be a bare NIP-19 naddr (`/naddr1…`). */
export const ZAP_STREAM_WATCH_URL_REGEX = export const ZAP_STREAM_WATCH_URL_REGEX =
/https?:\/\/(?:www\.)?zap\.stream\/(naddr1[02-9ac-hj-np-z]+)(?:\?[^\s#]*)?(?:#[^\s]*)?/gi /https?:\/\/(?:www\.)?zap\.stream\/(naddr1[02-9ac-hj-np-z]+)(?:\?[^\s#]*)?(?:#[^\s]*)?/gi

98
src/hooks/useFetchCalendarRsvps.tsx

@ -1,25 +1,23 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isCalendarEventKind } from '@/lib/calendar-event'
import {
calendarEventHexId,
calendarRsvpMatchesCalendarEvent,
parseCalendarRsvpStatus
} from '@/lib/calendar-rsvp-match'
import { import {
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { isCalendarEventKind } from '@/lib/calendar-event' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import client from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { tagNameEquals } from '@/lib/tag'
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined {
const status = rsvp.tags.find(tagNameEquals('status'))?.[1]
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status
return undefined
}
function mergeRsvp(prev: Event[], evt: Event): Event[] { function mergeRsvp(prev: Event[], evt: Event): Event[] {
const next = prev.filter((e) => e.id !== evt.id) const next = prev.filter((e) => e.id !== evt.id)
@ -38,11 +36,25 @@ function mergeRsvpList(events: Event[]): Event[] {
return acc return acc
} }
function filterMatchingRsvps(calendarEvent: Event, events: Event[]): Event[] {
return events.filter((ev) => calendarRsvpMatchesCalendarEvent(calendarEvent, ev))
}
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList, cacheRelayListEvent } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([]) const [rsvps, setRsvps] = useState<Event[]>([])
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const applyRsvp = useCallback(
(evt: Event) => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return
if (!calendarRsvpMatchesCalendarEvent(calendarEvent, evt)) return
void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined)
setRsvps((prev) => mergeRsvp(prev, evt))
},
[calendarEvent]
)
useEffect(() => { useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) { if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) {
setRsvps([]) setRsvps([])
@ -52,34 +64,35 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
let cancelled = false let cancelled = false
setIsFetching(true) setIsFetching(true)
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const userRead = userReadInboxUrls(relayList, cacheRelayListEvent) const userRead = userReadInboxUrls(relayList, cacheRelayListEvent)
const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent) const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent)
void (async () => { void (async () => {
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) const fromSession = filterMatchingRsvps(
calendarEvent,
client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)
)
setRsvps(mergeRsvpList(fromSession)) setRsvps(mergeRsvpList(fromSession))
const idbP = indexedDb const idbP = indexedDb
.getCalendarRsvpEventsByParentCoordinate(coordinate) .getCalendarRsvpEventsForCalendarEvent(calendarEvent)
.catch((): Event[] => []) .catch((): Event[] => [])
void idbP.then((rows) => { void idbP.then((rows) => {
if (cancelled) return if (cancelled) return
setRsvps(mergeRsvpList([...rows, ...fromSession])) setRsvps(mergeRsvpList(filterMatchingRsvps(calendarEvent, [...rows, ...fromSession])))
}) })
const baseUrls = new Set<string>([ const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...userRead.map((url) => normalizeAnyRelayUrl(url) || url), ...userRead.map((url) => normalizeAnyRelayUrl(url) || url),
...userWrite.map((url) => normalizeAnyRelayUrl(url) || url) ...userWrite.map((url) => normalizeAnyRelayUrl(url) || url),
...relayHintsFromEventTags(calendarEvent).map((url) => normalizeAnyRelayUrl(url) || url),
...client.getSeenEventRelayUrls(calendarEvent.id).map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]) ].filter(Boolean) as string[])
const organizerPubkey = calendarEvent.pubkey const organizerPubkey = calendarEvent.pubkey
try { try {
let relayUrls: string[]
try { try {
const organizerRelays = await client.fetchRelayList(organizerPubkey) const organizerRelays = await client.fetchRelayList(organizerPubkey)
if (!cancelled) { if (!cancelled) {
@ -93,17 +106,17 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
if (u) baseUrls.add(u) if (u) baseUrls.add(u)
}) })
} }
relayUrls = Array.from(baseUrls)
} catch { } catch {
relayUrls = Array.from(baseUrls) // keep baseUrls
} }
if (cancelled) return if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id) const coordinate = normalizeReplaceableCoordinateString(
? calendarEvent.id.toLowerCase() getReplaceableCoordinateFromEvent(calendarEvent)
: calendarEvent.id )
const calendarHexId = calendarEventHexId(calendarEvent)
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, Array.from(baseUrls),
[ [
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
@ -123,12 +136,16 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
} }
) )
if (cancelled) return if (cancelled) return
const fromRelay = events ?? [] const fromRelay = filterMatchingRsvps(calendarEvent, events ?? [])
const fromIdb = await idbP const fromIdb = await idbP
await Promise.allSettled( await Promise.allSettled(
fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined)) fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined))
) )
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...fromRelay])) setRsvps(
mergeRsvpList(
filterMatchingRsvps(calendarEvent, [...fromIdb, ...fromSession, ...fromRelay])
)
)
} finally { } finally {
if (!cancelled) setIsFetching(false) if (!cancelled) setIsFetching(false)
} }
@ -137,38 +154,23 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey, relayList]) }, [calendarEvent, relayList, cacheRelayListEvent])
// When we publish an RSVP, NostrProvider calls client.emitNewEvent(event). Merge it into rsvps so the UI updates immediately.
useEffect(() => { useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const handler = (e: CustomEvent<Event>) => { const handler = (e: CustomEvent<Event>) => {
const evt = e.detail applyRsvp(e.detail)
if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const aTag = evt.tags.find(tagNameEquals('a'))
const aCoord = aTag?.[1] ? normalizeReplaceableCoordinateString(aTag[1]) : ''
const eTag = evt.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase()
const matchesA = aCoord !== '' && aCoord === coordinate
const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId
if (!matchesA && !matchesE) return
void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined)
setRsvps((prev) => mergeRsvp(prev, evt))
} }
client.addEventListener('newEvent', handler as EventListener) client.addEventListener('newEvent', handler as EventListener)
return () => client.removeEventListener('newEvent', handler as EventListener) return () => client.removeEventListener('newEvent', handler as EventListener)
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey]) }, [calendarEvent, applyRsvp])
return { return {
rsvps, rsvps,
isFetching, isFetching,
getRsvpStatus getRsvpStatus: parseCalendarRsvpStatus,
applyRsvp
} }
} }

29
src/hooks/useNip57QuickZap.ts

@ -6,7 +6,7 @@ import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatSatsGrouped } from '@/lib/lightning' import { formatSatsGrouped } from '@/lib/lightning'
@ -20,11 +20,22 @@ export function useNip57QuickZap(opts: {
onZapDialogClose?: () => void onZapDialogClose?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, account, checkLogin } = useNostr()
const { isWalletConnected, defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap() const isLoggedIn = Boolean(pubkey && account && account.signerType !== 'npub')
const { defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap()
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const ignoreResultRef = useRef(false)
const enabled = opts.enabled ?? false const enabled = opts.enabled ?? false
useEffect(() => {
if (enabled) {
ignoreResultRef.current = false
return
}
ignoreResultRef.current = true
setZapping(false)
}, [enabled])
const lightningAddressOptionsKey = useMemo( const lightningAddressOptionsKey = useMemo(
() => () =>
buildOrderedZapLightningAddresses({ buildOrderedZapLightningAddresses({
@ -66,11 +77,10 @@ export function useNip57QuickZap(opts: {
const canQuickNip57Zap = const canQuickNip57Zap =
enabled && enabled &&
isWalletConnected && isLoggedIn &&
defaultZapSats >= 1 && defaultZapSats >= 1 &&
nip57Addresses !== null && nip57Addresses !== null &&
nip57Addresses.length > 0 && nip57Addresses.length > 0 &&
!!pubkey &&
pubkey !== opts.recipientPubkey pubkey !== opts.recipientPubkey
const recipientNpubLabel = useMemo(() => { const recipientNpubLabel = useMemo(() => {
@ -83,10 +93,16 @@ export function useNip57QuickZap(opts: {
n: formatSatsGrouped(defaultZapSats) n: formatSatsGrouped(defaultZapSats)
}) })
const cancelZap = useCallback(() => {
ignoreResultRef.current = true
setZapping(false)
}, [])
const sendQuickZap = useCallback(() => { const sendQuickZap = useCallback(() => {
if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return
checkLogin(async () => { checkLogin(async () => {
if (!pubkey) return if (!pubkey) return
ignoreResultRef.current = false
try { try {
setZapping(true) setZapping(true)
const zapResult = await lightning.zap( const zapResult = await lightning.zap(
@ -101,7 +117,7 @@ export function useNip57QuickZap(opts: {
candidates: nip57Addresses candidates: nip57Addresses
} }
) )
if (!zapResult) return if (ignoreResultRef.current || !zapResult) return
if (includePublicZapReceipt && zapResult.zapReceipt === null) { if (includePublicZapReceipt && zapResult.zapReceipt === null) {
toast.warning( toast.warning(
t( t(
@ -141,6 +157,7 @@ export function useNip57QuickZap(opts: {
canQuickNip57Zap, canQuickNip57Zap,
quickZapLabel, quickZapLabel,
sendQuickZap, sendQuickZap,
cancelZap,
zapping zapping
} }
} }

9
src/i18n/locales/de.ts

@ -842,6 +842,15 @@ export default {
'HTTP relays': 'HTTP-Relays', 'HTTP relays': 'HTTP-Relays',
httpRelaysDescription: httpRelaysDescription:
'HTTPS-Index-Relays (z. B. REST /api/events/filter). Gleiche Lese-/Schreib-/beides-Rollen wie Mailbox-Relays; gespeichert als Kind 10243. Liste leeren und speichern, um eine leere Liste zu veröffentlichen.', 'HTTPS-Index-Relays (z. B. REST /api/events/filter). Gleiche Lese-/Schreib-/beides-Rollen wie Mailbox-Relays; gespeichert als Kind 10243. Liste leeren und speichern, um eine leere Liste zu veröffentlichen.',
relaySettingsEventKindsLabel: 'Nostr-Event-Kind',
relaySettingsEventKindsEditOne:
'Speichern veröffentlicht hier ein ersetzbares Listen-Event dieses Kinds auf deinen Relays.',
relaySettingsEventKindsEditMany:
'Speichern veröffentlicht hier ersetzbare Listen-Events dieser Kinds auf deinen Relays.',
relaySettingsEventKindsView:
'Zeigt die veröffentlichte Relay-Liste dieses Nutzers aus Kind {{kinds}}, sofern der Client sie geladen hat.',
relaySettingsEventKindsSession:
'Kein ersetzbares Relay-Listen-Event — dieser Tab zeigt nur Session-Bewertung und Strafen im Speicher (nichts wird veröffentlicht).',
'HTTP relays saved': 'HTTP-Relays gespeichert', 'HTTP relays saved': 'HTTP-Relays gespeichert',
'Failed to save HTTP relay list': 'HTTP-Relay-Liste konnte nicht gespeichert werden', 'Failed to save HTTP relay list': 'HTTP-Relay-Liste konnte nicht gespeichert werden',
'HTTP relays must start with https:// or http://': 'HTTP relays must start with https:// or http://':

9
src/i18n/locales/en.ts

@ -862,6 +862,15 @@ export default {
'HTTP relays': 'HTTP relays', 'HTTP relays': 'HTTP relays',
httpRelaysDescription: httpRelaysDescription:
'HTTPS index relays (e.g. REST /api/events/filter). Same read/write/both roles as mailbox relays; stored as kind 10243. Clear the list and save to publish an empty list.', 'HTTPS index relays (e.g. REST /api/events/filter). Same read/write/both roles as mailbox relays; stored as kind 10243. Clear the list and save to publish an empty list.',
relaySettingsEventKindsLabel: 'Nostr event kind',
relaySettingsEventKindsEditOne:
'Saving here publishes a replaceable list event of this kind to your relays.',
relaySettingsEventKindsEditMany:
'Saving here publishes replaceable list events of these kinds to your relays.',
relaySettingsEventKindsView:
'Shows this user’s published relay list from kind {{kinds}} when the client has fetched it.',
relaySettingsEventKindsSession:
'No replaceable relay list event — this tab only shows in-memory session relay scoring and strikes (nothing is published).',
'HTTP relays saved': 'HTTP relays saved', 'HTTP relays saved': 'HTTP relays saved',
'Failed to save HTTP relay list': 'Failed to save HTTP relay list', 'Failed to save HTTP relay list': 'Failed to save HTTP relay list',
'HTTP relays must start with https:// or http://': 'HTTP relays must start with https:// or http://':

42
src/index.css

@ -25,6 +25,20 @@
} }
} }
/* Mobile: lock document scroll; feeds and note panels scroll inside .page-scroll-y regions. */
@media (max-width: 768px) {
html,
body {
height: 100%;
overflow: hidden;
}
#root {
height: 100%;
min-height: 0;
overflow: hidden;
}
}
input, input,
textarea, textarea,
button { button {
@ -96,27 +110,51 @@
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
/* Popover / select lists: keep a visible vertical scrollbar (not overlay-only). */ /*
* Primary/secondary pages, popovers, selects: visible vertical scrollbar (not overlay-only).
* Pair with Tailwind overflow-y-scroll on the same element.
*/
.page-scroll-y,
.popover-scroll-y { .popover-scroll-y {
overflow-y: scroll;
scrollbar-gutter: stable; scrollbar-gutter: stable;
scrollbar-width: thin; scrollbar-width: thin;
} }
.page-scroll-y::-webkit-scrollbar,
.popover-scroll-y::-webkit-scrollbar { .popover-scroll-y::-webkit-scrollbar {
width: 10px; width: 10px;
} }
.page-scroll-y::-webkit-scrollbar-thumb,
.popover-scroll-y::-webkit-scrollbar-thumb { .popover-scroll-y::-webkit-scrollbar-thumb {
border-radius: 9999px; border-radius: 9999px;
background-color: hsl(var(--muted-foreground) / 0.35); background-color: hsl(var(--muted-foreground) / 0.35);
} }
.page-scroll-y::-webkit-scrollbar-thumb:hover,
.popover-scroll-y::-webkit-scrollbar-thumb:hover { .popover-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5); background-color: hsl(var(--muted-foreground) / 0.5);
} }
.page-scroll-y::-webkit-scrollbar-track,
.popover-scroll-y::-webkit-scrollbar-track { .popover-scroll-y::-webkit-scrollbar-track {
border-radius: 9999px; border-radius: 9999px;
background-color: hsl(var(--muted) / 0.45); background-color: hsl(var(--muted) / 0.45);
} }
/* Narrow viewports: stronger scroll affordance (feed + note panels use inner scroll). */
@media (max-width: 768px) {
.page-scroll-y {
-webkit-overflow-scrolling: touch;
scrollbar-width: auto;
}
.page-scroll-y::-webkit-scrollbar {
width: 12px;
}
.page-scroll-y::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.55);
}
.page-scroll-y::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.7);
}
}
/* /*
* Radix Select injects a sibling <style> that hides scrollbars on the viewport. * Radix Select injects a sibling <style> that hides scrollbars on the viewport.
* That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select). * That sheet loads after app CSS, so undo it with !important (see VIEWPORT_NAME in @radix-ui/react-select).

64
src/layouts/PrimaryPageLayout/index.tsx

@ -13,10 +13,11 @@ import {
} from '@/lib/keyboard-shortcuts' } from '@/lib/keyboard-shortcuts'
import { import {
peekMobilePrimaryFeedScroll, peekMobilePrimaryFeedScroll,
registerMobilePrimaryFeedScrollElement,
saveMobilePrimaryFeedScroll saveMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll' } from '@/lib/mobile-primary-feed-scroll'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
const PrimaryPageLayout = forwardRef( const PrimaryPageLayout = forwardRef(
( (
@ -39,7 +40,6 @@ const PrimaryPageLayout = forwardRef(
ref ref
) => { ) => {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const smallScreenScrollAreaRef = useRef<HTMLDivElement>(null)
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current, display, frozen } = usePrimaryPage() const { current, display, frozen } = usePrimaryPage()
const savedScrollTopRef = useRef(0) const savedScrollTopRef = useRef(0)
@ -50,34 +50,45 @@ const PrimaryPageLayout = forwardRef(
() => ({ () => ({
scrollToTop: (behavior: ScrollBehavior = 'smooth') => { scrollToTop: (behavior: ScrollBehavior = 'smooth') => {
setTimeout(() => { setTimeout(() => {
if (scrollAreaRef.current) { scrollAreaRef.current?.scrollTo({ top: 0, behavior })
return scrollAreaRef.current.scrollTo({ top: 0, behavior })
}
window.scrollTo({ top: 0, behavior })
}, 10) }, 10)
} }
}), }),
[] []
) )
useLayoutEffect(() => {
if (!isSmallScreen) {
registerMobilePrimaryFeedScrollElement(null)
return
}
registerMobilePrimaryFeedScrollElement(scrollAreaRef.current)
return () => registerMobilePrimaryFeedScrollElement(null)
}, [isSmallScreen, display, current, pageName])
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName || frozen) return if (!isSmallScreen || current !== pageName) return
const el = scrollAreaRef.current
if (!el) return
const handleScroll = () => { const handleScroll = () => {
saveMobilePrimaryFeedScroll(pageName, window.scrollY) saveMobilePrimaryFeedScroll(pageName, el.scrollTop)
} }
window.addEventListener('scroll', handleScroll, { passive: true }) el.addEventListener('scroll', handleScroll, { passive: true })
return () => { return () => {
handleScroll() handleScroll()
window.removeEventListener('scroll', handleScroll) el.removeEventListener('scroll', handleScroll)
} }
}, [current, frozen, isSmallScreen, pageName]) }, [current, isSmallScreen, pageName])
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName || !display) return if (!isSmallScreen || current !== pageName || !display) return
const top = peekMobilePrimaryFeedScroll(pageName) const top = peekMobilePrimaryFeedScroll(pageName)
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' }) if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = top
}
}) })
}, [current, display, isSmallScreen, pageName]) }, [current, display, isSmallScreen, pageName])
@ -125,25 +136,32 @@ const PrimaryPageLayout = forwardRef(
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DeepBrowsingProvider active={current === pageName && display && !frozen}> <DeepBrowsingProvider
<div active={current === pageName && display && !frozen}
ref={smallScreenScrollAreaRef} scrollAreaRef={scrollAreaRef}
className="min-w-0 w-full overflow-x-hidden"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
> >
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{hasTitlebarRow ? ( {hasTitlebarRow ? (
<PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}> <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}>
{titlebar} {titlebar}
</PrimaryPageTitlebar> </PrimaryPageTitlebar>
) : null} ) : null}
{subHeader && <div className="shrink-0 w-full min-w-0 bg-background">{subHeader}</div>} {subHeader ? (
<div className="min-w-0 w-full"> <div className="min-w-0 shrink-0 border-b border-border/80 bg-background">
{subHeader}
</div>
) : null}
<div
ref={scrollAreaRef}
className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-hidden overscroll-y-contain touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{children} {children}
</div> </div>
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton />} {displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider> </DeepBrowsingProvider>
) )
} }
@ -165,7 +183,7 @@ const PrimaryPageLayout = forwardRef(
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto" className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-auto overscroll-y-contain"
> >
{children} {children}
<div className="h-4" /> <div className="h-4" />

32
src/layouts/SecondaryPageLayout/index.tsx

@ -62,11 +62,9 @@ const SecondaryPageLayout = forwardRef(
) )
useEffect(() => { useEffect(() => {
if (isSmallScreen) { if (!isSmallScreen) return
setTimeout(() => window.scrollTo({ top: 0 }), 10) setTimeout(() => scrollAreaRef.current?.scrollTo({ top: 0 }), 10)
return }, [isSmallScreen])
}
}, [])
useEffect(() => { useEffect(() => {
if (isSmallScreen) return if (isSmallScreen) return
@ -88,13 +86,8 @@ const SecondaryPageLayout = forwardRef(
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DeepBrowsingProvider active={currentIndex === index}> <DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{shouldRenderTitlebar ? ( {shouldRenderTitlebar ? (
<SecondaryPageTitlebar <SecondaryPageTitlebar
title={title} title={title}
@ -102,19 +95,26 @@ const SecondaryPageLayout = forwardRef(
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
hideBottomBorder={hideTitlebarBottomBorder} hideBottomBorder={hideTitlebarBottomBorder}
titlebar={titlebar} titlebar={titlebar}
sticky={isSmallScreen}
/> />
) : null} ) : null}
<div
ref={scrollAreaRef}
className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-hidden overscroll-y-contain touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}
>
{children} {children}
</div> </div>
{displayScrollToTopButton && <ScrollToTopButton />} </div>
{displayScrollToTopButton && <ScrollToTopButton scrollAreaRef={scrollAreaRef} />}
</DeepBrowsingProvider> </DeepBrowsingProvider>
) )
} }
return ( return (
<DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}> <DeepBrowsingProvider active={currentIndex === index} scrollAreaRef={scrollAreaRef}>
<div className="flex h-full min-h-0 min-w-0 flex-col"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{shouldRenderTitlebar ? ( {shouldRenderTitlebar ? (
<SecondaryPageTitlebar <SecondaryPageTitlebar
title={title} title={title}
@ -127,7 +127,7 @@ const SecondaryPageLayout = forwardRef(
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
tabIndex={-1} tabIndex={-1}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-auto" className="page-scroll-y min-h-0 min-w-0 flex-1 basis-0 overflow-y-scroll overflow-x-auto overscroll-y-contain"
> >
{children} {children}
<div className="h-12" /> <div className="h-12" />

105
src/lib/calendar-rsvp-match.test.ts

@ -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('')
})
})

38
src/lib/calendar-rsvp-match.ts

@ -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}` : ''
}

8
src/lib/content-parser.ts

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

22
src/lib/error-suppression.ts

@ -115,6 +115,15 @@ function isExpectedDevAppNoise(message: string): boolean {
) { ) {
return true return true
} }
if (
message.includes('feeds.nostrarchives.com') &&
(message.includes('CORS') ||
message.includes('Gleiche-Quelle') ||
message.includes('Cross-Origin') ||
message.includes('Access-Control-Allow-Origin'))
) {
return true
}
if ( if (
message.includes('localhost:4869') || message.includes('localhost:4869') ||
message.includes('127.0.0.1:4869') || message.includes('127.0.0.1:4869') ||
@ -126,11 +135,22 @@ function isExpectedDevAppNoise(message: string): boolean {
message.includes('[RelayOp]') || message.includes('[RelayOp]') ||
message.includes('connection failed') || message.includes('connection failed') ||
message.includes('connection timed out') || message.includes('connection timed out') ||
message.includes('Local relay connection timeout') message.includes('Local relay connection timeout') ||
message.includes('kann keine Verbindung') ||
message.includes('can\'t establish a connection') ||
message.includes("can't establish a connection")
) { ) {
return true return true
} }
} }
if (
message.includes('profiles.nostrver.se') &&
(message.includes('kann keine Verbindung') ||
message.includes('can\'t establish a connection') ||
message.includes("can't establish a connection"))
) {
return true
}
if (message.includes('[FetchRelayLists] Network relay-list fetch exceeded budget')) { if (message.includes('[FetchRelayLists] Network relay-list fetch exceeded budget')) {
return true return true
} }

1
src/lib/feed-kind-filter.test.ts

@ -36,6 +36,7 @@ describe('feed kind groups', () => {
expect(on.showKinds).toContain(ExtendedKind.DISCUSSION) expect(on.showKinds).toContain(ExtendedKind.DISCUSSION)
expect(on.showKinds).toContain(ExtendedKind.PICTURE) expect(on.showKinds).toContain(ExtendedKind.PICTURE)
expect(on.showKinds).toContain(ExtendedKind.VOICE) expect(on.showKinds).toContain(ExtendedKind.VOICE)
expect(on.showKinds).toContain(ExtendedKind.MUSIC_TRACK)
expect(isFeedPostsGroupEnabled(on.showKind1OPs, on.showKinds)).toBe(true) expect(isFeedPostsGroupEnabled(on.showKind1OPs, on.showKinds)).toBe(true)
}) })

3
src/lib/feed-kind-filter.ts

@ -8,7 +8,8 @@ export const FEED_POSTS_GROUP_KINDS: readonly number[] = [
kinds.Highlights, kinds.Highlights,
ExtendedKind.DISCUSSION, ExtendedKind.DISCUSSION,
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
ExtendedKind.VOICE ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK
] ]
/** Kind 1 replies, comments, voice comments, superchats — feed filter “Replies” group. */ /** Kind 1 replies, comments, voice comments, superchats — feed filter “Replies” group. */

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

@ -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)
})
})

37
src/lib/fountain-url.ts

@ -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
}

2
src/lib/kind-description.ts

@ -28,6 +28,8 @@ export function getKindDescription(
return { number: 1111, description: 'Comment' } return { number: 1111, description: 'Comment' }
case ExtendedKind.VOICE: case ExtendedKind.VOICE:
return { number: 1222, description: 'Voice Note' } return { number: 1222, description: 'Voice Note' }
case ExtendedKind.MUSIC_TRACK:
return { number: 36787, description: 'Music Track' }
case ExtendedKind.VOICE_COMMENT: case ExtendedKind.VOICE_COMMENT:
return { number: 1244, description: 'Voice Comment' } return { number: 1244, description: 'Voice Comment' }
case ExtendedKind.PICTURE: case ExtendedKind.PICTURE:

17
src/lib/mobile-primary-feed-scroll.ts

@ -1,8 +1,15 @@
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
/** Persist primary feed window scroll across mobile secondary unmount (PageManager hides the feed while a panel is open). */ /** Persist primary feed scroll across mobile secondary navigation. */
const scrollByPage = new Map<TPrimaryPageName, number>() const scrollByPage = new Map<TPrimaryPageName, number>()
/** Primary feed scroll container when using inner scroll (not window). */
let registeredScrollElement: HTMLElement | null = null
export function registerMobilePrimaryFeedScrollElement(el: HTMLElement | null): void {
registeredScrollElement = el
}
export function saveMobilePrimaryFeedScroll(page: TPrimaryPageName, top: number): void { export function saveMobilePrimaryFeedScroll(page: TPrimaryPageName, top: number): void {
if (!Number.isFinite(top) || top < 0) return if (!Number.isFinite(top) || top < 0) return
scrollByPage.set(page, top) scrollByPage.set(page, top)
@ -12,6 +19,12 @@ export function peekMobilePrimaryFeedScroll(page: TPrimaryPageName): number {
return scrollByPage.get(page) ?? 0 return scrollByPage.get(page) ?? 0
} }
export function captureMobilePrimaryFeedScroll(page: TPrimaryPageName): void {
const top = registeredScrollElement?.scrollTop ?? window.scrollY
saveMobilePrimaryFeedScroll(page, top)
}
/** @deprecated Use captureMobilePrimaryFeedScroll */
export function captureMobilePrimaryFeedScrollFromWindow(page: TPrimaryPageName): void { export function captureMobilePrimaryFeedScrollFromWindow(page: TPrimaryPageName): void {
saveMobilePrimaryFeedScroll(page, window.scrollY) captureMobilePrimaryFeedScroll(page)
} }

243
src/lib/music-track.test.ts

@ -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'
)
})
})

119
src/lib/music-track.ts

@ -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(' · ')
}

14
src/lib/new-user-template.test.ts

@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { NEW_USER_BLOCKED_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, buildNewUserTemplateDrafts, newUserProfileDisplayName, newUserProfileName, newUserProfileSuffix } from '@/lib/new-user-template' import {
NEW_USER_BLOCKED_RELAY_URLS,
NEW_USER_HTTP_RELAY_URL,
NEW_USER_TRENDING_RELAY_URL,
buildNewUserTemplateDrafts,
newUserProfileDisplayName,
newUserProfileName,
newUserProfileSuffix
} from '@/lib/new-user-template'
import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast' import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import type { TRelayList } from '@/types' import type { TRelayList } from '@/types'
@ -55,7 +63,9 @@ describe('buildNewUserTemplateDrafts', () => {
it('builds favorite relays kind 10012', () => { it('builds favorite relays kind 10012', () => {
expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS) expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS)
expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2) const favorites = drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay').map((t) => t[1])
expect(favorites).toContain(NEW_USER_TRENDING_RELAY_URL)
expect(favorites).toHaveLength(3)
}) })
it('builds blocked relays kind 10006 with dead relays', () => { it('builds blocked relays kind 10006 with dead relays', () => {

9
src/lib/new-user-template.ts

@ -3,6 +3,7 @@ import {
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS FAST_WRITE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { import {
createBlockedRelaysDraftEvent, createBlockedRelaysDraftEvent,
createFavoriteRelaysDraftEvent, createFavoriteRelaysDraftEvent,
@ -17,6 +18,9 @@ import { TDraftEvent, TMailboxRelay } from '@/types'
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/'
/** nostrarchives trending notes (reactions / today) — read-only feed for new accounts. */
export const NEW_USER_TRENDING_RELAY_URL = buildWispTrendingNotesRelayUrl('reactions', 'today')
/** Dead relays seeded into kind 10006 for new accounts. */ /** Dead relays seeded into kind 10006 for new accounts. */
export const NEW_USER_BLOCKED_RELAY_URLS = [ export const NEW_USER_BLOCKED_RELAY_URLS = [
'wss://orly-relay.imwald.eu', 'wss://orly-relay.imwald.eu',
@ -70,7 +74,10 @@ export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
} }
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], []) return createFavoriteRelaysDraftEvent(
[...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL],
[]
)
} }
export function buildNewUserBlockedRelaysDraft(): TDraftEvent { export function buildNewUserBlockedRelaysDraft(): TDraftEvent {

70
src/lib/open-graph.test.ts

@ -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
})
})
})

171
src/lib/open-graph.ts

@ -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 }
}

8
src/lib/parent-reply-blurb.ts

@ -1,4 +1,5 @@
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind } from '@/constants'
import { getMusicTrackFromEvent, musicTrackDisplayLine } from '@/lib/music-track'
import { import {
getLiveEventMetadataFromEvent, getLiveEventMetadataFromEvent,
getLongFormArticleMetadataFromEvent getLongFormArticleMetadataFromEvent
@ -64,6 +65,11 @@ export function getParentReplyBlurbDisplayText(
if (live.summary?.trim()) return truncateBlurb(stripMarkupForPreview(live.summary), maxLen) if (live.summary?.trim()) return truncateBlurb(stripMarkupForPreview(live.summary), maxLen)
} }
if (isMusicTrackKind(event.kind)) {
const track = getMusicTrackFromEvent(event)
if (track) return truncateBlurb(musicTrackDisplayLine(track), maxLen)
}
if (event.kind === ExtendedKind.PICTURE || isNip71StyleVideoKind(event.kind)) { if (event.kind === ExtendedKind.PICTURE || isNip71StyleVideoKind(event.kind)) {
const cap = truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen) const cap = truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen)
return cap return cap

14
src/lib/revealed-media-session.ts

@ -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
}

28
src/lib/spell-feed-request-identity.test.ts

@ -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)
})
})

18
src/lib/spell-feed-request-identity.ts

@ -95,3 +95,21 @@ export function isSpellSubRequestsSameFiltersDifferentRelays(
return false return false
} }
} }
/**
* True when `nextKey` keeps every prior REQ filter and adds more shards (e.g. notifications spell gains
* `#e` / `#a` subrequests after thread-watch lists load from relays).
*/
export function isSpellSubRequestsFilterSuperset(prevKey: string | null, nextKey: string): boolean {
if (!prevKey || prevKey === nextKey) return false
try {
type Item = { urls: string[]; filter: string }
const prev = JSON.parse(prevKey) as Item[]
const next = JSON.parse(nextKey) as Item[]
if (!Array.isArray(prev) || !Array.isArray(next) || next.length < prev.length) return false
const nextFilters = new Set(next.map((item) => item.filter))
return prev.every((item) => nextFilters.has(item.filter))
} catch {
return false
}
}

4
src/lib/viewer-blocked-relays.ts

@ -25,6 +25,10 @@ export function getViewerBlockedRelayUrls(): readonly string[] {
return viewerBlockedRelayUrls return viewerBlockedRelayUrls
} }
export function isViewerRelayBlocked(url: string): boolean {
return isRelayBlockedByUser(url, viewerBlockedRelayUrls)
}
/** Drop user-blocked relays (hostname-aware) before any REQ / query / WebSocket connect. */ /** Drop user-blocked relays (hostname-aware) before any REQ / query / WebSocket connect. */
export function filterViewerBlockedRelaysForFetch(urls: readonly string[]): string[] { export function filterViewerBlockedRelaysForFetch(urls: readonly string[]): string[] {
if (!viewerBlockedRelayUrls.length) return [...urls] if (!viewerBlockedRelayUrls.length) return [...urls]

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

@ -15,3 +15,9 @@ export function buildViteProxySitesFetchUrl(originalUrl: string, proxyServer: st
export function urlLooksLikeViteProxyRequest(url: string): boolean { export function urlLooksLikeViteProxyRequest(url: string): boolean {
return url.includes('/sites/') || url.includes('/sites/?url=') 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 @@
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 @@
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
}

25
src/lib/webln-payment.ts

@ -73,3 +73,28 @@ export async function sendWebLNPaymentWithRetry(
} }
throw lastError throw lastError
} }
const DEFAULT_WEBLN_PAYMENT_TIMEOUT_MS = 90_000
/** Same as {@link sendWebLNPaymentWithRetry} but rejects when the wallet never responds. */
export async function sendWebLNPaymentWithRetryAndTimeout(
provider: WebLNProvider,
invoice: string,
options?: { maxAttempts?: number; timeoutMs?: number }
): Promise<{ preimage: string }> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_WEBLN_PAYMENT_TIMEOUT_MS
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
sendWebLNPaymentWithRetry(provider, invoice, options?.maxAttempts ?? 3),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error('Wallet payment timed out')),
timeoutMs
)
})
])
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId)
}
}

81
src/pages/primary/SpellsPage/index.tsx

@ -813,38 +813,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</Button> </Button>
) )
return ( const spellsSubHeader = (
<PrimaryPageLayout <div className="flex flex-col gap-2 px-4 py-2.5 sm:px-4">
ref={layoutRef}
pageName="spells"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div
className="app-chrome-title min-w-0 flex-1 truncate pl-3"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
</div>
}
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{selectedFauxSpell ? ( {selectedFauxSpell ? (
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Button <Button
@ -860,9 +830,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</div> </div>
) : ( ) : (
<> <>
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> <div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<>
{isSmallScreen ? ( {isSmallScreen ? (
<> <>
<Button <Button
@ -914,7 +882,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</>
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
<Button <Button
@ -1010,7 +977,42 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
)} )}
</> </>
)} )}
</div>
)
return (
<PrimaryPageLayout
ref={layoutRef}
pageName="spells"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div
className="app-chrome-title min-w-0 flex-1 truncate pl-3"
title={spellsTitlebarTitle}
>
{spellsTitlebarTitle}
</div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={refreshSpellsFeedAndCatalog} />
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => {
setSpellToEdit(null)
setSpellToClone(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
</Button>
</div>
</div>
}
subHeader={spellsSubHeader}
displayScrollToTopButton
>
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 pb-4 pt-2">
{/* Feed — faux spells and kind-777 spells all use NoteList */} {/* Feed — faux spells and kind-777 spells all use NoteList */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedFauxSpell === 'notifications' && !pubkey ? ( {selectedFauxSpell === 'notifications' && !pubkey ? (
@ -1061,8 +1063,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells" hostPrimaryPageName="spells"
preserveTimelineOnSubRequestsChange={spellFauxMergeTimeline} preserveTimelineOnSubRequestsChange={
mergeTimelineWhenSubRequestFiltersMatch={spellFauxMergeTimeline} spellFauxMergeTimeline || selectedFauxSpell === 'notifications'
}
mergeTimelineWhenSubRequestFiltersMatch={
spellFauxMergeTimeline || selectedFauxSpell === 'notifications'
}
mergeLiveEventsImmediately={selectedFauxSpell === 'notifications'}
showKinds={ showKinds={
selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds
} }

3
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -3,6 +3,7 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { import {
DEFAULT_FONT_SIZE,
FONT_SIZE, FONT_SIZE,
MEDIA_AUTO_LOAD_POLICY, MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE, NOTIFICATION_LIST_STYLE,
@ -111,7 +112,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
<Label htmlFor="font-size" className="text-base font-normal"> <Label htmlFor="font-size" className="text-base font-normal">
{t('Font size')} {t('Font size')}
</Label> </Label>
<Select defaultValue={FONT_SIZE.MEDIUM} value={fontSize} onValueChange={setFontSize}> <Select defaultValue={DEFAULT_FONT_SIZE} value={fontSize} onValueChange={setFontSize}>
<SelectTrigger id="font-size" className="w-48"> <SelectTrigger id="font-size" className="w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

4
src/pages/secondary/NotePage/index.tsx

@ -74,6 +74,8 @@ function getEventTypeName(kind: number): string {
return 'Comment' return 'Comment'
case ExtendedKind.VOICE: case ExtendedKind.VOICE:
return 'Voice Post' return 'Voice Post'
case ExtendedKind.MUSIC_TRACK:
return 'Music Track'
case ExtendedKind.VOICE_COMMENT: case ExtendedKind.VOICE_COMMENT:
return 'Voice Comment' return 'Voice Comment'
case kinds.Highlights: case kinds.Highlights:
@ -284,6 +286,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Comment' return 'Note: Comment'
case 1222: // ExtendedKind.VOICE case 1222: // ExtendedKind.VOICE
return 'Note: Voice Post' return 'Note: Voice Post'
case 36787: // ExtendedKind.MUSIC_TRACK
return 'Note: Music Track'
case 1244: // ExtendedKind.VOICE_COMMENT case 1244: // ExtendedKind.VOICE_COMMENT
return 'Note: Voice Comment' return 'Note: Voice Comment'
default: default:

41
src/pages/secondary/RelaySettingsPage/index.tsx

@ -3,6 +3,7 @@ import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice'
import SessionRelaysTab from '@/components/SessionRelaysTab' import SessionRelaysTab from '@/components/SessionRelaysTab'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -17,18 +18,31 @@ import { ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { Code, MoreVertical } from 'lucide-react' import { Code, MoreVertical } from 'lucide-react'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const FAVORITE_TAB_KINDS = [
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
kinds.Relaysets
] as const
const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { account, relayList } = useNostr() const { account, relayList, requestAccountNetworkHydrate } = useNostr()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(async () => {
setContentKey((k) => k + 1)
const pk = account?.pubkey
if (!pk) return
await requestAccountNetworkHydrate()
await client.refreshAuthorPublishedReplaceablesOnProfileView(pk, { force: true })
}, [account?.pubkey, requestAccountNetworkHydrate])
useEffect(() => { useEffect(() => {
if (account?.pubkey) { if (account?.pubkey) {
@ -101,7 +115,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
controls={ controls={
hideTitlebar ? undefined : ( hideTitlebar ? undefined : (
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<RefreshButton onClick={bump} /> <RefreshButton onClick={() => void bump()} />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}> <Button variant="ghost" size="icon" aria-label={t('More options')}>
@ -125,24 +139,29 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger> <TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="http-relays" className="w-full sm:w-auto">{t('HTTP relays')}</TabsTrigger> <TabsTrigger value="http-relays" className="w-full sm:w-auto">{t('HTTP relays')}</TabsTrigger>
<TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger>
<TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache Relays')}</TabsTrigger> <TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache Relays')}</TabsTrigger>
<TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="favorite-relays"> <TabsContent value="favorite-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={FAVORITE_TAB_KINDS} />
<FavoriteRelaysSetting /> <FavoriteRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="mailbox"> <TabsContent value="mailbox" className="space-y-4">
<RelaySettingsKindNotice kinds={[kinds.RelayList]} />
<MailboxSetting /> <MailboxSetting />
</TabsContent> </TabsContent>
<TabsContent value="http-relays"> <TabsContent value="http-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={[ExtendedKind.HTTP_RELAY_LIST]} />
<HttpRelaysSetting /> <HttpRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="session-relays"> <TabsContent value="cache-relays" className="space-y-4">
<SessionRelaysTab /> <RelaySettingsKindNotice kinds={[ExtendedKind.CACHE_RELAYS]} />
</TabsContent>
<TabsContent value="cache-relays">
<CacheRelaysSetting /> <CacheRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="session-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={[]} variant="session" />
<SessionRelaysTab />
</TabsContent>
</Tabs> </Tabs>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

3
src/providers/FavoriteRelaysProvider.tsx

@ -7,7 +7,7 @@ import { getRelaySetFromEvent } from '@/lib/event-metadata'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { setViewerBlockedRelayUrls } from '@/lib/viewer-blocked-relays' import { setViewerBlockedRelayUrls } from '@/lib/viewer-blocked-relays'
import { queryService } from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { TRelaySet } from '@/types' import { TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -159,6 +159,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
useEffect(() => { useEffect(() => {
setViewerBlockedRelayUrls(blockedRelays) setViewerBlockedRelayUrls(blockedRelays)
client.closeViewerBlockedRelayConnections()
}, [blockedRelays]) }, [blockedRelays])
useEffect(() => { useEffect(() => {

36
src/providers/NostrProvider/index.tsx

@ -770,13 +770,31 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
if (favoriteRelaysEvent) { if (favoriteRelaysEvent) {
if ( if (hydrationGenForThisRun === accountHydrationGenerationRef.current) {
hydrationGenForThisRun === accountHydrationGenerationRef.current && setFavoriteRelaysEvent(resolvedFavoritePut ?? favoriteRelaysEvent)
resolvedFavoritePut &&
resolvedFavoritePut.id === favoriteRelaysEvent.id
) {
setFavoriteRelaysEvent(favoriteRelaysEvent)
} }
} else if (!storedFavoriteRelaysEvent) {
const trySetFavoriteRelays = (evt: Event) => {
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return
void indexedDb
.putReplaceableEvent(evt)
.then((stored) => {
if (hydrationGenForThisRun === accountHydrationGenerationRef.current) {
setFavoriteRelaysEvent(stored)
}
})
.catch(() => {
if (hydrationGenForThisRun === accountHydrationGenerationRef.current) {
setFavoriteRelaysEvent(evt)
}
})
}
void replaceableEventService
.fetchReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS)
.then((ev) => {
if (ev) trySetFavoriteRelays(ev)
})
.catch(() => {})
} }
if (blockedRelaysEvent) { if (blockedRelaysEvent) {
if (resolvedBlockedPut && resolvedBlockedPut.id === blockedRelaysEvent.id) { if (resolvedBlockedPut && resolvedBlockedPut.id === blockedRelaysEvent.id) {
@ -1953,8 +1971,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
/** Always sync UI to IndexedDB winner (same-second updates must not leave stale list + relay sets). */ /** Prefer the event we just published; only keep IDB row when it is strictly newer. */
setFavoriteRelaysEvent(stored) setFavoriteRelaysEvent(
stored.created_at > favoriteRelaysEvent.created_at ? stored : favoriteRelaysEvent
)
} }
const persistNewUserTemplateLocally = async ( const persistNewUserTemplateLocally = async (

38
src/providers/ScreenSizeProvider.tsx

@ -7,6 +7,17 @@ type TScreenSizeContext = {
const ScreenSizeContext = createContext<TScreenSizeContext | undefined>(undefined) 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 = () => { export const useScreenSize = () => {
const context = useContext(ScreenSizeContext) const context = useContext(ScreenSizeContext)
if (!context) { if (!context) {
@ -21,24 +32,31 @@ export function useScreenSizeOptional(): TScreenSizeContext | undefined {
} }
export function ScreenSizeProvider({ children }: { children: React.ReactNode }) { export function ScreenSizeProvider({ children }: { children: React.ReactNode }) {
const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768) const [flags, setFlags] = useState(readScreenSizeFlags)
const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280)
useEffect(() => { useEffect(() => {
const handleResize = () => { const smallMq = window.matchMedia(SMALL_SCREEN_MQ)
setIsSmallScreen(window.innerWidth <= 768) const largeMq = window.matchMedia(LARGE_SCREEN_MQ)
setIsLargeScreen(window.innerWidth >= 1280) const sync = () => setFlags(readScreenSizeFlags())
}
smallMq.addEventListener('change', sync)
largeMq.addEventListener('change', sync)
window.addEventListener('resize', sync)
window.visualViewport?.addEventListener('resize', sync)
window.addEventListener('resize', handleResize) return () => {
return () => window.removeEventListener('resize', handleResize) smallMq.removeEventListener('change', sync)
largeMq.removeEventListener('change', sync)
window.removeEventListener('resize', sync)
window.visualViewport?.removeEventListener('resize', sync)
}
}, []) }, [])
return ( return (
<ScreenSizeContext.Provider <ScreenSizeContext.Provider
value={{ value={{
isSmallScreen, isSmallScreen: flags.isSmallScreen,
isLargeScreen isLargeScreen: flags.isLargeScreen
}} }}
> >
{children} {children}

20
src/services/client-events.service.ts

@ -31,7 +31,7 @@ import {
normalizeReplaceableCoordinateString, normalizeReplaceableCoordinateString,
relayHintWssUrlsFromEvent relayHintWssUrlsFromEvent
} from '@/lib/event' } from '@/lib/event'
import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { getFirstHexEventIdFromETags } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@ -47,6 +47,7 @@ import {
} from './event-archive.service' } from './event-archive.service'
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import { calendarRsvpMatchesCalendarEvent } from '@/lib/calendar-rsvp-match'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter'
@ -1045,25 +1046,10 @@ export class EventService {
*/ */
getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] { getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] {
if (!isCalendarEventKind(calendarEvent.kind)) return [] if (!isCalendarEventKind(calendarEvent.kind)) return []
const coordNorm = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const out: NEvent[] = [] const out: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) { for (const [, event] of this.sessionEventCache.entries()) {
if (event.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) continue
if (shouldDropEventOnIngest(event)) continue if (shouldDropEventOnIngest(event)) continue
const rawA = event.tags.find(tagNameEquals('a'))?.[1]?.trim() if (calendarRsvpMatchesCalendarEvent(calendarEvent, event)) out.push(event)
if (rawA && normalizeReplaceableCoordinateString(rawA) === coordNorm) {
out.push(event)
continue
}
const eTag = event.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase()
if (eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId) {
out.push(event)
}
} }
return out.sort((a, b) => b.created_at - a.created_at) return out.sort((a, b) => b.created_at - a.created_at)
} }

5
src/services/client-query.service.ts

@ -41,6 +41,7 @@ import type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer, grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer, grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal'
import { filterViewerBlockedRelaysForFetch } from '@/lib/viewer-blocked-relays'
import { closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' import { closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle'
import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults' import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
@ -516,7 +517,9 @@ export class QueryService {
? FIRST_RELAY_RESULT_GRACE_MS ? FIRST_RELAY_RESULT_GRACE_MS
: null : null
const httpRelayBases = httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? []) const httpRelayBases = filterViewerBlockedRelaysForFetch(
httpIndexBasesForRelayQuery(urls, options?.httpIndexRelayBases ?? [])
)
.filter((u) => !relaySessionStrikes.isReadHttpSkipped(u)) .filter((u) => !relaySessionStrikes.isReadHttpSkipped(u))
.filter((u) => isRelayConnectionAllowedForViewer(u)) .filter((u) => isRelayConnectionAllowedForViewer(u))
const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u)))

7
src/services/client-replaceable-events.service.ts

@ -1591,12 +1591,17 @@ export class ReplaceableEventService {
*/ */
static readonly AUTHOR_REPLACEABLES_REFRESHED_EVENT = 'jumble:author-replaceables-refreshed' as const static readonly AUTHOR_REPLACEABLES_REFRESHED_EVENT = 'jumble:author-replaceables-refreshed' as const
async refreshAuthorPublishedReplaceablesFromRelays(pubkey: string): Promise<void> { async refreshAuthorPublishedReplaceablesFromRelays(
pubkey: string,
options?: { force?: boolean }
): Promise<void> {
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return if (!/^[0-9a-f]{64}$/.test(pk)) return
if (!options?.force) {
const notBefore = this.authorProfileViewRefreshNotBeforeMs.get(pk) ?? 0 const notBefore = this.authorProfileViewRefreshNotBeforeMs.get(pk) ?? 0
if (Date.now() < notBefore) return if (Date.now() < notBefore) return
}
const inFlight = this.authorReplaceablesRefreshByPubkey.get(pk) const inFlight = this.authorReplaceablesRefreshByPubkey.get(pk)
if (inFlight) return inFlight if (inFlight) return inFlight

50
src/services/client.service.ts

@ -69,6 +69,9 @@ import {
viewerUsesGlobalRelayDefaults viewerUsesGlobalRelayDefaults
} from '@/lib/viewer-relay-defaults' } from '@/lib/viewer-relay-defaults'
import { import {
filterViewerBlockedRelaysForFetch,
getViewerBlockedRelayUrls,
isViewerRelayBlocked,
parseBlockedRelayUrlsFromEvent, parseBlockedRelayUrlsFromEvent,
setViewerBlockedRelayUrls setViewerBlockedRelayUrls
} from '@/lib/viewer-blocked-relays' } from '@/lib/viewer-blocked-relays'
@ -720,11 +723,6 @@ class ClientService extends EventTarget {
/** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */ /** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */
const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk) const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk)
this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(storageUrls.all)
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent)
this.closeMetadataPolicyDisallowedRelayConnections()
try { try {
const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS) const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS)
@ -733,6 +731,13 @@ class ClientService extends EventTarget {
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
} }
this.viewerHttpIndexRelayBases = filterViewerBlockedRelaysForFetch(storageUrls.httpIndexBases)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(storageUrls.all)
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent)
this.closeMetadataPolicyDisallowedRelayConnections()
this.closeViewerBlockedRelayConnections()
const urls = [...storageUrls.all] const urls = [...storageUrls.all]
try { try {
urls.push(...(await this.fetchFavoriteRelays(pk))) urls.push(...(await this.fetchFavoriteRelays(pk)))
@ -742,6 +747,7 @@ class ClientService extends EventTarget {
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true })
syncViewerRelayStackNostrLandAggrEligible(urls) syncViewerRelayStackNostrLandAggrEligible(urls)
this.closeMetadataPolicyDisallowedRelayConnections() this.closeMetadataPolicyDisallowedRelayConnections()
this.closeViewerBlockedRelayConnections()
} }
/** NIP-65 / 10243 / 10432 / favorites (10012) from IndexedDB only — no network. */ /** NIP-65 / 10243 / 10432 / favorites (10012) from IndexedDB only — no network. */
@ -802,8 +808,9 @@ class ClientService extends EventTarget {
.map((u) => normalizeHttpRelayUrl(u) || u) .map((u) => normalizeHttpRelayUrl(u) || u)
.filter(Boolean) .filter(Boolean)
if (fresh.length > 0) { if (fresh.length > 0) {
this.viewerHttpIndexRelayBases = fresh const filtered = filterViewerBlockedRelaysForFetch(fresh)
return fresh this.viewerHttpIndexRelayBases = filtered
return filtered
} }
} catch { } catch {
/* keep session cache */ /* keep session cache */
@ -835,6 +842,19 @@ class ClientService extends EventTarget {
} }
} }
/** Close pooled sockets to relays on the viewer block list (hostname-aware, wss/https). */
closeViewerBlockedRelayConnections(): void {
if (!getViewerBlockedRelayUrls().length) return
try {
const toClose = [...this.pool.listConnectionStatus().keys()].filter((url) =>
isViewerRelayBlocked(url)
)
if (toClose.length > 0) this.pool.close(toClose)
} catch {
// ignore
}
}
/** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */ /** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */
private async fetchNip66RelayDiscovery(): Promise<void> { private async fetchNip66RelayDiscovery(): Promise<void> {
if (isMetadataRelaysOnlyPolicyActive()) return if (isMetadataRelaysOnlyPolicyActive()) return
@ -3190,12 +3210,9 @@ class ClientService extends EventTarget {
let eosedAt: number | null = null let eosedAt: number | null = null
let eventIds = new Set<string>() let eventIds = new Set<string>()
const httpTimelinePollBases = httpIndexBasesForRelayQuery( const httpTimelinePollBases = filterViewerBlockedRelaysForFetch(
originalDedupedRelays, httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases)
this.viewerHttpIndexRelayBases ).filter((u) => relayAuthoritativeTimeline || !relaySessionStrikes.isReadHttpSkipped(u))
).filter(
(u) => relayAuthoritativeTimeline || !relaySessionStrikes.isReadHttpSkipped(u)
)
let httpPollIntervalId: ReturnType<typeof setInterval> | null = null let httpPollIntervalId: ReturnType<typeof setInterval> | null = null
let httpPollCursorUnix = 0 let httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => { const clearHttpTimelinePoll = () => {
@ -3942,11 +3959,14 @@ class ClientService extends EventTarget {
* from a comprehensive relay set, persist to IndexedDB, and notify the app (see * from a comprehensive relay set, persist to IndexedDB, and notify the app (see
* `ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT`). * `ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT`).
*/ */
async refreshAuthorPublishedReplaceablesOnProfileView(pubkey: string): Promise<void> { async refreshAuthorPublishedReplaceablesOnProfileView(
pubkey: string,
options?: { force?: boolean }
): Promise<void> {
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return if (!/^[0-9a-f]{64}$/.test(pk)) return
try { try {
await this.replaceableEventService.refreshAuthorPublishedReplaceablesFromRelays(pk) await this.replaceableEventService.refreshAuthorPublishedReplaceablesFromRelays(pk, options)
} catch (err) { } catch (err) {
logger.debug('[client] refreshAuthorPublishedReplaceablesOnProfileView failed', { logger.debug('[client] refreshAuthorPublishedReplaceablesOnProfileView failed', {
pubkeySlice: pk.slice(0, 12), pubkeySlice: pk.slice(0, 12),

39
src/services/indexed-db.service.ts

@ -15,6 +15,7 @@ import {
getCalendarOccurrenceWindowMs, getCalendarOccurrenceWindowMs,
isCalendarEventKind isCalendarEventKind
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { calendarEventHexId, calendarRsvpParentKeyFromEventId } from '@/lib/calendar-rsvp-match'
import { import {
getReplaceableCoordinate, getReplaceableCoordinate,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
@ -3784,12 +3785,20 @@ class IndexedDbService {
}) })
} }
/** Persist a NIP-52 RSVP (31925). Indexed by normalized `a` parent coordinate. */ /** Persist a NIP-52 RSVP (31925). Indexed by normalized `a` coordinate or `e:<calendar-id>`. */
async putCalendarRsvpEventRow(ev: Event): Promise<void> { async putCalendarRsvpEventRow(ev: Event): Promise<void> {
if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim() const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim()
if (!rawA) return const rawE = ev.tags.find(tagNameEquals('e'))?.[1]?.trim()
const parentCoordinate = normalizeReplaceableCoordinateString(rawA) const eHex =
rawE && /^[0-9a-f]{64}$/i.test(rawE) ? rawE.toLowerCase() : ''
let parentCoordinate = ''
if (rawA) {
parentCoordinate = normalizeReplaceableCoordinateString(rawA)
} else if (eHex) {
parentCoordinate = `e:${eHex}`
}
if (!parentCoordinate) return
await this.initPromise await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return
@ -3860,7 +3869,29 @@ class IndexedDbService {
}) })
} }
/** Cached RSVPs for a calendar replaceable coordinate (`kind:pubkey:d`). */ /** RSVPs for a calendar note: `a` coordinate index plus `e:<event-id>` rows. */
async getCalendarRsvpEventsForCalendarEvent(calendarEvent: Event, limit = 400): Promise<Event[]> {
const coord = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const eKey = calendarRsvpParentKeyFromEventId(calendarEventHexId(calendarEvent))
const [byCoord, byE] = await Promise.all([
this.getCalendarRsvpEventsByParentCoordinate(coord, limit),
eKey ? this.getCalendarRsvpEventsByParentCoordinate(eKey, limit) : Promise.resolve([])
])
const seen = new Set<string>()
const out: Event[] = []
for (const ev of [...byCoord, ...byE]) {
const id = ev.id.toLowerCase()
if (seen.has(id)) continue
seen.add(id)
out.push(ev)
}
out.sort((a, b) => b.created_at - a.created_at)
return out.slice(0, limit)
}
/** Cached RSVPs for a calendar replaceable coordinate (`kind:pubkey:d`) or `e:<hex>`. */
async getCalendarRsvpEventsByParentCoordinate( async getCalendarRsvpEventsByParentCoordinate(
parentCoordinate: string, parentCoordinate: string,
limit = 400 limit = 400

17
src/services/lightning.service.ts

@ -6,10 +6,7 @@ import {
} from '@/constants' } from '@/constants'
import { getProfileFromEvent, getZapInfoFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getZapInfoFromEvent } from '@/lib/event-metadata'
import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { import { sendWebLNPaymentWithRetryAndTimeout } from '@/lib/webln-payment'
isNwcWalletServiceInfoError,
sendWebLNPaymentWithRetry
} from '@/lib/webln-payment'
import { Invoice } from '@getalby/lightning-tools' import { Invoice } from '@getalby/lightning-tools'
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
import { WebLNProvider } from '@webbtc/webln-types' import { WebLNProvider } from '@webbtc/webln-types'
@ -145,7 +142,7 @@ class LightningService {
if (this.provider) { if (this.provider) {
try { try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) const { preimage } = await sendWebLNPaymentWithRetryAndTimeout(this.provider, pr)
closeOuterModel?.() closeOuterModel?.()
const zapReceipt = const zapReceipt =
relays.length > 0 relays.length > 0
@ -160,9 +157,7 @@ class LightningService {
onPaymentFlowComplete?.(result) onPaymentFlowComplete?.(result)
return result return result
} catch (error) { } catch (error) {
if (!isNwcWalletServiceInfoError(error)) { logger.info('WebLN zap payment unavailable, falling back to invoice UI', { error })
throw error
}
} }
} }
@ -302,15 +297,13 @@ class LightningService {
): Promise<PaymentFlowResult> { ): Promise<PaymentFlowResult> {
if (this.provider) { if (this.provider) {
try { try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, invoice) const { preimage } = await sendWebLNPaymentWithRetryAndTimeout(this.provider, invoice)
closeOuterModel?.() closeOuterModel?.()
const result = { preimage, invoice } const result = { preimage, invoice }
onPaymentFlowComplete?.(result) onPaymentFlowComplete?.(result)
return result return result
} catch (error) { } catch (error) {
if (!isNwcWalletServiceInfoError(error)) { logger.info('WebLN invoice payment unavailable, falling back to invoice UI', { error })
throw error
}
} }
} }

15
src/services/local-storage.service.ts

@ -1,4 +1,5 @@
import { import {
DEFAULT_FONT_SIZE,
DEFAULT_NIP_96_SERVICE, DEFAULT_NIP_96_SERVICE,
ExtendedKind, ExtendedKind,
MEDIA_AUTO_LOAD_POLICY, MEDIA_AUTO_LOAD_POLICY,
@ -89,7 +90,7 @@ class LocalStorageService {
private themeSetting: TThemeSetting = 'system' private themeSetting: TThemeSetting = 'system'
private theme: TTheme = 'light' private theme: TTheme = 'light'
private addClientTag: boolean = true private addClientTag: boolean = true
private fontSize: TFontSize = 'medium' private fontSize: TFontSize = DEFAULT_FONT_SIZE
private accounts: TAccount[] = [] private accounts: TAccount[] = []
private currentAccount: TAccount | null = null private currentAccount: TAccount | null = null
private noteListMode: TNoteListMode = 'posts' private noteListMode: TNoteListMode = 'posts'
@ -139,7 +140,7 @@ class LocalStorageService {
const addClientTagStr = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) const addClientTagStr = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG)
this.addClientTag = addClientTagStr === null ? true : addClientTagStr === 'true' this.addClientTag = addClientTagStr === null ? true : addClientTagStr === 'true'
this.fontSize = this.fontSize =
(window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? 'medium' (window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? DEFAULT_FONT_SIZE
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS) const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
this.accounts = accountsStr ? JSON.parse(accountsStr) : [] this.accounts = accountsStr ? JSON.parse(accountsStr) : []
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT) const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
@ -324,13 +325,21 @@ class LocalStorageService {
} }
} }
} }
if (showKindsVersion < 15) {
if (
(showKinds.includes(ExtendedKind.VOICE) || showKinds.includes(ExtendedKind.PICTURE)) &&
!showKinds.includes(ExtendedKind.MUSIC_TRACK)
) {
showKinds.push(ExtendedKind.MUSIC_TRACK)
}
}
// v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent).
this.showKinds = showKinds this.showKinds = showKinds
// Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and // Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and
// keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's // keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's
// saved filter before initAsync/applySettings runs. // saved filter before initAsync/applySettings runs.
this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '14') this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '15')
} }
// Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)

1
src/services/mention-event-search.service.ts

@ -58,6 +58,7 @@ export const NADDR_KINDS = [
ExtendedKind.NOSTR_SPECIFICATION, ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
kinds.LongFormArticle, kinds.LongFormArticle,
ExtendedKind.MUSIC_TRACK
] as const ] as const
export type PickerSearchMode = 'nevent' | 'naddr' export type PickerSearchMode = 'nevent' | 'naddr'

1
src/services/nip89.service.ts

@ -223,6 +223,7 @@ class Nip89Service {
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
ExtendedKind.DISCUSSION, ExtendedKind.DISCUSSION,
ExtendedKind.RELAY_REVIEW, ExtendedKind.RELAY_REVIEW,

9
src/services/relay-info.service.ts

@ -1,3 +1,5 @@
import { isViewerRelayBlocked } from '@/lib/viewer-blocked-relays'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { import {
devProxyCorsProblematicHttpsIndexRelayBase, devProxyCorsProblematicHttpsIndexRelayBase,
devProxyLoopbackHttpRelayBase, devProxyLoopbackHttpRelayBase,
@ -142,6 +144,9 @@ class RelayInfoService {
} }
private async _getRelayInfo(url: string) { private async _getRelayInfo(url: string) {
if (isViewerRelayBlocked(url)) {
return undefined
}
const exist = this.relayInfoMap.get(url) const exist = this.relayInfoMap.get(url)
if (exist && !this.isStale(exist)) { if (exist && !this.isStale(exist)) {
return exist return exist
@ -165,6 +170,10 @@ class RelayInfoService {
} }
private async fetchRelayNip11(url: string) { private async fetchRelayNip11(url: string) {
// Path-based WS trending feed — no NIP-11 document at the derived https URL (avoids CORS noise).
if (isWispTrendingNotesRelayUrl(url)) {
return undefined
}
try { try {
const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') const httpCandidate = url.trim().replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate const httpBase = normalizeHttpRelayUrl(httpCandidate) || httpCandidate

179
src/services/web.service.ts

@ -4,157 +4,124 @@ import {
isSitesProxyUnavailableThisSession, isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session' } from '@/lib/optional-proxy-session'
import { buildViteProxySitesFetchUrl, urlLooksLikeViteProxyRequest } from '@/lib/vite-proxy-url' import { htmlLooksLikeImwaldAppShell, parseOpenGraphFromHtml } from '@/lib/open-graph'
import {
buildDevLocalSitesFetchUrl,
buildViteProxySitesFetchUrl,
urlLooksLikeViteProxyRequest
} from '@/lib/vite-proxy-url'
import { TWebMetadata } from '@/types' import { TWebMetadata } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import logger from '@/lib/logger' import logger from '@/lib/logger'
/** True when HTML is the Vite/React dev shell or another SPA stub, not the target page. */
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')
)
}
const HTML_FETCH_HEADERS = { const HTML_FETCH_HEADERS = {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (compatible; Imwald/1.0; +https://jumble.imwald.eu)' '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( async function tryFetchHtml(
fetchUrl: string, fetchUrl: string,
timeoutMs: number timeoutMs: number,
options?: { direct?: boolean }
): Promise<{ html: string | null; status?: number }> { ): Promise<{ html: string | null; status?: number }> {
try { try {
const res = await fetchWithTimeout(fetchUrl, { const res = await fetchWithTimeout(fetchUrl, {
timeoutMs, timeoutMs,
mode: 'cors', mode: 'cors',
credentials: 'omit', 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 } if (!res.ok) return { html: null, status: res.status }
const html = await res.text() const html = await res.text()
if (html.length < 50) return { html: null, status: res.status } if (html.length < 50) return { html: null, status: res.status }
if (htmlLooksLikeLocalDevAppShell(html)) return { html: null, status: res.status } if (htmlLooksLikeImwaldAppShell(html)) {
logger.debug('[WebService] Ignoring app-shell HTML from fetch', { fetchUrl })
return { html: null, status: res.status }
}
return { html } return { html }
} catch { } catch {
return { html: null } return { html: null }
} }
} }
/** type OgFetchAttempt = { label: string; url: string; timeoutMs: number; direct?: boolean }
* OG HTML: always use `VITE_PROXY_SERVER` first when set; if that fails or is unset, fetch the page directly.
*/
async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: string; via: string } | null> {
const isAlreadyProxyRequest = urlLooksLikeViteProxyRequest(originalUrl)
if (isAlreadyProxyRequest) {
const { html } = await tryFetchHtml(originalUrl, 35_000)
return html ? { html, via: originalUrl } : null
}
function buildOgFetchAttempts(originalUrl: string): OgFetchAttempt[] {
const attempts: OgFetchAttempt[] = []
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim() const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const proxyDown = isSitesProxyUnavailableThisSession()
if (proxyServer && !isSitesProxyUnavailableThisSession()) { if (proxyServer && !proxyDown && !urlLooksLikeViteProxyRequest(originalUrl)) {
const proxyFetchUrl = buildViteProxySitesFetchUrl(originalUrl, proxyServer) attempts.push({
logger.debug('[WebService] OG fetch via VITE_PROXY_SERVER', { originalUrl, proxyFetchUrl }) label: 'vite-proxy',
const proxyTry = await tryFetchHtml(proxyFetchUrl, 35_000) url: buildViteProxySitesFetchUrl(originalUrl, proxyServer),
if (proxyTry.html) { timeoutMs: 35_000
clearSitesProxyUnavailableThisSession() })
return { html: proxyTry.html, via: proxyFetchUrl }
}
if (typeof proxyTry.status === 'number') {
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, if (import.meta.env.DEV) {
// and the attempt spams DevTools with cross-origin errors without improving OG success. const devSitesUrl = buildDevLocalSitesFetchUrl(originalUrl)
if (!import.meta.env.PROD) { if (devSitesUrl && !proxyDown) {
const direct = await tryFetchHtml(originalUrl, 15_000) attempts.push({ label: 'dev-sites', url: devSitesUrl, timeoutMs: 35_000 })
return direct.html ? { html: direct.html, via: 'direct' } : null
} }
return null attempts.push({ label: 'direct', url: originalUrl, timeoutMs: 15_000, direct: true })
} else if (!proxyServer || proxyDown) {
attempts.push({ label: 'direct', url: originalUrl, timeoutMs: 15_000, direct: true })
} }
const directOnly = await tryFetchHtml(originalUrl, 15_000) attempts.push(
return directOnly.html ? { html: directOnly.html, via: 'direct' } : null {
label: 'allorigins',
url: `https://api.allorigins.win/raw?url=${encodeURIComponent(originalUrl)}`,
timeoutMs: 25_000
},
{
label: 'corsproxy',
url: `https://corsproxy.io/?${encodeURIComponent(originalUrl)}`,
timeoutMs: 25_000
} }
)
function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata { return attempts
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const ogTitleMeta = doc.querySelector('meta[property="og:title"]')
const titleTag = doc.querySelector('title')
let title = ogTitleMeta?.getAttribute('content') || titleTag?.textContent
if (title) {
const trimmedTitle = title.trim()
if (
/^(Redirecting|Loading|Please wait|Redirect)(\.\.\.|…)?$/i.test(trimmedTitle) ||
trimmedTitle === '...' ||
trimmedTitle === '…'
) {
title = undefined
} }
}
const description =
doc.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
(doc.querySelector('meta[name="description"]') as HTMLMetaElement | null)?.content
let image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null)?.content
if (image) { /**
try { * OG HTML: configured `/sites/?url=…` proxy first; then direct (dev or when proxy is down);
const urlObj = new URL(pageUrl) * then public CORS proxies as last resort.
if (image.startsWith('/')) { */
image = `${urlObj.protocol}//${urlObj.host}${image}` async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: string; via: string } | null> {
} else if (!image.match(/^https?:\/\//)) { if (urlLooksLikeViteProxyRequest(originalUrl)) {
const basePath = urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1) const { html } = await tryFetchHtml(originalUrl, 35_000)
image = `${urlObj.protocol}//${urlObj.host}${basePath}${image}` return html ? { html, via: originalUrl } : null
} }
const imageLower = image.toLowerCase() for (const attempt of buildOgFetchAttempts(originalUrl)) {
if ( logger.debug('[WebService] OG fetch attempt', {
imageLower.includes('/favicon') || originalUrl,
imageLower.endsWith('/favicon.ico') || label: attempt.label,
imageLower.endsWith('/favicon.svg') fetchUrl: attempt.url
) { })
logger.warn('[WebService] Filtered out favicon URL from OG image', { url: pageUrl, image }) const result = await tryFetchHtml(attempt.url, attempt.timeoutMs, { direct: attempt.direct })
image = undefined if (result.html) {
} if (attempt.label === 'vite-proxy' || attempt.label === 'dev-sites') {
} catch (error) { clearSitesProxyUnavailableThisSession()
logger.warn('[WebService] Failed to convert relative image URL', { image, url: pageUrl, error })
} }
return { html: result.html, via: attempt.label }
} }
if (
try { (attempt.label === 'vite-proxy' || attempt.label === 'dev-sites') &&
const urlObj = new URL(pageUrl) typeof result.status === 'number'
const isAppCanonicalHost = urlObj.hostname === 'jumble.imwald.eu' ) {
const isAppDefaultTitle = markSitesProxyUnavailableFromHttpStatus(result.status)
title?.includes('Imwald ') ||
title?.includes('Jumble - Imwald Edition') ||
title?.includes('Jumble Imwald Edition')
const isAppDefaultDesc = description?.includes(
'A user-friendly Nostr client focused on relay feed browsing'
)
if (!isAppCanonicalHost && (isAppDefaultTitle || isAppDefaultDesc)) {
logger.debug('[WebService] Filtered out Imwald default OG tags for external domain', {
url: pageUrl,
hostname: urlObj.hostname
})
return {}
} }
} catch {
/* ignore */
} }
return { title, description, image } return null
} }
class WebService { class WebService {

2
src/types/index.d.ts vendored

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

Loading…
Cancel
Save