@linktr.ee/messaging-react 1.0.0 → 1.0.1

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 (40) hide show
  1. package/package.json +3 -2
  2. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  3. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  4. package/src/components/ActionButton/index.tsx +33 -0
  5. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  6. package/src/components/Avatar/avatarColors.ts +36 -0
  7. package/src/components/Avatar/index.tsx +64 -0
  8. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  9. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  10. package/src/components/ChannelList/CustomChannelPreview.tsx +114 -0
  11. package/src/components/ChannelList/index.tsx +129 -0
  12. package/src/components/ChannelView.tsx +422 -0
  13. package/src/components/CloseButton/index.tsx +16 -0
  14. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  15. package/src/components/IconButton/index.tsx +32 -0
  16. package/src/components/Loading/Loading.stories.tsx +24 -0
  17. package/src/components/Loading/index.tsx +50 -0
  18. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  19. package/src/components/MessagingShell/EmptyState.tsx +55 -0
  20. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  21. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  22. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  23. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  24. package/src/components/MessagingShell/index.tsx +298 -0
  25. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  26. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  27. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  28. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  29. package/src/components/ParticipantPicker/index.tsx +234 -0
  30. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  31. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  32. package/src/components/SearchInput/index.tsx +50 -0
  33. package/src/hooks/useMessaging.ts +9 -0
  34. package/src/hooks/useParticipants.ts +92 -0
  35. package/src/index.ts +26 -0
  36. package/src/providers/MessagingProvider.tsx +282 -0
  37. package/src/stories/mocks.tsx +157 -0
  38. package/src/test/setup.ts +30 -0
  39. package/src/test/utils.tsx +23 -0
  40. package/src/types.ts +113 -0
