@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.
- package/dist/Card-CFFNq49v.js +163 -0
- package/dist/Card-CFFNq49v.js.map +1 -0
- package/dist/Card-CsJvUF_b.js +107 -0
- package/dist/Card-CsJvUF_b.js.map +1 -0
- package/dist/Card-D32U6KfZ.js +85 -0
- package/dist/Card-D32U6KfZ.js.map +1 -0
- package/dist/Card-DlMSDSdm.js +132 -0
- package/dist/Card-DlMSDSdm.js.map +1 -0
- package/dist/Card-DlSSJPip.js +60 -0
- package/dist/Card-DlSSJPip.js.map +1 -0
- package/dist/Card-zGbhRBwv.js +48 -0
- package/dist/Card-zGbhRBwv.js.map +1 -0
- package/dist/CardThumbnail-DTBuRQHF.js +239 -0
- package/dist/CardThumbnail-DTBuRQHF.js.map +1 -0
- package/dist/LockedThumbnail-DpJx169C.js +220 -0
- package/dist/LockedThumbnail-DpJx169C.js.map +1 -0
- package/dist/assets/index.css +1 -1
- package/dist/{index-Brz9orsI.js → index-DfcRe-Hj.js} +939 -889
- package/dist/index-DfcRe-Hj.js.map +1 -0
- package/dist/index.d.ts +217 -28
- package/dist/index.js +16 -15
- package/package.json +1 -1
- package/src/components/ChannelView.test.tsx +11 -0
- package/src/components/ChannelView.tsx +35 -32
- package/src/components/CustomMessage/index.tsx +2 -3
- package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
- package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
- package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
- package/src/components/CustomTypingIndicator/index.tsx +101 -37
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +307 -0
- package/src/components/LinkAttachment/components/Composer/Card.tsx +117 -0
- package/src/components/LinkAttachment/components/Composer/index.ts +2 -0
- package/src/components/LinkAttachment/components/Received/Card.tsx +132 -0
- package/src/components/LinkAttachment/components/Received/index.ts +2 -0
- package/src/components/LinkAttachment/components/Sent/Card.tsx +57 -0
- package/src/components/LinkAttachment/components/Sent/index.ts +2 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.tsx +117 -0
- package/src/components/LinkAttachment/components/_shared/CardCta.tsx +69 -0
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +120 -0
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +156 -0
- package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.ts +56 -0
- package/src/components/LinkAttachment/index.tsx +68 -0
- package/src/components/LinkAttachment/types.ts +69 -0
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
- package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
- package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
- package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
- package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
- package/src/components/LockedAttachment/components/Received/index.ts +2 -0
- package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
- package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
- package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
- package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
- package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
- package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
- package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
- package/src/components/LockedAttachment/index.tsx +43 -12
- package/src/components/LockedAttachment/types.ts +17 -0
- package/src/components/MediaMessage/index.tsx +8 -2
- package/src/index.ts +15 -1
- package/src/styles.css +7 -0
- package/dist/Card-BHknCeHw.js +0 -138
- package/dist/Card-BHknCeHw.js.map +0 -1
- package/dist/Card-DT7_ms2p.js +0 -127
- package/dist/Card-DT7_ms2p.js.map +0 -1
- package/dist/index-Brz9orsI.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
- package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
- package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
- 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
|
+
})
|
|
@@ -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
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
)
|