Browse Source

efficiency fix

imwald
Silberengel 1 month ago
parent
commit
451649b381
  1. 47
      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. 59
      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

47
src/App.tsx

@ -12,7 +12,6 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider' import { FeedProvider } from '@/providers/FeedProvider'
import { FontSizeProvider } from '@/providers/FontSizeProvider' import { FontSizeProvider } from '@/providers/FontSizeProvider'
import { FollowListProvider } from '@/providers/FollowListProvider' import { FollowListProvider } from '@/providers/FollowListProvider'
import { GroupListProvider } from '@/providers/GroupListProvider'
import { InterestListProvider } from '@/providers/InterestListProvider' import { InterestListProvider } from '@/providers/InterestListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider' import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
@ -50,30 +49,28 @@ export default function App(): JSX.Element {
<MuteListProvider> <MuteListProvider>
<FavoriteRelaysActivityProvider> <FavoriteRelaysActivityProvider>
<InterestListProvider> <InterestListProvider>
<GroupListProvider> <UserTrustProvider>
<UserTrustProvider> <BookmarksProvider>
<BookmarksProvider> <FeedProvider>
<FeedProvider> <ReplyProvider>
<ReplyProvider> <MediaUploadServiceProvider>
<MediaUploadServiceProvider> <KindFilterProvider>
<KindFilterProvider> <UserPreferencesProvider>
<UserPreferencesProvider> <LiveActivitiesProvider>
<LiveActivitiesProvider> <CacheBrowserProvider>
<CacheBrowserProvider> <PageManager />
<PageManager /> </CacheBrowserProvider>
</CacheBrowserProvider> </LiveActivitiesProvider>
</LiveActivitiesProvider> <ReadAloudPlayerModal />
<ReadAloudPlayerModal /> <PublishSuccessSubtleIndicator />
<PublishSuccessSubtleIndicator /> <Toaster />
<Toaster /> </UserPreferencesProvider>
</UserPreferencesProvider> </KindFilterProvider>
</KindFilterProvider> </MediaUploadServiceProvider>
</MediaUploadServiceProvider> </ReplyProvider>
</ReplyProvider> </FeedProvider>
</FeedProvider> </BookmarksProvider>
</BookmarksProvider> </UserTrustProvider>
</UserTrustProvider>
</GroupListProvider>
</InterestListProvider> </InterestListProvider>
</FavoriteRelaysActivityProvider> </FavoriteRelaysActivityProvider>
</MuteListProvider> </MuteListProvider>

75
src/components/PostEditor/PostContent.tsx

@ -60,7 +60,6 @@ import {
ListTodo, ListTodo,
MessageCircle, MessageCircle,
MessagesSquare, MessagesSquare,
Users,
X, X,
Highlighter, Highlighter,
FileText, FileText,
@ -94,7 +93,6 @@ import {
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { TDraftEvent } from '@/types' import { TDraftEvent } from '@/types'
import { useGroupList } from '@/providers/group-list-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics'
@ -194,7 +192,6 @@ export default function PostContent({
}) { }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { userGroups } = useGroupList()
const { addReplies } = useReply() const { addReplies } = useReply()
const mergePublishedReplyIntoThread = useCallback( const mergePublishedReplyIntoThread = useCallback(
@ -308,13 +305,11 @@ export default function PostContent({
return row?.label ?? 'general' return row?.label ?? 'general'
}) })
const [threadSelectedTopic, setThreadSelectedTopic] = useState('general') const [threadSelectedTopic, setThreadSelectedTopic] = useState('general')
const [threadSelectedGroup, setThreadSelectedGroup] = useState('')
const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false) const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false)
const [threadReadingAuthor, setThreadReadingAuthor] = useState('') const [threadReadingAuthor, setThreadReadingAuthor] = useState('')
const [threadReadingSubject, setThreadReadingSubject] = useState('') const [threadReadingSubject, setThreadReadingSubject] = useState('')
const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false) const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false)
const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false) const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false)
const [threadGroupPopoverOpen, setThreadGroupPopoverOpen] = useState(false)
const [threadErrors, setThreadErrors] = useState<{ const [threadErrors, setThreadErrors] = useState<{
title?: string title?: string
content?: string content?: string
@ -322,7 +317,6 @@ export default function PostContent({
relay?: string relay?: string
author?: string author?: string
subject?: string subject?: string
group?: string
}>({}) }>({})
const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null) const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([]) const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
@ -438,7 +432,6 @@ export default function PostContent({
processedContent: prefixNostrAddresses(text.trim()), processedContent: prefixNostrAddresses(text.trim()),
topicForTags: resolved, topicForTags: resolved,
title: threadTitle, title: threadTitle,
selectedGroup: threadSelectedGroup,
dynamicTopics: discussionDynamicTopics, dynamicTopics: discussionDynamicTopics,
isReadingGroup: threadIsReadingGroup, isReadingGroup: threadIsReadingGroup,
author: threadReadingAuthor, author: threadReadingAuthor,
@ -451,7 +444,6 @@ export default function PostContent({
allAvailableTopics, allAvailableTopics,
text, text,
threadTitle, threadTitle,
threadSelectedGroup,
discussionDynamicTopics, discussionDynamicTopics,
threadIsReadingGroup, threadIsReadingGroup,
threadReadingAuthor, threadReadingAuthor,
@ -485,8 +477,7 @@ export default function PostContent({
!!text.trim() && !!text.trim() &&
text.length <= 5000 && text.length <= 5000 &&
additionalRelayUrls.length > 0 && additionalRelayUrls.length > 0 &&
(!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())) && (!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())))
(threadTopicResolved !== 'groups' || !!threadSelectedGroup.trim()))
const result = ( const result = (
!!pubkey && !!pubkey &&
!posting && !posting &&
@ -537,7 +528,6 @@ export default function PostContent({
threadIsReadingGroup, threadIsReadingGroup,
threadReadingAuthor, threadReadingAuthor,
threadReadingSubject, threadReadingSubject,
threadSelectedGroup,
relayCapBlockInfo relayCapBlockInfo
]) ])
@ -805,7 +795,6 @@ export default function PostContent({
processedContent: processed, processedContent: processed,
topicForTags: topicResolved, topicForTags: topicResolved,
title: threadTitle, title: threadTitle,
selectedGroup: threadSelectedGroup,
dynamicTopics: discussionDynamicTopics, dynamicTopics: discussionDynamicTopics,
isReadingGroup: threadIsReadingGroup, isReadingGroup: threadIsReadingGroup,
author: threadReadingAuthor, author: threadReadingAuthor,
@ -1130,7 +1119,6 @@ export default function PostContent({
allAvailableTopics, allAvailableTopics,
threadSelectedTopic, threadSelectedTopic,
threadTitle, threadTitle,
threadSelectedGroup,
discussionDynamicTopics, discussionDynamicTopics,
threadIsReadingGroup, threadIsReadingGroup,
threadReadingAuthor, threadReadingAuthor,
@ -1299,9 +1287,6 @@ export default function PostContent({
newErrors.subject = t('Subject (book title) is required for reading groups') 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) setThreadErrors(newErrors)
if (Object.keys(newErrors).length > 0) { if (Object.keys(newErrors).length > 0) {
return return
@ -2246,7 +2231,6 @@ export default function PostContent({
setThreadSelectedTopic('general') setThreadSelectedTopic('general')
const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general') const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general')
setThreadTopicInput(gRow?.label ?? 'general') setThreadTopicInput(gRow?.label ?? 'general')
setThreadSelectedGroup('')
setThreadIsReadingGroup(false) setThreadIsReadingGroup(false)
setThreadReadingAuthor('') setThreadReadingAuthor('')
setThreadReadingSubject('') setThreadReadingSubject('')
@ -2420,63 +2404,6 @@ export default function PostContent({
</p> </p>
</div> </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"> <div className="space-y-2">
<Label htmlFor="discussion-thread-title" className="text-sm font-medium"> <Label htmlFor="discussion-thread-title" className="text-sm font-medium">
{t('Title')} <span className="text-destructive">*</span> {t('Title')} <span className="text-destructive">*</span>

13
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

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

1
src/constants.ts

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

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

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

14
src/lib/nip05.ts

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

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

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

59
src/lib/translate-client.ts

@ -8,6 +8,19 @@ const memoryCache = new Map<string, { text: string; at: number }>()
const MAX_MEMORY = 80 const MAX_MEMORY = 80
const CACHE_TTL_MS = 1000 * 60 * 60 * 24 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 { function cacheKey(source: string, sourceLang: string, targetLang: string): string {
const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`))) const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`)))
return h return h
@ -25,6 +38,10 @@ function pruneMemory(): void {
} }
} }
export function isTranslateBackendUnreachableThisSession(): boolean {
return translateBackendGoneThisSession
}
export function isTranslateConfigured(): boolean { export function isTranslateConfigured(): boolean {
return Boolean(TRANSLATE_URL.trim()) return Boolean(TRANSLATE_URL.trim())
} }
@ -135,6 +152,14 @@ function parseLanguagesResponse(data: unknown): TranslateLanguageOption[] {
export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption[]> { export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption[]> {
const base = TRANSLATE_URL.trim().replace(/\/$/u, '') const base = TRANSLATE_URL.trim().replace(/\/$/u, '')
if (!base) return [] 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() const now = Date.now()
if (languagesCache) { if (languagesCache) {
const ttl = languagesCache.fromFailure ? LANGUAGES_FAILURE_CACHE_TTL_MS : LANGUAGES_CACHE_TTL_MS const ttl = languagesCache.fromFailure ? LANGUAGES_FAILURE_CACHE_TTL_MS : LANGUAGES_CACHE_TTL_MS
@ -152,16 +177,14 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const res = await electronAwareFetch(url) const res = await electronAwareFetch(url)
if (!res.ok) { if (!res.ok) {
const t = Date.now() 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 lastLanguagesFailureLogAt = t
if (import.meta.env.DEV && (res.status === 503 || res.status === 502)) { logger.warn('[Translate] /languages failed', { status: res.status })
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 } languagesCache = { list: [], at: t, fromFailure: true }
recordAdvertisedTranslateCodesFromServer([]) recordAdvertisedTranslateCodesFromServer([])
@ -171,6 +194,7 @@ export async function fetchTranslateLanguages(): Promise<TranslateLanguageOption
const data = (await res.json()) as unknown const data = (await res.json()) as unknown
const list = parseLanguagesResponse(data) const list = parseLanguagesResponse(data)
languagesCache = { list, at: Date.now() } languagesCache = { list, at: Date.now() }
translateBackendGoneThisSession = false
recordAdvertisedTranslateCodesFromServer(list) recordAdvertisedTranslateCodesFromServer(list)
return list return list
} catch (e) { } catch (e) {
@ -194,6 +218,8 @@ export function clearTranslateLanguagesCache(): void {
languagesCache = null languagesCache = null
advertisedTranslateApiCodes = null advertisedTranslateApiCodes = null
warmTranslateLanguagesPromise = null warmTranslateLanguagesPromise = null
translateBackendGoneThisSession = false
translateOptionalLoggedKeys.clear()
} }
/** /**
@ -213,6 +239,13 @@ export async function translatePlainText(
if (!base) { if (!base) {
throw new Error('Translation URL not configured') 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. */ /** LibreTranslate often trims `q` / `translatedText`; keep edge whitespace so markup segments still join cleanly. */
const leadingWs = text.match(/^\s*/u)?.[0] ?? '' const leadingWs = text.match(/^\s*/u)?.[0] ?? ''
@ -269,6 +302,13 @@ export async function translatePlainText(
}) })
}) })
if (!res.ok) { 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(() => '') const err = await res.text().catch(() => '')
logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) }) logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) })
const detail = err.replace(/\s+/gu, ' ').trim().slice(0, 160) const detail = err.replace(/\s+/gu, ' ').trim().slice(0, 160)
@ -278,6 +318,7 @@ export async function translatePlainText(
} }
const data = (await res.json()) as { translatedText?: string } const data = (await res.json()) as { translatedText?: string }
const outCore = data.translatedText ?? '' const outCore = data.translatedText ?? ''
translateBackendGoneThisSession = false
pruneMemory() pruneMemory()
memoryCache.set(key, { text: outCore, at: Date.now() }) memoryCache.set(key, { text: outCore, at: Date.now() })
logger.info('[AdvancedLab] translate', { logger.info('[AdvancedLab] translate', {

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

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

8
src/providers/FavoriteRelaysActivityProvider.tsx

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

118
src/providers/GroupListProvider.tsx

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

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

@ -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 {
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import { invalidateArchiveFootprintCache } from './event-archive.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 nip66Service from './nip66.service'
import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters' import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
@ -361,8 +361,9 @@ class ClientService extends EventTarget {
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/** /**
* IndexedDB profile index + NIP-66 relay discovery run once per page session; when logged in, * 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. * {@link initUserIndexFromFollowings} runs **after** this batch completes (deferred) so startup is not
* blocked on hundreds of follow relay round-trips.
* @see {@link runSessionPrewarm} * @see {@link runSessionPrewarm}
*/ */
private sessionPrewarmBaseCompleted = false private sessionPrewarmBaseCompleted = false
@ -447,6 +448,16 @@ class ClientService extends EventTarget {
return ClientService.instance 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> { private async prewarmProfileSearchIndexFromIdb(): Promise<void> {
const t0 = typeof performance !== 'undefined' ? performance.now() : 0 const t0 = typeof performance !== 'undefined' ? performance.now() : 0
let profileRows = 0 let profileRows = 0
@ -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). * One-shot: local profile @-index + NIP-66 relay discovery (once per session). When logged in,
* Call after Nostr session is ready so it does not compete with the first relay-list REQ. * 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> { async runSessionPrewarm(options: { pubkey: string | null; signal?: AbortSignal }): Promise<void> {
const signal = options.signal ?? new AbortController().signal const signal = options.signal ?? new AbortController().signal
const t0 = typeof performance !== 'undefined' ? performance.now() : 0 const t0 = typeof performance !== 'undefined' ? performance.now() : 0
const tasks: Promise<unknown>[] = [] const fastTasks: Promise<unknown>[] = []
if (!this.sessionPrewarmBaseCompleted) { if (!this.sessionPrewarmBaseCompleted) {
this.sessionPrewarmBaseCompleted = true this.sessionPrewarmBaseCompleted = true
tasks.push(this.prewarmProfileSearchIndexFromIdb(), this.fetchNip66RelayDiscovery()) fastTasks.push(this.prewarmProfileSearchIndexFromIdb(), this.fetchNip66RelayDiscovery())
}
if (options.pubkey) {
tasks.push(this.initUserIndexFromFollowings(options.pubkey, signal))
} }
if (tasks.length === 0) { if (fastTasks.length === 0 && !options.pubkey) {
notifyLiveActivitiesPrewarmComplete() notifySessionInteractivePrewarmComplete()
return return
} }
logger.info('[client] Session prewarm batch started (parallel)', { logger.info('[client] Session prewarm batch started (interactive)', {
hasPubkey: !!options.pubkey, hasPubkey: !!options.pubkey,
taskCount: tasks.length fastTaskCount: fastTasks.length
}) })
const results = await Promise.allSettled(tasks) const fastResults = await Promise.allSettled(fastTasks)
logger.info('[client] Session prewarm batch finished', { logger.info('[client] Session prewarm batch finished (interactive)', {
ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined, ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined,
results: results.map((r) => r.status) fastResults: fastResults.map((r) => r.status)
}) })
notifyLiveActivitiesPrewarmComplete() 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)
})
}
})
}
} }
// Update signer in query service when it changes // Update signer in query service when it changes
@ -3478,6 +3502,9 @@ class ClientService extends EventTarget {
profileResolved += profiles.length profileResolved += profiles.length
await Promise.allSettled(profiles.map((ev) => this.addUsernameToIndex(ev))) await Promise.allSettled(profiles.map((ev) => this.addUsernameToIndex(ev)))
profiles.forEach((ev) => this.updateProfileEventCache(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', { logger.info('[client] Prewarm: following profile + contacts + NIP-65 fetch finished', {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: pubkey.slice(0, 12),

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

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

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

@ -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 @@
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' 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 { buildViteProxySitesFetchUrl, urlLooksLikeViteProxyRequest } from '@/lib/vite-proxy-url'
import { TWebMetadata } from '@/types' import { TWebMetadata } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@ -20,7 +25,10 @@ const HTML_FETCH_HEADERS = {
'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)'
} }
async function tryFetchHtml(fetchUrl: string, timeoutMs: number): Promise<string | null> { async function tryFetchHtml(
fetchUrl: string,
timeoutMs: number
): Promise<{ html: string | null; status?: number }> {
try { try {
const res = await fetchWithTimeout(fetchUrl, { const res = await fetchWithTimeout(fetchUrl, {
timeoutMs, timeoutMs,
@ -28,13 +36,13 @@ async function tryFetchHtml(fetchUrl: string, timeoutMs: number): Promise<string
credentials: 'omit', credentials: 'omit',
headers: HTML_FETCH_HEADERS headers: HTML_FETCH_HEADERS
}) })
if (!res.ok) return null if (!res.ok) return { html: null, status: res.status }
const html = await res.text() const html = await res.text()
if (html.length < 50) return null if (html.length < 50) return { html: null, status: res.status }
if (htmlLooksLikeLocalDevAppShell(html)) return null if (htmlLooksLikeLocalDevAppShell(html)) return { html: null, status: res.status }
return html return { html }
} catch { } catch {
return null return { html: null }
} }
} }
@ -45,31 +53,35 @@ async function fetchHtmlForOpenGraph(originalUrl: string): Promise<{ html: strin
const isAlreadyProxyRequest = urlLooksLikeViteProxyRequest(originalUrl) const isAlreadyProxyRequest = urlLooksLikeViteProxyRequest(originalUrl)
if (isAlreadyProxyRequest) { if (isAlreadyProxyRequest) {
const html = await tryFetchHtml(originalUrl, 35_000) const { html } = await tryFetchHtml(originalUrl, 35_000)
return html ? { html, via: originalUrl } : null return html ? { html, via: originalUrl } : null
} }
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim() const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
if (proxyServer) { if (proxyServer && !isSitesProxyUnavailableThisSession()) {
const proxyFetchUrl = buildViteProxySitesFetchUrl(originalUrl, proxyServer) const proxyFetchUrl = buildViteProxySitesFetchUrl(originalUrl, proxyServer)
logger.debug('[WebService] OG fetch via VITE_PROXY_SERVER', { originalUrl, proxyFetchUrl }) logger.debug('[WebService] OG fetch via VITE_PROXY_SERVER', { originalUrl, proxyFetchUrl })
let html = await tryFetchHtml(proxyFetchUrl, 35_000) const proxyTry = await tryFetchHtml(proxyFetchUrl, 35_000)
if (html) { if (proxyTry.html) {
return { html, via: proxyFetchUrl } 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, // 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. // and the attempt spams DevTools with cross-origin errors without improving OG success.
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
html = await tryFetchHtml(originalUrl, 15_000) const direct = await tryFetchHtml(originalUrl, 15_000)
return html ? { html, via: 'direct' } : null return direct.html ? { html: direct.html, via: 'direct' } : null
} }
return null return null
} }
const html = await tryFetchHtml(originalUrl, 15_000) const directOnly = await tryFetchHtml(originalUrl, 15_000)
return html ? { html, via: 'direct' } : null return directOnly.html ? { html: directOnly.html, via: 'direct' } : null
} }
function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata { function parseOpenGraphFromHtml(html: string, pageUrl: string): TWebMetadata {

Loading…
Cancel
Save