Browse Source

fix build

imwald
Silberengel 1 month ago
parent
commit
8709d9d9e7
  1. 2272
      package-lock.json
  2. 10
      package.json
  3. 65
      src/PageManager.tsx
  4. 11
      src/components/ui/select.tsx
  5. 182
      src/i18n/index.ts
  6. 211
      src/lib/live-activities.test.ts
  7. 359
      src/lib/live-activities.ts
  8. 3
      src/main.tsx
  9. 6
      src/pages/secondary/GeneralSettingsPage/index.tsx
  10. 6
      src/providers/LiveActivitiesProvider.tsx
  11. 102
      vite.config.ts

2272
package-lock.json generated

File diff suppressed because it is too large Load Diff

10
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "21.1.0",
"version": "21.1.1",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",
@ -87,7 +87,7 @@ @@ -87,7 +87,7 @@
"lucide-react": "^0.469.0",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.2.0",
"path-to-regexp": "^8.3.0",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"react": "^18.3.1",
@ -129,8 +129,8 @@ @@ -129,8 +129,8 @@
"wait-on": "^8.0.1"
},
"optionalDependencies": {
"electron": "^34.2.0",
"electron-builder": "^25.1.8"
"electron": "^35.7.5",
"electron-builder": "^26.8.1"
},
"build": {
"appId": "eu.imwald.jumble",
@ -159,7 +159,7 @@ @@ -159,7 +159,7 @@
"vite-plugin-pwa": {
"workbox-build": {
"@rollup/plugin-terser": {
"serialize-javascript": "7.0.4"
"serialize-javascript": "7.0.5"
}
}
}

65
src/PageManager.tsx

@ -7,16 +7,11 @@ import { NavigationService } from '@/services/navigation.service' @@ -7,16 +7,11 @@ import { NavigationService } from '@/services/navigation.service'
// Page imports needed for primary note view
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
import NoteDrawer from '@/components/NoteDrawer'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import storage from '@/services/local-storage.service'
import client from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import type { Event } from 'nostr-tools'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
import MuteListPage from '@/pages/secondary/MuteListPage'
import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage'
import SecondaryRelayPage from '@/pages/secondary/RelayPage'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
// DEPRECATED: useUserPreferences removed - double-panel functionality disabled
import { TPageRef } from '@/types'
@ -89,6 +84,17 @@ const RelayPulseActiveNpubsSheetLazy = lazy( @@ -89,6 +84,17 @@ const RelayPulseActiveNpubsSheetLazy = lazy(
() => import('@/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet').then((m) => ({ default: m.RelayPulseActiveNpubsSheet }))
)
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
function suspensePrimaryPage(page: ReactElement) {
return <Suspense fallback={primaryPageLazyFallback}>{page}</Suspense>
}
type TStackItem = {
index: number
url: string
@ -469,7 +475,10 @@ export function useSmartRelayNavigation() { @@ -469,7 +475,10 @@ export function useSmartRelayNavigation() {
if (isSmallScreen) {
// Use primary note view on mobile
window.history.pushState(null, '', contextualUrl)
setPrimaryNoteView(<SecondaryRelayPage url={relayUrl} index={0} hideTitlebar={true} />, 'relay')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryRelayPageLazy url={relayUrl} index={0} hideTitlebar={true} />),
'relay'
)
} else {
// Desktop: always use secondary routing (will be rendered in drawer in single-pane, side panel in double-pane)
pushSecondaryPage(contextualUrl)
@ -502,7 +511,10 @@ export function useSmartRelayNavigationOptional() { @@ -502,7 +511,10 @@ export function useSmartRelayNavigationOptional() {
const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage)
if (isSmallScreen) {
window.history.pushState(null, '', contextualUrl)
setPrimaryNoteView(<SecondaryRelayPage url={relayUrl} index={0} hideTitlebar={true} />, 'relay')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryRelayPageLazy url={relayUrl} index={0} hideTitlebar={true} />),
'relay'
)
} else {
pushSecondaryPage(contextualUrl)
}
@ -528,7 +540,10 @@ export function useSmartProfileNavigation() { @@ -528,7 +540,10 @@ export function useSmartProfileNavigation() {
// Use primary note view on mobile
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryProfilePageLazy id={profileId} index={0} hideTitlebar={true} />),
'profile'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
@ -540,7 +555,10 @@ export function useSmartProfileNavigation() { @@ -540,7 +555,10 @@ export function useSmartProfileNavigation() {
// Use primary note view on mobile
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryProfilePageLazy id={profileId} index={0} hideTitlebar={true} />),
'profile'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
@ -578,7 +596,10 @@ export function useSmartProfileNavigationOptional() { @@ -578,7 +596,10 @@ export function useSmartProfileNavigationOptional() {
if (isSmallScreen) {
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryProfilePageLazy id={profileId} index={0} hideTitlebar={true} />),
'profile'
)
} else {
pushSecondaryPage(url)
}
@ -587,7 +608,10 @@ export function useSmartProfileNavigationOptional() { @@ -587,7 +608,10 @@ export function useSmartProfileNavigationOptional() {
if (isSmallScreen) {
const profileId = url.replace('/users/', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryProfilePageLazy id={profileId} index={0} hideTitlebar={true} />),
'profile'
)
} else {
pushSecondaryPage(url)
}
@ -666,7 +690,10 @@ export function useSmartFollowingListNavigation() { @@ -666,7 +690,10 @@ export function useSmartFollowingListNavigation() {
// Use primary note view on mobile
const profileId = url.replace('/users/', '').replace('/following', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<FollowingListPage id={profileId} index={0} hideTitlebar={true} />, 'following')
setPrimaryNoteView(
suspensePrimaryPage(<PrimaryFollowingListPageLazy id={profileId} index={0} hideTitlebar={true} />),
'following'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
@ -686,7 +713,7 @@ export function useSmartMuteListNavigation() { @@ -686,7 +713,7 @@ export function useSmartMuteListNavigation() {
if (isSmallScreen) {
// Use primary note view on mobile
window.history.pushState(null, '', url)
setPrimaryNoteView(<MuteListPage index={0} hideTitlebar={true} />, 'mute')
setPrimaryNoteView(suspensePrimaryPage(<PrimaryMuteListPageLazy index={0} hideTitlebar={true} />), 'mute')
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
@ -707,7 +734,12 @@ export function useSmartOthersRelaySettingsNavigation() { @@ -707,7 +734,12 @@ export function useSmartOthersRelaySettingsNavigation() {
// Use primary note view on mobile
const profileId = url.replace('/users/', '').replace('/relays', '')
window.history.pushState(null, '', url)
setPrimaryNoteView(<OthersRelaySettingsPage id={profileId} index={0} hideTitlebar={true} />, 'others-relay-settings')
setPrimaryNoteView(
suspensePrimaryPage(
<PrimaryOthersRelaySettingsPageLazy id={profileId} index={0} hideTitlebar={true} />
),
'others-relay-settings'
)
} else {
// Use secondary routing on desktop
pushSecondaryPage(url)
@ -1642,7 +1674,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1642,7 +1674,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
const profileUrl = `/users/${profileId}`
window.history.pushState(null, '', profileUrl)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
setPrimaryNoteView(
suspensePrimaryPage(<SecondaryProfilePageLazy id={profileId} index={0} hideTitlebar={true} />),
'profile'
)
return
}
window.history.back()

11
src/components/ui/select.tsx

@ -2,6 +2,7 @@ import * as React from 'react' @@ -2,6 +2,7 @@ import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { DialogContext } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
@ -61,12 +62,15 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam @@ -61,12 +62,15 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => {
const inDialog = React.useContext(DialogContext)
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-[110] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'relative max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[110]',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
@ -87,7 +91,8 @@ const SelectContent = React.forwardRef< @@ -87,7 +91,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
)
})
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<

182
src/i18n/index.ts

@ -2,95 +2,113 @@ import dayjs from 'dayjs' @@ -2,95 +2,113 @@ import dayjs from 'dayjs'
import i18n, { Resource } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import ar from './locales/ar'
import de from './locales/de'
import en from './locales/en'
import es from './locales/es'
import fa from './locales/fa'
import fr from './locales/fr'
import hi from './locales/hi'
import it from './locales/it'
import ja from './locales/ja'
import ko from './locales/ko'
import pl from './locales/pl'
import pt_BR from './locales/pt-BR'
import pt_PT from './locales/pt-PT'
import ru from './locales/ru'
import th from './locales/th'
import zh from './locales/zh'
const languages = {
ar: { resource: ar, name: 'العربية' },
de: { resource: de, name: 'Deutsch' },
en: { resource: en, name: 'English' },
es: { resource: es, name: 'Español' },
fa: { resource: fa, name: 'فارسی' },
fr: { resource: fr, name: 'Français' },
hi: { resource: hi, name: 'हि' },
it: { resource: it, name: 'Italiano' },
ja: { resource: ja, name: '日本語' },
ko: { resource: ko, name: '한국어' },
pl: { resource: pl, name: 'Polski' },
'pt-BR': { resource: pt_BR, name: 'Português (Brasil)' },
'pt-PT': { resource: pt_PT, name: 'Português (Portugal)' },
ru: { resource: ru, name: 'Русский' },
th: { resource: th, name: 'ไทย' },
zh: { resource: zh, name: '简体中文' }
/** Display names only — keeps this module small; locale strings load on demand (except English). */
const LANGUAGE_META = {
ar: 'العربية',
de: 'Deutsch',
en: 'English',
es: 'Español',
fa: 'فارسی',
fr: 'Français',
hi: 'हि',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
pl: 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
ru: 'Русский',
th: 'ไทย',
zh: '简体中文'
} as const
export type TLanguage = keyof typeof languages
export const LocalizedLanguageNames: { [key in TLanguage]?: string } = {}
const resources: { [key in TLanguage]?: Resource } = {}
const supportedLanguages: TLanguage[] = []
for (const [key, value] of Object.entries(languages)) {
const lang = key as TLanguage
LocalizedLanguageNames[lang] = value.name
resources[lang] = value.resource
supportedLanguages.push(lang)
export type TLanguage = keyof typeof LANGUAGE_META
export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGUAGE_META }
export const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[]
const localeModules = import.meta.glob<{ default: Resource }>('./locales/*.ts')
const localePath = (code: TLanguage): string => `./locales/${code}.ts`
function normalizeToSupported(lng: string): TLanguage {
const exact = supportedLanguages.find((s) => lng === s)
if (exact) return exact
return supportedLanguages.find((s) => lng.startsWith(s)) ?? 'en'
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
resources,
interpolation: {
escapeValue: false // react already safes from xss
},
detection: {
convertDetectedLanguage: (lng) => {
const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
return supported || 'en'
export async function ensureLocaleLoaded(code: TLanguage): Promise<void> {
if (code === 'en') return
if (i18n.hasResourceBundle(code, 'translation')) return
const load = localeModules[localePath(code)]
if (!load) {
console.warn('[i18n] Missing locale module for', code)
return
}
const mod = await load()
i18n.addResourceBundle(code, 'translation', mod.default.translation, true, true)
}
export async function changeAppLanguage(code: TLanguage): Promise<void> {
await ensureLocaleLoaded(code)
await i18n.changeLanguage(code)
}
let initPromise: Promise<void> | null = null
export function initI18n(): Promise<void> {
if (initPromise) return initPromise
initPromise = (async () => {
await i18n.use(LanguageDetector).use(initReactI18next).init({
fallbackLng: 'en',
supportedLngs: supportedLanguages,
resources: { en },
partialBundledLanguages: true,
interpolation: {
escapeValue: false
},
detection: {
convertDetectedLanguage: (lng) => normalizeToSupported(lng)
}
}
})
})
i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) {
case 'zh':
case 'ja':
return dayjs(timestamp).format('YYYY年MM月DD日')
case 'pl':
case 'de':
case 'ru':
return dayjs(timestamp).format('DD.MM.YYYY')
case 'fa':
return dayjs(timestamp).format('YYYY/MM/DD')
case 'it':
case 'es':
case 'fr':
case 'pt-BR':
case 'pt-PT':
case 'ar':
case 'hi':
case 'th':
return dayjs(timestamp).format('DD/MM/YYYY')
case 'ko':
return dayjs(timestamp).format('YYYY년 MM월 DD일')
default:
return dayjs(timestamp).format('MMM D, YYYY')
}
})
i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) {
case 'zh':
case 'ja':
return dayjs(timestamp).format('YYYY年MM月DD日')
case 'pl':
case 'de':
case 'ru':
return dayjs(timestamp).format('DD.MM.YYYY')
case 'fa':
return dayjs(timestamp).format('YYYY/MM/DD')
case 'it':
case 'es':
case 'fr':
case 'pt-BR':
case 'pt-PT':
case 'ar':
case 'hi':
case 'th':
return dayjs(timestamp).format('DD/MM/YYYY')
case 'ko':
return dayjs(timestamp).format('YYYY년 MM월 DD일')
default:
return dayjs(timestamp).format('MMM D, YYYY')
}
})
const target = normalizeToSupported(i18n.language)
if (target !== 'en') {
await ensureLocaleLoaded(target)
await i18n.changeLanguage(target)
}
})()
return initPromise
}
export default i18n

211
src/lib/live-activities.test.ts

@ -0,0 +1,211 @@ @@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest'
import {
parseLiveActivityEvent,
preferredLiveJoinUrlForEvent,
resolveParentSpacesForLiveActivities
} from './live-activities'
import { nip19, type Event } from 'nostr-tools'
const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
({
kind,
pubkey,
content: '',
tags,
id: 'b'.repeat(64),
sig: 'c'.repeat(128),
created_at: 1_700_000_000
}) as Event
describe('parseLiveActivityEvent (NIP-53)', () => {
it('accepts 30312 meeting space when status is open (not live)', () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'Main Hall'],
['status', 'open'],
['service', 'https://meet.example.com/r/abc']
])
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
expect(item?.title).toBe('Main Hall')
expect(item?.joinUrl).toBe('https://meet.example.com/r/abc')
})
it('rejects 30312 when status is closed', () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'X'],
['status', 'closed'],
['service', 'https://meet.example.com/r/abc']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('requires status live for 30311', () => {
const ev = base(30311, [
['d', 's1'],
['status', 'planned'],
['streaming', 'https://x/stream.m3u8']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('excludes 30311 when ends is in the past (even if status is still live)', () => {
const nowSec = 1_700_000_000
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['ends', String(nowSec - 60)]
],
pk
)
expect(parseLiveActivityEvent(ev, new Set(), new Map(), nowSec)).toBeNull()
})
it('keeps 30311 when ends is in the future', () => {
const nowSec = 1_700_000_000
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['ends', String(nowSec + 3600)]
],
pk
)
expect(parseLiveActivityEvent(ev, new Set(), new Map(), nowSec)).not.toBeNull()
})
it('excludes 30311 when status is ended', () => {
const ev = base(30311, [
['d', 's1'],
['status', 'ended'],
['streaming', 'https://example.com/x.m3u8']
])
expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
})
it('uses zap.stream naddr page for 30311 when streaming is only HLS manifest', () => {
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'live'],
['streaming', 'https://example.com/live/stream.m3u8']
],
pk
)
const item = parseLiveActivityEvent(ev, new Set())
expect(item?.joinUrl).toMatch(/^https:\/\/zap\.stream\/naddr1/)
})
it('30311 prefers canonical zap.stream URL over legacy https service', () => {
const pk = 'c'.repeat(64)
const ev = base(
30311,
[
['d', 'my-stream'],
['status', 'live'],
['service', 'https://legacy.example.com/watch/old']
],
pk
)
const naddr = nip19.naddrEncode({ kind: 30311, pubkey: pk, identifier: 'my-stream' })
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(`https://zap.stream/${naddr}`)
})
it('30313 inherits join URL from parent 30312 via `a` tag', () => {
const spacePk = 'f'.repeat(64)
const parentAddr = `30312:${spacePk}:conf-room`
const parent = base(
30312,
[
['d', 'conf-room'],
['room', 'Conference'],
['status', 'open'],
['service', 'https://meet.example.com/space/xyz']
],
spacePk
)
const meeting = base(30313, [
['d', 'annual-2025'],
['a', parentAddr, 'wss://relay.example.com'],
['title', 'Annual Meeting'],
['status', 'live'],
['starts', '1700000000']
])
const map = new Map<string, Event>([[parentAddr, parent]])
const item = parseLiveActivityEvent(meeting, new Set(), map)
expect(item).not.toBeNull()
expect(item?.joinUrl).toBe('https://meet.example.com/space/xyz')
expect(item?.title).toBe('Annual Meeting')
})
})
describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
it('30312 Nostr Nests: prefers nostrnests.com naddr over MoQ streaming URL', () => {
const pk = 'a'.repeat(64)
const ev = base(30312, [
['d', 'nest-room-1'],
['title', 'Jam session'],
['summary', ''],
['streaming', 'https://moq.nostrnests.com'],
['auth', 'https://moq-auth.nostrnests.com'],
['status', 'open'],
['starts', '1700000000'],
['relays', 'wss://nos.lol']
])
const naddr = nip19.naddrEncode({
kind: 30312,
pubkey: pk,
identifier: 'nest-room-1',
relays: ['wss://nos.lol']
})
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`)
})
it('Corny Chat kind 1: prefers r over service when they differ', () => {
const ev = base(1, [
['L', 'com.cornychat'],
['audioserver', 'cornychat.com'],
['r', 'https://cornychat.com/room-a'],
['service', 'https://cornychat.com/room-b'],
['streaming', 'https://cornychat.com/room-b']
])
expect(preferredLiveJoinUrlForEvent(ev)).toBe('https://cornychat.com/room-a')
})
})
describe('resolveParentSpacesForLiveActivities', () => {
it('fetches 30312 when 30313 references parent but has no URL', async () => {
const spacePk = 'e'.repeat(64)
const parentAddr = `30312:${spacePk}:hall`
const meeting = base(30313, [
['d', 'm1'],
['a', parentAddr],
['title', 'Town hall'],
['status', 'live'],
['starts', '1700000000']
])
const parent = base(
30312,
[
['d', 'hall'],
['room', 'Main'],
['status', 'open'],
['service', 'https://join.example/hall']
],
spacePk
)
const fetchEvents = vi.fn().mockResolvedValue([parent])
const map = await resolveParentSpacesForLiveActivities([meeting], ['wss://r.test'], fetchEvents)
expect(fetchEvents).toHaveBeenCalledTimes(1)
expect(map.get(parentAddr)?.kind).toBe(30312)
expect(map.get(parentAddr)?.pubkey).toBe(spacePk)
})
})

359
src/lib/live-activities.ts

@ -7,7 +7,24 @@ import { @@ -7,7 +7,24 @@ import {
relayUrlsLocalsFirst
} from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import { nip19, type Event, type Filter } from 'nostr-tools'
/** [zap.stream](https://github.com/v0l/zap.stream) resolves `/:naddr` (NIP-19) for NIP-53 streams — no separate public API needed for “open in player”. */
const ZAP_STREAM_ORIGIN = 'https://zap.stream'
/** [Nostr Nests](https://nostrnests.com/) web app loads rooms at `/:naddr` (same pattern as their share modal). */
const NOSTR_NESTS_WEB_ORIGIN = 'https://nostrnests.com'
const EMPTY_PARENT_MAP = new Map<string, Event>()
/** Max extra REQ filters when resolving 30312 parents for 30313 meetings (relay limits). */
export const LIVE_ACTIVITIES_MAX_PARENT_FETCH = 32
export type LiveActivitiesFetchEventsFn = (
urls: string[],
filter: Filter | Filter[],
opts?: { eoseTimeout?: number; globalTimeout?: number; replaceableRace?: boolean; immediateReturn?: boolean }
) => Promise<Event[]>
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const
@ -36,6 +53,31 @@ function firstTagValue(ev: Event, name: string): string | undefined { @@ -36,6 +53,31 @@ function firstTagValue(ev: Event, name: string): string | undefined {
return undefined
}
function parseOptionalUnixTag(ev: Event, name: string): number | undefined {
const v = firstTagValue(ev, name)
if (v === undefined) return undefined
const n = Number.parseInt(v, 10)
if (!Number.isFinite(n)) return undefined
return n
}
/** True when `ends` is in the past (NIP-53 scheduled window). */
function isPastScheduledEndsTag(ev: Event, nowSec: number): boolean {
const ends = parseOptionalUnixTag(ev, 'ends')
if (ends === undefined) return false
return nowSec > ends
}
/** Hide ticker entries that are explicitly ended or past `ends`; `live` is often stale. */
function isNip53TickerExpired(ev: Event, nowSec: number): boolean {
const st = firstTagValue(ev, 'status')?.toLowerCase()
if (st === 'ended') return true
if (ev.kind === 30311 || ev.kind === 30313) {
if (isPastScheduledEndsTag(ev, nowSec)) return true
}
return false
}
/** HLS/DASH manifests and similar — opening in a tab usually triggers a download, not a join page. */
function isLikelyRawStreamManifestUrl(url: string): boolean {
try {
@ -51,11 +93,125 @@ function isLikelyRawStreamManifestUrl(url: string): boolean { @@ -51,11 +93,125 @@ function isLikelyRawStreamManifestUrl(url: string): boolean {
}
}
function relayHintsFromEvent(ev: Event): string[] | undefined {
const out: string[] = []
for (const t of ev.tags) {
if (t[0] !== 'relays') continue
for (let i = 1; i < t.length; i++) {
const u = t[i]?.trim()
if (u) out.push(u)
}
}
return out.length > 0 ? out.slice(0, 8) : undefined
}
/** Bare `naddr1…` or zap.stream path → canonical https URL (matches zap.stream router `/:id`). */
function normalizeTaggedJoinCandidate(raw: string): string | undefined {
const t = raw.trim()
if (!t) return undefined
if (t.startsWith('naddr1') && t.length >= 16) {
return `${ZAP_STREAM_ORIGIN}/${t}`
}
if (t.startsWith('https://zap.stream/')) return t
if (t.startsWith('http://zap.stream/')) {
return `https://zap.stream/${t.slice('http://zap.stream/'.length)}`
}
if (t.startsWith('https://')) return t
return undefined
}
function naddrPageUrlForAddressable(ev: Event, origin: string): string | undefined {
const d = firstTagValue(ev, 'd')
if (!d) return undefined
try {
const relays = relayHintsFromEvent(ev)
const naddr = nip19.naddrEncode({
kind: ev.kind,
pubkey: ev.pubkey,
identifier: d,
relays: relays?.length ? relays : undefined
})
return `${origin}/${naddr}`
} catch {
return undefined
}
}
/** NIP-19 naddr for this addressable event → `https://zap.stream/naddr1…` (live player page). */
function zapStreamUrlForAddressable(ev: Event): string | undefined {
return naddrPageUrlForAddressable(ev, ZAP_STREAM_ORIGIN)
}
/**
* Official Nostr Nests ([nostrnests/nests](https://github.com/nostrnests/nests)) rooms tag MoQ relay + moq-auth;
* `streaming` is not a browser join URL prefer the web app naddr route.
*/
function isNostrNestsOfficialMoq30312(ev: Event): boolean {
if (ev.kind !== 30312) return false
const auth = firstTagValue(ev, 'auth') ?? ''
if (auth.includes('moq-auth.nostrnests.com')) return true
const stream = firstTagValue(ev, 'streaming') ?? ''
try {
const host = new URL(stream).hostname.toLowerCase()
return host === 'moq.nostrnests.com'
} catch {
return stream.includes('moq.nostrnests.com')
}
}
function nostrNestsWebUrlForAddressable(ev: Event): string | undefined {
return naddrPageUrlForAddressable(ev, NOSTR_NESTS_WEB_ORIGIN)
}
/** [Corny Chat](https://github.com/vicariousdrama/cornychat) kind-1 invites: same room URL on `r` / `service` / `streaming`; prefer `r` (explicit room link). */
function isCornyChatKind1Invite(ev: Event): boolean {
if (ev.kind !== 1) return false
let hasL = false
let hasAudioServer = false
for (const t of ev.tags) {
if (t[0] === 'L' && t[1] === 'com.cornychat') hasL = true
if (t[0] === 'audioserver' && t[1]) hasAudioServer = true
}
return hasL || hasAudioServer
}
function firstHttpsJoinFromTagNames(ev: Event, names: readonly string[]): string | undefined {
for (const name of names) {
const raw = firstTagValue(ev, name)
if (!raw?.trim()) continue
const url = normalizeTaggedJoinCandidate(raw.trim())
if (!url?.startsWith('https://')) continue
if (isLikelyRawStreamManifestUrl(url)) continue
return url
}
return undefined
}
/**
* URL for join this live space in the browser. NIP-53 `streaming` is often a raw `.m3u8` feed; prefer
* `service` (access URL), then `r` (e.g. Corny Chat room page), then non-manifest `streaming` / `endpoint`.
* URL to open for this activity.
* **30311:** Always use canonical [zap.stream/naddr](https://zap.stream) when `d` is present so we never
* stick on stale `service`/`r` URLs publishers no longer use. zap.stream loads the same NIP-53 event and
* plays `streaming` / etc. Fallbacks only if naddr cannot be built.
* **30312 (Nostr Nests official MoQ):** Prefer [nostrnests.com/naddr](https://nostrnests.com/) over `streaming` (MoQ).
* **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape.
* **Other 30312 / 30313:** Use tagged https URLs, bare `naddr1`, or (for 30313) parent space URLs via {@link resolveJoinUrl}.
*/
function pickHttpsJoinUrl(ev: Event): string | undefined {
function pickJoinUrl(ev: Event): string | undefined {
if (ev.kind === 30311) {
const zap = zapStreamUrlForAddressable(ev)
if (zap) return zap
}
if (ev.kind === 30312 && isNostrNestsOfficialMoq30312(ev)) {
const nests = nostrNestsWebUrlForAddressable(ev)
if (nests) return nests
}
if (isCornyChatKind1Invite(ev)) {
const corny = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming'])
if (corny) return corny
}
const candidates: Array<string | undefined> = [
firstTagValue(ev, 'service'),
firstTagValue(ev, 'r'),
@ -63,24 +219,179 @@ function pickHttpsJoinUrl(ev: Event): string | undefined { @@ -63,24 +219,179 @@ function pickHttpsJoinUrl(ev: Event): string | undefined {
firstTagValue(ev, 'endpoint')
]
for (const raw of candidates) {
if (!raw?.startsWith('https://')) continue
if (isLikelyRawStreamManifestUrl(raw)) continue
return raw
if (!raw?.trim()) continue
const url = normalizeTaggedJoinCandidate(raw.trim())
if (!url?.startsWith('https://')) continue
if (isLikelyRawStreamManifestUrl(url)) continue
return url
}
if (ev.kind === 30311) {
const stream = firstTagValue(ev, 'streaming')
if (stream?.startsWith('https://')) return stream.trim()
}
return undefined
}
export function parseLiveActivityEvent(ev: Event, followSet: Set<string>): TLiveActivityItem | null {
/**
* Browser join URL for NIP-53 ticker kinds and known audio-space invites (e.g. Corny Chat kind 1 with `L`/`audioserver`).
* Prefer this over raw tag order when opening rooms from the feed or tooling.
*/
export function preferredLiveJoinUrlForEvent(ev: Event): string | undefined {
return pickJoinUrl(ev)
}
/**
* NIP-53 uses different `status` vocabulary per kind:
* - 30311 live stream: `planned` | `live` | `ended`
* - 30312 meeting space: `open` | `private` | `closed` (never `live`)
* - 30313 meeting in a space: `planned` | `live` | `ended`
*/
function isActiveLiveActivityStatus(ev: Event): boolean {
const status = firstTagValue(ev, 'status')
if (ev.kind === 30312) {
return status === 'open' || status === 'private'
}
if (ev.kind === 30311 || ev.kind === 30313) {
return status === 'live'
}
return false
}
/** Parse NIP-33 address `kind:hex64pubkey:d` (used in `a` tags and dedupe keys). */
export function parseNip33Address(ref: string): { kind: number; pubkey: string; d: string } | null {
const m = /^(\d+):([0-9a-f]{64}):(.+)$/i.exec(ref.trim())
if (!m) return null
const kind = Number(m[1])
if (!Number.isFinite(kind)) return null
return { kind, pubkey: m[2], d: m[3] }
}
/** Parent meeting space (30312) address from a 30313 event’s `a` tag, if any. */
export function firstParent30312Address(ev: Event): string | null {
for (const t of ev.tags) {
if (t[0] !== 'a' || !t[1]) continue
const p = parseNip33Address(t[1])
if (p && p.kind === 30312) return `30312:${p.pubkey}:${p.d}`
}
return null
}
function resolveJoinUrl(ev: Event, parentByAddress: ReadonlyMap<string, Event>): string | undefined {
const direct = pickJoinUrl(ev)
if (direct) return direct
if (ev.kind !== 30313) return undefined
const parentAddr = firstParent30312Address(ev)
if (!parentAddr) return undefined
const parent = parentByAddress.get(parentAddr)
return parent ? pickJoinUrl(parent) : undefined
}
function dedupeEventsById(events: Event[]): Event[] {
const byId = new Map<string, Event>()
for (const ev of events) {
const prev = byId.get(ev.id)
if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev)
}
return [...byId.values()]
}
function dedupeLatestForLiveTicker(events: Event[]): Map<string, Event> {
const byAddress = new Map<string, Event>()
for (const ev of events) {
const d = firstTagValue(ev, 'd')
if (!d) continue
const addr = `${ev.kind}:${ev.pubkey}:${d}`
const prev = byAddress.get(addr)
if (!prev || ev.created_at > prev.created_at) {
byAddress.set(addr, ev)
}
}
return byAddress
}
/** Latest 30312 space event per address from an event list (no network). */
export function parent30312MapFromEvents(events: Event[]): Map<string, Event> {
const m = new Map<string, Event>()
for (const ev of events) {
if (ev.kind !== 30312) continue
const d = firstTagValue(ev, 'd')
if (!d) continue
const addr = `30312:${ev.pubkey}:${d}`
const prev = m.get(addr)
if (!prev || ev.created_at > prev.created_at) m.set(addr, ev)
}
return m
}
/**
* Fetch kind 30312 parent spaces referenced by kind 30313 meetings that lack their own join URL.
* Merges with any 30312 already present in `events`.
*/
export async function resolveParentSpacesForLiveActivities(
events: Event[],
relayUrls: string[],
fetchEvents: LiveActivitiesFetchEventsFn
): Promise<Map<string, Event>> {
const parentMap = parent30312MapFromEvents(events)
const latest = dedupeLatestForLiveTicker(events)
const needed: string[] = []
const seen = new Set<string>()
for (const ev of latest.values()) {
if (ev.kind !== 30313) continue
if (pickJoinUrl(ev)) continue
const pa = firstParent30312Address(ev)
if (!pa || parentMap.has(pa) || seen.has(pa)) continue
seen.add(pa)
needed.push(pa)
}
const slice = needed.slice(0, LIVE_ACTIVITIES_MAX_PARENT_FETCH)
if (slice.length === 0) return parentMap
const filters: Filter[] = []
for (const addr of slice) {
const p = parseNip33Address(addr)
if (!p || p.kind !== 30312) continue
filters.push({ kinds: [30312], authors: [p.pubkey], '#d': [p.d], limit: 12 })
}
if (filters.length === 0) return parentMap
const fetched = await fetchEvents(relayUrls, filters, {
eoseTimeout: 6000,
globalTimeout: 12_000
})
const merged = new Map(parentMap)
for (const ev of fetched) {
if (ev.kind !== 30312) continue
const d = firstTagValue(ev, 'd')
if (!d) continue
const addr = `30312:${ev.pubkey}:${d}`
const prev = merged.get(addr)
if (!prev || ev.created_at > prev.created_at) merged.set(addr, ev)
}
return merged
}
export function parseLiveActivityEvent(
ev: Event,
followSet: Set<string>,
parentByAddress: ReadonlyMap<string, Event> = EMPTY_PARENT_MAP,
nowSec: number = Math.floor(Date.now() / 1000)
): TLiveActivityItem | null {
if (!LIVE_ACTIVITY_KINDS.includes(ev.kind as (typeof LIVE_ACTIVITY_KINDS)[number])) return null
if (firstTagValue(ev, 'status') !== 'live') return null
if (isNip53TickerExpired(ev, nowSec)) return null
if (!isActiveLiveActivityStatus(ev)) return null
const dTag = firstTagValue(ev, 'd')
if (!dTag) return null
const joinUrl = pickHttpsJoinUrl(ev)
const joinUrl = resolveJoinUrl(ev, parentByAddress)
if (!joinUrl) return null
const title =
firstTagValue(ev, 'title')?.trim() ||
firstTagValue(ev, 'room')?.trim() ||
'Live'
ev.kind === 30312
? firstTagValue(ev, 'room')?.trim() ||
firstTagValue(ev, 'title')?.trim() ||
'Live space'
: firstTagValue(ev, 'title')?.trim() ||
firstTagValue(ev, 'room')?.trim() ||
'Live'
const summary = firstTagValue(ev, 'summary')?.trim() || ''
const image = firstTagValue(ev, 'image')
const imageUrl = image?.startsWith('https://') ? image : undefined
@ -101,22 +412,20 @@ export function parseLiveActivityEvent(ev: Event, followSet: Set<string>): TLive @@ -101,22 +412,20 @@ export function parseLiveActivityEvent(ev: Event, followSet: Set<string>): TLive
/**
* Keep newest event per NIP-33 address (`kind:pubkey:d`), then sort: followed hosts first, then `updatedAt` desc.
* `parentByAddress`: latest 30312 per `30312:pubkey:d` for resolving 30313 join URLs from parent `service`.
*/
export function mergeLiveActivityEvents(events: Event[], followPubkeys: string[]): TLiveActivityItem[] {
export function mergeLiveActivityEvents(
events: Event[],
followPubkeys: string[],
parentByAddress: ReadonlyMap<string, Event> = EMPTY_PARENT_MAP
): TLiveActivityItem[] {
const followSet = new Set(followPubkeys)
const byAddress = new Map<string, Event>()
for (const ev of events) {
const d = firstTagValue(ev, 'd')
if (!d) continue
const addr = `${ev.kind}:${ev.pubkey}:${d}`
const prev = byAddress.get(addr)
if (!prev || ev.created_at > prev.created_at) {
byAddress.set(addr, ev)
}
}
const nowSec = Math.floor(Date.now() / 1000)
const unique = dedupeEventsById(events)
const byAddress = dedupeLatestForLiveTicker(unique)
const items: TLiveActivityItem[] = []
for (const ev of byAddress.values()) {
const parsed = parseLiveActivityEvent(ev, followSet)
const parsed = parseLiveActivityEvent(ev, followSet, parentByAddress, nowSec)
if (parsed) items.push(parsed)
}
items.sort((a, b) => {

3
src/main.tsx

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import './i18n'
import './index.css'
import './polyfill'
import './services/lightning.service'
@ -9,6 +8,7 @@ import { StrictMode } from 'react' @@ -9,6 +8,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import { initI18n } from './i18n'
import storage from './services/local-storage.service'
import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service'
@ -64,6 +64,7 @@ async function bootstrap() { @@ -64,6 +64,7 @@ async function bootstrap() {
window.__RUNTIME_CONFIG__ = {}
console.info('[jumble] Boot: opening storage and loading config…')
await Promise.all([
initI18n(),
storage.initAsync(),
(async () => {
try {

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

@ -8,7 +8,7 @@ import { @@ -8,7 +8,7 @@ import {
NOTIFICATION_LIST_STYLE,
RANDOM_PUBLISH_RELAY_COUNT
} from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import { changeAppLanguage, LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
@ -51,8 +51,8 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -51,8 +51,8 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
updateShowLiveActivitiesBanner
} = useUserPreferences()
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
const handleLanguageChange = async (value: TLanguage) => {
await changeAppLanguage(value)
setLanguage(value)
}

6
src/providers/LiveActivitiesProvider.tsx

@ -3,6 +3,7 @@ import { @@ -3,6 +3,7 @@ import {
LIVE_ACTIVITY_KINDS,
mergeLiveActivityEvents,
msUntilNextQuarterHour,
resolveParentSpacesForLiveActivities,
type TLiveActivityItem
} from '@/lib/live-activities'
import logger from '@/lib/logger'
@ -69,7 +70,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -69,7 +70,10 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
{ kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 },
{ eoseTimeout: 6000, globalTimeout: 14_000 }
)
const merged = mergeLiveActivityEvents(events, followings)
const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) =>
client.fetchEvents(u, f, o)
)
const merged = mergeLiveActivityEvents(events, followings, parentByAddress)
setItems(merged)
logger.debug('[LiveActivities] poll done', { relayCount: urls.length, raw: events.length, merged: merged.length })
} catch (e) {

102
vite.config.ts

@ -123,117 +123,149 @@ export default defineConfig(({ mode }) => { @@ -123,117 +123,149 @@ export default defineConfig(({ mode }) => {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
const norm = id.replace(/\\/g, '/')
// One chunk per locale file — `i18n/index` statically imports all of them; splitting keeps
// the main app chunk smaller and allows parallel fetch + finer cache invalidation.
const localeMatch = norm.match(/\/i18n\/locales\/([^/]+)\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/)
if (localeMatch) {
const code = localeMatch[1].replace(/[^a-zA-Z0-9_-]/g, '_')
return `i18n-locale-${code}`
}
if (!norm.includes('node_modules')) return undefined
// Lazy-loaded only — must not share a chunk with sync vendors or it gets preloaded
if (id.includes('@asciidoctor')) {
if (norm.includes('@asciidoctor')) {
return 'vendor-asciidoctor'
}
if (id.includes('/katex/') || id.includes('node_modules/katex/')) {
if (norm.includes('/katex/') || norm.includes('node_modules/katex/')) {
return 'vendor-katex'
}
// React core (load first; keep together)
if (/node_modules\/(react-dom|react\/|scheduler\/|use-sync-external-store\/)/.test(id)) {
if (/node_modules\/(react-dom|react\/|scheduler\/|use-sync-external-store\/)/.test(norm)) {
return 'vendor-react'
}
// TipTap + ProseMirror
if (id.includes('@tiptap') || id.includes('prosemirror-')) {
return 'vendor-editor'
// ProseMirror vs TipTap — avoids one ~750k editor blob; both load together when editor mounts
if (norm.includes('prosemirror-')) {
return 'vendor-prosemirror'
}
if (norm.includes('@tiptap')) {
return 'vendor-tiptap'
}
// Radix UI primitives
if (id.includes('@radix-ui')) {
if (norm.includes('@radix-ui')) {
return 'vendor-radix'
}
// Nostr + crypto used by the stack
if (
id.includes('nostr-tools') ||
id.includes('@noble') ||
id.includes('@scure')
norm.includes('nostr-tools') ||
norm.includes('@noble') ||
norm.includes('@scure')
) {
return 'vendor-nostr'
}
if (id.includes('lucide-react')) {
if (norm.includes('lucide-react')) {
return 'vendor-lucide'
}
if (id.includes('i18next') || id.includes('react-i18next')) {
return 'vendor-i18n'
if (norm.includes('i18next') || norm.includes('react-i18next')) {
return 'vendor-i18n-runtime'
}
if (id.includes('@dnd-kit')) {
if (norm.includes('@dnd-kit')) {
return 'vendor-dnd'
}
if (id.includes('highlight.js')) {
if (norm.includes('highlight.js')) {
return 'vendor-highlight'
}
if (id.includes('flexsearch')) {
if (norm.includes('flexsearch')) {
return 'vendor-flexsearch'
}
if (id.includes('emoji-picker-react')) {
if (norm.includes('emoji-picker-react')) {
return 'vendor-emoji'
}
if (id.includes('yet-another-react-lightbox')) {
if (norm.includes('yet-another-react-lightbox')) {
return 'vendor-lightbox'
}
if (
id.includes('@getalby') ||
id.includes('bitcoin-connect') ||
id.includes('nstart-modal')
) {
return 'vendor-lightning'
if (norm.includes('@getalby') || norm.includes('bitcoin-connect')) {
return 'vendor-lightning-alby'
}
if (norm.includes('nstart-modal')) {
return 'vendor-lightning-nstart'
}
if (id.includes('embla-carousel')) {
if (norm.includes('embla-carousel')) {
return 'vendor-embla'
}
if (id.includes('qr-code-styling') || id.includes('/qr-scanner/')) {
if (norm.includes('qr-code-styling') || norm.includes('/qr-scanner/')) {
return 'vendor-qr'
}
if (id.includes('/cmdk/')) {
if (norm.includes('/cmdk/')) {
return 'vendor-cmdk'
}
if (id.includes('/vaul/')) {
if (norm.includes('/vaul/')) {
return 'vendor-vaul'
}
if (id.includes('tippy.js')) {
if (norm.includes('tippy.js')) {
return 'vendor-tippy'
}
if (id.includes('/zod/') || id.includes('node_modules/zod')) {
if (norm.includes('/zod/') || norm.includes('node_modules/zod')) {
return 'vendor-zod'
}
if (id.includes('/dayjs/')) {
if (norm.includes('/dayjs/')) {
return 'vendor-dayjs'
}
if (id.includes('/sonner/')) {
if (norm.includes('/sonner/')) {
return 'vendor-sonner'
}
if (id.includes('blossom-client-sdk')) {
if (norm.includes('blossom-client-sdk')) {
return 'vendor-blossom'
}
if (id.includes('@popperjs')) {
if (norm.includes('@popperjs')) {
return 'vendor-popper'
}
if (norm.includes('@floating-ui')) {
return 'vendor-floating-ui'
}
if (norm.includes('/blurhash/') || norm.includes('node_modules/blurhash')) {
return 'vendor-blurhash'
}
if (norm.includes('/dataloader/') || norm.includes('node_modules/dataloader')) {
return 'vendor-dataloader'
}
if (
norm.includes('tailwind-merge') ||
norm.includes('/clsx/') ||
norm.includes('class-variance-authority')
) {
return 'vendor-clsx-tailwind'
}
return 'vendor-misc'
}
},

Loading…
Cancel
Save