@linktr.ee/messaging-react 3.10.0-rc-1783660962 → 3.10.0

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 (86) hide show
  1. package/dist/Card-B-L2MST4.js +107 -0
  2. package/dist/Card-B-L2MST4.js.map +1 -0
  3. package/dist/Card-B56QxJW3.cjs +2 -0
  4. package/dist/Card-B56QxJW3.cjs.map +1 -0
  5. package/dist/Card-Bqh-BQaX.js +132 -0
  6. package/dist/Card-Bqh-BQaX.js.map +1 -0
  7. package/dist/Card-ZCE6Lhfc.cjs +2 -0
  8. package/dist/Card-ZCE6Lhfc.cjs.map +1 -0
  9. package/dist/Card-g4JD-2ES.js +163 -0
  10. package/dist/Card-g4JD-2ES.js.map +1 -0
  11. package/dist/Card-wac6AMNc.cjs +2 -0
  12. package/dist/Card-wac6AMNc.cjs.map +1 -0
  13. package/dist/LockedThumbnail-BAQmoIff.js +220 -0
  14. package/dist/LockedThumbnail-BAQmoIff.js.map +1 -0
  15. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs +2 -0
  16. package/dist/LockedThumbnail-Cr0Gqs3Y.cjs.map +1 -0
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-BPhZyaxT.cjs +2 -0
  19. package/dist/index-BPhZyaxT.cjs.map +1 -0
  20. package/dist/{index-DFZrAiLB.js → index-BxzzWK4K.js} +1249 -1276
  21. package/dist/index-BxzzWK4K.js.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +101 -164
  24. package/dist/index.js +1 -1
  25. package/dist/testing.d.ts +0 -1
  26. package/package.json +2 -2
  27. package/src/components/ChannelList/CustomChannelPreview.test.tsx +0 -95
  28. package/src/components/ChannelList/CustomChannelPreview.tsx +9 -21
  29. package/src/components/CustomMessage/CustomMessage.stories.tsx +1 -54
  30. package/src/components/CustomMessage/MessageTag.tsx +0 -8
  31. package/src/components/CustomMessage/context.tsx +1 -1
  32. package/src/components/CustomMessage/index.tsx +42 -76
  33. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +3 -5
  34. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +450 -0
  35. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  36. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  37. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  38. package/src/components/{CustomMessage/LockedAttachment/components/Media → LockedAttachment/components/Received}/CardActions.tsx +2 -9
  39. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  40. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  41. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  42. package/src/components/{CustomMessage/LockedAttachment → LockedAttachment}/components/_shared/CardBody.tsx +7 -8
  43. package/src/components/{CustomMessage/LockedAttachment → LockedAttachment}/components/_shared/GalleryThumbnail.tsx +39 -31
  44. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  45. package/src/components/{CustomMessage/LockedAttachment/components/_shared/MediaShell.tsx → LockedAttachment/components/_shared/LockedCardShell.tsx} +8 -8
  46. package/src/components/{CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx → LockedAttachment/components/_shared/LockedThumbnail.tsx} +7 -11
  47. package/src/components/LockedAttachment/index.tsx +63 -0
  48. package/src/components/LockedAttachment/types.ts +51 -0
  49. package/src/index.ts +8 -3
  50. package/src/stream-custom-data.ts +0 -1
  51. package/src/styles.css +0 -12
  52. package/src/types.ts +1 -1
  53. package/dist/Card-415OfRhw.cjs +0 -2
  54. package/dist/Card-415OfRhw.cjs.map +0 -1
  55. package/dist/Card-CyWdUMCB.cjs +0 -2
  56. package/dist/Card-CyWdUMCB.cjs.map +0 -1
  57. package/dist/Card-V5d6qhDT.js +0 -694
  58. package/dist/Card-V5d6qhDT.js.map +0 -1
  59. package/dist/Card-qS17qnN0.js +0 -103
  60. package/dist/Card-qS17qnN0.js.map +0 -1
  61. package/dist/LoadingDots-Ct3WmVlT.js +0 -59
  62. package/dist/LoadingDots-Ct3WmVlT.js.map +0 -1
  63. package/dist/LoadingDots-D9GGphgM.cjs +0 -2
  64. package/dist/LoadingDots-D9GGphgM.cjs.map +0 -1
  65. package/dist/index-Bx_zjNMR.cjs +0 -2
  66. package/dist/index-Bx_zjNMR.cjs.map +0 -1
  67. package/dist/index-DFZrAiLB.js.map +0 -1
  68. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.stories.tsx +0 -541
  69. package/src/components/CustomMessage/LockedAttachment/LockedAttachment.test.tsx +0 -117
  70. package/src/components/CustomMessage/LockedAttachment/components/Media/Card.tsx +0 -358
  71. package/src/components/CustomMessage/LockedAttachment/components/Media/types.ts +0 -79
  72. package/src/components/CustomMessage/LockedAttachment/components/Text/Card.tsx +0 -153
  73. package/src/components/CustomMessage/LockedAttachment/components/Text/types.ts +0 -58
  74. package/src/components/CustomMessage/LockedAttachment/components/_shared/CardThumbnail.tsx +0 -92
  75. package/src/components/CustomMessage/LockedAttachment/components/_shared/LoadingDots.tsx +0 -36
  76. package/src/components/CustomMessage/LockedAttachment/components/_shared/LockBadge.tsx +0 -63
  77. package/src/components/CustomMessage/LockedAttachment/components/_shared/PreviewToggle.tsx +0 -58
  78. package/src/components/CustomMessage/LockedAttachment/components/_shared/PriceLockRow.tsx +0 -21
  79. package/src/components/CustomMessage/LockedAttachment/components/_shared/PurchaseStatusBadge.tsx +0 -74
  80. package/src/components/CustomMessage/LockedAttachment/components/_shared/SkeletonTextLines.tsx +0 -29
  81. package/src/components/CustomMessage/LockedAttachment/components/_shared/TextShell.tsx +0 -28
  82. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.test.ts +0 -67
  83. package/src/components/CustomMessage/LockedAttachment/components/_shared/usePreviewToggle.ts +0 -89
  84. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +0 -83
  85. package/src/components/CustomMessage/LockedAttachment/index.tsx +0 -105
  86. package/src/components/CustomMessage/LockedAttachment/types.ts +0 -44
