@flamingo-stack/openframe-frontend-core 0.0.473 → 0.0.474
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-4EU6WL5O.cjs → chunk-2R5NM3FC.cjs} +40 -40
- package/dist/{chunk-4EU6WL5O.cjs.map → chunk-2R5NM3FC.cjs.map} +1 -1
- package/dist/{chunk-B52SBOUY.js → chunk-3NNCBZEM.js} +6 -6
- package/dist/{chunk-KP37QDNS.cjs → chunk-464NJS2W.cjs} +73 -73
- package/dist/{chunk-KP37QDNS.cjs.map → chunk-464NJS2W.cjs.map} +1 -1
- package/dist/{chunk-CDBQQA7V.cjs → chunk-5TQQ3CPE.cjs} +29 -29
- package/dist/{chunk-CDBQQA7V.cjs.map → chunk-5TQQ3CPE.cjs.map} +1 -1
- package/dist/{chunk-B472QGDO.cjs → chunk-6KD6KVAI.cjs} +5 -5
- package/dist/{chunk-B472QGDO.cjs.map → chunk-6KD6KVAI.cjs.map} +1 -1
- package/dist/{chunk-CH7MDKPI.cjs → chunk-7VBGYCXG.cjs} +7 -7
- package/dist/{chunk-CH7MDKPI.cjs.map → chunk-7VBGYCXG.cjs.map} +1 -1
- package/dist/{chunk-FPOHUGEK.js → chunk-AGX565ZT.js} +2 -2
- package/dist/{chunk-ZQRGJHNH.cjs → chunk-BOPLMNSN.cjs} +117 -151
- package/dist/chunk-BOPLMNSN.cjs.map +1 -0
- package/dist/{chunk-SWNZZVYG.js → chunk-FBUIZTAI.js} +2 -2
- package/dist/{chunk-HQL5GJ35.cjs → chunk-FM5KWLKU.cjs} +3 -3
- package/dist/{chunk-HQL5GJ35.cjs.map → chunk-FM5KWLKU.cjs.map} +1 -1
- package/dist/{chunk-J5S5U76A.cjs → chunk-GELW7JOQ.cjs} +32 -32
- package/dist/{chunk-J5S5U76A.cjs.map → chunk-GELW7JOQ.cjs.map} +1 -1
- package/dist/{chunk-QESV36I2.js → chunk-IIOHHNMI.js} +36 -70
- package/dist/chunk-IIOHHNMI.js.map +1 -0
- package/dist/{chunk-4Y6LX6XR.js → chunk-K52S6LZ5.js} +3 -3
- package/dist/{chunk-6VEEQBVJ.cjs → chunk-KPVKZ5ML.cjs} +15 -15
- package/dist/{chunk-6VEEQBVJ.cjs.map → chunk-KPVKZ5ML.cjs.map} +1 -1
- package/dist/{chunk-4WHDDAVP.cjs → chunk-KXKTXAGN.cjs} +40 -40
- package/dist/{chunk-4WHDDAVP.cjs.map → chunk-KXKTXAGN.cjs.map} +1 -1
- package/dist/{chunk-5LTLYPIP.cjs → chunk-LFXUFFDZ.cjs} +73 -73
- package/dist/{chunk-5LTLYPIP.cjs.map → chunk-LFXUFFDZ.cjs.map} +1 -1
- package/dist/{chunk-4YH4WKLC.js → chunk-OKRUL7YV.js} +4 -4
- package/dist/{chunk-KXBXPOYM.cjs → chunk-PFD2VRZI.cjs} +18 -18
- package/dist/{chunk-KXBXPOYM.cjs.map → chunk-PFD2VRZI.cjs.map} +1 -1
- package/dist/{chunk-I5L3KUTE.js → chunk-PTFSF3UP.js} +6 -6
- package/dist/{chunk-H6FKYKEQ.js → chunk-R4OGP4OV.js} +3 -3
- package/dist/{chunk-2SPAHQYE.js → chunk-RIPWRVA3.js} +5 -5
- package/dist/{chunk-DBYUQOLS.js → chunk-S6IB3BZK.js} +2 -2
- package/dist/{chunk-6WPUUGW5.js → chunk-SAH4FR36.js} +5 -5
- package/dist/{chunk-TONMD7QX.cjs → chunk-VCOL4C2W.cjs} +86 -20
- package/dist/chunk-VCOL4C2W.cjs.map +1 -0
- package/dist/{chunk-LWIVRGDN.js → chunk-VNDAMVMI.js} +2 -2
- package/dist/{chunk-6OGOPPTI.cjs → chunk-YJWISFIK.cjs} +12 -12
- package/dist/{chunk-6OGOPPTI.cjs.map → chunk-YJWISFIK.cjs.map} +1 -1
- package/dist/{chunk-UCY6VITT.js → chunk-Z4LAZ4ZZ.js} +3 -3
- package/dist/{chunk-OTV4A6NA.js → chunk-ZPUZPJDV.js} +80 -14
- package/dist/chunk-ZPUZPJDV.js.map +1 -0
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- 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 +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +136 -136
- package/dist/components/index.js +11 -11
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/entity-image.d.ts.map +1 -1
- package/dist/components/ui/image-uploader.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/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/components/ui/square-avatar.d.ts.map +1 -1
- package/dist/hooks/index.cjs +6 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -1
- package/dist/hooks/use-authed-image-src.d.ts +10 -0
- package/dist/hooks/use-authed-image-src.d.ts.map +1 -0
- package/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/dist/utils/embed-authed-fetch.d.ts +15 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/navigation/mobile-burger-menu.tsx +1 -0
- package/src/components/ui/entity-image.tsx +5 -3
- package/src/components/ui/image-uploader.tsx +21 -12
- package/src/components/ui/simple-markdown-renderer.tsx +57 -58
- package/src/components/ui/square-avatar.tsx +5 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-authed-image-src.ts +93 -0
- package/src/utils/embed-authed-fetch.ts +30 -0
- package/dist/chunk-OTV4A6NA.js.map +0 -1
- package/dist/chunk-QESV36I2.js.map +0 -1
- package/dist/chunk-TONMD7QX.cjs.map +0 -1
- package/dist/chunk-ZQRGJHNH.cjs.map +0 -1
- /package/dist/{chunk-B52SBOUY.js.map → chunk-3NNCBZEM.js.map} +0 -0
- /package/dist/{chunk-FPOHUGEK.js.map → chunk-AGX565ZT.js.map} +0 -0
- /package/dist/{chunk-SWNZZVYG.js.map → chunk-FBUIZTAI.js.map} +0 -0
- /package/dist/{chunk-4Y6LX6XR.js.map → chunk-K52S6LZ5.js.map} +0 -0
- /package/dist/{chunk-4YH4WKLC.js.map → chunk-OKRUL7YV.js.map} +0 -0
- /package/dist/{chunk-I5L3KUTE.js.map → chunk-PTFSF3UP.js.map} +0 -0
- /package/dist/{chunk-H6FKYKEQ.js.map → chunk-R4OGP4OV.js.map} +0 -0
- /package/dist/{chunk-2SPAHQYE.js.map → chunk-RIPWRVA3.js.map} +0 -0
- /package/dist/{chunk-DBYUQOLS.js.map → chunk-S6IB3BZK.js.map} +0 -0
- /package/dist/{chunk-6WPUUGW5.js.map → chunk-SAH4FR36.js.map} +0 -0
- /package/dist/{chunk-LWIVRGDN.js.map → chunk-VNDAMVMI.js.map} +0 -0
- /package/dist/{chunk-UCY6VITT.js.map → chunk-Z4LAZ4ZZ.js.map} +0 -0
package/package.json
CHANGED
|
@@ -79,6 +79,7 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
79
79
|
disabled={disabled}
|
|
80
80
|
className={cn(
|
|
81
81
|
"flex items-center gap-1 p-3 relative",
|
|
82
|
+
"focus:outline-none focus-visible:outline-none",
|
|
82
83
|
"transition-colors duration-200",
|
|
83
84
|
"bg-ods-card border border-ods-border rounded-md",
|
|
84
85
|
!disabled && "hover:bg-ods-bg-hover",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React from 'react'
|
|
4
|
+
import { useAuthedImageSrc } from '../../hooks/use-authed-image-src'
|
|
4
5
|
import { cn } from '../../utils/cn'
|
|
5
6
|
import { getFirstLastInitials } from '../../utils/format'
|
|
6
7
|
|
|
@@ -13,13 +14,14 @@ export interface EntityImageProps {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function EntityImage({ src, alt, fallbackText, className }: EntityImageProps) {
|
|
17
|
+
const resolvedSrc = useAuthedImageSrc(src)
|
|
16
18
|
const [imageFailed, setImageFailed] = React.useState(false)
|
|
17
19
|
|
|
18
20
|
React.useEffect(() => {
|
|
19
21
|
setImageFailed(false)
|
|
20
|
-
}, [
|
|
22
|
+
}, [resolvedSrc])
|
|
21
23
|
|
|
22
|
-
const showFallback = imageFailed || !
|
|
24
|
+
const showFallback = imageFailed || !resolvedSrc
|
|
23
25
|
const initials = getFirstLastInitials(fallbackText ?? alt)
|
|
24
26
|
|
|
25
27
|
if (showFallback) {
|
|
@@ -38,7 +40,7 @@ export function EntityImage({ src, alt, fallbackText, className }: EntityImagePr
|
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
42
|
<img
|
|
41
|
-
src={
|
|
43
|
+
src={resolvedSrc}
|
|
42
44
|
alt={alt ?? ''}
|
|
43
45
|
onError={() => setImageFailed(true)}
|
|
44
46
|
className={cn(
|
|
@@ -5,6 +5,7 @@ import { Loader2 } from "lucide-react"
|
|
|
5
5
|
import { ImagePlusIcon } from "../icons-v2-generated/audio-and-visual/image-plus-icon"
|
|
6
6
|
import { Refresh02VrIcon } from "../icons-v2-generated/media-playback/refresh-02-vr-icon"
|
|
7
7
|
import { TrashIcon } from "../icons-v2-generated/interface/trash-icon"
|
|
8
|
+
import { useAuthedImageSrc } from "../../hooks/use-authed-image-src"
|
|
8
9
|
import { cn } from "../../utils/cn"
|
|
9
10
|
import { FieldWrapper } from "./field-wrapper"
|
|
10
11
|
|
|
@@ -98,6 +99,12 @@ export function ImageUploader({
|
|
|
98
99
|
const [validationError, setValidationError] = React.useState<string | null>(null)
|
|
99
100
|
|
|
100
101
|
const interactive = !disabled && !loading
|
|
102
|
+
// Bearer-mode shells: swap a gateway URL for an authed blob URL (native
|
|
103
|
+
// <img> loads can't carry Authorization); pass-through everywhere else,
|
|
104
|
+
// including local blob/data preview URLs. `hasImage` stays keyed on the
|
|
105
|
+
// caller's `value` so the preview chrome doesn't flash to the empty
|
|
106
|
+
// state while the blob fetch is in flight.
|
|
107
|
+
const resolvedValue = useAuthedImageSrc(value)
|
|
101
108
|
const hasImage = Boolean(value)
|
|
102
109
|
const displayError = error || validationError || undefined
|
|
103
110
|
const resolvedHint = hint === undefined ? `Max. size: ${formatSize(maxSize)}` : hint
|
|
@@ -196,18 +203,20 @@ export function ImageUploader({
|
|
|
196
203
|
{hasImage ? (
|
|
197
204
|
<>
|
|
198
205
|
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-md">
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
{resolvedValue && (
|
|
207
|
+
<img
|
|
208
|
+
src={resolvedValue}
|
|
209
|
+
alt={alt}
|
|
210
|
+
className={cn(
|
|
211
|
+
"pointer-events-none absolute inset-0 size-full",
|
|
212
|
+
objectFit === "cover" && "object-cover",
|
|
213
|
+
objectFit === "contain" && "object-contain",
|
|
214
|
+
objectFit === "fill" && "object-fill",
|
|
215
|
+
objectFit === "none" && "object-none",
|
|
216
|
+
objectFit === "scale-down" && "object-scale-down",
|
|
217
|
+
)}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
211
220
|
</div>
|
|
212
221
|
|
|
213
222
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-4">
|
|
@@ -9,6 +9,7 @@ import rehypeHighlight from 'rehype-highlight';
|
|
|
9
9
|
import rehypeRaw from 'rehype-raw';
|
|
10
10
|
import { visit } from 'unist-util-visit';
|
|
11
11
|
import Image from '../../embed-shims/next-image';
|
|
12
|
+
import { useAuthedImageSrc } from '../../hooks/use-authed-image-src';
|
|
12
13
|
import { AlertCircleIcon } from '../icons-v2-generated';
|
|
13
14
|
import { cn } from '../../utils/cn';
|
|
14
15
|
import type { ResolveLinkResult } from '../../types/doc-source';
|
|
@@ -453,6 +454,58 @@ const MermaidDiagram: React.FC<{ chart: string }> = ({ chart }) => {
|
|
|
453
454
|
);
|
|
454
455
|
};
|
|
455
456
|
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// MarkdownContentImage
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Inline content image renderer. Used by blog posts, docs, AND chat
|
|
461
|
+
// messages (where users may attach screenshots / photos via the +
|
|
462
|
+
// attachment button). A standalone component (rather than inline JSX in
|
|
463
|
+
// the `img` renderer) because it needs `useAuthedImageSrc`: in
|
|
464
|
+
// bearer-mode native shells a gateway-hosted image can't load via a
|
|
465
|
+
// plain `<img>` (no Authorization header on native asset loads), so the
|
|
466
|
+
// hook swaps in an authed blob URL; everywhere else it passes `src`
|
|
467
|
+
// through untouched. While the blob fetch is in flight the component
|
|
468
|
+
// renders nothing — same as the no-src case.
|
|
469
|
+
//
|
|
470
|
+
// Sizing rules (2025-2026 best practice — Claude.ai, ChatGPT,
|
|
471
|
+
// iMessage, Slack, Discord inline image patterns):
|
|
472
|
+
//
|
|
473
|
+
// - CAP max-width at 400px so inline images don't blow out the
|
|
474
|
+
// message column on wide panels. Click-to-expand opens a
|
|
475
|
+
// full-resolution modal for users who need detail.
|
|
476
|
+
// - CAP max-height at 400px so portrait-orientation images
|
|
477
|
+
// don't dominate vertical space (a 1000x3000 phone screenshot
|
|
478
|
+
// would otherwise push the next message off-screen).
|
|
479
|
+
// - Small images render at NATURAL pixel size — a 64x64
|
|
480
|
+
// thumbnail stays 64x64, not stretched to fill the column.
|
|
481
|
+
// - `object-contain` preserves aspect ratio when both dimensions
|
|
482
|
+
// are constrained (long landscape, tall portrait).
|
|
483
|
+
//
|
|
484
|
+
// Implementation: Next.js `<Image>` — the project's canonical
|
|
485
|
+
// image primitive (WebP/AVIF conversion, responsive `srcset`, lazy
|
|
486
|
+
// loading, async decode). `width={400} height={400}` are REQUIRED by
|
|
487
|
+
// Next.js `<Image>` (non-`fill` mode throws without them) but they're
|
|
488
|
+
// effectively a CEILING here, not the display size — the CSS overrides
|
|
489
|
+
// (`w-auto h-auto max-w-full max-h-[400px]`) drive the actual rendered
|
|
490
|
+
// size, and the inline `style={{ width: 'auto', height: 'auto' }}` is
|
|
491
|
+
// belt-and-suspenders against the rendered `<img>`'s HTML width/height
|
|
492
|
+
// attributes.
|
|
493
|
+
const MarkdownContentImage: React.FC<{ src: string; alt?: string }> = ({ src, alt }) => {
|
|
494
|
+
const resolvedSrc = useAuthedImageSrc(src);
|
|
495
|
+
if (!resolvedSrc) return null;
|
|
496
|
+
return (
|
|
497
|
+
<Image
|
|
498
|
+
src={resolvedSrc}
|
|
499
|
+
alt={alt ?? 'No image available'}
|
|
500
|
+
width={400}
|
|
501
|
+
height={400}
|
|
502
|
+
sizes="(max-width: 400px) 100vw, 400px"
|
|
503
|
+
className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
|
|
504
|
+
style={{ width: 'auto', height: 'auto' }}
|
|
505
|
+
/>
|
|
506
|
+
);
|
|
507
|
+
};
|
|
508
|
+
|
|
456
509
|
// ---------------------------------------------------------------------------
|
|
457
510
|
// Utility: extract plain text from React children
|
|
458
511
|
// ---------------------------------------------------------------------------
|
|
@@ -820,66 +873,12 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
|
|
|
820
873
|
},
|
|
821
874
|
|
|
822
875
|
// --- images ---
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
// Sizing rules (2025-2026 best practice — Claude.ai, ChatGPT,
|
|
828
|
-
// iMessage, Slack, Discord inline image patterns):
|
|
829
|
-
//
|
|
830
|
-
// - CAP max-width at 400px so inline images don't blow out the
|
|
831
|
-
// message column on wide panels. Click-to-expand opens a
|
|
832
|
-
// full-resolution modal for users who need detail.
|
|
833
|
-
// - CAP max-height at 400px so portrait-orientation images
|
|
834
|
-
// don't dominate vertical space (a 1000x3000 phone screenshot
|
|
835
|
-
// would otherwise push the next message off-screen).
|
|
836
|
-
// - Small images render at NATURAL pixel size — a 64x64
|
|
837
|
-
// thumbnail stays 64x64, not stretched to fill the column.
|
|
838
|
-
// - `object-contain` preserves aspect ratio when both dimensions
|
|
839
|
-
// are constrained (long landscape, tall portrait).
|
|
840
|
-
//
|
|
841
|
-
// Implementation: Next.js `<Image>` — the project's canonical
|
|
842
|
-
// image primitive. Gives us:
|
|
843
|
-
// - WebP/AVIF format conversion for modern browsers (smaller
|
|
844
|
-
// bytes for the same visual quality).
|
|
845
|
-
// - Responsive `srcset` via the `sizes` prop (browser picks the
|
|
846
|
-
// right variant for the viewport).
|
|
847
|
-
// - Automatic lazy-loading (`loading="lazy"` by default, mid-
|
|
848
|
-
// page images skipped until they near the viewport).
|
|
849
|
-
// - Automatic `decoding="async"` so image decode doesn't block
|
|
850
|
-
// paint.
|
|
851
|
-
//
|
|
852
|
-
// `width={400} height={400}` props are REQUIRED by Next.js
|
|
853
|
-
// `<Image>` (non-`fill` mode throws without them) but they're
|
|
854
|
-
// effectively a CEILING here, not the display size — the CSS
|
|
855
|
-
// overrides (`w-auto h-auto max-w-full max-h-[400px]`) drive
|
|
856
|
-
// the actual rendered size. The inline `style={{ width: 'auto',
|
|
857
|
-
// height: 'auto' }}` is belt-and-suspenders: Next.js Image sets
|
|
858
|
-
// matching HTML `width`/`height` attributes on the rendered
|
|
859
|
-
// `<img>` and inline style wins over both HTML attributes AND
|
|
860
|
-
// utility classes regardless of CSS-specificity surprises.
|
|
861
|
-
//
|
|
862
|
-
// Layout reservation trade-off: until image bytes arrive, the
|
|
863
|
-
// browser may reserve a placeholder box up to 400x400 (the props'
|
|
864
|
-
// intrinsic-ratio hint). Once loaded, the box collapses to the
|
|
865
|
-
// natural size if smaller. This is the standard Next.js Image
|
|
866
|
-
// behavior across the codebase — accepted for the optimizer +
|
|
867
|
-
// responsive-srcset benefits. Chat attachments hosted in side
|
|
868
|
-
// panels see this only on first render of a fresh attachment
|
|
869
|
-
// (cached re-renders pop in without a perceptible shift).
|
|
876
|
+
// Delegates to MarkdownContentImage (module scope, above) — sizing
|
|
877
|
+
// rules, Next.js <Image> rationale, and the bearer-mode authed-src
|
|
878
|
+
// handling are documented there.
|
|
870
879
|
img: ({ src, alt }: any) => {
|
|
871
880
|
if (!src || typeof src !== 'string' || src.trim() === '') return null;
|
|
872
|
-
return
|
|
873
|
-
<Image
|
|
874
|
-
src={src}
|
|
875
|
-
alt={alt ?? 'No image available'}
|
|
876
|
-
width={400}
|
|
877
|
-
height={400}
|
|
878
|
-
sizes="(max-width: 400px) 100vw, 400px"
|
|
879
|
-
className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
|
|
880
|
-
style={{ width: 'auto', height: 'auto' }}
|
|
881
|
-
/>
|
|
882
|
-
);
|
|
881
|
+
return <MarkdownContentImage src={src} alt={alt} />;
|
|
883
882
|
},
|
|
884
883
|
|
|
885
884
|
// --- lists ---
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import Image from "../../embed-shims/next-image";
|
|
5
|
+
import { useAuthedImageSrc } from "../../hooks/use-authed-image-src";
|
|
5
6
|
import { cn } from "../../utils/cn";
|
|
6
7
|
import { getFirstLastInitials } from "../../utils/format";
|
|
7
8
|
|
|
@@ -22,6 +23,7 @@ interface SquareAvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
22
23
|
|
|
23
24
|
const SquareAvatar = React.memo(React.forwardRef<HTMLDivElement, SquareAvatarProps>(
|
|
24
25
|
({ className, src, alt, size = 'md', sizePx, fallback, variant = 'square', initialsClassName, style, ...props }, ref) => {
|
|
26
|
+
const resolvedSrc = useAuthedImageSrc(src)
|
|
25
27
|
const sizeClasses = {
|
|
26
28
|
sm: 'h-8 w-8',
|
|
27
29
|
md: 'h-10 w-10',
|
|
@@ -66,14 +68,14 @@ const SquareAvatar = React.memo(React.forwardRef<HTMLDivElement, SquareAvatarPro
|
|
|
66
68
|
// keeps the later class).
|
|
67
69
|
'flex items-center justify-center text-xs font-medium text-[color:var(--ods-avatar-initials,var(--color-text-primary))]',
|
|
68
70
|
initialsClassName,
|
|
69
|
-
|
|
71
|
+
resolvedSrc && 'hidden'
|
|
70
72
|
)}>
|
|
71
73
|
{getFirstLastInitials(fallback || alt) || '?'}
|
|
72
74
|
</div>
|
|
73
|
-
{
|
|
75
|
+
{resolvedSrc && (
|
|
74
76
|
<Image
|
|
75
77
|
className="absolute -inset-px h-[calc(100%+2px)] w-[calc(100%+2px)] max-w-none object-cover"
|
|
76
|
-
src={
|
|
78
|
+
src={resolvedSrc}
|
|
77
79
|
alt={alt || ''}
|
|
78
80
|
width={sizePx ?? sizePxBySize[size]}
|
|
79
81
|
height={sizePx ?? sizePxBySize[size]}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useReducer } from 'react'
|
|
4
|
+
import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an image URL for hosts whose auth rides in request headers
|
|
8
|
+
* instead of cookies (native shells — `capacitor://localhost`,
|
|
9
|
+
* `tauri://localhost` — running in bearer mode). A native `<img>` load
|
|
10
|
+
* can't carry an `Authorization` header, so a gateway image URL that
|
|
11
|
+
* works on the cookie-auth web 401s in a shell. When
|
|
12
|
+
* `needsBearerAssetFetch` says the URL's origin is bearer-authed, this
|
|
13
|
+
* hook fetches it through `embedAuthedFetch` (bearer + deduped
|
|
14
|
+
* 401-refresh-retry + the adapter's cross-origin guard) and returns a
|
|
15
|
+
* blob object-URL; every other URL is returned unchanged, so on the
|
|
16
|
+
* cookie-auth web the hook is a pass-through.
|
|
17
|
+
*
|
|
18
|
+
* Returns `undefined` while the fetch is in flight OR after it fails, so
|
|
19
|
+
* callers show their existing initials/placeholder fallback instead of a
|
|
20
|
+
* broken image; a failed fetch retries on the next mount.
|
|
21
|
+
*
|
|
22
|
+
* Cache: module-level, session-lifetime, keyed by full URL. Gateway image
|
|
23
|
+
* URLs carry a `?v=<content-hash>` cache-buster, so a changed image is a
|
|
24
|
+
* new key; entries are never revoked (avatars are a few KB — refcount
|
|
25
|
+
* churn isn't worth it).
|
|
26
|
+
*
|
|
27
|
+
* Distinct from the legacy `useAuthenticatedImage`, which ALWAYS
|
|
28
|
+
* blob-fetches with `credentials: 'include'` and its own global config.
|
|
29
|
+
* This hook is pass-through-first and keys off the single auth knob the
|
|
30
|
+
* lib already has: the registered `EmbedAuthAdapter`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const resolvedCache = new Map<string, string>()
|
|
34
|
+
const inFlight = new Map<string, Promise<void>>()
|
|
35
|
+
let cacheGeneration = 0
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drop every cached blob and revoke its object URL. Hosts call this at
|
|
39
|
+
* session end (logout / forced re-login) so a follow-on login as a
|
|
40
|
+
* different identity cannot be served blobs fetched under the previous
|
|
41
|
+
* one's bearer. Fetches still in flight when the clear happens are fenced
|
|
42
|
+
* by a generation counter — their results are revoked instead of cached.
|
|
43
|
+
*/
|
|
44
|
+
export function clearAuthedImageCache(): void {
|
|
45
|
+
cacheGeneration++
|
|
46
|
+
for (const url of resolvedCache.values()) URL.revokeObjectURL(url)
|
|
47
|
+
resolvedCache.clear()
|
|
48
|
+
inFlight.clear()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fetchAsBlobUrl(src: string): Promise<void> {
|
|
52
|
+
let pending = inFlight.get(src)
|
|
53
|
+
if (!pending) {
|
|
54
|
+
const startedGeneration = cacheGeneration
|
|
55
|
+
pending = embedAuthedFetch(src, { headers: { Accept: 'image/*' } })
|
|
56
|
+
.then(async response => {
|
|
57
|
+
if (!response.ok) throw new Error(`image fetch failed: ${response.status}`)
|
|
58
|
+
const blobUrl = URL.createObjectURL(await response.blob())
|
|
59
|
+
if (startedGeneration === cacheGeneration) {
|
|
60
|
+
resolvedCache.set(src, blobUrl)
|
|
61
|
+
} else {
|
|
62
|
+
URL.revokeObjectURL(blobUrl)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
if (inFlight.get(src) === pending) inFlight.delete(src)
|
|
67
|
+
})
|
|
68
|
+
inFlight.set(src, pending)
|
|
69
|
+
}
|
|
70
|
+
return pending
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useAuthedImageSrc(src?: string | null): string | undefined {
|
|
74
|
+
const bearerSrc = src && needsBearerAssetFetch(src) ? src : null
|
|
75
|
+
const [, rerender] = useReducer((c: number) => c + 1, 0)
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!bearerSrc || resolvedCache.has(bearerSrc)) return
|
|
79
|
+
let cancelled = false
|
|
80
|
+
fetchAsBlobUrl(bearerSrc)
|
|
81
|
+
.catch(() => {})
|
|
82
|
+
.finally(() => {
|
|
83
|
+
if (!cancelled) rerender()
|
|
84
|
+
})
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true
|
|
87
|
+
}
|
|
88
|
+
}, [bearerSrc])
|
|
89
|
+
|
|
90
|
+
if (!src) return undefined
|
|
91
|
+
if (!bearerSrc) return src
|
|
92
|
+
return resolvedCache.get(bearerSrc)
|
|
93
|
+
}
|
|
@@ -156,6 +156,36 @@ export function hasEmbedAuthAdapter(): boolean {
|
|
|
156
156
|
return getRegisteredAuthAdapter() !== null
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether `url` is an asset the browser CANNOT load natively because its
|
|
161
|
+
* auth rides in request headers: the registered adapter is currently
|
|
162
|
+
* supplying an `Authorization` header AND the URL's origin is one the
|
|
163
|
+
* adapter explicitly sanctions for that bearer (`allowedOrigins` — the
|
|
164
|
+
* host's gateway, cross-origin by construction in native shells).
|
|
165
|
+
*
|
|
166
|
+
* Native asset loads (`<img src>`, CSS `background-image`) can't carry
|
|
167
|
+
* custom headers, so a URL this returns `true` for must go through
|
|
168
|
+
* `embedAuthedFetch` → blob object-URL instead (see `useAuthedImageSrc`).
|
|
169
|
+
* Everything else — cookie-auth web (no Authorization), relative /
|
|
170
|
+
* same-origin URLs (cookies work), third-party origins the bearer does
|
|
171
|
+
* NOT belong to (public images) — loads natively, unchanged.
|
|
172
|
+
*/
|
|
173
|
+
export function needsBearerAssetFetch(url: string): boolean {
|
|
174
|
+
if (typeof window === 'undefined') return false
|
|
175
|
+
const adapter = getRegisteredAuthAdapter()
|
|
176
|
+
if (!adapter?.allowedOrigins?.length || adapter.getHeaders?.().Authorization === undefined) {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
let target: URL
|
|
180
|
+
try {
|
|
181
|
+
target = new URL(url, window.location.href)
|
|
182
|
+
} catch {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') return false
|
|
186
|
+
return adapter.allowedOrigins.includes(target.origin)
|
|
187
|
+
}
|
|
188
|
+
|
|
159
189
|
/**
|
|
160
190
|
* `fetch` wrapper that attaches embed-proxy bearer headers (when
|
|
161
191
|
* present in sessionStorage) and forces `credentials: 'same-origin'`
|