@linktr.ee/messaging-react 2.0.0 → 2.0.1-rc-1778694826

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 (72) hide show
  1. package/dist/Card-CFFNq49v.js +163 -0
  2. package/dist/Card-CFFNq49v.js.map +1 -0
  3. package/dist/Card-CsJvUF_b.js +107 -0
  4. package/dist/Card-CsJvUF_b.js.map +1 -0
  5. package/dist/Card-D32U6KfZ.js +85 -0
  6. package/dist/Card-D32U6KfZ.js.map +1 -0
  7. package/dist/Card-DlMSDSdm.js +132 -0
  8. package/dist/Card-DlMSDSdm.js.map +1 -0
  9. package/dist/Card-DlSSJPip.js +60 -0
  10. package/dist/Card-DlSSJPip.js.map +1 -0
  11. package/dist/Card-zGbhRBwv.js +48 -0
  12. package/dist/Card-zGbhRBwv.js.map +1 -0
  13. package/dist/CardThumbnail-DTBuRQHF.js +239 -0
  14. package/dist/CardThumbnail-DTBuRQHF.js.map +1 -0
  15. package/dist/LockedThumbnail-DpJx169C.js +220 -0
  16. package/dist/LockedThumbnail-DpJx169C.js.map +1 -0
  17. package/dist/assets/index.css +1 -1
  18. package/dist/{index-Brz9orsI.js → index-DfcRe-Hj.js} +939 -889
  19. package/dist/index-DfcRe-Hj.js.map +1 -0
  20. package/dist/index.d.ts +217 -28
  21. package/dist/index.js +16 -15
  22. package/package.json +1 -1
  23. package/src/components/ChannelView.test.tsx +11 -0
  24. package/src/components/ChannelView.tsx +35 -32
  25. package/src/components/CustomMessage/index.tsx +2 -3
  26. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  27. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  28. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  29. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  30. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +307 -0
  31. package/src/components/LinkAttachment/components/Composer/Card.tsx +117 -0
  32. package/src/components/LinkAttachment/components/Composer/index.ts +2 -0
  33. package/src/components/LinkAttachment/components/Received/Card.tsx +132 -0
  34. package/src/components/LinkAttachment/components/Received/index.ts +2 -0
  35. package/src/components/LinkAttachment/components/Sent/Card.tsx +57 -0
  36. package/src/components/LinkAttachment/components/Sent/index.ts +2 -0
  37. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +117 -0
  38. package/src/components/LinkAttachment/components/_shared/CardCta.tsx +69 -0
  39. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +120 -0
  40. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +156 -0
  41. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.ts +56 -0
  42. package/src/components/LinkAttachment/index.tsx +68 -0
  43. package/src/components/LinkAttachment/types.ts +69 -0
  44. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  45. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  46. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  47. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  48. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  49. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  50. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  51. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  52. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  53. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  54. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  55. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  56. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  57. package/src/components/LockedAttachment/index.tsx +43 -12
  58. package/src/components/LockedAttachment/types.ts +17 -0
  59. package/src/components/MediaMessage/index.tsx +8 -2
  60. package/src/index.ts +15 -1
  61. package/src/styles.css +7 -0
  62. package/dist/Card-BHknCeHw.js +0 -138
  63. package/dist/Card-BHknCeHw.js.map +0 -1
  64. package/dist/Card-DT7_ms2p.js +0 -127
  65. package/dist/Card-DT7_ms2p.js.map +0 -1
  66. package/dist/index-Brz9orsI.js.map +0 -1
  67. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  68. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  69. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  70. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  71. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  72. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
