@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.
Files changed (116) hide show
  1. package/dist/{chunk-4EU6WL5O.cjs → chunk-2R5NM3FC.cjs} +40 -40
  2. package/dist/{chunk-4EU6WL5O.cjs.map → chunk-2R5NM3FC.cjs.map} +1 -1
  3. package/dist/{chunk-B52SBOUY.js → chunk-3NNCBZEM.js} +6 -6
  4. package/dist/{chunk-KP37QDNS.cjs → chunk-464NJS2W.cjs} +73 -73
  5. package/dist/{chunk-KP37QDNS.cjs.map → chunk-464NJS2W.cjs.map} +1 -1
  6. package/dist/{chunk-CDBQQA7V.cjs → chunk-5TQQ3CPE.cjs} +29 -29
  7. package/dist/{chunk-CDBQQA7V.cjs.map → chunk-5TQQ3CPE.cjs.map} +1 -1
  8. package/dist/{chunk-B472QGDO.cjs → chunk-6KD6KVAI.cjs} +5 -5
  9. package/dist/{chunk-B472QGDO.cjs.map → chunk-6KD6KVAI.cjs.map} +1 -1
  10. package/dist/{chunk-CH7MDKPI.cjs → chunk-7VBGYCXG.cjs} +7 -7
  11. package/dist/{chunk-CH7MDKPI.cjs.map → chunk-7VBGYCXG.cjs.map} +1 -1
  12. package/dist/{chunk-FPOHUGEK.js → chunk-AGX565ZT.js} +2 -2
  13. package/dist/{chunk-ZQRGJHNH.cjs → chunk-BOPLMNSN.cjs} +117 -151
  14. package/dist/chunk-BOPLMNSN.cjs.map +1 -0
  15. package/dist/{chunk-SWNZZVYG.js → chunk-FBUIZTAI.js} +2 -2
  16. package/dist/{chunk-HQL5GJ35.cjs → chunk-FM5KWLKU.cjs} +3 -3
  17. package/dist/{chunk-HQL5GJ35.cjs.map → chunk-FM5KWLKU.cjs.map} +1 -1
  18. package/dist/{chunk-J5S5U76A.cjs → chunk-GELW7JOQ.cjs} +32 -32
  19. package/dist/{chunk-J5S5U76A.cjs.map → chunk-GELW7JOQ.cjs.map} +1 -1
  20. package/dist/{chunk-QESV36I2.js → chunk-IIOHHNMI.js} +36 -70
  21. package/dist/chunk-IIOHHNMI.js.map +1 -0
  22. package/dist/{chunk-4Y6LX6XR.js → chunk-K52S6LZ5.js} +3 -3
  23. package/dist/{chunk-6VEEQBVJ.cjs → chunk-KPVKZ5ML.cjs} +15 -15
  24. package/dist/{chunk-6VEEQBVJ.cjs.map → chunk-KPVKZ5ML.cjs.map} +1 -1
  25. package/dist/{chunk-4WHDDAVP.cjs → chunk-KXKTXAGN.cjs} +40 -40
  26. package/dist/{chunk-4WHDDAVP.cjs.map → chunk-KXKTXAGN.cjs.map} +1 -1
  27. package/dist/{chunk-5LTLYPIP.cjs → chunk-LFXUFFDZ.cjs} +73 -73
  28. package/dist/{chunk-5LTLYPIP.cjs.map → chunk-LFXUFFDZ.cjs.map} +1 -1
  29. package/dist/{chunk-4YH4WKLC.js → chunk-OKRUL7YV.js} +4 -4
  30. package/dist/{chunk-KXBXPOYM.cjs → chunk-PFD2VRZI.cjs} +18 -18
  31. package/dist/{chunk-KXBXPOYM.cjs.map → chunk-PFD2VRZI.cjs.map} +1 -1
  32. package/dist/{chunk-I5L3KUTE.js → chunk-PTFSF3UP.js} +6 -6
  33. package/dist/{chunk-H6FKYKEQ.js → chunk-R4OGP4OV.js} +3 -3
  34. package/dist/{chunk-2SPAHQYE.js → chunk-RIPWRVA3.js} +5 -5
  35. package/dist/{chunk-DBYUQOLS.js → chunk-S6IB3BZK.js} +2 -2
  36. package/dist/{chunk-6WPUUGW5.js → chunk-SAH4FR36.js} +5 -5
  37. package/dist/{chunk-TONMD7QX.cjs → chunk-VCOL4C2W.cjs} +86 -20
  38. package/dist/chunk-VCOL4C2W.cjs.map +1 -0
  39. package/dist/{chunk-LWIVRGDN.js → chunk-VNDAMVMI.js} +2 -2
  40. package/dist/{chunk-6OGOPPTI.cjs → chunk-YJWISFIK.cjs} +12 -12
  41. package/dist/{chunk-6OGOPPTI.cjs.map → chunk-YJWISFIK.cjs.map} +1 -1
  42. package/dist/{chunk-UCY6VITT.js → chunk-Z4LAZ4ZZ.js} +3 -3
  43. package/dist/{chunk-OTV4A6NA.js → chunk-ZPUZPJDV.js} +80 -14
  44. package/dist/chunk-ZPUZPJDV.js.map +1 -0
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/help-center-pages/index.cjs +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +136 -136
  62. package/dist/components/index.js +11 -11
  63. package/dist/components/navigation/index.cjs +3 -3
  64. package/dist/components/navigation/index.js +2 -2
  65. package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
  66. package/dist/components/onboarding-guides/index.cjs +7 -7
  67. package/dist/components/onboarding-guides/index.js +6 -6
  68. package/dist/components/related-content/index.cjs +5 -5
  69. package/dist/components/related-content/index.js +4 -4
  70. package/dist/components/tickets/index.cjs +6 -6
  71. package/dist/components/tickets/index.js +5 -5
  72. package/dist/components/ui/entity-image.d.ts.map +1 -1
  73. package/dist/components/ui/image-uploader.d.ts.map +1 -1
  74. package/dist/components/ui/index.cjs +3 -3
  75. package/dist/components/ui/index.js +2 -2
  76. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  77. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  78. package/dist/hooks/index.cjs +6 -2
  79. package/dist/hooks/index.cjs.map +1 -1
  80. package/dist/hooks/index.d.ts +1 -0
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js +5 -1
  83. package/dist/hooks/use-authed-image-src.d.ts +10 -0
  84. package/dist/hooks/use-authed-image-src.d.ts.map +1 -0
  85. package/dist/index.cjs +7 -3
  86. package/dist/index.cjs.map +1 -1
  87. package/dist/index.js +6 -2
  88. package/dist/utils/embed-authed-fetch.d.ts +15 -0
  89. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  90. package/dist/utils/index.cjs.map +1 -1
  91. package/dist/utils/index.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/components/navigation/mobile-burger-menu.tsx +1 -0
  94. package/src/components/ui/entity-image.tsx +5 -3
  95. package/src/components/ui/image-uploader.tsx +21 -12
  96. package/src/components/ui/simple-markdown-renderer.tsx +57 -58
  97. package/src/components/ui/square-avatar.tsx +5 -3
  98. package/src/hooks/index.ts +1 -0
  99. package/src/hooks/use-authed-image-src.ts +93 -0
  100. package/src/utils/embed-authed-fetch.ts +30 -0
  101. package/dist/chunk-OTV4A6NA.js.map +0 -1
  102. package/dist/chunk-QESV36I2.js.map +0 -1
  103. package/dist/chunk-TONMD7QX.cjs.map +0 -1
  104. package/dist/chunk-ZQRGJHNH.cjs.map +0 -1
  105. /package/dist/{chunk-B52SBOUY.js.map → chunk-3NNCBZEM.js.map} +0 -0
  106. /package/dist/{chunk-FPOHUGEK.js.map → chunk-AGX565ZT.js.map} +0 -0
  107. /package/dist/{chunk-SWNZZVYG.js.map → chunk-FBUIZTAI.js.map} +0 -0
  108. /package/dist/{chunk-4Y6LX6XR.js.map → chunk-K52S6LZ5.js.map} +0 -0
  109. /package/dist/{chunk-4YH4WKLC.js.map → chunk-OKRUL7YV.js.map} +0 -0
  110. /package/dist/{chunk-I5L3KUTE.js.map → chunk-PTFSF3UP.js.map} +0 -0
  111. /package/dist/{chunk-H6FKYKEQ.js.map → chunk-R4OGP4OV.js.map} +0 -0
  112. /package/dist/{chunk-2SPAHQYE.js.map → chunk-RIPWRVA3.js.map} +0 -0
  113. /package/dist/{chunk-DBYUQOLS.js.map → chunk-S6IB3BZK.js.map} +0 -0
  114. /package/dist/{chunk-6WPUUGW5.js.map → chunk-SAH4FR36.js.map} +0 -0
  115. /package/dist/{chunk-LWIVRGDN.js.map → chunk-VNDAMVMI.js.map} +0 -0
  116. /package/dist/{chunk-UCY6VITT.js.map → chunk-Z4LAZ4ZZ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.473",
3
+ "version": "0.0.474",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- }, [src])
22
+ }, [resolvedSrc])
21
23
 
