@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.
@@ -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,