@@ -0,0 +1,422 @@
1
+ import { FlagIcon } from "@phosphor-icons/react/dist/csr/Flag";
2
+ import { ProhibitInsetIcon } from "@phosphor-icons/react/dist/csr/ProhibitInset";
3
+ import { SignOutIcon } from "@phosphor-icons/react/dist/csr/SignOut";
4
+ import { SpinnerGapIcon } from "@phosphor-icons/react/dist/csr/SpinnerGap";
5
+
6
+ import { ArrowLeftIcon } from "@phosphor-icons/react/dist/csr/ArrowLeft";
7
+ import { DotsThreeIcon } from "@phosphor-icons/react/dist/csr/DotsThree";
8
+
9
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
10
+ import classNames from 'classnames';
11
+ import {
12
+ Channel,
13
+ Window,
14
+ MessageList,
15
+ MessageInput,
16
+ useChannelStateContext,
17
+ } from 'stream-chat-react';
18
+ import type { ChannelViewProps } from '../types';
19
+ import { useMessagingContext } from '../providers/MessagingProvider';
20
+ import { CloseButton } from './CloseButton';
21
+ import { IconButton } from './IconButton';
22
+ import ActionButton from './ActionButton';
23
+ import { Avatar } from './Avatar';
24
+
25
+ /**
26
+ * Custom message input component with render prop for actions
27
+ */
28
+ const CustomMessageInput: React.FC<{
29
+ renderActions?: () => React.ReactNode;
30
+ }> = ({ renderActions }) => (
31
+ <div className="message-input flex items-center gap-2 p-4">
32
+ {renderActions && renderActions()}
33
+
34
+ <div className="flex-1">
35
+ <MessageInput focus maxRows={4} />
36
+ </div>
37
+ </div>
38
+ );
39
+
40
+ /**
41
+ * Custom channel header component
42
+ */
43
+ const CustomChannelHeader: React.FC<{
44
+ onBack?: () => void;
45
+ showBackButton: boolean;
46
+ onShowInfo?: () => void;
47
+ canShowInfo: boolean;
48
+ }> = ({ onBack, showBackButton, onShowInfo, canShowInfo }) => {
49
+ const { channel } = useChannelStateContext();
50
+
51
+ // Get participant info (excluding current user)
52
+ const participant = React.useMemo(() => {
53
+ const members = Object.values(channel.state.members || {});
54
+ return members.find(member =>
55
+ member.user?.id && member.user.id !== channel._client.userID
56
+ );
57
+ }, [channel._client.userID, channel.state.members]);
58
+
59
+ const participantName = participant?.user?.name ||
60
+ participant?.user?.id ||
61
+ 'Unknown member';
62
+ const participantImage = participant?.user?.image;
63
+
64
+ return (
65
+ <div className="flex items-center justify-between gap-3 min-h-12">
66
+ <div className="flex items-center gap-3 min-w-0">
67
+ {showBackButton && onBack && (
68
+ <button
69
+ type="button"
70
+ onClick={onBack}
71
+ className="inline-flex items-center justify-center w-8 h-8 rounded-lg hover:bg-sand focus:outline-none focus:ring-2 focus:ring-primary transition-colors lg:hidden"
72
+ aria-label="Back to channel list"
73
+ >
74
+ <ArrowLeftIcon className="h-5 w-5 text-stone" weight="bold" />
75
+ </button>
76
+ )}
77
+
78
+ {/* Avatar */}
79
+ <Avatar
80
+ id={participant?.user?.id || channel.id || 'unknown'}
81
+ name={participantName}
82
+ image={participantImage}
83
+ size={40}
84
+ />
85
+
86
+ <div className="min-w-0">
87
+ <h1 className="text-lg font-semibold text-charcoal truncate">
88
+ {participantName}
89
+ </h1>
90
+ </div>
91
+ </div>
92
+
93
+ {canShowInfo && onShowInfo && (
94
+ <IconButton
95
+ label="Chat info"
96
+ onClick={onShowInfo}
97
+ >
98
+ <DotsThreeIcon className="h-6 w-6 text-charcoal" weight="bold" />
99
+ </IconButton>
100
+ )}
101
+ </div>
102
+ );
103
+ };
104
+
105
+ /**
106
+ * Channel info dialog (matching original implementation)
107
+ */
108
+ const ChannelInfoDialog: React.FC<{
109
+ isOpen: boolean;
110
+ onClose: () => void;
111
+ participant: any;
112
+ channel: any;
113
+ followerStatusLabel?: string;
114
+ onLeaveConversation?: (channel: any) => void;
115
+ onBlockParticipant?: (participantId?: string) => void;
116
+ }> = ({ isOpen, onClose, participant, channel, followerStatusLabel, onLeaveConversation, onBlockParticipant }) => {
117
+ const { service, debug } = useMessagingContext();
118
+ const dialogRef = useRef<HTMLDialogElement>(null);
119
+ const [isParticipantBlocked, setIsParticipantBlocked] = useState(false);
120
+ const [isLeaving, setIsLeaving] = useState(false);
121
+ const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false);
122
+
123
+ // Sync dialog open state with prop
124
+ useEffect(() => {
125
+ const dialog = dialogRef.current;
126
+ if (!dialog) return;
127
+
128
+ if (isOpen) {
129
+ dialog.showModal();
130
+ } else {
131
+ dialog.close();
132
+ }
133
+ }, [isOpen]);
134
+
135
+ // Check if participant is blocked
136
+ const checkIsParticipantBlocked = useCallback(async () => {
137
+ if (!service || !participant?.user?.id) return;
138
+
139
+ try {
140
+ const blockedUsers = await service.getBlockedUsers();
141
+ const isBlocked = blockedUsers.some(
142
+ (user: any) => user.blocked_user_id === participant.user.id,
143
+ );
144
+ setIsParticipantBlocked(isBlocked);
145
+ } catch (error) {
146
+ console.error('[ChannelInfoDialog] Failed to check blocked status:', error);
147
+ }
148
+ }, [service, participant?.user?.id]);
149
+
150
+ useEffect(() => {
151
+ if (isOpen) {
152
+ checkIsParticipantBlocked();
153
+ }
154
+ }, [isOpen, checkIsParticipantBlocked]);
155
+
156
+ const handleLeaveConversation = async () => {
157
+ if (isLeaving) return;
158
+
159
+ if (debug) {
160
+ console.log('[ChannelInfoDialog] Leave conversation', channel.cid);
161
+ }
162
+ setIsLeaving(true);
163
+
164
+ try {
165
+ const actingUserId = channel._client?.userID ?? null;
166
+ await channel.hide(actingUserId, false);
167
+
168
+ if (onLeaveConversation) {
169
+ await onLeaveConversation(channel);
170
+ }
171
+
172
+ onClose();
173
+ } catch (error) {
174
+ console.error('[ChannelInfoDialog] Failed to leave conversation', error);
175
+ } finally {
176
+ setIsLeaving(false);
177
+ }
178
+ };
179
+
180
+ const handleBlockUser = async () => {
181
+ if (isUpdatingBlockStatus || !service) return;
182
+
183
+ if (debug) {
184
+ console.log('[ChannelInfoDialog] Block member', participant?.user?.id);
185
+ }
186
+ setIsUpdatingBlockStatus(true);
187
+
188
+ try {
189
+ await service.blockUser(participant?.user?.id);
190
+
191
+ if (onBlockParticipant) {
192
+ await onBlockParticipant(participant?.user?.id);
193
+ }
194
+
195
+ onClose();
196
+ } catch (error) {
197
+ console.error('[ChannelInfoDialog] Failed to block member', error);
198
+ } finally {
199
+ setIsUpdatingBlockStatus(false);
200
+ }
201
+ };
202
+
203
+ const handleUnblockUser = async () => {
204
+ if (isUpdatingBlockStatus || !service) return;
205
+
206
+ if (debug) {
207
+ console.log('[ChannelInfoDialog] Unblock member', participant?.user?.id);
208
+ }
209
+ setIsUpdatingBlockStatus(true);
210
+
211
+ try {
212
+ await service.unBlockUser(participant?.user?.id);
213
+
214
+ if (onBlockParticipant) {
215
+ await onBlockParticipant(participant?.user?.id);
216
+ }
217
+
218
+ onClose();
219
+ } catch (error) {
220
+ console.error('[ChannelInfoDialog] Failed to unblock member', error);
221
+ } finally {
222
+ setIsUpdatingBlockStatus(false);
223
+ }
224
+ };
225
+
226
+ const handleReportUser = () => {
227
+ onClose();
228
+ window.open(
229
+ 'https://linktr.ee/s/about/trust-center/report',
230
+ '_blank',
231
+ 'noopener,noreferrer',
232
+ );
233
+ };
234
+
235
+ if (!participant) return null;
236
+
237
+ const participantName = participant.user?.name || participant.user?.id || 'Unknown member';
238
+ const participantImage = participant.user?.image;
239
+ const participantEmail = participant.user?.email;
240
+ const participantUsername = participant.user?.username;
241
+ const participantSecondary = participantEmail
242
+ ? participantEmail
243
+ : participantUsername
244
+ ? `linktr.ee/${participantUsername}`
245
+ : undefined;
246
+ const participantId = participant.user?.id || 'unknown';
247
+
248
+ return (
249
+ <dialog
250
+ ref={dialogRef}
251
+ className="mes-dialog"
252
+ onClose={onClose}
253
+ onClick={(e) => {
254
+ if (e.target === dialogRef.current) {
255
+ onClose();
256
+ }
257
+ }}
258
+ >
259
+ <div className="ml-auto flex h-full w-full flex-col bg-white shadow-max-elevation-light">
260
+ <div className="flex items-center justify-between border-b border-sand px-4 py-3">
261
+ <h2 className="text-base font-semibold text-charcoal">Chat info</h2>
262
+ <CloseButton onClick={onClose} />
263
+ </div>
264
+
265
+ <div className="flex-1 overflow-y-auto px-6 py-6">
266
+ <div className="rounded-2xl bg-chalk p-4">
267
+ <div className="flex items-center gap-4">
268
+ <Avatar
269
+ id={participantId}
270
+ name={participantName}
271
+ image={participantImage}
272
+ size={64}
273
+ />
274
+ <div className="min-w-0 flex-1">
275
+ <p className="truncate text-base font-semibold text-charcoal">
276
+ {participantName}
277
+ </p>
278
+ {participantSecondary && (
279
+ <p className="truncate text-sm text-stone">
280
+ {participantSecondary}
281
+ </p>
282
+ )}
283
+ {followerStatusLabel && (
284
+ <span className="mt-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
285
+ {followerStatusLabel}
286
+ </span>
287
+ )}
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <ul className="flex flex-col gap-2 mt-2">
293
+ <li>
294
+ <ActionButton
295
+ onClick={handleLeaveConversation}
296
+ disabled={isLeaving}
297
+ aria-busy={isLeaving}
298
+ >
299
+ {isLeaving ? (
300
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
301
+ ) : (
302
+ <SignOutIcon className="h-5 w-5" />
303
+ )}
304
+ <span>Leave Conversation</span>
305
+ </ActionButton>
306
+ </li>
307
+ <li>
308
+ {isParticipantBlocked ? (
309
+ <ActionButton
310
+ onClick={handleUnblockUser}
311
+ disabled={isUpdatingBlockStatus}
312
+ aria-busy={isUpdatingBlockStatus}
313
+ >
314
+ {isUpdatingBlockStatus ? (
315
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
316
+ ) : (
317
+ <ProhibitInsetIcon className="h-5 w-5" />
318
+ )}
319
+ <span>Unblock</span>
320
+ </ActionButton>
321
+ ) : (
322
+ <ActionButton
323
+ onClick={handleBlockUser}
324
+ disabled={isUpdatingBlockStatus}
325
+ aria-busy={isUpdatingBlockStatus}
326
+ >
327
+ {isUpdatingBlockStatus ? (
328
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
329
+ ) : (
330
+ <ProhibitInsetIcon className="h-5 w-5" />
331
+ )}
332
+ <span>Block</span>
333
+ </ActionButton>
334
+ )}
335
+ </li>
336
+ <li>
337
+ <ActionButton variant="danger" onClick={handleReportUser}>
338
+ <FlagIcon className="h-5 w-5" />
339
+ <span>Report</span>
340
+ </ActionButton>
341
+ </li>
342
+ </ul>
343
+ </div>
344
+ </div>
345
+ </dialog>
346
+ );
347
+ };
348
+
349
+ /**
350
+ * Channel view component with message list and input
351
+ */
352
+ export const ChannelView: React.FC<ChannelViewProps> = ({
353
+ channel,
354
+ onBack,
355
+ showBackButton = false,
356
+ renderMessageInputActions,
357
+ onLeaveConversation,
358
+ onBlockParticipant,
359
+ className,
360
+ }) => {
361
+ const [showInfo, setShowInfo] = useState(false);
362
+
363
+ // Get participant info for info dialog
364
+ const participant = React.useMemo(() => {
365
+ const members = Object.values(channel.state.members || {});
366
+ return members.find(member =>
367
+ member.user?.id && member.user.id !== channel._client.userID
368
+ );
369
+ }, [channel._client.userID, channel.state.members]);
370
+
371
+ // Get follower status label from channel data
372
+ const followerStatusLabel = React.useMemo(() => {
373
+ const channelExtraData = (channel.data ?? {}) as {
374
+ followerStatus?: string;
375
+ isFollower?: boolean;
376
+ };
377
+ return channelExtraData.followerStatus
378
+ ? String(channelExtraData.followerStatus)
379
+ : channelExtraData.isFollower
380
+ ? 'Subscribed to you'
381
+ : undefined;
382
+ }, [channel.data]);
383
+
384
+ return (
385
+ <div className={classNames('h-full flex flex-col', className)}>
386
+ <Channel channel={channel}>
387
+ <Window>
388
+ {/* Custom Channel Header */}
389
+ <div className="border-b border-sand bg-white px-4 py-3">
390
+ <CustomChannelHeader
391
+ onBack={onBack}
392
+ showBackButton={showBackButton}
393
+ onShowInfo={() => setShowInfo(true)}
394
+ canShowInfo={Boolean(participant)}
395
+ />
396
+ </div>
397
+
398
+ {/* Message List */}
399
+ <div className="flex-1 overflow-hidden">
400
+ <MessageList hideDeletedMessages hideNewMessageSeparator={false} />
401
+ </div>
402
+
403
+ {/* Message Input */}
404
+ <CustomMessageInput
405
+ renderActions={() => renderMessageInputActions?.(channel)}
406
+ />
407
+ </Window>
408
+ </Channel>
409
+
410
+ {/* Channel Info Dialog */}
411
+ <ChannelInfoDialog
412
+ isOpen={showInfo}
413
+ onClose={() => setShowInfo(false)}
414
+ participant={participant}
415
+ channel={channel}
416
+ followerStatusLabel={followerStatusLabel}
417
+ onLeaveConversation={onLeaveConversation}
418
+ onBlockParticipant={onBlockParticipant}
419
+ />
420
+ </div>
421
+ );
422
+ };
@@ -0,0 +1,16 @@
1
+ import { XIcon } from "@phosphor-icons/react/dist/csr/X";
2
+ import React from "react";
3
+
4
+ import { IconButton } from "../IconButton";
5
+
6
+ interface CloseButtonProps {
7
+ onClick: () => void;
8
+ }
9
+
10
+ export function CloseButton({ onClick }: CloseButtonProps) {
11
+ return (
12
+ <IconButton label="Close" onClick={onClick} className="p-1">
13
+ <XIcon className="h-5 w-5 text-stone" weight="bold" />
14
+ </IconButton>
15
+ );
16
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { IconButton } from '.'
3
+ import { XIcon } from '@phosphor-icons/react/dist/csr/X'
4
+ import React from 'react'
5
+
6
+ type ComponentProps = React.ComponentProps<typeof IconButton>
7
+
8
+ const meta: Meta<ComponentProps> = {
9
+ title: 'IconButton',
10
+ component: IconButton,
11
+ parameters: {
12
+ layout: 'centered',
13
+ },
14
+ argTypes: {
15
+ onClick: { action: 'clicked' },
16
+ },
17
+ }
18
+ export default meta
19
+
20
+ const Template: StoryFn<ComponentProps> = (args) => {
21
+ return (
22
+ <div className="p-12">
23
+ <IconButton {...args} />
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
29
+ Default.args = {
30
+ label: 'Close',
31
+ children: <XIcon className="h-5 w-5" />,
32
+ }
33
+
34
+ export const WithCustomClassName: StoryFn<ComponentProps> = Template.bind({})
35
+ WithCustomClassName.args = {
36
+ label: 'Custom styled button',
37
+ children: <XIcon className="h-6 w-6" />,
38
+ className: 'bg-primary text-white size-12',
39
+ }
40
+
@@ -0,0 +1,32 @@
1
+ import classNames from "classnames";
2
+ import React from "react";
3
+
4
+ interface IconButtonProps
5
+ extends Omit<
6
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
7
+ "type" | "children"
8
+ > {
9
+ label: string;
10
+ children: React.ReactNode;
11
+ className?: string;
12
+ }
13
+
14
+ export function IconButton({ label, className, children, ...rest }: IconButtonProps) {
15
+ return (
16
+ <button
17
+ type="button"
18
+ className={classNames(
19
+ "rounded-full p-2 transition-colors focus-ring",
20
+ {
21
+ "cursor-not-allowed opacity-50": rest.disabled,
22
+ "hover:bg-sand": !rest.disabled,
23
+ },
24
+ className,
25
+ )}
26
+ {...rest}
27
+ >
28
+ <span className="sr-only">{label}</span>
29
+ {children}
30
+ </button>
31
+ );
32
+ }
@@ -0,0 +1,24 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import Loading from '.'
3
+ import React from 'react'
4
+
5
+ type ComponentProps = React.ComponentProps<typeof Loading>
6
+
7
+ const meta: Meta<ComponentProps> = {
8
+ title: 'Loading',
9
+ component: Loading,
10
+ parameters: {
11
+ layout: 'centered',
12
+ },
13
+ }
14
+ export default meta
15
+
16
+ const Template: StoryFn<ComponentProps> = (args) => {
17
+ return (
18
+ <Loading {...args} className='w-10 h-10' />
19
+ )
20
+ }
21
+
22
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
23
+ Default.args = {}
24
+
@@ -0,0 +1,50 @@
1
+ import classNames from "classnames";
2
+ import React from "react";
3
+
4
+ type LoadingProps = {
5
+ className?: string;
6
+ message?: string;
7
+ };
8
+
9
+ const Loading = ({ className, message }: LoadingProps) => (
10
+ <div
11
+ className={classNames("flex items-center justify-center h-full", className)}
12
+ >
13
+ <svg viewBox="0 0 100 100" className="size-8 fill-pebble" stroke="none">
14
+ <circle cx="6" cy="50" r="6">
15
+ <animateTransform
16
+ attributeName="transform"
17
+ dur="1s"
18
+ type="translate"
19
+ values="0 15 ; 0 -15; 0 15"
20
+ repeatCount="indefinite"
21
+ begin="0.1"
22
+ />
23
+ </circle>
24
+ <circle cx="30" cy="50" r="6">
25
+ <animateTransform
26
+ attributeName="transform"
27
+ dur="1s"
28
+ type="translate"
29
+ values="0 10 ; 0 -10; 0 10"
30
+ repeatCount="indefinite"
31
+ begin="0.2"
32
+ />
33
+ </circle>
34
+ <circle cx="54" cy="50" r="6">
35
+ <animateTransform
36
+ attributeName="transform"
37
+ dur="1s"
38
+ type="translate"
39
+ values="0 5 ; 0 -5; 0 5"
40
+ repeatCount="indefinite"
41
+ begin="0.3"
42
+ />
43
+ </circle>
44
+ </svg>
45
+ {message && <span className="text-stone">{message}</span>}
46
+ </div>
47
+ );
48
+
49
+ export default Loading;
50
+
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { EmptyState } from './EmptyState'
3
+ import React from 'react'
4
+
5
+
6
+ type ComponentProps = React.ComponentProps<typeof EmptyState>
7
+
8
+ const meta: Meta<ComponentProps> = {
9
+ title: 'States/EmptyState',
10
+ component: EmptyState,
11
+ parameters: {
12
+ layout: 'fullscreen',
13
+ },
14
+ }
15
+
16
+ export default meta
17
+
18
+ const Template: StoryFn<ComponentProps> = (args) => {
19
+ return (
20
+ <div className="h-screen w-full bg-white">
21
+ <EmptyState {...args} />
22
+ </div>
23
+ )
24
+ }
25
+
26
+ export const NoChannels: StoryFn<ComponentProps> = Template.bind({})
27
+ NoChannels.args = {
28
+ hasChannels: false,
29
+ participantLabel: 'followers',
30
+ onStartConversation: () => console.log('Start conversation clicked'),
31
+ }
32
+
33
+ export const WithChannels: StoryFn<ComponentProps> = Template.bind({})
34
+ WithChannels.args = {
35
+ hasChannels: true,
36
+ participantLabel: 'participants',
37
+ onStartConversation: () => console.log('Start conversation clicked'),
38
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Empty state component shown when no channel is selected
5
+ */
6
+ export const EmptyState: React.FC<{
7
+ hasChannels: boolean;
8
+ onStartConversation?: () => void;
9
+ participantLabel: string;
10
+ }> = ({ hasChannels, onStartConversation, participantLabel }) => (
11
+ <div className="flex items-center justify-center h-full p-8 text-balance">
12
+ <div className="text-center max-w-sm">
13
+ <div className="w-24 h-24 bg-primary-alt bg-opacity-10 rounded-full flex items-center justify-center mx-auto mb-6">
14
+ <span className="text-4xl">💬</span>
15
+ </div>
16
+
17
+ <h2 className="font-semibold text-charcoal">
18
+ Welcome to Messages
19
+ </h2>
20
+
21
+ <p className="text-stone text-sm mb-6">
22
+ {hasChannels ? (
23
+ <>
24
+ Choose a conversation from the list or{' '}
25
+ {onStartConversation && (
26
+ <TextButton onClick={onStartConversation}>
27
+ start a new conversation with a {participantLabel.slice(0, -1)}.
28
+ </TextButton>
29
+ )}
30
+ </>
31
+ ) : (
32
+ onStartConversation && (
33
+ <>
34
+ <TextButton onClick={onStartConversation}>
35
+ Start a new conversation with one of your {participantLabel}
36
+ </TextButton>{' '}
37
+ to begin messaging.
38
+ </>
39
+ )
40
+ )}
41
+ </p>
42
+ </div>
43
+ </div>
44
+ );
45
+
46
+
47
+ const TextButton = ({ onClick, children }: { onClick: () => void, children: React.ReactNode }) => (
48
+ <button
49
+ type="button"
50
+ onClick={onClick}
51
+ className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:text-primary-alt focus:outline-none focus:ring-2 focus:ring-primary"
52
+ >
53
+ {children}
54
+ </button>
55
+ )