22
- const showFallback = imageFailed || !src
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={src ?? undefined}
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
- <img
200
- src={value}
201
- alt={alt}
202
- className={cn(
203
- "pointer-events-none absolute inset-0 size-full",
204
- objectFit === "cover" && "object-cover",
205
- objectFit === "contain" && "object-contain",
206
- objectFit === "fill" && "object-fill",
207
- objectFit === "none" && "object-none",
208
- objectFit === "scale-down" && "object-scale-down",
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
- // Inline content image renderer. Used by blog posts, docs, AND chat
824
- // messages (where users may attach screenshots / photos via the +
825
- // attachment button).
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
- src && 'hidden'
71
+ resolvedSrc && 'hidden'
70
72
  )}>
71
73
  {getFirstLastInitials(fallback || alt) || '?'}
72
74
  </div>
73
- {src && (
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={src}
78
+ src={resolvedSrc}
77
79
  alt={alt || ''}
78
80
  width={sizePx ?? sizePxBySize[size]}
79
81
  height={sizePx ?? sizePxBySize[size]}
@@ -8,6 +8,7 @@ export * from './use-copy-to-clipboard'
8
8
  // Image authentication hooks
9
9
  export * from './use-batch-images'
10
10
  export * from './use-authenticated-image'
11
+ export * from './use-authed-image-src'
11
12
 
12
13
  // URL State Management hooks
13
14
  export * from './state'
@@ -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'`