@flamingo-stack/openframe-frontend-core 0.0.533 → 0.0.534-snapshot.20260812085314
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-2MYDGLJS.cjs → chunk-2P4J2YLI.cjs} +98 -98
- package/dist/{chunk-2MYDGLJS.cjs.map → chunk-2P4J2YLI.cjs.map} +1 -1
- package/dist/{chunk-GS4HJA2G.js → chunk-4A736Z6S.js} +74 -20
- package/dist/chunk-4A736Z6S.js.map +1 -0
- package/dist/{chunk-LIUARTOZ.js → chunk-4DTUUM5V.js} +150 -309
- package/dist/chunk-4DTUUM5V.js.map +1 -0
- package/dist/{chunk-RKITTO5V.cjs → chunk-4HQFV4PK.cjs} +14 -14
- package/dist/{chunk-RKITTO5V.cjs.map → chunk-4HQFV4PK.cjs.map} +1 -1
- package/dist/{chunk-GYONSC3U.cjs → chunk-4QYUNDS3.cjs} +3 -3
- package/dist/{chunk-GYONSC3U.cjs.map → chunk-4QYUNDS3.cjs.map} +1 -1
- package/dist/{chunk-GNOK5J34.cjs → chunk-5OLF6WXF.cjs} +40 -40
- package/dist/{chunk-GNOK5J34.cjs.map → chunk-5OLF6WXF.cjs.map} +1 -1
- package/dist/{chunk-SXLEPNY2.cjs → chunk-5RJUHUCI.cjs} +106 -52
- package/dist/chunk-5RJUHUCI.cjs.map +1 -0
- package/dist/{chunk-OLOKQQAP.cjs → chunk-7MHYRBBQ.cjs} +5 -5
- package/dist/{chunk-OLOKQQAP.cjs.map → chunk-7MHYRBBQ.cjs.map} +1 -1
- package/dist/{chunk-UPHN2VRZ.cjs → chunk-E3YB55RR.cjs} +954 -1113
- package/dist/chunk-E3YB55RR.cjs.map +1 -0
- package/dist/{chunk-5U67WTAS.js → chunk-EHPQKXXW.js} +3 -3
- package/dist/{chunk-JDW2IL7A.cjs → chunk-GV4FDDFV.cjs} +18 -18
- package/dist/{chunk-JDW2IL7A.cjs.map → chunk-GV4FDDFV.cjs.map} +1 -1
- package/dist/{chunk-6W3ULZ4X.js → chunk-JVO5CYMK.js} +8 -8
- package/dist/{chunk-4QKXNWH5.js → chunk-K6566PEB.js} +3 -3
- package/dist/{chunk-YLB6XXO6.js → chunk-KLFGP6PR.js} +2 -2
- package/dist/{chunk-6YXN5RHW.js → chunk-LIQJAC6Z.js} +2 -2
- package/dist/{chunk-46FKXLNE.js → chunk-LIX5OUHH.js} +2 -2
- package/dist/{chunk-RCBRCZUF.js → chunk-LQPHB2YF.js} +2 -2
- package/dist/{chunk-2XGHEJZK.js → chunk-MZVFZMQ6.js} +6 -6
- package/dist/{chunk-6SMLYZLH.js → chunk-N3AEJTCK.js} +5 -5
- package/dist/{chunk-LE3BJ5H2.js → chunk-NB6BP6JB.js} +3 -3
- package/dist/{chunk-QZ2NAPLF.cjs → chunk-P65EZEBW.cjs} +12 -12
- package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-P65EZEBW.cjs.map} +1 -1
- package/dist/{chunk-MVGL5NNJ.js → chunk-PLGITXT7.js} +2 -2
- package/dist/{chunk-KMB66NYX.cjs → chunk-PSBL3S2B.cjs} +7 -7
- package/dist/{chunk-KMB66NYX.cjs.map → chunk-PSBL3S2B.cjs.map} +1 -1
- package/dist/{chunk-U555TMPC.js → chunk-QUVIT4JP.js} +6 -6
- package/dist/{chunk-4ZRHVOEB.cjs → chunk-RO67OOKJ.cjs} +40 -40
- package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-RO67OOKJ.cjs.map} +1 -1
- package/dist/{chunk-NAV4FRNL.cjs → chunk-TS2EHMEX.cjs} +29 -29
- package/dist/{chunk-NAV4FRNL.cjs.map → chunk-TS2EHMEX.cjs.map} +1 -1
- package/dist/{chunk-Z3NOI7X7.cjs → chunk-U2Q7GEX2.cjs} +32 -32
- package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-U2Q7GEX2.cjs.map} +1 -1
- package/dist/{chunk-XN4JKIJY.js → chunk-VPDTDKO5.js} +2 -2
- package/dist/{chunk-ENPGPZIB.cjs → chunk-Y55XQFJK.cjs} +75 -75
- package/dist/{chunk-ENPGPZIB.cjs.map → chunk-Y55XQFJK.cjs.map} +1 -1
- package/dist/{chunk-2TUOBFPK.js → chunk-Y72MXG5N.js} +4 -4
- package/dist/{chunk-DFQLUK75.cjs → chunk-ZKKEKG73.cjs} +3 -3
- package/dist/{chunk-DFQLUK75.cjs.map → chunk-ZKKEKG73.cjs.map} +1 -1
- package/dist/{chunk-J4BE4FIR.cjs → chunk-ZR5CJTNR.cjs} +4 -4
- package/dist/{chunk-J4BE4FIR.cjs.map → chunk-ZR5CJTNR.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +138 -138
- package/dist/components/index.js +13 -13
- package/dist/components/meeting-scheduler/index.cjs +40 -40
- package/dist/components/meeting-scheduler/index.js +4 -4
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/popover.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/file-upload.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
- package/dist/components/ui/search-input.d.ts.map +1 -1
- package/dist/components/ui/searchable-select.d.ts.map +1 -1
- package/dist/components/ui/tag-select-dropdown.d.ts.map +1 -1
- package/dist/components/ui/tags-manager.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-keyboard-collision-padding.d.ts +2 -0
- package/dist/hooks/ui/use-keyboard-collision-padding.d.ts.map +1 -0
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/dist/utils/social-embed-cache.d.ts +16 -16
- package/dist/utils/social-embed-cache.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-input.tsx +84 -32
- package/src/components/popover.tsx +22 -14
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
- package/src/components/ui/autocomplete.tsx +9 -2
- package/src/components/ui/file-upload.tsx +5 -1
- package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
- package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
- package/src/components/ui/search-input.tsx +9 -2
- package/src/components/ui/searchable-select.tsx +9 -2
- package/src/components/ui/tag-select-dropdown.tsx +8 -2
- package/src/components/ui/tags-manager.tsx +9 -2
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-keyboard-collision-padding.ts +101 -0
- package/src/utils/social-embed-cache.ts +63 -336
- package/dist/chunk-GS4HJA2G.js.map +0 -1
- package/dist/chunk-LIUARTOZ.js.map +0 -1
- package/dist/chunk-SXLEPNY2.cjs.map +0 -1
- package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
- /package/dist/{chunk-5U67WTAS.js.map → chunk-EHPQKXXW.js.map} +0 -0
- /package/dist/{chunk-6W3ULZ4X.js.map → chunk-JVO5CYMK.js.map} +0 -0
- /package/dist/{chunk-4QKXNWH5.js.map → chunk-K6566PEB.js.map} +0 -0
- /package/dist/{chunk-YLB6XXO6.js.map → chunk-KLFGP6PR.js.map} +0 -0
- /package/dist/{chunk-6YXN5RHW.js.map → chunk-LIQJAC6Z.js.map} +0 -0
- /package/dist/{chunk-46FKXLNE.js.map → chunk-LIX5OUHH.js.map} +0 -0
- /package/dist/{chunk-RCBRCZUF.js.map → chunk-LQPHB2YF.js.map} +0 -0
- /package/dist/{chunk-2XGHEJZK.js.map → chunk-MZVFZMQ6.js.map} +0 -0
- /package/dist/{chunk-6SMLYZLH.js.map → chunk-N3AEJTCK.js.map} +0 -0
- /package/dist/{chunk-LE3BJ5H2.js.map → chunk-NB6BP6JB.js.map} +0 -0
- /package/dist/{chunk-MVGL5NNJ.js.map → chunk-PLGITXT7.js.map} +0 -0
- /package/dist/{chunk-U555TMPC.js.map → chunk-QUVIT4JP.js.map} +0 -0
- /package/dist/{chunk-XN4JKIJY.js.map → chunk-VPDTDKO5.js.map} +0 -0
- /package/dist/{chunk-2TUOBFPK.js.map → chunk-Y72MXG5N.js.map} +0 -0
package/src/hooks/ui/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './use-horizontal-scrollbar'
|
|
|
8
8
|
export * from './use-image-edge-color'
|
|
9
9
|
export * from './use-is-truncated'
|
|
10
10
|
export * from './use-isomorphic-layout-effect'
|
|
11
|
+
export * from './use-keyboard-collision-padding'
|
|
11
12
|
export * from './use-local-storage'
|
|
12
13
|
export * from './use-marquee-engine'
|
|
13
14
|
export * from './use-media-query'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extra `collisionPadding.bottom` (px) a Radix popper needs to clear the
|
|
7
|
+
* software keyboard. 0 on desktop and anywhere the keyboard is down.
|
|
8
|
+
*
|
|
9
|
+
* Radix positions every popper with floating-ui at `strategy: 'fixed'`, and
|
|
10
|
+
* floating-ui takes its collision viewport from `window.visualViewport`.
|
|
11
|
+
* Neither mobile shell shrinks the box a keyboard opens into: iOS runs
|
|
12
|
+
* Capacitor's Keyboard plugin at `resize: 'none'`, which leaves the WKWebView
|
|
13
|
+
* frame alone, and Android's window is edge-to-edge, where the IME is only a
|
|
14
|
+
* WindowInsets type. So `flip`/`shift`/`size` all believe the screen still
|
|
15
|
+
* reaches the bottom, and a dropdown whose own search field just raised the
|
|
16
|
+
* keyboard is placed straight behind it — worst inside modals, which sit near
|
|
17
|
+
* the bottom of the screen by design.
|
|
18
|
+
*
|
|
19
|
+
* The shell publishes the keyboard height as `--of-keyboard-inset` on <html>
|
|
20
|
+
* (openframe-frontend's `keyboard-inset.ts`; unset everywhere else, hence the
|
|
21
|
+
* 0 default). Handing the part floating-ui does not already know about back as
|
|
22
|
+
* collision padding is what makes the top of the keyboard the bottom edge of
|
|
23
|
+
* the screen.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const keyboardPadding = useKeyboardCollisionPadding()
|
|
27
|
+
* <PopoverPrimitive.Content collisionPadding={{ bottom: keyboardPadding }} />
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Pair it with `max-h-[var(--radix-popper-available-height)]` on the content:
|
|
31
|
+
* padding alone flips a dropdown above its trigger, but a list taller than what
|
|
32
|
+
* is left above the keyboard would then spill back over it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const KEYBOARD_INSET_VAR = '--of-keyboard-inset'
|
|
36
|
+
|
|
37
|
+
function measure(): number {
|
|
38
|
+
if (typeof document === 'undefined') return 0
|
|
39
|
+
const inset = Number.parseFloat(
|
|
40
|
+
getComputedStyle(document.documentElement).getPropertyValue(KEYBOARD_INSET_VAR),
|
|
41
|
+
)
|
|
42
|
+
// Also catches the NaN of an unset variable.
|
|
43
|
+
if (!(inset > 0)) return 0
|
|
44
|
+
// How far floating-ui's own viewport bottom sits below the top of the
|
|
45
|
+
// keyboard. iOS reports the keyboard on the visual viewport and Android does
|
|
46
|
+
// not; measuring the difference rather than branching on the platform keeps
|
|
47
|
+
// both correct and can never reserve the same keyboard twice.
|
|
48
|
+
const layoutHeight = document.documentElement.clientHeight
|
|
49
|
+
const viewport = window.visualViewport
|
|
50
|
+
const viewportBottom = viewport ? viewport.offsetTop + viewport.height : layoutHeight
|
|
51
|
+
return Math.max(0, Math.round(viewportBottom - (layoutHeight - inset)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// One document-wide subscription for every popper on the page: the keyboard is
|
|
55
|
+
// a single global, and each open dropdown re-reading it would mean another
|
|
56
|
+
// MutationObserver plus a forced style resolve per keystroke-driven re-render.
|
|
57
|
+
const listeners = new Set<() => void>()
|
|
58
|
+
let padding = 0
|
|
59
|
+
let teardown: (() => void) | null = null
|
|
60
|
+
|
|
61
|
+
function sync(): void {
|
|
62
|
+
const next = measure()
|
|
63
|
+
if (next === padding) return
|
|
64
|
+
padding = next
|
|
65
|
+
for (const notify of listeners) notify()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function subscribe(onStoreChange: () => void): () => void {
|
|
69
|
+
listeners.add(onStoreChange)
|
|
70
|
+
if (listeners.size === 1) {
|
|
71
|
+
padding = measure()
|
|
72
|
+
// Two independent signals. The inset lands on <html>'s inline style, so the
|
|
73
|
+
// attribute is the only notification for it; the visual viewport moves on
|
|
74
|
+
// its own schedule (iOS reports the keyboard there, and a pinch-zoom shifts
|
|
75
|
+
// it with no keyboard involved at all) and changes what is left to reserve.
|
|
76
|
+
const observer = new MutationObserver(sync)
|
|
77
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
|
78
|
+
const viewport = window.visualViewport
|
|
79
|
+
viewport?.addEventListener('resize', sync)
|
|
80
|
+
viewport?.addEventListener('scroll', sync)
|
|
81
|
+
teardown = () => {
|
|
82
|
+
observer.disconnect()
|
|
83
|
+
viewport?.removeEventListener('resize', sync)
|
|
84
|
+
viewport?.removeEventListener('scroll', sync)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return () => {
|
|
88
|
+
listeners.delete(onStoreChange)
|
|
89
|
+
if (listeners.size > 0) return
|
|
90
|
+
teardown?.()
|
|
91
|
+
teardown = null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useKeyboardCollisionPadding(): number {
|
|
96
|
+
return useSyncExternalStore(
|
|
97
|
+
subscribe,
|
|
98
|
+
() => padding,
|
|
99
|
+
() => 0,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
// Unified Social Media Embed
|
|
2
|
-
// Shared between Reddit and Twitter
|
|
1
|
+
// Unified Social Media Embed fetch layer.
|
|
2
|
+
// Shared between Reddit and Twitter embed clients for consistent behavior.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// SIMPLIFIED (2026-08, "all 58 Reddit embeds broken" incident): the server
|
|
5
|
+
// proxy is now the single authority — it owns a DURABLE database cache and
|
|
6
|
+
// the authenticated upstream fetch (Reddit's `.json` API 403s all
|
|
7
|
+
// unauthenticated clients, so the old browser direct-fetch could never work
|
|
8
|
+
// again). The old client-side hierarchy (cache-only preflight round-trip,
|
|
9
|
+
// direct browser fetch, client→server POST cache pushes, background
|
|
10
|
+
// refreshes) was flaky machinery layered over an unreliable server cache and
|
|
11
|
+
// is DELETED — do not reintroduce it. Client flow is exactly:
|
|
12
|
+
//
|
|
13
|
+
// memory (per-pageload dedupe) → GET proxy (server cache + upstream)
|
|
7
14
|
//
|
|
8
15
|
// Endpoint paths are passed in via `apiEndpoint` on each call so embedders
|
|
9
16
|
// can route through their own reverse proxy (e.g. `/content/api/blog/reddit-proxy`).
|
|
10
17
|
|
|
11
|
-
//
|
|
12
|
-
const cachingRequests = new Map<string, Promise<void>>();
|
|
13
|
-
|
|
14
|
-
// Global data cache to share fetched data between component instances
|
|
18
|
+
// Per-pageload memory cache shared between component instances.
|
|
15
19
|
const dataCache = new Map<string, any>();
|
|
16
20
|
|
|
17
|
-
//
|
|
18
|
-
|
|
21
|
+
// In-flight GET dedupe: several embeds of the same URL mount concurrently
|
|
22
|
+
// (before any of them has populated the memory cache).
|
|
23
|
+
const inflightRequests = new Map<string, Promise<any | null>>();
|
|
19
24
|
|
|
20
|
-
interface
|
|
25
|
+
interface FetchOptions {
|
|
21
26
|
platform: 'reddit' | 'twitter';
|
|
22
27
|
url: string;
|
|
23
28
|
apiEndpoint: string;
|
|
29
|
+
dataValidator: (data: any) => boolean;
|
|
30
|
+
onDataUpdate: (data: any) => void;
|
|
31
|
+
onError: (error: string) => void;
|
|
32
|
+
onLoading: (loading: boolean) => void;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export class SocialEmbedCache {
|
|
@@ -33,358 +42,76 @@ export class SocialEmbedCache {
|
|
|
33
42
|
return SocialEmbedCache.instance;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
// Check if data exists in memory cache
|
|
37
45
|
getFromMemory(url: string): any | null {
|
|
38
46
|
return dataCache.get(url) || null;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
// Store data in memory cache
|
|
42
49
|
setInMemory(url: string, data: any): void {
|
|
43
50
|
dataCache.set(url, data);
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (response.ok) {
|
|
53
|
-
const data = await response.json();
|
|
54
|
-
console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache hit: ${options.url}`);
|
|
55
|
-
return data;
|
|
56
|
-
} else if (response.status === 404) {
|
|
57
|
-
console.log(`💨 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] No server cache available for: ${options.url}`);
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
} catch (error) {
|
|
61
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache check failed for: ${options.url}`, error);
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Helper method to update server cache asynchronously
|
|
67
|
-
private async updateServerCache(apiEndpoint: string, url: string, data: any): Promise<void> {
|
|
68
|
-
try {
|
|
69
|
-
console.log(`💾 [Cache] Updating server cache for: ${url}`);
|
|
70
|
-
await fetch(`${apiEndpoint}?url=${encodeURIComponent(url)}`, {
|
|
71
|
-
method: 'POST',
|
|
72
|
-
headers: { 'Content-Type': 'application/json' },
|
|
73
|
-
body: JSON.stringify(data)
|
|
74
|
-
});
|
|
75
|
-
console.log(`✅ [Cache] Server cache updated for: ${url}`);
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.log(`❌ [Cache] Server cache update failed for: ${url}`, error);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
53
|
+
/**
|
|
54
|
+
* Memory → proxy. Kept the historical name so the embed clients'
|
|
55
|
+
* call sites are unchanged.
|
|
56
|
+
*/
|
|
57
|
+
async fetchWithHierarchy(options: FetchOptions): Promise<void> {
|
|
58
|
+
const { url, platform, apiEndpoint, dataValidator, onDataUpdate, onError, onLoading } = options;
|
|
80
59
|
|
|
81
|
-
// Store data in server cache (with deduplication)
|
|
82
|
-
async setInServer(options: CacheOptions, data: any): Promise<void> {
|
|
83
|
-
const cacheKey = `${options.platform}-${options.url}`;
|
|
84
|
-
|
|
85
|
-
// Prevent duplicate caching requests
|
|
86
|
-
if (cachingRequests.has(cacheKey)) {
|
|
87
|
-
console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache update skipped (already in progress): ${options.url}`);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
console.log(`💾 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Updating server cache for: ${options.url}`);
|
|
92
|
-
const cachePromise = fetch(`${options.apiEndpoint}?url=${encodeURIComponent(options.url)}`, {
|
|
93
|
-
method: 'POST',
|
|
94
|
-
headers: { 'Content-Type': 'application/json' },
|
|
95
|
-
body: JSON.stringify(data)
|
|
96
|
-
}).then(() => {
|
|
97
|
-
console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache updated successfully: ${options.url}`);
|
|
98
|
-
cachingRequests.delete(cacheKey);
|
|
99
|
-
}).catch((error) => {
|
|
100
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache update failed: ${options.url}`, error);
|
|
101
|
-
cachingRequests.delete(cacheKey);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
cachingRequests.set(cacheKey, cachePromise);
|
|
105
|
-
await cachePromise;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Direct fetch with platform-specific logic
|
|
109
|
-
async fetchDirect(options: CacheOptions & {
|
|
110
|
-
directFetcher: () => Promise<any>
|
|
111
|
-
}): Promise<any | null> {
|
|
112
|
-
try {
|
|
113
|
-
console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetching content: ${options.url}`);
|
|
114
|
-
const result = await options.directFetcher();
|
|
115
|
-
console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetch successful: ${options.url}`);
|
|
116
|
-
return result;
|
|
117
|
-
} catch (error) {
|
|
118
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetch failed: ${options.url}`, error);
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Unified cache hierarchy: Memory → Server → Direct
|
|
124
|
-
async fetchWithHierarchy(options: CacheOptions & {
|
|
125
|
-
dataValidator: (data: any) => boolean;
|
|
126
|
-
onDataUpdate: (data: any) => void;
|
|
127
|
-
onError: (error: string) => void;
|
|
128
|
-
onLoading: (loading: boolean) => void;
|
|
129
|
-
}): Promise<void> {
|
|
130
|
-
const { url, platform, onDataUpdate, onError, onLoading, dataValidator } = options;
|
|
131
|
-
const platformName = platform.charAt(0).toUpperCase() + platform.slice(1);
|
|
132
|
-
|
|
133
|
-
// Schedule background refresh for ALL loads (not just cache hits)
|
|
134
|
-
this.scheduleAsyncRefresh(platform, url, dataValidator, options.apiEndpoint);
|
|
135
|
-
|
|
136
|
-
// Step 1: Check memory cache first
|
|
137
|
-
console.log(`🔍 [${platformName} Cache] Checking memory cache for: ${url}`);
|
|
138
60
|
const memoryData = this.getFromMemory(url);
|
|
139
61
|
if (memoryData && dataValidator(memoryData)) {
|
|
140
|
-
console.log(`💎 [${platformName} Cache] Memory cache hit for: ${url}`);
|
|
141
62
|
onDataUpdate(memoryData);
|
|
142
63
|
onLoading(false);
|
|
143
64
|
return;
|
|
144
65
|
}
|
|
145
66
|
|
|
146
|
-
// Step 2: Check server cache (file system cache)
|
|
147
|
-
console.log(`🔍 [${platformName} Cache] No memory cache, checking server cache for: ${url}`);
|
|
148
|
-
const serverCacheData = await this.getFromServer(options);
|
|
149
|
-
if (serverCacheData && dataValidator(serverCacheData)) {
|
|
150
|
-
console.log(`🎯 [${platformName} Cache] Server cache hit (cached data) for: ${url}`);
|
|
151
|
-
onDataUpdate(serverCacheData);
|
|
152
|
-
this.setInMemory(url, serverCacheData); // Update memory cache
|
|
153
|
-
onLoading(false);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Step 3: Direct fetch from browser (bypasses server proxy)
|
|
158
|
-
console.log(`🔍 [${platformName} Cache] No server cache, attempting direct fetch for: ${url}`);
|
|
159
|
-
|
|
160
|
-
// For Reddit, try direct browser fetch first (CORS enabled). Use
|
|
161
|
-
// `AbortSignal.timeout(...)` to bound the wait — without it a hung
|
|
162
|
-
// upstream (Reddit dropping the connection without RST, or a captive-
|
|
163
|
-
// portal proxy stalling) would block this code path indefinitely and
|
|
164
|
-
// never fall through to the server proxy.
|
|
165
|
-
if (platform === 'reddit') {
|
|
166
|
-
try {
|
|
167
|
-
const directResponse = await fetch(url, {
|
|
168
|
-
method: 'GET',
|
|
169
|
-
headers: {
|
|
170
|
-
'Accept': 'application/json',
|
|
171
|
-
},
|
|
172
|
-
signal: AbortSignal.timeout(10_000),
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
if (directResponse.ok) {
|
|
176
|
-
const directData = await directResponse.json();
|
|
177
|
-
if (directData && dataValidator(directData)) {
|
|
178
|
-
console.log(`✅ [${platformName} Direct] Browser direct fetch successful for: ${url}`);
|
|
179
|
-
onDataUpdate(directData);
|
|
180
|
-
this.setInMemory(url, directData);
|
|
181
|
-
onLoading(false);
|
|
182
|
-
|
|
183
|
-
// Async server cache update
|
|
184
|
-
setTimeout(() => {
|
|
185
|
-
this.updateServerCache(options.apiEndpoint, url, directData);
|
|
186
|
-
}, 0);
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
console.log(`💨 [${platformName} Direct] Browser direct fetch failed (${directResponse.status}), trying server proxy...`);
|
|
191
|
-
} catch (directError) {
|
|
192
|
-
console.log(`💨 [${platformName} Direct] Browser direct fetch failed, trying server proxy...`, directError);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Step 4: Server proxy as fallback
|
|
197
|
-
console.log(`🔍 [${platformName} Cache] Trying server proxy for: ${url}`);
|
|
198
67
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const cacheStatus = response.headers.get('x-cache') || 'UNKNOWN';
|
|
206
|
-
if (cacheStatus === 'HIT') {
|
|
207
|
-
console.log(`🎯 [${platformName} Proxy] Server cache hit (cached data) for: ${url}`);
|
|
208
|
-
} else if (cacheStatus === 'MISS-FETCHED') {
|
|
209
|
-
console.log(`🔄 [${platformName} Proxy] Server processed fresh fetch (now cached) for: ${url}`);
|
|
210
|
-
} else {
|
|
211
|
-
console.log(`✅ [${platformName} Proxy] Server returned data (${cacheStatus}) for: ${url}`);
|
|
212
|
-
}
|
|
213
|
-
onDataUpdate(serverData);
|
|
214
|
-
this.setInMemory(url, serverData);
|
|
215
|
-
onLoading(false);
|
|
216
|
-
return;
|
|
217
|
-
} else {
|
|
218
|
-
// 200 with a body that fails our shape check — throw so the
|
|
219
|
-
// outer catch surfaces `onError` instead of silently dropping.
|
|
220
|
-
console.log(`⚠️ [${platformName} Proxy] Server returned data but validation failed for: ${url}`);
|
|
221
|
-
throw new Error('Server proxy returned invalid data shape');
|
|
222
|
-
}
|
|
223
|
-
} else {
|
|
224
|
-
// The Response body is a one-shot stream — read it ONCE and branch
|
|
225
|
-
// on shape afterwards. The previous code parsed 404s, fell through
|
|
226
|
-
// to the generic-error path, and then called `response.json()` a
|
|
227
|
-
// second time, which throws `TypeError: body stream already read`
|
|
228
|
-
// and masks the real status with a confusing error.
|
|
229
|
-
let errorData: { error?: string; message?: string } | null = null;
|
|
230
|
-
try {
|
|
231
|
-
errorData = await response.json();
|
|
232
|
-
} catch {
|
|
233
|
-
// Body wasn't JSON; errorData stays null and we fall through.
|
|
234
|
-
}
|
|
235
|
-
if (response.status === 404 && errorData?.error && errorData?.message) {
|
|
236
|
-
// Structured 404 (e.g. an unavailable Reddit post). Surface
|
|
237
|
-
// the proxy's message directly to the consumer.
|
|
238
|
-
console.log(`🚫 [${platformName} Proxy] Content unavailable: ${errorData.message}`);
|
|
239
|
-
onError(errorData.message);
|
|
240
|
-
onLoading(false);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (errorData?.error) {
|
|
244
|
-
throw new Error(`Server error: ${errorData.error}`);
|
|
245
|
-
}
|
|
246
|
-
throw new Error(`Server proxy failed: ${response.status}`);
|
|
68
|
+
const data = await this.fetchFromProxy(url, apiEndpoint, dataValidator);
|
|
69
|
+
if (data) {
|
|
70
|
+
this.setInMemory(url, data);
|
|
71
|
+
onDataUpdate(data);
|
|
72
|
+
onLoading(false);
|
|
73
|
+
return;
|
|
247
74
|
}
|
|
75
|
+
// Structured 404 from the proxy — the content itself is unavailable.
|
|
76
|
+
onError(`This ${platform === 'reddit' ? 'Reddit post' : 'tweet'} could not be loaded.`);
|
|
248
77
|
} catch (error) {
|
|
249
|
-
console.log(`❌ [${
|
|
78
|
+
console.log(`❌ [${platform} embed] Proxy fetch failed for: ${url}`, error);
|
|
250
79
|
onError(`Unable to load ${platform} content`);
|
|
251
|
-
onLoading(false);
|
|
252
80
|
}
|
|
81
|
+
onLoading(false);
|
|
253
82
|
}
|
|
254
83
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
private
|
|
261
|
-
platform: string,
|
|
84
|
+
/**
|
|
85
|
+
* Returns the validated payload, `null` for a structured "content
|
|
86
|
+
* unavailable" 404, and throws for everything else. Concurrent calls for
|
|
87
|
+
* the same URL share one request.
|
|
88
|
+
*/
|
|
89
|
+
private fetchFromProxy(
|
|
262
90
|
url: string,
|
|
263
|
-
dataValidator: (data: any) => boolean,
|
|
264
91
|
apiEndpoint: string,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
})
|
|
279
|
-
.then(data => {
|
|
280
|
-
if (data && dataValidator(data)) {
|
|
281
|
-
this.setInMemory(url, data);
|
|
282
|
-
console.log(`✅ [${platformName} Async] Background refresh completed for: ${url}`);
|
|
283
|
-
} else if (data) {
|
|
284
|
-
console.log(`❌ [${platformName} Async] Background refresh failed (invalid data) for: ${url}`);
|
|
285
|
-
}
|
|
286
|
-
})
|
|
287
|
-
.catch((error) => console.log(`❌ [${platformName} Async] Background refresh failed for: ${url}`, error));
|
|
288
|
-
}, 1000); // Runs exactly once, 1 second after load
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Background refresh to keep cache fresh (with request deduplication)
|
|
292
|
-
private async backgroundRefresh(options: CacheOptions & {
|
|
293
|
-
directFetcher: () => Promise<any>;
|
|
294
|
-
dataValidator: (data: any) => boolean;
|
|
295
|
-
onDataUpdate: (data: any) => void;
|
|
296
|
-
}, currentData: any): Promise<void> {
|
|
297
|
-
const refreshKey = `refresh-${options.platform}-${options.url}`;
|
|
298
|
-
const now = Date.now();
|
|
299
|
-
|
|
300
|
-
// Check if we've refreshed this URL recently (within 30 seconds)
|
|
301
|
-
const lastRefresh = refreshTimestamps.get(refreshKey);
|
|
302
|
-
if (lastRefresh && (now - lastRefresh) < 30000) {
|
|
303
|
-
console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Refresh skipped (recent refresh): ${options.url}`);
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Prevent multiple background refresh requests for the same URL
|
|
308
|
-
if (cachingRequests.has(refreshKey)) {
|
|
309
|
-
console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Refresh skipped (already in progress): ${options.url}`);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const refreshPromise = (async () => {
|
|
314
|
-
try {
|
|
315
|
-
console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Starting background refresh for: ${options.url}`);
|
|
316
|
-
refreshTimestamps.set(refreshKey, now);
|
|
317
|
-
|
|
318
|
-
const freshData = await this.fetchDirect(options);
|
|
319
|
-
|
|
320
|
-
if (freshData && options.dataValidator(freshData)) {
|
|
321
|
-
// Update cache silently without triggering UI re-render
|
|
322
|
-
this.setInMemory(options.url, freshData);
|
|
323
|
-
await this.setInServer(options, freshData);
|
|
324
|
-
|
|
325
|
-
console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh completed for: ${options.url}`);
|
|
326
|
-
} else {
|
|
327
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh failed (invalid data) for: ${options.url}`);
|
|
328
|
-
}
|
|
329
|
-
} catch (error) {
|
|
330
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh failed for: ${options.url}`, error);
|
|
331
|
-
} finally {
|
|
332
|
-
cachingRequests.delete(refreshKey);
|
|
92
|
+
dataValidator: (data: any) => boolean
|
|
93
|
+
): Promise<any | null> {
|
|
94
|
+
const inflight = inflightRequests.get(url);
|
|
95
|
+
if (inflight) return inflight;
|
|
96
|
+
|
|
97
|
+
const request = (async () => {
|
|
98
|
+
const response = await fetch(`${apiEndpoint}?url=${encodeURIComponent(url)}`, {
|
|
99
|
+
signal: AbortSignal.timeout(15_000),
|
|
100
|
+
});
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
const data = await response.json();
|
|
103
|
+
if (data && dataValidator(data)) return data;
|
|
104
|
+
throw new Error('Server proxy returned invalid data shape');
|
|
333
105
|
}
|
|
106
|
+
if (response.status === 404) return null;
|
|
107
|
+
throw new Error(`Server proxy failed: ${response.status}`);
|
|
334
108
|
})();
|
|
335
109
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
private silentBackgroundRefresh(options: CacheOptions & {
|
|
342
|
-
directFetcher: () => Promise<any>;
|
|
343
|
-
dataValidator: (data: any) => boolean;
|
|
344
|
-
}): void {
|
|
345
|
-
const refreshKey = `silent-refresh-${options.platform}-${options.url}`;
|
|
346
|
-
const now = Date.now();
|
|
347
|
-
|
|
348
|
-
// Check if we've refreshed this URL recently (within 5 minutes for silent refresh)
|
|
349
|
-
const lastRefresh = refreshTimestamps.get(refreshKey);
|
|
350
|
-
if (lastRefresh && (now - lastRefresh) < 300000) {
|
|
351
|
-
console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh skipped (recent refresh): ${options.url}`);
|
|
352
|
-
return; // Skip if refreshed within 5 minutes
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Prevent multiple silent refresh requests for the same URL
|
|
356
|
-
if (cachingRequests.has(refreshKey)) {
|
|
357
|
-
console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh skipped (already in progress): ${options.url}`);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Run in next tick to avoid blocking UI thread
|
|
362
|
-
setTimeout(() => {
|
|
363
|
-
const refreshPromise = (async () => {
|
|
364
|
-
try {
|
|
365
|
-
console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Starting silent background refresh for: ${options.url}`);
|
|
366
|
-
refreshTimestamps.set(refreshKey, now);
|
|
367
|
-
|
|
368
|
-
// Fetch fresh data silently
|
|
369
|
-
const freshData = await this.fetchDirect(options);
|
|
370
|
-
|
|
371
|
-
if (freshData && options.dataValidator(freshData)) {
|
|
372
|
-
// Update cache silently - no UI notifications
|
|
373
|
-
this.setInMemory(options.url, freshData);
|
|
374
|
-
await this.setInServer(options, freshData);
|
|
375
|
-
console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh completed for: ${options.url}`);
|
|
376
|
-
} else {
|
|
377
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh failed (invalid data) for: ${options.url}`);
|
|
378
|
-
}
|
|
379
|
-
} catch (error) {
|
|
380
|
-
console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh failed for: ${options.url}`, error);
|
|
381
|
-
} finally {
|
|
382
|
-
cachingRequests.delete(refreshKey);
|
|
383
|
-
}
|
|
384
|
-
})();
|
|
385
|
-
|
|
386
|
-
cachingRequests.set(refreshKey, refreshPromise);
|
|
387
|
-
}, 0); // Defer to next event loop tick
|
|
110
|
+
inflightRequests.set(
|
|
111
|
+
url,
|
|
112
|
+
request.finally(() => inflightRequests.delete(url))
|
|
113
|
+
);
|
|
114
|
+
return inflightRequests.get(url)!;
|
|
388
115
|
}
|
|
389
116
|
}
|
|
390
117
|
|