@@ -0,0 +1,187 @@
1
+ import React from 'react'
2
+ import type { Event } from 'stream-chat'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderWithProviders, screen } from '../../test/utils'
6
+
7
+ import { DmAgentEnabledContext } from './DmAgentContext'
8
+
9
+ const visitor = { id: 'visitor-1', name: 'Visitor' }
10
+ const agent = { id: 'creator-1', name: 'Creator', image: 'agent.png' }
11
+
12
+ let typingContext: { typing: Record<string, Event> } = { typing: {} }
13
+ let aiStateContext: { aiState: string } = { aiState: 'AI_STATE_IDLE' }
14
+ let channelStateContext: {
15
+ channel: {
16
+ state: { members: Record<string, { user: typeof visitor | typeof agent }> }
17
+ }
18
+ channelConfig: { typing_events: boolean }
19
+ thread: undefined
20
+ } = {
21
+ channel: {
22
+ state: {
23
+ members: {
24
+ [visitor.id]: { user: visitor },
25
+ [agent.id]: { user: agent },
26
+ },
27
+ },
28
+ },
29
+ channelConfig: { typing_events: true },
30
+ thread: undefined,
31
+ }
32
+
33
+ vi.mock('stream-chat-react', () => ({
34
+ AIStates: {
35
+ Error: 'AI_STATE_ERROR',
36
+ ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
37
+ Generating: 'AI_STATE_GENERATING',
38
+ Idle: 'AI_STATE_IDLE',
39
+ Stop: 'AI_STATE_STOP',
40
+ Thinking: 'AI_STATE_THINKING',
41
+ },
42
+ useAIState: () => aiStateContext,
43
+ useChannelStateContext: () => channelStateContext,
44
+ useChatContext: () => ({ client: { user: visitor } }),
45
+ useTypingContext: () => typingContext,
46
+ }))
47
+
48
+ vi.mock('../Avatar', () => ({
49
+ Avatar: ({ name, id }: { name: string; id: string }) => (
50
+ <div data-testid="avatar" data-id={id}>
51
+ {name}
52
+ </div>
53
+ ),
54
+ }))
55
+
56
+ const importIndicator = async () => (await import('.')).default
57
+
58
+ const renderIndicator = async (dmAgentEnabled = true) => {
59
+ const CustomTypingIndicator = await importIndicator()
60
+ return renderWithProviders(
61
+ <DmAgentEnabledContext.Provider value={dmAgentEnabled}>
62
+ <CustomTypingIndicator />
63
+ </DmAgentEnabledContext.Provider>
64
+ )
65
+ }
66
+
67
+ describe('CustomTypingIndicator', () => {
68
+ beforeEach(() => {
69
+ typingContext = { typing: {} }
70
+ aiStateContext = { aiState: 'AI_STATE_IDLE' }
71
+ channelStateContext = {
72
+ channel: {
73
+ state: {
74
+ members: {
75
+ [visitor.id]: { user: visitor },
76
+ [agent.id]: { user: agent },
77
+ },
78
+ },
79
+ },
80
+ channelConfig: { typing_events: true },
81
+ thread: undefined,
82
+ }
83
+ })
84
+
85
+ it('renders nothing when idle and no typers', async () => {
86
+ await renderIndicator()
87
+ expect(screen.queryByTestId('typing-indicator')).toBeNull()
88
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
89
+ })
90
+
91
+ it('renders the human typing bubble when someone else is typing', async () => {
92
+ typingContext = {
93
+ typing: {
94
+ [agent.id]: {
95
+ type: 'typing.start',
96
+ user: agent,
97
+ parent_id: undefined,
98
+ } as Event,
99
+ },
100
+ }
101
+ await renderIndicator()
102
+ expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
103
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
104
+ })
105
+
106
+ it('hides the human typing bubble when typing_events is disabled', async () => {
107
+ channelStateContext.channelConfig.typing_events = false
108
+ typingContext = {
109
+ typing: {
110
+ [agent.id]: {
111
+ type: 'typing.start',
112
+ user: agent,
113
+ parent_id: undefined,
114
+ } as Event,
115
+ },
116
+ }
117
+ await renderIndicator()
118
+ expect(screen.queryByTestId('typing-indicator')).toBeNull()
119
+ })
120
+
121
+ it('renders the AI bubble when the agent is thinking', async () => {
122
+ aiStateContext = { aiState: 'AI_STATE_THINKING' }
123
+ await renderIndicator()
124
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
125
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
126
+ })
127
+
128
+ it('renders the AI bubble when the agent is generating', async () => {
129
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
130
+ await renderIndicator()
131
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
132
+ })
133
+
134
+ it('renders the AI bubble when the agent is checking external sources', async () => {
135
+ aiStateContext = { aiState: 'AI_STATE_EXTERNAL_SOURCES' }
136
+ await renderIndicator()
137
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
138
+ })
139
+
140
+ it('does not render the AI bubble inside a thread list', async () => {
141
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
142
+ const CustomTypingIndicator = await importIndicator()
143
+ renderWithProviders(
144
+ <DmAgentEnabledContext.Provider value={true}>
145
+ <CustomTypingIndicator threadList />
146
+ </DmAgentEnabledContext.Provider>
147
+ )
148
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
149
+ })
150
+
151
+ it('renders the AI bubble even when typing_events is disabled', async () => {
152
+ channelStateContext.channelConfig.typing_events = false
153
+ aiStateContext = { aiState: 'AI_STATE_THINKING' }
154
+ await renderIndicator()
155
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
156
+ })
157
+
158
+ it('falls back to a default avatar id when no other channel member exists', async () => {
159
+ channelStateContext.channel.state.members = {
160
+ [visitor.id]: { user: visitor },
161
+ }
162
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
163
+ await renderIndicator()
164
+ expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
165
+ expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', 'ai-agent')
166
+ })
167
+
168
+ it('does not render the AI bubble when dmAgentEnabled is false', async () => {
169
+ aiStateContext = { aiState: 'AI_STATE_GENERATING' }
170
+ await renderIndicator(false)
171
+ expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
172
+ })
173
+
174
+ it('still renders the human bubble when dmAgentEnabled is false', async () => {
175
+ typingContext = {
176
+ typing: {
177
+ [agent.id]: {
178
+ type: 'typing.start',
179
+ user: agent,
180
+ parent_id: undefined,
181
+ } as Event,
182
+ },
183
+ }
184
+ await renderIndicator(false)
185
+ expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
186
+ })
187
+ })
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react'
2
+
3
+ export const DmAgentEnabledContext = createContext<boolean>(false)
@@ -1,6 +1,8 @@
1
- import React from 'react'
2
- import type { Event } from 'stream-chat'
1
+ import React, { useContext } from 'react'
2
+ import type { Event, UserResponse } from 'stream-chat'
3
3
  import {
4
+ AIStates,
5
+ useAIState,
4
6
  useChannelStateContext,
5
7
  useChatContext,
6
8
  useTypingContext,
@@ -8,6 +10,8 @@ import {
8
10
 
9
11
  import { Avatar } from '../Avatar'
10
12
 
13
+ import { DmAgentEnabledContext } from './DmAgentContext'
14
+
11
15
  interface CustomTypingIndicatorProps {
12
16
  threadList?: boolean
13
17
  }
@@ -25,10 +29,38 @@ const Circle = ({ cx, index }: { cx: string; index: number }) => (
25
29
  </circle>
26
30
  )
27
31
 
32
+ const AI_ACTIVE_STATES = new Set<string>([
33
+ AIStates.Thinking,
34
+ AIStates.Generating,
35
+ AIStates.ExternalSources,
36
+ ])
37
+
28
38
  const CustomTypingIndicator = ({ threadList }: CustomTypingIndicatorProps) => {
29
39
  const { channel, channelConfig, thread } = useChannelStateContext()
30
40
  const { client } = useChatContext()
31
41
  const { typing = {} } = useTypingContext()
42
+ const { aiState } = useAIState(channel)
43
+ const dmAgentEnabled = useContext(DmAgentEnabledContext)
44
+
45
+ // Show the AI indicator whenever the consumer agent is producing a reply.
46
+ // This event stream is independent of `typing.start`/`typing.stop`, so it is
47
+ // intentionally NOT gated by `channelConfig.typing_events`. Gate strictly on
48
+ // `dmAgentEnabled` so stale or off-surface ai_indicator events never surface
49
+ // the bubble on channels where the agent is not active.
50
+ const isAiActive =
51
+ !threadList && dmAgentEnabled && AI_ACTIVE_STATES.has(aiState)
52
+
53
+ if (isAiActive) {
54
+ const agentUser = findOtherChannelUser(channel, client.user?.id)
55
+ return (
56
+ <TypingBubble
57
+ avatarId={agentUser?.id ?? 'ai-agent'}
58
+ avatarName={agentUser?.name ?? agentUser?.id ?? 'Agent'}
59
+ avatarImage={agentUser?.image}
60
+ testId="typing-indicator-ai"
61
+ />
62
+ )
63
+ }
32
64
 
33
65
  if (channelConfig?.typing_events === false) {
34
66
  return null
@@ -59,43 +91,75 @@ const CustomTypingIndicator = ({ threadList }: CustomTypingIndicatorProps) => {
59
91
  ? channel.state.members[typingUser.id].user
60
92
  : undefined
61
93
 
62
- const avatarId = typingUser?.id ?? memberUser?.id ?? 'typing-user'
63
- const avatarName =
64
- typingUser?.name ?? memberUser?.name ?? typingUser?.id ?? 'Typing user'
65
- const avatarImage = typingUser?.image ?? memberUser?.image
66
-
67
94
  return (
68
- <div
69
- className="str-chat__typing-indicator !items-end !bg-transparent"
70
- data-testid="typing-indicator"
71
- style={{ insetInlineStart: 0, insetInlineEnd: 'auto' }}
72
- >
73
- <div className="shrink-0" aria-hidden="true">
74
- <Avatar
75
- id={avatarId}
76
- name={avatarName}
77
- image={avatarImage}
78
- size={24}
79
- shape="circle"
80
- />
81
- </div>
82
-
83
- <div className="px-4 py-3 rounded-lg bg-[#E9EAED] h-12 flex flex-col justify-end">
84
- <svg
85
- aria-hidden="true"
86
- className="block overflow-visible mb-[0.2rem]"
87
- viewBox="0 0 32 8"
88
- width="32"
89
- height="8"
90
- overflow="visible"
91
- >
92
- <Circle cx="4" index={0} />
93
- <Circle cx="16" index={1} />
94
- <Circle cx="28" index={2} />
95
- </svg>
96
- </div>
97
- </div>
95
+ <TypingBubble
96
+ avatarId={typingUser?.id ?? memberUser?.id ?? 'typing-user'}
97
+ avatarName={
98
+ typingUser?.name ?? memberUser?.name ?? typingUser?.id ?? 'Typing user'
99
+ }
100
+ avatarImage={typingUser?.image ?? memberUser?.image}
101
+ testId="typing-indicator"
102
+ />
98
103
  )
99
104
  }
100
105
 
106
+ const TypingBubble = ({
107
+ avatarId,
108
+ avatarName,
109
+ avatarImage,
110
+ testId,
111
+ }: {
112
+ avatarId: string
113
+ avatarName: string
114
+ avatarImage?: string | null
115
+ testId: string
116
+ }) => (
117
+ <div
118
+ className="str-chat__typing-indicator !items-end !bg-transparent"
119
+ data-testid={testId}
120
+ style={{ insetInlineStart: 0, insetInlineEnd: 'auto' }}
121
+ >
122
+ <div className="shrink-0" aria-hidden="true">
123
+ <Avatar
124
+ id={avatarId}
125
+ name={avatarName}
126
+ image={avatarImage ?? undefined}
127
+ size={24}
128
+ shape="circle"
129
+ />
130
+ </div>
131
+
132
+ <div className="px-4 py-3 rounded-lg bg-[#E9EAED] h-12 flex flex-col justify-end">
133
+ <svg
134
+ aria-hidden="true"
135
+ className="block overflow-visible mb-[0.2rem]"
136
+ viewBox="0 0 32 8"
137
+ width="32"
138
+ height="8"
139
+ overflow="visible"
140
+ >
141
+ <Circle cx="4" index={0} />
142
+ <Circle cx="16" index={1} />
143
+ <Circle cx="28" index={2} />
144
+ </svg>
145
+ </div>
146
+ </div>
147
+ )
148
+
149
+ type ChannelLike = ReturnType<typeof useChannelStateContext>['channel']
150
+
151
+ function findOtherChannelUser(
152
+ channel: ChannelLike,
153
+ selfId: string | undefined
154
+ ): UserResponse | undefined {
155
+ const members = channel?.state?.members ?? {}
156
+ for (const member of Object.values(members)) {
157
+ const memberUser = member?.user
158
+ if (memberUser && memberUser.id !== selfId) {
159
+ return memberUser
160
+ }
161
+ }
162
+ return undefined
163
+ }
164
+
101
165
  export default CustomTypingIndicator
@@ -0,0 +1,307 @@
1
+ import {
2
+ ArticleIcon,
3
+ MusicNotesIcon,
4
+ PlayIcon,
5
+ QuestionIcon,
6
+ } from '@phosphor-icons/react'
7
+ import type { Meta, StoryFn } from '@storybook/react'
8
+ import React from 'react'
9
+
10
+ import LinkAttachment from '.'
11
+
12
+ const IMAGE_THUMBNAIL = '/image-thumbnail.jpg'
13
+ const VIDEO_SOURCE = '/video-source.mp4'
14
+ const VIDEO_POSTER = '/video-thumbnail.jpg'
15
+ const AUDIO_SOURCE = '/audio-source.mp3'
16
+ const PDF_SOURCE = '/document-source.pdf'
17
+
18
+ const meta: Meta = {
19
+ title: 'LinkAttachment',
20
+ parameters: { layout: 'fullscreen' },
21
+ }
22
+ export default meta
23
+
24
+ const Table = ({ children }: { children: React.ReactNode }) => (
25
+ <div className="min-h-screen w-full bg-[#F9F7F4] p-12">
26
+ <table className="border-separate border-spacing-4">{children}</table>
27
+ </div>
28
+ )
29
+
30
+ const TableHead = ({ columns }: { columns: string[] }) => (
31
+ <thead>
32
+ <tr>
33
+ <th className="pb-2 text-left text-xs font-medium text-black/40" />
34
+ {columns.map((column) => (
35
+ <th
36
+ key={column}
37
+ className="pb-2 text-left text-xs font-medium text-black/40"
38
+ >
39
+ {column}
40
+ </th>
41
+ ))}
42
+ </tr>
43
+ </thead>
44
+ )
45
+
46
+ const RowLabel = ({ children }: { children: React.ReactNode }) => (
47
+ <td className="pr-4 pt-2 text-right align-top text-xs font-medium text-black/40">
48
+ {children}
49
+ </td>
50
+ )
51
+
52
+ const APP_ICON_BASE =
53
+ 'inline-flex size-4 items-center justify-center overflow-hidden rounded-[4px]'
54
+
55
+ const SpotifyBadge = () => (
56
+ <span
57
+ aria-hidden
58
+ className={`${APP_ICON_BASE} bg-[#1ed760] text-white`}
59
+ title="Spotify"
60
+ >
61
+ <MusicNotesIcon className="size-3" weight="fill" />
62
+ </span>
63
+ )
64
+
65
+ const TikTokBadge = () => (
66
+ <span
67
+ aria-hidden
68
+ className={`${APP_ICON_BASE} bg-[#101211] text-white`}
69
+ title="TikTok"
70
+ >
71
+ <PlayIcon className="size-3" weight="fill" />
72
+ </span>
73
+ )
74
+
75
+ const FaqBadge = () => (
76
+ <span
77
+ aria-hidden
78
+ className={`${APP_ICON_BASE} bg-[#061492] text-white`}
79
+ title="FAQ"
80
+ >
81
+ <QuestionIcon className="size-3" weight="bold" />
82
+ </span>
83
+ )
84
+
85
+ const FormBadge = () => (
86
+ <span
87
+ aria-hidden
88
+ className={`${APP_ICON_BASE} bg-[#2665d6] text-white`}
89
+ title="Form"
90
+ >
91
+ <ArticleIcon className="size-3" weight="fill" />
92
+ </span>
93
+ )
94
+
95
+ const LINK_APPS: Array<{
96
+ key: string
97
+ appIcon: React.ReactNode
98
+ title: string
99
+ description: string
100
+ url?: string
101
+ ctaLabel?: string
102
+ }> = [
103
+ {
104
+ key: 'spotify',
105
+ appIcon: <SpotifyBadge />,
106
+ title: 'My Playlist',
107
+ description: 'A handpicked workout mix I made for my clients.',
108
+ url: 'tr.ee/briemix',
109
+ },
110
+ {
111
+ key: 'tiktok',
112
+ appIcon: <TikTokBadge />,
113
+ title: 'My TikTok',
114
+ description: 'New form-check clips every week — follow along.',
115
+ url: 'tr.ee/brietok',
116
+ },
117
+ {
118
+ key: 'faq',
119
+ appIcon: <FaqBadge />,
120
+ title: 'Brie’s FAQ',
121
+ description: 'Get answers on my process and what to expect.',
122
+ ctaLabel: 'View FAQs',
123
+ },
124
+ {
125
+ key: 'form',
126
+ appIcon: <FormBadge />,
127
+ title: 'Fitness Assessment Fillout',
128
+ description: 'Share information about your journey and I’ll work on...',
129
+ ctaLabel: 'Complete form',
130
+ },
131
+ ]
132
+
133
+ /**
134
+ * "Attachments" section of the Figma board — image / video / audio /
135
+ * file attachments shown across the three messaging states. These are
136
+ * pure media cards: the hero fills the card and there's no title /
137
+ * description / link metadata. Playable types use inline native controls:
138
+ * - **Image** — full-bleed thumbnail; Received opens an image preview.
139
+ * - **Video** — inline `<video controls>` with a poster.
140
+ * - **Audio** — inline `<audio controls>` over the audio type-icon.
141
+ * - **PDF** — type-icon placeholder; Received opens the PDF in the
142
+ * browser's native viewer.
143
+ * - **File** — generic file type-icon; Received opens a preview.
144
+ * - **Placeholder** — empty draft state with the image type-icon.
145
+ */
146
+ const ATTACHMENT_ROWS: Array<{
147
+ label: string
148
+ /** Drives the placeholder type-icon and the inline player switch. */
149
+ mimeType?: string
150
+ /** Thumbnail (image source or video poster). */
151
+ thumbnailUrl?: string
152
+ /** Playable media URL — when set with a video/audio mime, renders inline. */
153
+ sourceUrl?: string
154
+ /**
155
+ * Override the Received `onClick` handler. Used by the PDF row to open
156
+ * the PDF in the browser's native viewer.
157
+ */
158
+ onReceivedClick?: () => void
159
+ }> = [
160
+ { label: 'Image', mimeType: 'image/jpeg', thumbnailUrl: IMAGE_THUMBNAIL },
161
+ {
162
+ label: 'Video',
163
+ mimeType: 'video/mp4',
164
+ thumbnailUrl: VIDEO_POSTER,
165
+ sourceUrl: VIDEO_SOURCE,
166
+ },
167
+ { label: 'Audio', mimeType: 'audio/mpeg', sourceUrl: AUDIO_SOURCE },
168
+ {
169
+ label: 'PDF',
170
+ mimeType: 'application/pdf',
171
+ onReceivedClick: () => window.open(PDF_SOURCE, '_blank', 'noopener'),
172
+ },
173
+ { label: 'File', mimeType: 'application/octet-stream' },
174
+ { label: 'Placeholder' },
175
+ ]
176
+
177
+ export const Attachments: StoryFn = () => (
178
+ <Table>
179
+ <TableHead columns={['Composer', 'Sent', 'Received']} />
180
+ <tbody>
181
+ {ATTACHMENT_ROWS.map(
182
+ ({ label, mimeType, thumbnailUrl, sourceUrl, onReceivedClick }) => (
183
+ <tr key={label}>
184
+ <RowLabel>{label}</RowLabel>
185
+ <td className="align-top">
186
+ <LinkAttachment.Composer
187
+ mimeType={mimeType}
188
+ thumbnailUrl={thumbnailUrl}
189
+ sourceUrl={sourceUrl}
190
+ onDismiss={() => alert(`Dismissed ${label}`)}
191
+ />
192
+ </td>
193
+ <td className="align-top">
194
+ <LinkAttachment.Sent
195
+ mimeType={mimeType}
196
+ thumbnailUrl={thumbnailUrl}
197
+ sourceUrl={sourceUrl}
198
+ />
199
+ </td>
200
+ <td className="align-top">
201
+ <LinkAttachment.Received
202
+ mimeType={mimeType}
203
+ thumbnailUrl={thumbnailUrl}
204
+ sourceUrl={sourceUrl}
205
+ onClick={
206
+ onReceivedClick ?? (() => alert(`Open ${label} preview`))
207
+ }
208
+ />
209
+ </td>
210
+ </tr>
211
+ )
212
+ )}
213
+ </tbody>
214
+ </Table>
215
+ )
216
+
217
+ type LinkAppLayout = 'featured' | 'classic'
218
+ type LinkAppState = 'Received' | 'Sent' | 'Composer'
219
+
220
+ const LINK_APP_ROWS: Array<{ layout: LinkAppLayout; state: LinkAppState }> = [
221
+ { layout: 'featured', state: 'Received' },
222
+ { layout: 'featured', state: 'Sent' },
223
+ { layout: 'featured', state: 'Composer' },
224
+ { layout: 'classic', state: 'Received' },
225
+ { layout: 'classic', state: 'Sent' },
226
+ { layout: 'classic', state: 'Composer' },
227
+ ]
228
+
229
+ const renderLinkAppCard = (
230
+ layout: LinkAppLayout,
231
+ state: LinkAppState,
232
+ app: (typeof LINK_APPS)[number]
233
+ ) => {
234
+ const { key, appIcon, title, description, url, ctaLabel } = app
235
+ const thumbnailUrl = layout === 'featured' ? IMAGE_THUMBNAIL : undefined
236
+ const ctaWithHandler = ctaLabel
237
+ ? { label: ctaLabel, onClick: () => alert(`Tapped ${ctaLabel}`) }
238
+ : undefined
239
+ const ctaSilent = ctaLabel ? { label: ctaLabel } : undefined
240
+
241
+ if (state === 'Received') {
242
+ return (
243
+ <LinkAttachment.Received
244
+ layout={layout}
245
+ appIcon={appIcon}
246
+ title={title}
247
+ description={description}
248
+ thumbnailUrl={thumbnailUrl}
249
+ url={url}
250
+ cta={ctaWithHandler}
251
+ />
252
+ )
253
+ }
254
+ if (state === 'Sent') {
255
+ return (
256
+ <LinkAttachment.Sent
257
+ layout={layout}
258
+ appIcon={appIcon}
259
+ title={title}
260
+ description={description}
261
+ thumbnailUrl={thumbnailUrl}
262
+ url={url}
263
+ cta={ctaSilent}
264
+ />
265
+ )
266
+ }
267
+ return (
268
+ <LinkAttachment.Composer
269
+ layout={layout}
270
+ appIcon={appIcon}
271
+ title={title}
272
+ description={description}
273
+ thumbnailUrl={thumbnailUrl}
274
+ url={url}
275
+ cta={ctaSilent}
276
+ onDismiss={() => alert(`Dismissed ${key}`)}
277
+ />
278
+ )
279
+ }
280
+
281
+ /**
282
+ * "LinkApps" section of the Figma board — link previews with a brand
283
+ * badge prefixing the title and either a URL footer (Spotify, TikTok) or
284
+ * a CTA button (FAQ, Form). Each app is shown in both the **Featured**
285
+ * (hero image) and **Classic** (compact, no hero) layouts across all
286
+ * three messaging states.
287
+ */
288
+ export const LinkApps: StoryFn = () => (
289
+ <Table>
290
+ <TableHead columns={LINK_APPS.map(({ key }) => key)} />
291
+ <tbody>
292
+ {LINK_APP_ROWS.map(({ layout, state }) => {
293
+ const label = `${layout === 'featured' ? 'Featured' : 'Classic'} (${state})`
294
+ return (
295
+ <tr key={label}>
296
+ <RowLabel>{label}</RowLabel>
297
+ {LINK_APPS.map((app) => (
298
+ <td key={app.key} className="align-top">
299
+ {renderLinkAppCard(layout, state, app)}
300
+ </td>
301
+ ))}
302
+ </tr>
303
+ )
304
+ })}
305
+ </tbody>
306
+ </Table>
307
+ )