Browse Source

efficiency fix

imwald
Silberengel 1 month ago
parent
commit
451649b381
  1. 3
      src/App.tsx
  2. 75
      src/components/PostEditor/PostContent.tsx
  3. 13
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  4. 1
      src/constants.ts
  5. 20
      src/lib/discussion-thread-composer.ts
  6. 14
      src/lib/nip05.ts
  7. 34
      src/lib/optional-proxy-session.ts
  8. 57
      src/lib/translate-client.ts
  9. 3
      src/pages/primary/DiscussionsPage/discussionTopics.ts
  10. 8
      src/providers/FavoriteRelaysActivityProvider.tsx
  11. 118
      src/providers/GroupListProvider.tsx
  12. 5
      src/providers/LiveActivitiesProvider.tsx
  13. 18
      src/providers/group-list-context.tsx
  14. 63
      src/services/client.service.ts
  15. 18
      src/services/live-activities-prewarm-bridge.ts
  16. 9
      src/services/rss-feed.service.ts
  17. 27
      src/services/session-interactive-prewarm-bridge.ts
  18. 44
      src/services/web.service.ts

3
src/App.tsx

@ -12,7 +12,6 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' @@ -12,7 +12,6 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FontSizeProvider } from '@/providers/FontSizeProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
import { GroupListProvider } from '@/providers/GroupListProvider'
import { InterestListProvider } from '@/providers/InterestListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
@ -50,7 +49,6 @@ export default function App(): JSX.Element { @@ -50,7 +49,6 @@ export default function App(): JSX.Element {
<MuteListProvider>
<FavoriteRelaysActivityProvider>
<InterestListProvider>
<GroupListProvider>
<UserTrustProvider>
<BookmarksProvider>
<FeedProvider>
@ -73,7 +71,6 @@ export default function App(): JSX.Element { @@ -73,7 +71,6 @@ export default function App(): JSX.Element {
</FeedProvider>
</BookmarksProvider>
</UserTrustProvider>
</GroupListProvider>
</InterestListProvider>
</FavoriteRelaysActivityProvider>
</MuteListProvider>

75
src/components/PostEditor/PostContent.tsx

@ -60,7 +60,6 @@ import { @@ -60,7 +60,6 @@ import {
ListTodo,
MessageCircle,
MessagesSquare,
Users,
X,
Highlighter,
FileText,
@ -94,7 +93,6 @@ import { @@ -94,7 +93,6 @@ import {
import { prefixNostrAddresses } from '@/lib/nostr-address'
import dayjs from 'dayjs'
import { TDraftEvent } from '@/types'
import { useGroupList } from '@/providers/group-list-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics'
@ -194,7 +192,6 @@ export default function PostContent({ @@ -194,7 +192,6 @@ export default function PostContent({
}) {
const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const { userGroups } = useGroupList()
const { addReplies } = useReply()
const mergePublishedReplyIntoThread = useCallback(
@ -308,13 +305,11 @@ export default function PostContent({ @@ -308,13 +305,11 @@ export default function PostContent({
return row?.label ?? 'general'
})
const [threadSelectedTopic, setThreadSelectedTopic] = useState('general')
const [threadSelectedGroup, setThreadSelectedGroup] = useState('')
const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false)
const [threadReadingAuthor, setThreadReadingAuthor] = useState('')
const [threadReadingSubject, setThreadReadingSubject] = useState('')
const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false)
const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false)
const [threadGroupPopoverOpen, setThreadGroupPopoverOpen] = useState(false)
const [threadErrors, setThreadErrors] = useState<{
title?: string
content?: string
@ -322,7 +317,6 @@ export default function PostContent({ @@ -322,7 +317,6 @@ export default function PostContent({
relay?: string
author?: string
subject?: string
group?: string
}>({})
const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
@ -438,7 +432,6 @@ export default function PostContent({ @@ -438,7 +432,6 @@ export default function PostContent({
processedContent: prefixNostrAddresses(text.trim()),
topicForTags: resolved,
title: threadTitle,
selectedGroup: threadSelectedGroup,
dynamicTopics: discussionDynamicTopics,
isReadingGroup: threadIsReadingGroup,
author: threadReadingAuthor,
@ -451,7 +444,6 @@ export default function PostContent({ @@ -451,7 +444,6 @@ export default function PostContent({
allAvailableTopics,
text,
threadTitle,
threadSelectedGroup,
discussionDynamicTopics,
threadIsReadingGroup,
threadReadingAuthor,
@ -485,8 +477,7 @@ export default function PostContent({ @@ -485,8 +477,7 @@ export default function PostContent({
!!text.trim() &&
text.length <= 5000 &&
additionalRelayUrls.length > 0 &&
(!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())) &&
(threadTopicResolved !== 'groups' || !!threadSelectedGroup.trim()))
(!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())))
const result = (
!!pubkey &&
!posting &&
@ -537,7 +528,6 @@ export default function PostContent({ @@ -537,7 +528,6 @@ export default function PostContent({
threadIsReadingGroup,
threadReadingAuthor,
threadReadingSubject,
threadSelectedGroup,
relayCapBlockInfo
])
@ -805,7 +795,6 @@ export default function PostContent({ @@ -805,7 +795,6 @@ export default function PostContent({
processedContent: processed,
topicForTags: topicResolved,
title: threadTitle,
selectedGroup: threadSelectedGroup,
dynamicTopics: discussionDynamicTopics,
isReadingGroup: threadIsReadingGroup,
author: threadReadingAuthor,
@ -1130,7 +1119,6 @@ export default function PostContent({ @@ -1130,7 +1119,6 @@ export default function PostContent({
allAvailableTopics,
threadSelectedTopic,
threadTitle,
threadSelectedGroup,
discussionDynamicTopics,
threadIsReadingGroup,
threadReadingAuthor,
@ -1299,9 +1287,6 @@ export default function PostContent({ @@ -1299,9 +1287,6 @@ export default function PostContent({
newErrors.subject = t('Subject (book title) is required for reading groups')
}
}
if (topicResolved === 'groups' && !threadSelectedGroup.trim()) {
newErrors.group = t('Please select a group')
}
setThreadErrors(newErrors)
if (Object.keys(newErrors).length > 0) {
return
@ -2246,7 +2231,6 @@ export default function PostContent({ @@ -2246,7 +2231,6 @@ export default function PostContent({
setThreadSelectedTopic('general')
const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general')
setThreadTopicInput(gRow?.label ?? 'general')
setThreadSelectedGroup('')
setThreadIsReadingGroup(false)
setThreadReadingAuthor('')
setThreadReadingSubject('')
@ -2420,63 +2404,6 @@ export default function PostContent({ @@ -2420,63 +2404,6 @@ export default function PostContent({
</p>
</div>
{threadTopicResolved === 'groups' && (
<div className="space-y-2">
<Label htmlFor="discussion-group" className="text-sm font-medium">
{t('Select Group')}
</Label>
<Popover open={threadGroupPopoverOpen} onOpenChange={setThreadGroupPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={threadGroupPopoverOpen}
title={t('Select group...')}
className="h-9 w-full justify-between bg-background font-normal"
>
{threadSelectedGroup ? threadSelectedGroup : t('Select group...')}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="z-[10000] w-[--radix-popover-trigger-width] p-2"
align="start"
side="bottom"
sideOffset={4}
>
<div className="max-h-60 overflow-y-auto">
{userGroups.length === 0 ? (
<div className="p-2 text-center text-sm text-muted-foreground">
{t('No groups available. Join some groups first.')}
</div>
) : (
userGroups.map((groupId) => (
<div
key={groupId}
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent"
onClick={() => {
setThreadSelectedGroup(groupId)
setThreadGroupPopoverOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${threadSelectedGroup === groupId ? 'opacity-100' : 'opacity-0'}`}
/>
<Users className="mr-2 h-4 w-4" />
{groupId}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
{threadErrors.group && <p className="text-sm text-destructive">{threadErrors.group}</p>}
<p className="text-xs text-muted-foreground">
{t('Select the group where you want to create this discussion.')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="discussion-thread-title" className="text-sm font-medium">
{t('Title')} <span className="text-destructive">*</span>

13
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -17,12 +17,13 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -17,12 +17,13 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFollowListOptional } from '@/providers/follow-list-context'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge'
import indexedDb from '@/services/indexed-db.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
import { type Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { Button } from '@/components/ui/button'
@ -47,6 +48,14 @@ export default function SidebarCalendarWeekWidget() { @@ -47,6 +48,14 @@ export default function SidebarCalendarWeekWidget() {
const { navigateToNote } = useSmartNoteNavigation()
const { navigate: navigatePrimary } = usePrimaryPage()
const [prewarmRefreshKey, bumpPrewarmRefresh] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
return registerSessionInteractivePrewarmListener(() => {
bumpPrewarmRefresh()
})
}, [])
const [weekOffset, setWeekOffset] = useState(0)
const [rawEvents, setRawEvents] = useState<Event[]>([])
@ -269,7 +278,7 @@ export default function SidebarCalendarWeekWidget() { @@ -269,7 +278,7 @@ export default function SidebarCalendarWeekWidget() {
cancelled = true
if (lateMergeTimer != null) window.clearTimeout(lateMergeTimer)
}
}, [relayKey, followAuthorsKey, weekOffset])
}, [relayKey, followAuthorsKey, weekOffset, prewarmRefreshKey])
const openEvent = useCallback(
(ev: Event) => {

1
src/constants.ts

@ -504,7 +504,6 @@ export const ExtendedKind = { @@ -504,7 +504,6 @@ export const ExtendedKind = {
HTTP_RELAY_LIST: 10243,
RELAY_REVIEW: 31987,
GROUP_METADATA: 39000,
GROUP_LIST: 10009, // NIP-51 Group List
/** NIP-51 follow sets (addressable); `p` tags name pubkeys in the set */
FOLLOW_SET: 30000,
ZAP_REQUEST: 9734,

20
src/lib/discussion-thread-composer.ts

@ -74,10 +74,9 @@ export function buildAllAvailableTopics(dynamicTopics?: TDiscussionDynamicTopics @@ -74,10 +74,9 @@ export function buildAllAvailableTopics(dynamicTopics?: TDiscussionDynamicTopics
if (dynamicTopics) {
dynamicTopics.mainTopics.forEach((dynamicTopic) => {
const isGroupsTopic = dynamicTopic.id === 'groups'
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) ${isGroupsTopic ? '👥' : '🔥'}`,
label: `${dynamicTopic.label} (${dynamicTopic.count}) 🔥`,
icon: Hash
})
})
@ -138,32 +137,17 @@ export function collectDiscussionThreadTags(params: { @@ -138,32 +137,17 @@ export function collectDiscussionThreadTags(params: {
processedContent: string
topicForTags: string
title: string
selectedGroup: string
dynamicTopics?: TDiscussionDynamicTopics | null
isReadingGroup: boolean
author: string
subject: string
isNsfw: boolean
}): string[][] {
const {
processedContent,
topicForTags,
title,
selectedGroup,
dynamicTopics,
isReadingGroup,
author,
subject,
isNsfw
} = params
const { processedContent, topicForTags, title, dynamicTopics, isReadingGroup, author, subject, isNsfw } = params
const images = extractImagesFromContent(processedContent)
const hashtags = extractHashtagsFromContent(processedContent)
const tags: string[][] = [['title', title.trim()], ['-']]
if (topicForTags === 'groups' && selectedGroup) {
tags.push(['h', selectedGroup])
}
if (topicForTags !== 'all' && topicForTags !== 'general' && topicForTags !== 'groups') {
const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags)

14
src/lib/nip05.ts

@ -1,5 +1,10 @@ @@ -1,5 +1,10 @@
import { LRUCache } from 'lru-cache'
import { nip19 } from 'nostr-tools'
import {
clearSitesProxyUnavailableThisSession,
isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session'
import { buildViteProxySitesFetchUrl } from '@/lib/vite-proxy-url'
import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from './pubkey'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
@ -187,7 +192,8 @@ async function fetchWellKnownNostrJsonOnce( @@ -187,7 +192,8 @@ async function fetchWellKnownNostrJsonOnce(
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const fetchUrl = proxyServer ? buildViteProxySitesFetchUrl(targetUrl, proxyServer) : targetUrl
const useProxy = Boolean(proxyServer && !isSitesProxyUnavailableThisSession())
const fetchUrl = useProxy ? buildViteProxySitesFetchUrl(targetUrl, proxyServer!) : targetUrl
try {
const res = await fetchWithTimeout(fetchUrl, {
credentials: 'omit',
@ -197,7 +203,11 @@ async function fetchWellKnownNostrJsonOnce( @@ -197,7 +203,11 @@ async function fetchWellKnownNostrJsonOnce(
timeoutMs: 15_000
})
/** NIP-05: well-known MUST NOT redirect; following redirects can land on unrelated JSON. */
if (res.redirected || !res.ok) return null
if (res.redirected || !res.ok) {
if (useProxy && !res.redirected) markSitesProxyUnavailableFromHttpStatus(res.status)
return null
}
if (useProxy) clearSitesProxyUnavailableThisSession()
const data: unknown = await res.json()
return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : null
} catch {

34
src/lib/optional-proxy-session.ts

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
import logger from '@/lib/logger'
/**
* When `VITE_PROXY_SERVER` points at a dev stub that returns 502/503/504 for `/sites/?url=…`,
* remember for the rest of the tab lifetime so OG, NIP-05, and RSS do not hammer the proxy.
* Cleared on a successful proxy response.
*/
let sitesProxyUnavailableThisSession = false
let sitesProxySkipLogged = false
export function isSitesProxyUnavailableThisSession(): boolean {
return sitesProxyUnavailableThisSession
}
export function clearSitesProxyUnavailableThisSession(): void {
sitesProxyUnavailableThisSession = false
sitesProxySkipLogged = false
}
const BAD_GATEWAYISH = new Set([502, 503, 504])
export function markSitesProxyUnavailableFromHttpStatus(status: number): void {
if (!BAD_GATEWAYISH.has(status)) return
if (!sitesProxyUnavailableThisSession) {
sitesProxyUnavailableThisSession = true
if (import.meta.env.DEV && !sitesProxySkipLogged) {
sitesProxySkipLogged = true
logger.debug(
'[Optional proxy] Sites proxy returned ' +
`${status}; skipping further /sites/ proxy fetches this session (direct or fallbacks only).`
)
}
}
}

57
src/lib/translate-client.ts

@ -8,6 +8,19 @@ const memoryCache = new Map<string, { text: string; at: number }>() @@ -8,6 +8,19 @@ const memoryCache = new Map<string, { text: string; at: number }>()
const MAX_MEMORY = 80
const CACHE_TTL_MS = 1000 * 60 * 60 * 24
/** After `/languages` or `/translate` hits 502/503/504, skip further translate HTTP this tab (optional dev proxy). */
let translateBackendGoneThisSession = false
const translateOptionalLoggedKeys = new Set<string>()
function translateDevLogOnce(key: string, message: string, payload?: Record<string, unknown>): void {
if (translateOptionalLoggedKeys.has(key)) return
translateOptionalLoggedKeys.add(key)
if (import.meta.env.DEV) {
logger.debug(message, payload)
}
}
function cacheKey(source: string, sourceLang: string, targetLang: string): string {
const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`)))
return h
@ -25,6 +38,10 @@ function pruneMemory(): void { @@ -25,6 +38,10 @@ function pruneMemory(): void {
}
}
export function isTranslateBackendUnreachableThisSession(): boolean {
return translateBackendGoneThisSession
}
export function isTranslateConfigured(): boolean {
return Boolean(TRANSLATE_URL.trim())
}
@ -135,6 +152,14 @@ function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] { @@ -135,6 +152,14 @@ function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] {
export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption[]> {
const base = TRANSLATE_URL.trim().replace(/\/$/u, '')
if (!base) return []
if (translateBackendGoneThisSession) {
translateDevLogOnce(
'languages-skip',
'[Translate] /languages skipped — optional translate backend unavailable this session.'
)
recordAdvertisedTranslateCodesFromServer(languagesCache?.list ?? [])
return languagesCache?.list ?? []
}
const now = Date.now()
if (languagesCache) {
const ttl = languagesCache.fromFailure ? LANGUAGES_FAILURE_CACHE_TTL_MS : LANGUAGES_CACHE_TTL_MS
@ -152,17 +177,15 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption @@ -152,17 +177,15 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const res = await electronAwareFetch(url)
if (!res.ok) {
const t = Date.now()
if (t - lastLanguagesFailureLogAt > 10_000) {
if (res.status === 502 || res.status === 503 || res.status === 504) {
translateBackendGoneThisSession = true
translateDevLogOnce('languages-fail', '[Translate] Optional translate proxy offline (502/503/504); skipping further translate HTTP this session.', {
status: res.status
})
} else if (t - lastLanguagesFailureLogAt > 10_000) {
lastLanguagesFailureLogAt = t
if (import.meta.env.DEV && (res.status === 503 || res.status === 502)) {
logger.debug(
'[Translate] /languages skipped — dev translate proxy has no backend (:5000). See PROXY_SETUP.md.',
{ status: res.status }
)
} else {
logger.warn('[Translate] /languages failed', { status: res.status })
}
}
languagesCache = { list: [], at: t, fromFailure: true }
recordAdvertisedTranslateCodesFromServer([])
return []
@ -171,6 +194,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption @@ -171,6 +194,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const data = (await res.json()) as unknown
const list = parseLanguagesResponse(data)
languagesCache = { list, at: Date.now() }
translateBackendGoneThisSession = false
recordAdvertisedTranslateCodesFromServer(list)
return list
} catch (e) {
@ -194,6 +218,8 @@ export function clearTranslateLanguagesCache(): void { @@ -194,6 +218,8 @@ export function clearTranslateLanguagesCache(): void {
languagesCache = null
advertisedTranslateApiCodes = null
warmTranslateLanguagesPromise = null
translateBackendGoneThisSession = false
translateOptionalLoggedKeys.clear()
}
/**
@ -213,6 +239,13 @@ export async function translatePlainText( @@ -213,6 +239,13 @@ export async function translatePlainText(
if (!base) {
throw new Error('Translation URL not configured')
}
if (translateBackendGoneThisSession) {
translateDevLogOnce(
'translate-post-skip',
'[Translate] Skipping translate POST — optional backend unavailable this session.'
)
return text
}
/** LibreTranslate often trims `q` / `translatedText`; keep edge whitespace so markup segments still join cleanly. */
const leadingWs = text.match(/^\s*/u)?.[0] ?? ''
@ -269,6 +302,13 @@ export async function translatePlainText( @@ -269,6 +302,13 @@ export async function translatePlainText(
})
})
if (!res.ok) {
if (res.status === 502 || res.status === 503 || res.status === 504) {
translateBackendGoneThisSession = true
translateDevLogOnce('translate-post-fail', '[Translate] Optional translate proxy offline; skipping further translate HTTP this session.', {
status: res.status
})
return text
}
const err = await res.text().catch(() => '')
logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) })
const detail = err.replace(/\s+/gu, ' ').trim().slice(0, 160)
@ -278,6 +318,7 @@ export async function translatePlainText( @@ -278,6 +318,7 @@ export async function translatePlainText(
}
const data = (await res.json()) as { translatedText?: string }
const outCore = data.translatedText ?? ''
translateBackendGoneThisSession = false
pruneMemory()
memoryCache.set(key, { text: outCore, at: Date.now() })
logger.info('[AdvancedLab] translate', {

3
src/pages/primary/DiscussionsPage/discussionTopics.ts

@ -39,6 +39,5 @@ export const DISCUSSION_TOPICS = [ @@ -39,6 +39,5 @@ export const DISCUSSION_TOPICS = [
{ id: 'travel', label: 'Travel & Adventure', icon: MapPin },
{ id: 'home', label: 'Home & Garden', icon: Home },
{ id: 'pets', label: 'Pets & Animals', icon: PawPrint },
{ id: 'fashion', label: 'Fashion & Beauty', icon: Shirt },
{ id: 'groups', label: 'Groups', icon: Users }
{ id: 'fashion', label: 'Fashion & Beauty', icon: Shirt }
]

8
src/providers/FavoriteRelaysActivityProvider.tsx

@ -13,6 +13,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -13,6 +13,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { queryService, replaceableEventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -306,6 +307,13 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R @@ -306,6 +307,13 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
}
}, [viewerPubkey, followings.length])
/** After session interactive prewarm, relay URLs / follow context are stable — refresh pulse once. */
useEffect(() => {
return registerSessionInteractivePrewarmListener(() => {
void fetchRef.current()
})
}, [])
/** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */
useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | undefined

118
src/providers/GroupListProvider.tsx

@ -1,118 +0,0 @@ @@ -1,118 +0,0 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { ExtendedKind } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { buildPrioritizedReadRelayUrls } from '@/lib/relay-url-priority'
import client from '@/services/client.service'
import logger from '@/lib/logger'
import { GroupListContext } from './group-list-context'
export function GroupListProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [userGroups, setUserGroups] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
// Build comprehensive relay list for fetching group list
const buildComprehensiveRelayList = useCallback(async () => {
const myRelayList = accountPubkey
? await client.fetchRelayList(accountPubkey)
: {
write: [],
read: [],
originalRelays: [],
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
}
const favoritesTier = getFavoritesFeedRelayUrls(favoriteRelays ?? [], blockedRelays)
return buildPrioritizedReadRelayUrls({
userReadRelays: [...(myRelayList.httpRead ?? []), ...(myRelayList.read ?? [])],
userWriteRelays: [...(myRelayList.httpWrite ?? []), ...(myRelayList.write ?? [])],
favoriteRelays: favoritesTier,
blockedRelays,
applySocialKindBlockedFilter: false
})
}, [accountPubkey, favoriteRelays, blockedRelays])
// Fetch user's group list (kind 10009)
const fetchGroupList = useCallback(async () => {
if (!accountPubkey) {
setUserGroups([])
return
}
try {
setIsLoading(true)
logger.debug('[GroupListProvider] Fetching group list for user:', accountPubkey.substring(0, 8))
// Get comprehensive relay list
const allRelays = await buildComprehensiveRelayList()
const groupListEvent = await fetchLatestReplaceableListEvent(
accountPubkey,
ExtendedKind.GROUP_LIST,
allRelays
)
if (groupListEvent) {
logger.debug('[GroupListProvider] Found group list event:', groupListEvent.id.substring(0, 8))
// Extract groups from a-tags (group coordinates)
const groups: string[] = []
groupListEvent.tags.forEach(tag => {
if (tag[0] === 'a' && tag[1]) {
// Parse group coordinate: kind:pubkey:group-id
const coordinate = tag[1]
const parts = coordinate.split(':')
if (parts.length >= 3) {
const groupId = parts[2]
groups.push(groupId)
}
}
})
setUserGroups(groups)
logger.debug('[GroupListProvider] Extracted groups:', groups)
} else {
setUserGroups([])
logger.debug('[GroupListProvider] No group list found')
}
} catch (error) {
logger.error('[GroupListProvider] Error fetching group list:', error)
setUserGroups([])
} finally {
setIsLoading(false)
}
}, [accountPubkey, buildComprehensiveRelayList])
// Check if user is in a specific group
const isUserInGroup = useCallback((groupId: string): boolean => {
return userGroups.includes(groupId)
}, [userGroups])
// Refresh group list
const refreshGroupList = useCallback(async () => {
await fetchGroupList()
}, [fetchGroupList])
// Load group list on mount and when account changes
useEffect(() => {
fetchGroupList()
}, [fetchGroupList])
const contextValue = useMemo(() => ({
userGroups,
isUserInGroup,
refreshGroupList,
isLoading
}), [userGroups, isUserInGroup, refreshGroupList, isLoading])
return (
<GroupListContext.Provider value={contextValue}>
{children}
</GroupListContext.Provider>
)
}

5
src/providers/LiveActivitiesProvider.tsx

@ -12,7 +12,7 @@ import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' @@ -12,7 +12,7 @@ import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge'
import { registerSessionInteractivePrewarmListener } from '@/services/session-interactive-prewarm-bridge'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LiveActivitiesContext } from './live-activities-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
@ -125,10 +125,9 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -125,10 +125,9 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
refreshRef.current = refresh
useEffect(() => {
registerLiveActivitiesPrewarmCallback(() => {
return registerSessionInteractivePrewarmListener(() => {
void refreshRef.current()
})
return () => registerLiveActivitiesPrewarmCallback(null)
}, [])
useEffect(() => {

18
src/providers/group-list-context.tsx

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
import { createContext, useContext } from 'react'
export interface GroupListContextType {
userGroups: string[]
isUserInGroup: (groupId: string) => boolean
refreshGroupList: () => Promise<void>
isLoading: boolean
}
export const GroupListContext = createContext<GroupListContextType | undefined>(undefined)
export const useGroupList = (): GroupListContextType => {
const context = useContext(GroupListContext)
if (context === undefined) {
throw new Error('useGroupList must be used within a GroupListProvider')
}
return context
}

63
src/services/client.service.ts

@ -175,7 +175,7 @@ import { @@ -175,7 +175,7 @@ import {
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service'
import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge'
import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge'
import nip66Service from './nip66.service'
import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
@ -361,8 +361,9 @@ class ClientService extends EventTarget { @@ -361,8 +361,9 @@ class ClientService extends EventTarget {
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/**
* IndexedDB profile index + NIP-66 relay discovery run once per page session; when logged in,
* {@link initUserIndexFromFollowings} hydrates each follow's kind 0, 3, and 10002 in batches.
* IndexedDB profile index + NIP-66 relay discovery run once per page session. When logged in,
* {@link initUserIndexFromFollowings} runs **after** this batch completes (deferred) so startup is not
* blocked on hundreds of follow relay round-trips.
* @see {@link runSessionPrewarm}
*/
private sessionPrewarmBaseCompleted = false
@ -447,6 +448,16 @@ class ClientService extends EventTarget { @@ -447,6 +448,16 @@ class ClientService extends EventTarget {
return ClientService.instance
}
private async yieldForUiPaint(): Promise<void> {
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve())
} else {
setTimeout(resolve, 0)
}
})
}
private async prewarmProfileSearchIndexFromIdb(): Promise<void> {
const t0 = typeof performance !== 'undefined' ? performance.now() : 0
let profileRows = 0
@ -461,37 +472,50 @@ class ClientService extends EventTarget { @@ -461,37 +472,50 @@ class ClientService extends EventTarget {
}
/**
* One-shot batch: local profile search index + NIP-66 relay discovery (once per session) + optional following-profile fetch (parallel).
* Call after Nostr session is ready so it does not compete with the first relay-list REQ.
* One-shot: local profile @-index + NIP-66 relay discovery (once per session). When logged in,
* the heavy follow-list relay fetch runs **after** this returns (see {@link runSessionPrewarm}) so the
* session gate and live-activities prewarm hook are not held for minutes on large follow graphs.
*/
async runSessionPrewarm(options: { pubkey: string | null; signal?: AbortSignal }): Promise<void> {
const signal = options.signal ?? new AbortController().signal
const t0 = typeof performance !== 'undefined' ? performance.now() : 0
const tasks: Promise<unknown>[] = []
const fastTasks: Promise<unknown>[] = []
if (!this.sessionPrewarmBaseCompleted) {
this.sessionPrewarmBaseCompleted = true
tasks.push(this.prewarmProfileSearchIndexFromIdb(), this.fetchNip66RelayDiscovery())
}
if (options.pubkey) {
tasks.push(this.initUserIndexFromFollowings(options.pubkey, signal))
fastTasks.push(this.prewarmProfileSearchIndexFromIdb(), this.fetchNip66RelayDiscovery())
}
if (tasks.length === 0) {
notifyLiveActivitiesPrewarmComplete()
if (fastTasks.length === 0 && !options.pubkey) {
notifySessionInteractivePrewarmComplete()
return
}
logger.info('[client] Session prewarm batch started (parallel)', {
logger.info('[client] Session prewarm batch started (interactive)', {
hasPubkey: !!options.pubkey,
taskCount: tasks.length
fastTaskCount: fastTasks.length
})
const results = await Promise.allSettled(tasks)
logger.info('[client] Session prewarm batch finished', {
const fastResults = await Promise.allSettled(fastTasks)
logger.info('[client] Session prewarm batch finished (interactive)', {
ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined,
results: results.map((r) => r.status)
fastResults: fastResults.map((r) => r.status)
})
notifySessionInteractivePrewarmComplete()
if (options.pubkey) {
const pk = options.pubkey
/** Defer: follow graph pulls compete with first feed REQs; same hydrate {@link AbortSignal} still applies. */
void Promise.resolve().then(async () => {
try {
await this.initUserIndexFromFollowings(pk, signal)
} catch (err) {
logger.debug('[client] Prewarm: following index background pass failed', {
pubkeySlice: pk.slice(0, 12),
err: err instanceof Error ? err.message : String(err)
})
notifyLiveActivitiesPrewarmComplete()
}
})
}
}
// Update signer in query service when it changes
@ -3478,6 +3502,9 @@ class ClientService extends EventTarget { @@ -3478,6 +3502,9 @@ class ClientService extends EventTarget {
profileResolved += profiles.length
await Promise.allSettled(profiles.map((ev) => this.addUsernameToIndex(ev)))
profiles.forEach((ev) => this.updateProfileEventCache(ev))
if ((i + 1) * chunkSize < followings.length && !signal.aborted) {
await this.yieldForUiPaint()
}
}
logger.info('[client] Prewarm: following profile + contacts + NIP-65 fetch finished', {
pubkeySlice: pubkey.slice(0, 12),

18
src/services/live-activities-prewarm-bridge.ts

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
/**
* Fired when {@link ClientService.runSessionPrewarm} finishes so the live-activities banner can refresh
* in step with the initial session batch (logged-in or anonymous).
*/
let onPrewarmComplete: (() => void) | null = null
export function registerLiveActivitiesPrewarmCallback(fn: (() => void) | null): void {
onPrewarmComplete = fn
}
export function notifyLiveActivitiesPrewarmComplete(): void {
try {
onPrewarmComplete?.()
} catch {
// ignore listener errors
}
}

9
src/services/rss-feed.service.ts

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
import { DEFAULT_RSS_FEEDS } from '@/constants'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import {
isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session'
import { cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import { buildViteProxySitesFetchUrl, urlLooksLikeViteProxyRequest } from '@/lib/vite-proxy-url'
@ -285,7 +289,7 @@ class RssFeedService { @@ -285,7 +289,7 @@ class RssFeedService {
// Strategy 1: Same `VITE_PROXY_SERVER` contract as OG/link preview (`sites/?url=…`), not path-encoded `/sites/{url}`.
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
if (proxyServer && !urlLooksLikeViteProxyRequest(url)) {
if (proxyServer && !urlLooksLikeViteProxyRequest(url) && !isSitesProxyUnavailableThisSession()) {
strategies.push({
name: 'configured-proxy',
getUrl: (u) => buildViteProxySitesFetchUrl(u, proxyServer)
@ -335,6 +339,9 @@ class RssFeedService { @@ -335,6 +339,9 @@ class RssFeedService {
})
if (!res.ok) {
if (strategy.name === 'configured-proxy') {
markSitesProxyUnavailableFromHttpStatus(res.status)
}
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}

27
src/services/session-interactive-prewarm-bridge.ts

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
/**
* Multicast hook for {@link ClientService.runSessionPrewarm}'s **interactive** phase (IndexedDB @-mention
* index + NIP-66). Widgets that depend on a settled relay/follow picture (live activities, relay pulse,
* sidebar calendar) can register here so they refresh once without waiting for the follow-graph background pass.
*/
const listeners: Array<() => void> = []
/** Returns an unsubscribe function. Safe under React Strict Mode (pair register/unregister). */
export function registerSessionInteractivePrewarmListener(fn: () => void): () => void {
listeners.push(fn)
return () => {
const i = listeners.indexOf(fn)
if (i >= 0) listeners.splice(i, 1)
}
}
export function notifySessionInteractivePrewarmComplete(): void {
const snapshot = [...listeners]
for (const fn of snapshot) {
try {
fn()
} catch {
// ignore listener errors
}
}
}

44
src/services/web.service.ts

@ -1,4 +1,9 @@ @@ -1,4 +1,9 @@
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import {
clearSitesProxyUnavailableThisSession,
isSitesProxyUnavailableThisSession,
markSitesProxyUnavailableFromHttpStatus
} from '@/lib/optional-proxy-session'
import { buildViteProxySitesFetchUrl, urlLooksLikeViteProxyRequest } from '@/lib/vite-proxy-url'
import { TWebMetadata } from '@/types'
import DataLoader from 'dataloader'
@ -20,7 +25,10 @@ const HTML_FETCH_HEADERS = { @@ -20,7 +25,10 @@ const HTML_FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; Imwald/1.0; +https://jumble.imwald.eu)'
}
async function tryFetchHtml(fetchUrl: string, timeoutMs: number): Promise<string | null> {
async function tryFetchHtml(
fetchUrl: string,
timeoutMs: number
): Promise<{ html: string | null; status?: number }> {
try {
const res = await fetchWithTimeout(fetchUrl, {
timeoutMs,
@ -28,13 +36,13 @@ async function tryFetchHtml(fetchUrl: string, timeoutMs: number): Promise<string @@ -28,13 +36,13 @@ async function tryFetchHtml(fetchUrl: string, timeoutMs: number): Promise<string
credentials: 'omit',
headers: HTML_FETCH_HEADERS
})
if (!res.ok) return null
if (!res.ok) return { html: null, status: res.status }
const html = await res.text()
if (html.length < 50) return null
if (htmlLooksLikeLocalDevAppShell(html)) return null
return html
if (html.length < 50) return { html: null, status: res.status }
if (htmlLooksLikeLocalDevAppShell(html)) return { html: null, status: res.status }
return { html }
} catch {
return null
return { html: null }
}
}
@ -45,31 +53,35 @@ async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: strin @@ -45,31 +53,35 @@ async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: strin
const isAlreadyProxyRequest = urlLooksLikeViteProxyRequest(originalUrl)
if (isAlreadyProxyRequest) {
const html = await tryFetchHtml(originalUrl, 35_000)
const { html } = await tryFetchHtml(originalUrl, 35_000)
return html ? { html, via: originalUrl } : null
}
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
if (proxyServer) {
if (proxyServer && !isSitesProxyUnavailableThisSession()) {
const proxyFetchUrl = buildViteProxySitesFetchUrl(originalUrl, proxyServer)
logger.debug('[WebService] OG fetch via VITE_PROXY_SERVER', { originalUrl, proxyFetchUrl })
let html = await tryFetchHtml(proxyFetchUrl, 35_000)
if (html) {
return { html, via: proxyFetchUrl }
const proxyTry = await tryFetchHtml(proxyFetchUrl, 35_000)
if (proxyTry.html) {
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 })
logger.debug('[WebService] OG proxy unavailable or bad response', { originalUrl, status: proxyTry.status })
// In production with a configured proxy, skip direct fetch: random sites rarely allow browser CORS,
// and the attempt spams DevTools with cross-origin errors without improving OG success.
if (!import.meta.env.PROD) {
html = await tryFetchHtml(originalUrl, 15_000)
return html ? { html, via: 'direct' } : null
const direct = await tryFetchHtml(originalUrl, 15_000)
return direct.html ? { html: direct.html, via: 'direct' } : null
}
return null
}
const html = await tryFetchHtml(originalUrl, 15_000)
return html ? { html, via: 'direct' } : null
const directOnly = await tryFetchHtml(originalUrl, 15_000)
return directOnly.html ? { html: directOnly.html, via: 'direct' } : null
}
function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata {

Loading…
Cancel
Save