@linktr.ee/messaging-react 3.34.0 → 3.35.0-rc-1786104411
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/{Card-D28R0tmP.js → Card-ByFbaqsR.js} +2 -2
- package/dist/{Card-D28R0tmP.js.map → Card-ByFbaqsR.js.map} +1 -1
- package/dist/{Card-CbxXZm7k.cjs → Card-C6IwpYii.cjs} +2 -2
- package/dist/{Card-CbxXZm7k.cjs.map → Card-C6IwpYii.cjs.map} +1 -1
- package/dist/{index-Czf2XyYL.js → index-BaeffiED.js} +1044 -857
- package/dist/index-BaeffiED.js.map +1 -0
- package/dist/index-DNWZLZ_A.cjs +2 -0
- package/dist/index-DNWZLZ_A.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +95 -0
- package/dist/index.js +21 -20
- package/package.json +3 -3
- package/src/components/LinkAttachment/components/_shared/CardCta.tsx +21 -2
- package/src/components/RichCard/RichCard.stories.tsx +364 -0
- package/src/components/RichCard/RichCard.test.tsx +116 -0
- package/src/components/RichCard/RichCardBody.tsx +123 -0
- package/src/components/RichCard/RichCardCard.tsx +93 -0
- package/src/components/RichCard/RichCardImages.tsx +108 -0
- package/src/components/RichCard/index.tsx +44 -0
- package/src/components/RichCard/types.ts +72 -0
- package/src/index.ts +8 -0
- package/dist/index-BrVCdobn.cjs +0 -2
- package/dist/index-BrVCdobn.cjs.map +0 -1
- package/dist/index-Czf2XyYL.js.map +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen, fireEvent } from '../../test/utils'
|
|
5
|
+
|
|
6
|
+
import RichCard from '.'
|
|
7
|
+
|
|
8
|
+
const HERO = 'https://cdn.example.com/hero.jpg'
|
|
9
|
+
const IMG_2 = 'https://cdn.example.com/two.jpg'
|
|
10
|
+
const IMG_3 = 'https://cdn.example.com/three.jpg'
|
|
11
|
+
|
|
12
|
+
describe('RichCard', () => {
|
|
13
|
+
it('renders the title and subtitle', async () => {
|
|
14
|
+
renderWithProviders(
|
|
15
|
+
<RichCard.Received
|
|
16
|
+
title="Set up your welcome message"
|
|
17
|
+
subtitle="Automatically greet new followers."
|
|
18
|
+
images={[HERO]}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
expect(await screen.findByTestId('rich-card')).toBeInTheDocument()
|
|
22
|
+
expect(screen.getByText('Set up your welcome message')).toBeInTheDocument()
|
|
23
|
+
expect(screen.getByText('Automatically greet new followers.')).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders a single hero image for the hero presentation', async () => {
|
|
27
|
+
renderWithProviders(
|
|
28
|
+
<RichCard.Received
|
|
29
|
+
title="Welcome"
|
|
30
|
+
images={[HERO]}
|
|
31
|
+
imagePresentation="hero"
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
await screen.findByTestId('rich-card')
|
|
35
|
+
const images = screen.getAllByRole('img')
|
|
36
|
+
expect(images).toHaveLength(1)
|
|
37
|
+
expect(images[0]).toHaveAttribute('alt', 'Welcome')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders up to three fanned images for the fan presentation', async () => {
|
|
41
|
+
const { container } = renderWithProviders(
|
|
42
|
+
<RichCard.Received
|
|
43
|
+
title="Collection"
|
|
44
|
+
images={[HERO, IMG_2, IMG_3]}
|
|
45
|
+
imagePresentation="fan"
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
await screen.findByTestId('rich-card')
|
|
49
|
+
// Three <img> nodes render; only the first is labelled (the rest are
|
|
50
|
+
// decorative, alt="" + aria-hidden, so they're out of the a11y tree).
|
|
51
|
+
expect(container.querySelectorAll('img')).toHaveLength(3)
|
|
52
|
+
expect(screen.getByAltText('Collection')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('defaults presentation from image count (>=2 fans)', async () => {
|
|
56
|
+
const { container } = renderWithProviders(
|
|
57
|
+
<RichCard.Sent title="Two" images={[HERO, IMG_2]} />
|
|
58
|
+
)
|
|
59
|
+
await screen.findByTestId('rich-card')
|
|
60
|
+
expect(container.querySelectorAll('img')).toHaveLength(2)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders no image region when there are no images', async () => {
|
|
64
|
+
renderWithProviders(
|
|
65
|
+
<RichCard.Received title="Text only" subtitle="No hero here." />
|
|
66
|
+
)
|
|
67
|
+
await screen.findByTestId('rich-card')
|
|
68
|
+
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
|
69
|
+
expect(screen.getByText('Text only')).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders a url action as an external anchor, others as buttons', async () => {
|
|
73
|
+
const onClick = vi.fn()
|
|
74
|
+
renderWithProviders(
|
|
75
|
+
<RichCard.Received
|
|
76
|
+
title="Card"
|
|
77
|
+
images={[HERO]}
|
|
78
|
+
actions={[
|
|
79
|
+
{ label: 'Shop now', href: 'tr.ee/x', variant: 'secondary' },
|
|
80
|
+
{ label: 'Set up', onClick, variant: 'secondary' },
|
|
81
|
+
]}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
const link = await screen.findByText('Shop now')
|
|
85
|
+
expect(link.tagName).toBe('A')
|
|
86
|
+
expect(link).toHaveAttribute('href', 'https://tr.ee/x')
|
|
87
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
88
|
+
|
|
89
|
+
const button = screen.getByText('Set up')
|
|
90
|
+
expect(button.tagName).toBe('BUTTON')
|
|
91
|
+
fireEvent.click(button)
|
|
92
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('stacks a primary footer full-width and lays a secondary footer inline', async () => {
|
|
96
|
+
const { rerender } = renderWithProviders(
|
|
97
|
+
<RichCard.Received
|
|
98
|
+
title="Card"
|
|
99
|
+
images={[HERO]}
|
|
100
|
+
actions={[{ label: 'Set up welcome message', variant: 'primary' }]}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
// Primary → stacked, full-width button.
|
|
104
|
+
expect(await screen.findByText('Set up welcome message')).toHaveClass('w-full')
|
|
105
|
+
|
|
106
|
+
rerender(
|
|
107
|
+
<RichCard.Received
|
|
108
|
+
title="Card"
|
|
109
|
+
images={[HERO]}
|
|
110
|
+
actions={[{ label: 'Shop now', href: 'tr.ee/x', variant: 'secondary' }]}
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
// Secondary → inline, content-width button (no w-full).
|
|
114
|
+
expect(screen.getByText('Shop now')).not.toHaveClass('w-full')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import CardCta from '../LinkAttachment/components/_shared/CardCta'
|
|
5
|
+
import type { LinkAttachmentVariant } from '../LinkAttachment/components/_shared/CardShell'
|
|
6
|
+
|
|
7
|
+
import type { RichCardActionButton } from './types'
|
|
8
|
+
|
|
9
|
+
export interface RichCardBodyProps {
|
|
10
|
+
/** Surface the action buttons paint against (from `useChinPalette().surface`). */
|
|
11
|
+
ctaSurface: LinkAttachmentVariant
|
|
12
|
+
/** Ink for the title + subtitle, from `useChinPalette` — fixed per surface. */
|
|
13
|
+
chinTextColor: string
|
|
14
|
+
title?: string
|
|
15
|
+
subtitle?: string
|
|
16
|
+
subtitleLines?: number
|
|
17
|
+
appIcon?: React.ReactNode
|
|
18
|
+
actions?: RichCardActionButton[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Mirror LinkAttachment's `CardBody` type scales so the two cards read
|
|
22
|
+
// identically (14/20 medium title, 12/16 subtitle). Colour is always supplied
|
|
23
|
+
// via `chinTextColor`, so no per-variant text class is needed here.
|
|
24
|
+
const TITLE_CLASS = 'line-clamp-2 text-[14px] font-medium leading-5 tracking-[0.28px]'
|
|
25
|
+
const SUBTITLE_CLASS = 'text-[12px] leading-4 tracking-[0.24px]'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Footer of action buttons. The **first** action's `variant` picks the layout:
|
|
29
|
+
* `primary` → stacked full-width buttons; `secondary` → an inline compact row.
|
|
30
|
+
* Each button still paints in its own emphasis. Returns `null` when there are
|
|
31
|
+
* no actions.
|
|
32
|
+
*/
|
|
33
|
+
const RichCardFooter: React.FC<{
|
|
34
|
+
surface: LinkAttachmentVariant
|
|
35
|
+
actions?: RichCardActionButton[]
|
|
36
|
+
}> = ({ surface, actions }) => {
|
|
37
|
+
if (actions == null || actions.length === 0) return null
|
|
38
|
+
|
|
39
|
+
const inline = (actions[0].variant ?? 'primary') === 'secondary'
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={classNames(
|
|
44
|
+
'mt-3 flex gap-2',
|
|
45
|
+
inline ? 'flex-row flex-wrap' : 'flex-col'
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{actions.map((action, index) => (
|
|
49
|
+
<CardCta
|
|
50
|
+
key={`${action.label}-${index}`}
|
|
51
|
+
variant={surface}
|
|
52
|
+
cta={action}
|
|
53
|
+
fullWidth={!inline}
|
|
54
|
+
standalone
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The rich card's "chin": title, subtitle (prose, multi-line by default), and
|
|
63
|
+
* the action footer. Padding matches `CardBody` (16px x, 12px y) so a rich card
|
|
64
|
+
* lines up with the link cards around it.
|
|
65
|
+
*/
|
|
66
|
+
const RichCardBody: React.FC<RichCardBodyProps> = ({
|
|
67
|
+
ctaSurface,
|
|
68
|
+
chinTextColor,
|
|
69
|
+
title,
|
|
70
|
+
subtitle,
|
|
71
|
+
subtitleLines = 3,
|
|
72
|
+
appIcon,
|
|
73
|
+
actions,
|
|
74
|
+
}) => {
|
|
75
|
+
const hasTitle = title != null && title.trim() !== ''
|
|
76
|
+
const hasSubtitle = subtitle != null && subtitle.trim() !== ''
|
|
77
|
+
const hasActions = actions != null && actions.length > 0
|
|
78
|
+
|
|
79
|
+
if (!hasTitle && !hasSubtitle && !hasActions) return null
|
|
80
|
+
|
|
81
|
+
const chinTextStyle: React.CSSProperties = { color: chinTextColor }
|
|
82
|
+
const subtitleStyle: React.CSSProperties =
|
|
83
|
+
subtitleLines > 1
|
|
84
|
+
? {
|
|
85
|
+
...chinTextStyle,
|
|
86
|
+
display: '-webkit-box',
|
|
87
|
+
WebkitBoxOrient: 'vertical',
|
|
88
|
+
WebkitLineClamp: subtitleLines,
|
|
89
|
+
overflow: 'hidden',
|
|
90
|
+
}
|
|
91
|
+
: chinTextStyle
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="shrink-0 px-4 py-3">
|
|
95
|
+
<div className="flex min-w-0 flex-col gap-[2px]">
|
|
96
|
+
{hasTitle && (
|
|
97
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
98
|
+
{appIcon ? <span className="shrink-0">{appIcon}</span> : null}
|
|
99
|
+
<p className={classNames('min-w-0', TITLE_CLASS)} style={chinTextStyle}>
|
|
100
|
+
{title}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{hasSubtitle && (
|
|
106
|
+
<p
|
|
107
|
+
className={classNames(
|
|
108
|
+
SUBTITLE_CLASS,
|
|
109
|
+
subtitleLines <= 1 && 'truncate'
|
|
110
|
+
)}
|
|
111
|
+
style={subtitleStyle}
|
|
112
|
+
>
|
|
113
|
+
{subtitle}
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<RichCardFooter surface={ctaSurface} actions={actions} />
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default RichCardBody
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveRichCardImagePresentation } from '@linktr.ee/messaging-taxonomy'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import BubbleTail from '../LinkAttachment/components/_shared/BubbleTail'
|
|
6
|
+
import CardShell from '../LinkAttachment/components/_shared/CardShell'
|
|
7
|
+
import type { LinkAttachmentVariant } from '../LinkAttachment/components/_shared/CardShell'
|
|
8
|
+
import { useChinPalette } from '../LinkAttachment/components/_shared/useChinPalette'
|
|
9
|
+
|
|
10
|
+
import RichCardBody from './RichCardBody'
|
|
11
|
+
import RichCardImages from './RichCardImages'
|
|
12
|
+
import type { RichCardBaseProps } from './types'
|
|
13
|
+
|
|
14
|
+
export interface RichCardCardProps extends RichCardBaseProps {
|
|
15
|
+
variant: LinkAttachmentVariant
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The shared internal rich-card renderer, parameterised by surface. `Sent`
|
|
20
|
+
* (dark) and `Received` (light) are thin wrappers over it. Composes
|
|
21
|
+
* LinkAttachment's card chrome primitives (`CardShell`, `useChinPalette`,
|
|
22
|
+
* `BubbleTail`) with the rich-card-specific image region + multi-action chin.
|
|
23
|
+
*/
|
|
24
|
+
const RichCardCard: React.FC<RichCardCardProps> = ({
|
|
25
|
+
variant,
|
|
26
|
+
title,
|
|
27
|
+
subtitle,
|
|
28
|
+
subtitleLines,
|
|
29
|
+
images = [],
|
|
30
|
+
imagePresentation,
|
|
31
|
+
actions,
|
|
32
|
+
appIcon,
|
|
33
|
+
accentColor,
|
|
34
|
+
groupPosition = 'single',
|
|
35
|
+
}) => {
|
|
36
|
+
const presentation = resolveRichCardImagePresentation({
|
|
37
|
+
imagePresentation,
|
|
38
|
+
images,
|
|
39
|
+
})
|
|
40
|
+
const hasImages = images.length > 0
|
|
41
|
+
const isHero = hasImages && presentation === 'hero'
|
|
42
|
+
|
|
43
|
+
// Accent derives only from a single on-screen hero image — a `fan` (or
|
|
44
|
+
// image-less) card keeps the plain fill. Passing the hero URL through
|
|
45
|
+
// `useChinPalette` reuses its hero-failure tracking so a broken hero drops
|
|
46
|
+
// the accent with it, exactly as LinkAttachment does.
|
|
47
|
+
const chin = useChinPalette({
|
|
48
|
+
variant,
|
|
49
|
+
accentColor,
|
|
50
|
+
layout: 'featured',
|
|
51
|
+
thumbnailUrl: isHero ? images[0] : undefined,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const tailVisible =
|
|
55
|
+
groupPosition === 'single' || groupPosition === 'end'
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<BubbleTail
|
|
59
|
+
accentHex={chin.accentHex}
|
|
60
|
+
side={variant === 'dark' ? 'sender' : 'receiver'}
|
|
61
|
+
visible={tailVisible}
|
|
62
|
+
>
|
|
63
|
+
<CardShell
|
|
64
|
+
variant={variant}
|
|
65
|
+
accentHex={chin.accentHex}
|
|
66
|
+
fixedHeight={hasImages}
|
|
67
|
+
data-testid="rich-card"
|
|
68
|
+
>
|
|
69
|
+
{hasImages && (
|
|
70
|
+
<RichCardImages
|
|
71
|
+
variant={variant}
|
|
72
|
+
presentation={presentation}
|
|
73
|
+
images={images}
|
|
74
|
+
title={title}
|
|
75
|
+
heroUrl={isHero ? chin.thumbnailUrl : undefined}
|
|
76
|
+
onHeroError={chin.onImageError}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
<RichCardBody
|
|
80
|
+
ctaSurface={chin.surface}
|
|
81
|
+
chinTextColor={chin.chinTextColor}
|
|
82
|
+
title={title}
|
|
83
|
+
subtitle={subtitle}
|
|
84
|
+
subtitleLines={subtitleLines}
|
|
85
|
+
appIcon={appIcon}
|
|
86
|
+
actions={actions}
|
|
87
|
+
/>
|
|
88
|
+
</CardShell>
|
|
89
|
+
</BubbleTail>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default RichCardCard
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { RichCardImagePresentation } from '@linktr.ee/messaging-taxonomy'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl'
|
|
7
|
+
// Reuse LinkAttachment's card chrome primitives — the rich card is a sibling
|
|
8
|
+
// renderer over the same building blocks, not a from-scratch card.
|
|
9
|
+
import type { LinkAttachmentVariant } from '../LinkAttachment/components/_shared/CardShell'
|
|
10
|
+
import CardThumbnail from '../LinkAttachment/components/_shared/CardThumbnail'
|
|
11
|
+
|
|
12
|
+
export interface RichCardImagesProps {
|
|
13
|
+
variant: LinkAttachmentVariant
|
|
14
|
+
presentation: RichCardImagePresentation
|
|
15
|
+
images: string[]
|
|
16
|
+
title?: string
|
|
17
|
+
/** Hero-only: the palette-tracked URL (cleared when the hero image errors). */
|
|
18
|
+
heroUrl?: string
|
|
19
|
+
onHeroError?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Placeholder fill behind the fanned images, per surface. */
|
|
23
|
+
const FAN_BG: Record<LinkAttachmentVariant, string> = {
|
|
24
|
+
dark: 'bg-white/[0.06]',
|
|
25
|
+
light: 'bg-black/[0.04]',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Deterministic fan geometry (no randomness — Chromatic snapshots must be
|
|
29
|
+
// stable). Rotation + horizontal offset per image, keyed on how many there are.
|
|
30
|
+
const FAN_ROTATION: Record<2 | 3, number[]> = {
|
|
31
|
+
2: [-6, 6],
|
|
32
|
+
3: [-9, 0, 9],
|
|
33
|
+
}
|
|
34
|
+
const FAN_OFFSET_PX: Record<2 | 3, number[]> = {
|
|
35
|
+
2: [-28, 28],
|
|
36
|
+
3: [-52, 0, 52],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A fanned stack of 2–3 thumbnails for the `fan` presentation (collection
|
|
41
|
+
* cards). An initial visual interpretation — the exact fan geometry should be
|
|
42
|
+
* reconciled against the Figma collection frames when they land.
|
|
43
|
+
*/
|
|
44
|
+
const RichCardFan: React.FC<{
|
|
45
|
+
variant: LinkAttachmentVariant
|
|
46
|
+
images: string[]
|
|
47
|
+
title?: string
|
|
48
|
+
}> = ({ variant, images, title }) => {
|
|
49
|
+
const shown = images.slice(0, 3)
|
|
50
|
+
const count = shown.length >= 3 ? 3 : 2
|
|
51
|
+
const rotations = FAN_ROTATION[count]
|
|
52
|
+
const offsets = FAN_OFFSET_PX[count]
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={classNames(
|
|
57
|
+
'relative flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden',
|
|
58
|
+
FAN_BG[variant]
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{shown.map((src, index) => (
|
|
62
|
+
<img
|
|
63
|
+
key={`${src}-${index}`}
|
|
64
|
+
src={optimizeMessagingAttachmentUrl(src) ?? src}
|
|
65
|
+
alt={index === 0 ? (title ?? '') : ''}
|
|
66
|
+
aria-hidden={index !== 0}
|
|
67
|
+
draggable={false}
|
|
68
|
+
loading="lazy"
|
|
69
|
+
decoding="async"
|
|
70
|
+
className="absolute h-[68%] w-[52%] rounded-xl object-cover shadow-lg ring-1 ring-black/10"
|
|
71
|
+
style={{
|
|
72
|
+
transform: `translateX(${offsets[index]}px) rotate(${rotations[index]}deg)`,
|
|
73
|
+
zIndex: index,
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The rich card's hero region. `hero` reuses `CardThumbnail` (single image, the
|
|
83
|
+
* official/link-share card); `fan` renders the fanned stack (collection card).
|
|
84
|
+
*/
|
|
85
|
+
const RichCardImages: React.FC<RichCardImagesProps> = ({
|
|
86
|
+
variant,
|
|
87
|
+
presentation,
|
|
88
|
+
images,
|
|
89
|
+
title,
|
|
90
|
+
heroUrl,
|
|
91
|
+
onHeroError,
|
|
92
|
+
}) => {
|
|
93
|
+
if (presentation === 'fan') {
|
|
94
|
+
return <RichCardFan variant={variant} images={images} title={title} />
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<CardThumbnail
|
|
99
|
+
variant={variant}
|
|
100
|
+
thumbnailUrl={heroUrl ?? images[0]}
|
|
101
|
+
title={title}
|
|
102
|
+
mimeType="image/*"
|
|
103
|
+
onImageError={onHeroError}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default RichCardImages
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import RichCardCard from './RichCardCard'
|
|
4
|
+
import type { RichCardBaseProps } from './types'
|
|
5
|
+
|
|
6
|
+
/** Props for `RichCard.Sent` / `RichCard.Received`. */
|
|
7
|
+
export type RichCardSentCardProps = RichCardBaseProps
|
|
8
|
+
export type RichCardReceivedCardProps = RichCardBaseProps
|
|
9
|
+
|
|
10
|
+
const SentCard: React.FC<RichCardSentCardProps> = (props) => (
|
|
11
|
+
<RichCardCard {...props} variant="dark" />
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const ReceivedCard: React.FC<RichCardReceivedCardProps> = (props) => (
|
|
15
|
+
<RichCardCard {...props} variant="light" />
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The generic rich message card (`linktree_rich_card`) — the shared render
|
|
20
|
+
* primitive for rich message surfaces. Render `RichCard.Sent` for the sender's
|
|
21
|
+
* own copy (dark chrome) and `RichCard.Received` in the recipient's thread
|
|
22
|
+
* (light chrome). The official welcome card and broadcast link/collection cards
|
|
23
|
+
* are all this one card at different knobs:
|
|
24
|
+
*
|
|
25
|
+
* - `hero` + `primary` action → the official welcome card (stacked button)
|
|
26
|
+
* - `hero` + `secondary` action → a single-link broadcast (inline button)
|
|
27
|
+
* - `fan` + `secondary` action → a collection broadcast
|
|
28
|
+
*
|
|
29
|
+
* A standalone renderer, not tied to Stream's `Attachment`: the dispatching
|
|
30
|
+
* consumer narrows an attachment with the taxonomy's `isRichCard`, then maps
|
|
31
|
+
* the taxonomy `RichCard` fields onto these props (resolving each action's
|
|
32
|
+
* `kind` to an `href` / `onClick`).
|
|
33
|
+
*/
|
|
34
|
+
const RichCard = {
|
|
35
|
+
Sent: SentCard,
|
|
36
|
+
Received: ReceivedCard,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default RichCard
|
|
40
|
+
export type {
|
|
41
|
+
RichCardBaseProps,
|
|
42
|
+
RichCardActionButton,
|
|
43
|
+
RichCardImagePresentation,
|
|
44
|
+
} from './types'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RichCardImagePresentation } from '@linktr.ee/messaging-taxonomy'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
|
|
4
|
+
import type { BubbleGroupPosition } from '../MessageAttachment/types'
|
|
5
|
+
|
|
6
|
+
export type { RichCardImagePresentation }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single footer action button — the **presentational** half of a taxonomy
|
|
10
|
+
* `RichCardAction`. The dispatching consumer resolves the action's `kind` to a
|
|
11
|
+
* behaviour before handing it here: `open_external_url` → `href`; a `launch_*`
|
|
12
|
+
* / `post_to_socials` kind → an `onClick` that opens its client surface. A kind
|
|
13
|
+
* the consumer does not recognise is dropped (no button) rather than mapped, so
|
|
14
|
+
* the card stands on its own image/title/subtitle — exactly the taxonomy's
|
|
15
|
+
* unknown-kind rule.
|
|
16
|
+
*/
|
|
17
|
+
export interface RichCardActionButton {
|
|
18
|
+
label: string
|
|
19
|
+
/** Renders the button as an `<a target="_blank">` (for `open_external_url`). */
|
|
20
|
+
href?: string
|
|
21
|
+
/** Called on activation (in addition to `href` navigation when both are set). */
|
|
22
|
+
onClick?: () => void
|
|
23
|
+
/**
|
|
24
|
+
* Button emphasis. The **first** action's variant drives the footer layout:
|
|
25
|
+
* `primary` → a stacked full-width button; `secondary` → an inline compact
|
|
26
|
+
* row. Defaults to `primary`.
|
|
27
|
+
*/
|
|
28
|
+
variant?: 'primary' | 'secondary'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared props for the `RichCard.*` variants (Sent, Received). The generic
|
|
33
|
+
* rich message card: a `hero` or `fan` image region, a title + subtitle, and a
|
|
34
|
+
* `variant`-driven footer of action buttons. The official welcome card and
|
|
35
|
+
* broadcast link/collection cards are all this one card at different knobs.
|
|
36
|
+
*/
|
|
37
|
+
export interface RichCardBaseProps {
|
|
38
|
+
title?: string
|
|
39
|
+
/** Subtitle / body copy — the base attachment's `text`. */
|
|
40
|
+
subtitle?: string
|
|
41
|
+
/**
|
|
42
|
+
* Max lines the subtitle wraps before clamping. Defaults to `3` — rich cards
|
|
43
|
+
* carry prose, unlike the single-line link-preview default.
|
|
44
|
+
*/
|
|
45
|
+
subtitleLines?: number
|
|
46
|
+
/** SAFE public thumbnail URLs. `hero` shows the first; `fan` shows up to 3. */
|
|
47
|
+
images?: string[]
|
|
48
|
+
/**
|
|
49
|
+
* Image layout. Defaults from {@link images} length (≤1 → `hero`, ≥2 →
|
|
50
|
+
* `fan`) via the taxonomy's `resolveRichCardImagePresentation`.
|
|
51
|
+
*/
|
|
52
|
+
imagePresentation?: RichCardImagePresentation
|
|
53
|
+
/**
|
|
54
|
+
* Footer actions. Empty / absent → no footer. Clients pass one today; the
|
|
55
|
+
* shape is future-proof for several.
|
|
56
|
+
*/
|
|
57
|
+
actions?: RichCardActionButton[]
|
|
58
|
+
/** Optional 16×16 brand badge rendered before the title. */
|
|
59
|
+
appIcon?: React.ReactNode
|
|
60
|
+
/**
|
|
61
|
+
* Dominant colour of the hero image (`#RRGGBB` or bare hex), painted as the
|
|
62
|
+
* card + tail surface. Only applied for a single on-screen `hero` image (a
|
|
63
|
+
* `fan` card keeps the plain fill) — the same gate LinkAttachment uses.
|
|
64
|
+
*/
|
|
65
|
+
accentColor?: string
|
|
66
|
+
/**
|
|
67
|
+
* Position of this card inside its same-author run. `'single'` / `'end'`
|
|
68
|
+
* paint the accent-coloured bubble tail; `'first'` / `'middle'` omit it.
|
|
69
|
+
* Defaults to `'single'`.
|
|
70
|
+
*/
|
|
71
|
+
groupPosition?: BubbleGroupPosition
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { ChannelView } from './components/ChannelView'
|
|
|
8
8
|
export { default as ActionButton } from './components/ActionButton'
|
|
9
9
|
export { default as LockedAttachment } from './components/CustomMessage/LockedAttachment'
|
|
10
10
|
export { default as LinkAttachment } from './components/LinkAttachment'
|
|
11
|
+
export { default as RichCard } from './components/RichCard'
|
|
11
12
|
export { default as MessageAttachment } from './components/MessageAttachment'
|
|
12
13
|
export { Avatar } from './components/Avatar'
|
|
13
14
|
export { default as MessageBubble } from './components/MessageBubble'
|
|
@@ -93,6 +94,13 @@ export type {
|
|
|
93
94
|
LinkAttachmentLayout,
|
|
94
95
|
LinkAttachmentStatus,
|
|
95
96
|
} from './components/LinkAttachment'
|
|
97
|
+
export type {
|
|
98
|
+
RichCardBaseProps,
|
|
99
|
+
RichCardActionButton,
|
|
100
|
+
RichCardImagePresentation,
|
|
101
|
+
RichCardSentCardProps,
|
|
102
|
+
RichCardReceivedCardProps,
|
|
103
|
+
} from './components/RichCard'
|
|
96
104
|
export type {
|
|
97
105
|
ImageComposerProps as MessageAttachmentImageComposerProps,
|
|
98
106
|
ImageSentProps as MessageAttachmentImageSentProps,
|