@@ -1,92 +0,0 @@
1
- import { ImagesIcon } from '@phosphor-icons/react'
2
- import type React from 'react'
3
-
4
- import type {
5
- LockedAttachmentGalleryItem,
6
- LockedAttachmentSource,
7
- PaymentStatus,
8
- } from '../../types'
9
-
10
- import GalleryThumbnail from './GalleryThumbnail'
11
- import type { CardVariant } from './MediaShell'
12
- import SingleThumbnail from './SingleThumbnail'
13
-
14
- export interface CardThumbnailProps {
15
- variant: CardVariant
16
- gallery: LockedAttachmentGalleryItem[]
17
- title?: string
18
- showLocked: boolean
19
- paymentStatus?: PaymentStatus
20
- /** Single-asset props — ignored when the card renders as a gallery. */
21
- mimeType: string
22
- thumbnailUrl?: string
23
- source?: LockedAttachmentSource
24
- containedImage?: boolean
25
- /** Top-right overlay (e.g. Composer dismiss X). Rendered inside the thumbnail. */
26
- topRight?: React.ReactNode
27
- }
28
-
29
- /** True when the card should render as a mixed-media carousel (2+ items). */
30
- export const isGalleryCard = (
31
- gallery: LockedAttachmentGalleryItem[]
32
- ): boolean => gallery.length >= 2
33
-
34
- /**
35
- * The body-row icon a card shows when it renders as a gallery — `CardBody`
36
- * otherwise auto-detects the icon from `mimeType`. Returned as a ready node so
37
- * every card shares the same size/tone treatment.
38
- */
39
- export const galleryBodyIcon = (
40
- variant: CardVariant
41
- ): React.ReactNode => (
42
- <ImagesIcon
43
- className={
44
- variant === 'dark'
45
- ? 'size-5 shrink-0 text-white/55'
46
- : 'size-5 shrink-0 text-black/55'
47
- }
48
- />
49
- )
50
-
51
- /**
52
- * Owns the single-vs-gallery thumbnail decision shared by Composer /
53
- * `LockedAttachment.Media` cards: renders `GalleryThumbnail` for a 2+ item `gallery`,
54
- * else `SingleThumbnail` for the single asset. Callers pass the superset of
55
- * props; the unused half is ignored by whichever branch renders.
56
- */
57
- const CardThumbnail: React.FC<CardThumbnailProps> = ({
58
- variant,
59
- gallery,
60
- title,
61
- showLocked,
62
- paymentStatus,
63
- mimeType,
64
- thumbnailUrl,
65
- source,
66
- containedImage,
67
- topRight,
68
- }) =>
69
- isGalleryCard(gallery) ? (
70
- <GalleryThumbnail
71
- variant={variant}
72
- gallery={gallery}
73
- title={title}
74
- showLocked={showLocked}
75
- paymentStatus={paymentStatus}
76
- topRight={topRight}
77
- />
78
- ) : (
79
- <SingleThumbnail
80
- variant={variant}
81
- mimeType={mimeType}
82
- thumbnailUrl={thumbnailUrl}
83
- title={title}
84
- source={source}
85
- showLocked={showLocked}
86
- paymentStatus={paymentStatus}
87
- topRight={topRight}
88
- containedImage={containedImage}
89
- />
90
- )
91
-
92
- export default CardThumbnail
@@ -1,36 +0,0 @@
1
- import React from 'react'
2
-
3
- const Dot = ({ cx, index }: { cx: string; index: number }) => (
4
- <circle cx={cx} cy="6.15" r="2.4" fill="currentColor">
5
- <animateTransform
6
- attributeName="transform"
7
- type="translate"
8
- values="0 0; 0 -2.25; 0 0;"
9
- dur="900ms"
10
- begin={`${120 * index}ms`}
11
- repeatCount="indefinite"
12
- />
13
- </circle>
14
- )
15
-
16
- /**
17
- * Three bouncing dots, inline-sized for use inside buttons/labels (e.g. the
18
- * Unlock CTA while a purchase is in flight). Mirrors the typing indicator's
19
- * animation but uses `currentColor` so it adopts the surrounding text color.
20
- */
21
- const LoadingDots: React.FC<{ className?: string }> = ({ className }) => (
22
- <svg
23
- aria-hidden="true"
24
- className={className}
25
- viewBox="0 0 20 12.3"
26
- width="20"
27
- height="13"
28
- overflow="visible"
29
- >
30
- <Dot cx="3" index={0} />
31
- <Dot cx="10" index={1} />
32
- <Dot cx="17" index={2} />
33
- </svg>
34
- )
35
-
36
- export default LoadingDots
@@ -1,63 +0,0 @@
1
- import { LockOpenIcon, LockSimpleIcon, PlayIcon } from '@phosphor-icons/react'
2
- import classNames from 'classnames'
3
- import React from 'react'
4
-
5
- import { getSourceType } from '../../../../AttachmentCard'
6
- import type { PaymentStatus } from '../../types'
7
-
8
- export interface LockBadgeProps {
9
- paymentStatus?: PaymentStatus
10
- mimeType?: string
11
- /**
12
- * Overrides the `mimeType`-derived playable check. Lets a caller that
13
- * already knows per-item playability (e.g. `GalleryThumbnail`, checking a
14
- * carousel item's own mimeType rather than the card's single `mimeType`)
15
- * skip re-deriving it.
16
- */
17
- isPlayable?: boolean
18
- /** Whether the item is still locked. Play only ever shows once unlocked —
19
- * a locked item always shows the lock, even for otherwise-playable media. */
20
- isLocked: boolean
21
- }
22
-
23
- /**
24
- * Centered 48x48 lock badge that overlays the thumbnail of any locked attachment.
25
- * Uses `bg-black/30`. Stays `pointer-events-none` so clicks pass through to
26
- * the underlying player/toggle; the play state highlights via `group-hover`
27
- * (same `bg-black/40` as the gallery's prev/next arrows) — the ancestor
28
- * providing `group` must wrap the actual clickable/hoverable player area.
29
- *
30
- * For playable media (video/audio) that's unlocked, the badge shows a play
31
- * icon instead of a lock — matching the convention used by `GalleryThumbnail`.
32
- */
33
- export const LockBadge: React.FC<LockBadgeProps> = ({
34
- paymentStatus,
35
- mimeType,
36
- isPlayable,
37
- isLocked,
38
- }) => {
39
- const sourceType = mimeType ? getSourceType(mimeType) : undefined
40
- const resolvedIsPlayable =
41
- isPlayable ?? (sourceType === 'video' || sourceType === 'audio')
42
-
43
- const Icon =
44
- resolvedIsPlayable && !isLocked
45
- ? PlayIcon
46
- : paymentStatus === 'paid'
47
- ? LockOpenIcon
48
- : LockSimpleIcon
49
- return (
50
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
51
- <div
52
- className={classNames(
53
- 'flex size-12 items-center justify-center rounded-full bg-black/30 text-white',
54
- Icon === PlayIcon && 'group-hover:bg-black/40'
55
- )}
56
- >
57
- <Icon className="size-6" weight="fill" />
58
- </div>
59
- </div>
60
- )
61
- }
62
-
63
- export default LockBadge
@@ -1,58 +0,0 @@
1
- import React from 'react'
2
-
3
- export interface PreviewToggleProps {
4
- /** Toggles the preview; omit to render `children` with no wrapper at all (nothing to toggle). */
5
- onToggle: (() => void | Promise<void>) | undefined
6
- isBusy: boolean
7
- isPreviewVisible: boolean
8
- children: React.ReactNode
9
- }
10
-
11
- /**
12
- * Makes a locked thumbnail clickable to toggle its preview. Uses a
13
- * `role="button"` div rather than a native `<button>` because the thumbnail
14
- * it wraps can itself contain interactive children (gallery carousel arrows,
15
- * a Composer dismiss X) — nesting a real `<button>` around those would
16
- * produce invalid, inaccessible nested-interactive-element markup.
17
- */
18
- const PreviewToggle: React.FC<PreviewToggleProps> = ({
19
- onToggle,
20
- isBusy,
21
- isPreviewVisible,
22
- children,
23
- }) => {
24
- if (!onToggle) return <React.Fragment>{children}</React.Fragment>
25
-
26
- return (
27
- <div
28
- role="button"
29
- tabIndex={isBusy ? -1 : 0}
30
- aria-label="Toggle preview"
31
- aria-busy={isBusy}
32
- aria-pressed={isPreviewVisible}
33
- aria-disabled={isBusy || undefined}
34
- onClick={isBusy ? undefined : onToggle}
35
- onKeyDown={(e) => {
36
- if (isBusy) return
37
- // Only handle keys that originate on the wrapper itself, not on
38
- // inner interactive elements (dismiss X, carousel arrows). Without
39
- // this guard, pressing Enter/Space on a focused child would bubble
40
- // up and toggle the preview in addition to activating the child.
41
- if (e.target !== e.currentTarget) return
42
- if (e.key === 'Enter' || e.key === ' ') {
43
- e.preventDefault()
44
- void onToggle()
45
- }
46
- }}
47
- className={
48
- !isBusy
49
- ? 'block w-full cursor-pointer text-left'
50
- : 'block w-full text-left'
51
- }
52
- >
53
- {children}
54
- </div>
55
- )
56
- }
57
-
58
- export default PreviewToggle
@@ -1,21 +0,0 @@
1
- import { LockSimpleIcon } from '@phosphor-icons/react'
2
- import type React from 'react'
3
-
4
- /**
5
- * The lock-icon + amount/status row shared by every LockedAttachment text-mode
6
- * card. Only the wrapper and the leading lock icon are shared — the amount and
7
- * any trailing status live in `children`, since they genuinely differ per card
8
- * (placeholder fallback vs. paid badge vs. bare amount).
9
- */
10
- const PriceLockRow: React.FC<React.PropsWithChildren> = ({ children }) => (
11
- <div className="flex items-center gap-1 text-xs font-medium">
12
- <LockSimpleIcon
13
- className="size-3.5 shrink-0 opacity-55"
14
- weight="fill"
15
- aria-hidden
16
- />
17
- {children}
18
- </div>
19
- )
20
-
21
- export default PriceLockRow
@@ -1,74 +0,0 @@
1
- import { CheckCircleIcon } from '@phosphor-icons/react'
2
- import classNames from 'classnames'
3
- import React from 'react'
4
-
5
- import type { PaymentStatus } from '../../types'
6
-
7
- export interface PurchaseStatusBadgeProps {
8
- /** 'own' matches the sender's dark/own-message context; 'other' matches the recipient's light/other-message context. */
9
- isMine: boolean
10
- /** 'paid' renders the "Sold"/"Purchased" + checkmark variant; otherwise falls back to plain amount text, or nothing. */
11
- paymentStatus?: PaymentStatus
12
- amountText?: string
13
- }
14
-
15
- /**
16
- * Bullet + amount/label + checkmark row for a paid attachment or message.
17
- * Shared by `LockedAttachment.Media`/`.Text`'s status badges.
18
- */
19
- const PurchaseStatusBadge: React.FC<PurchaseStatusBadgeProps> = ({
20
- isMine,
21
- paymentStatus,
22
- amountText,
23
- }) => {
24
- const labelColour = isMine ? 'text-white/55' : 'text-black/55'
25
- const successColour = isMine ? 'text-[#34c759]' : 'text-[#008236]'
26
- if (paymentStatus === 'paid') {
27
- return (
28
- <React.Fragment>
29
- <span className={classNames('text-xs font-medium', labelColour)}>
30
- &bull;
31
- </span>
32
- <span className={classNames('text-xs font-medium', successColour)}>
33
- {isMine ? 'Sold' : 'Purchased'}
34
- </span>
35
- <CheckCircleIcon
36
- className={classNames('shrink-0 size-3.5', successColour)}
37
- weight="bold"
38
- aria-hidden
39
- />
40
- </React.Fragment>
41
- )
42
- }
43
-
44
- if (amountText != null) {
45
- return (
46
- <React.Fragment>
47
- <span className={classNames('text-xs font-medium', labelColour)}>
48
- &bull;
49
- </span>
50
- <span className={classNames('text-xs font-medium', labelColour)}>
51
- {amountText}
52
- </span>
53
- </React.Fragment>
54
- )
55
- }
56
-
57
-
58
- if (paymentStatus === 'failed' || paymentStatus === 'refunded') {
59
- return (
60
- <React.Fragment>
61
- <span className={classNames('text-xs font-medium', labelColour)}>
62
- &bull;
63
- </span>
64
- <span className={classNames('text-xs font-medium', labelColour)}>
65
- {paymentStatus === 'failed' ? 'Payment failed' : 'Refunded'}
66
- </span>
67
- </React.Fragment>
68
- )
69
- }
70
-
71
- return null
72
- }
73
-
74
- export default PurchaseStatusBadge
@@ -1,29 +0,0 @@
1
- import classNames from 'classnames'
2
- import React from 'react'
3
-
4
- export interface SkeletonTextLinesProps {
5
- /** Matches the bubble it sits in — "dark" for the sender's own bubble. */
6
- variant: 'light' | 'dark'
7
- }
8
-
9
- /**
10
- * Two skeleton bars standing in for the redacted text of a paid text-only
11
- * message — the real content is never sent to the client until purchase, so
12
- * there's nothing to render yet.
13
- *
14
- * Uses explicit black/white (not `bg-current`) because Tailwind doesn't
15
- * support opacity modifiers on the `current` color — `bg-current/30` silently
16
- * produces no CSS rule at all.
17
- */
18
- const SkeletonTextLines: React.FC<SkeletonTextLinesProps> = ({ variant = 'dark' }) => {
19
- const colour = variant === 'dark' ? 'bg-white/25' : 'bg-black/15'
20
-
21
- return (
22
- <div className="flex flex-col gap-2 str-chat__message-text my-1 animate-pulse" aria-hidden>
23
- <div className={classNames('h-3 w-40 rounded-full', colour)} />
24
- <div className={classNames('h-3 w-24 rounded-full', colour)} />
25
- </div>
26
- )
27
- }
28
-
29
- export default SkeletonTextLines
@@ -1,28 +0,0 @@
1
- import classNames from 'classnames'
2
- import React from 'react'
3
-
4
- /**
5
- * Chat-bubble chrome for a paid text-only message/preview. Deliberately
6
- * doesn't lean on `.str-chat`'s CSS variables (`--str-chat__message-bubble-*`)
7
- * — this renders in the Composer preview too, which sits outside any
8
- * `.str-chat` ancestor, so every value here (radius, colour, type scale) is
9
- * hardcoded to match the Figma spec directly.
10
- */
11
- const TextShell: React.FC<
12
- React.PropsWithChildren<{ isMine: boolean; className?: string }>
13
- > = ({ isMine, children, className }) => (
14
- <div
15
- className={classNames(
16
- 'message-locked-attachment-text',
17
- 'relative flex min-w-[225px] max-w-[280px] flex-col gap-1 rounded-[1.5rem] px-4 py-3',
18
- 'text-[0.875rem] leading-[1.3125rem] tracking-[0.14px]',
19
- isMine ? 'bg-[#1e2330] text-white' : 'bg-[#f2f1ef] text-black',
20
- 'shadow-[0_1px_2px_rgba(0,0,0,0.08)]',
21
- className
22
- )}
23
- >
24
- {children}
25
- </div>
26
- )
27
-
28
- export default TextShell
@@ -1,67 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
-
3
- import { act, renderHook } from '../../../../../test/utils'
4
-
5
- import { usePreviewToggle } from './usePreviewToggle'
6
-
7
- const source = { sourceUrl: 'https://example.test/asset.mp4' }
8
-
9
- describe('usePreviewToggle', () => {
10
- it('gallery: reveals without invoking the async source fetcher', async () => {
11
- const onFetchSource = vi.fn().mockResolvedValue(source)
12
- const onPreviewClick = vi.fn()
13
- const { result } = renderHook(() =>
14
- usePreviewToggle({ isGallery: true, onFetchSource, onPreviewClick })
15
- )
16
-
17
- await act(async () => {
18
- await result.current.handleToggle()
19
- })
20
-
21
- expect(result.current.isPreviewVisible).toBe(true)
22
- expect(onPreviewClick).toHaveBeenCalledTimes(1)
23
- expect(onFetchSource).not.toHaveBeenCalled()
24
- })
25
-
26
- it('first reveal fetches the source, then caches it on subsequent toggles', async () => {
27
- const onFetchSource = vi.fn().mockResolvedValue(source)
28
- const { result } = renderHook(() =>
29
- usePreviewToggle({ isGallery: false, onFetchSource })
30
- )
31
-
32
- // First reveal → fetches.
33
- await act(async () => {
34
- await result.current.handleToggle()
35
- })
36
- expect(result.current.isPreviewVisible).toBe(true)
37
- expect(result.current.source).toEqual(source)
38
- expect(onFetchSource).toHaveBeenCalledTimes(1)
39
-
40
- // Toggle off.
41
- await act(async () => {
42
- await result.current.handleToggle()
43
- })
44
- expect(result.current.isPreviewVisible).toBe(false)
45
-
46
- // Toggle back on → reuses the cache, no second fetch.
47
- await act(async () => {
48
- await result.current.handleToggle()
49
- })
50
- expect(result.current.isPreviewVisible).toBe(true)
51
- expect(onFetchSource).toHaveBeenCalledTimes(1)
52
- })
53
-
54
- it('stays locked when the fetcher resolves without a source', async () => {
55
- const onFetchSource = vi.fn().mockResolvedValue(undefined)
56
- const { result } = renderHook(() =>
57
- usePreviewToggle({ isGallery: false, onFetchSource })
58
- )
59
-
60
- await act(async () => {
61
- await result.current.handleToggle()
62
- })
63
-
64
- expect(result.current.isPreviewVisible).toBe(false)
65
- expect(result.current.source).toBeUndefined()
66
- })
67
- })
@@ -1,89 +0,0 @@
1
- import { useCallback, useRef, useState } from 'react'
2
-
3
- import type { LockedAttachmentSource } from '../../types'
4
-
5
- export interface UsePreviewToggleOptions {
6
- /** Gallery items carry their own per-item sources on the `gallery` prop already, so toggling skips the async fetch entirely. */
7
- isGallery: boolean
8
- /**
9
- * Lazily loads the underlying source; the resolved value is cached in
10
- * `source` and reused on subsequent toggles.
11
- */
12
- onFetchSource?: () => Promise<LockedAttachmentSource | void>
13
- /** Fired on every toggle tap, before any visibility/fetch logic runs. */
14
- onPreviewClick?: () => void
15
- }
16
-
17
- export interface UsePreviewToggleResult {
18
- source: LockedAttachmentSource | undefined
19
- isPreviewVisible: boolean
20
- isLoadingPreview: boolean
21
- handleToggle: () => Promise<void>
22
- /**
23
- * `handleToggle` when the card can actually be previewed (gallery, or a
24
- * fetch/click handler is wired), else `undefined` — pass straight to
25
- * `PreviewToggle.onToggle` so a non-previewable card renders no
26
- * toggle affordance.
27
- */
28
- togglePreview: (() => Promise<void>) | undefined
29
- }
30
-
31
- /**
32
- * Shared preview-toggle state for Composer/Media isMine: flips a locked thumbnail to
33
- * its unlocked preview, lazily fetching and caching the source on first
34
- * reveal. Media !isMine doesn't use this — it fetches on scroll-into-view/paid
35
- * instead of on a toggle tap.
36
- */
37
- export function usePreviewToggle({
38
- isGallery,
39
- onFetchSource,
40
- onPreviewClick,
41
- }: UsePreviewToggleOptions): UsePreviewToggleResult {
42
- const [source, setSource] = useState<LockedAttachmentSource | undefined>()
43
- const [isPreviewVisible, setIsPreviewVisible] = useState(false)
44
- const [isLoadingPreview, setIsLoadingPreview] = useState(false)
45
- const fetchingRef = useRef(false)
46
-
47
- const handleToggle = useCallback(async () => {
48
- onPreviewClick?.()
49
- if (isPreviewVisible) {
50
- setIsPreviewVisible(false)
51
- return
52
- }
53
- if (isGallery) {
54
- setIsPreviewVisible(true)
55
- return
56
- }
57
- if (source) {
58
- setIsPreviewVisible(true)
59
- return
60
- }
61
- if (!onFetchSource) return
62
- if (fetchingRef.current) return
63
- fetchingRef.current = true
64
- setIsLoadingPreview(true)
65
- try {
66
- const result = await onFetchSource()
67
- if (result) {
68
- setSource(result)
69
- setIsPreviewVisible(true)
70
- }
71
- } finally {
72
- fetchingRef.current = false
73
- setIsLoadingPreview(false)
74
- }
75
- }, [isPreviewVisible, isGallery, source, onPreviewClick, onFetchSource])
76
-
77
- const togglePreview =
78
- isGallery || onFetchSource != null || onPreviewClick != null
79
- ? handleToggle
80
- : undefined
81
-
82
- return {
83
- source,
84
- isPreviewVisible,
85
- isLoadingPreview,
86
- handleToggle,
87
- togglePreview,
88
- }
89
- }
@@ -1,83 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
2
-
3
- import type {
4
- FetchSourceHandler,
5
- LockedAttachmentSource,
6
- PaymentStatus,
7
- } from '../../types'
8
-
9
- export interface UseReceivedSourceOptions {
10
- paymentStatus?: PaymentStatus
11
- onFetchSource?: FetchSourceHandler
12
- /** Called on Unlock click while the card is still unpaid (opens checkout). */
13
- onUnlockClick?: () => void
14
- }
15
-
16
- export interface UseReceivedSourceResult {
17
- source: LockedAttachmentSource | undefined
18
- /** Attach to the card root so the paid-source auto-fetch can observe it. */
19
- cardRef: React.RefObject<HTMLDivElement>
20
- /** Fetches the source when already paid, else defers to `onUnlockClick`. */
21
- handleUnlockClick: () => void
22
- }
23
-
24
- /**
25
- * Media !isMine's source-reveal logic: unlike Composer/Media isMine's
26
- * tap-to-toggle (`usePreviewToggle`), the recipient's card reveals its source
27
- * by fetching once — on click when already paid, or automatically the first
28
- * time a paid card scrolls fully into view. The fetch is guarded so it runs
29
- * at most once in flight.
30
- */
31
- export function useReceivedSource({
32
- paymentStatus,
33
- onFetchSource,
34
- onUnlockClick,
35
- }: UseReceivedSourceOptions): UseReceivedSourceResult {
36
- const [source, setSource] = useState<LockedAttachmentSource | undefined>()
37
-
38
- const cardRef = useRef<HTMLDivElement>(null)
39
- const fetchingRef = useRef(false)
40
-
41
- const onFetchSourceRef = useRef(onFetchSource)
42
- onFetchSourceRef.current = onFetchSource
43
-
44
- const fetchSource = useCallback(async (): Promise<void> => {
45
- if (fetchingRef.current) return
46
- fetchingRef.current = true
47
- try {
48
- const result = await onFetchSourceRef.current?.()
49
- if (result) setSource(result)
50
- } finally {
51
- fetchingRef.current = false
52
- }
53
- }, [])
54
-
55
- const handleUnlockClick = useCallback(() => {
56
- if (paymentStatus === 'paid') {
57
- void fetchSource()
58
- } else {
59
- onUnlockClick?.()
60
- }
61
- }, [paymentStatus, fetchSource, onUnlockClick])
62
-
63
- // Auto-fetch the source once the paid card scrolls into view.
64
- useEffect(() => {
65
- if (!cardRef.current) return
66
- if (paymentStatus !== 'paid' || source !== undefined) return
67
-
68
- const observer = new IntersectionObserver(
69
- ([entry]) => {
70
- if (entry.isIntersecting) {
71
- void fetchSource()
72
- observer.disconnect()
73
- }
74
- },
75
- { threshold: 1.0 }
76
- )
77
-
78
- observer.observe(cardRef.current)
79
- return () => observer.disconnect()
80
- }, [paymentStatus, source, fetchSource])
81
-
82
- return { source, cardRef, handleUnlockClick }
83
- }