@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.
- package/package.json +3 -2
- package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
- package/src/components/ActionButton/ActionButton.test.tsx +112 -0
- package/src/components/ActionButton/index.tsx +33 -0
- package/src/components/Avatar/Avatar.stories.tsx +144 -0
- package/src/components/Avatar/avatarColors.ts +36 -0
- package/src/components/Avatar/index.tsx +64 -0
- package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +114 -0
- package/src/components/ChannelList/index.tsx +129 -0
- package/src/components/ChannelView.tsx +422 -0
- package/src/components/CloseButton/index.tsx +16 -0
- package/src/components/IconButton/IconButton.stories.tsx +40 -0
- package/src/components/IconButton/index.tsx +32 -0
- package/src/components/Loading/Loading.stories.tsx +24 -0
- package/src/components/Loading/index.tsx +50 -0
- package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
- package/src/components/MessagingShell/EmptyState.tsx +55 -0
- package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
- package/src/components/MessagingShell/ErrorState.tsx +33 -0
- package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
- package/src/components/MessagingShell/LoadingState.tsx +15 -0
- package/src/components/MessagingShell/index.tsx +298 -0
- package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
- package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
- package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
- package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
- package/src/components/ParticipantPicker/index.tsx +234 -0
- package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
- package/src/components/SearchInput/SearchInput.test.tsx +108 -0
- package/src/components/SearchInput/index.tsx +50 -0
- package/src/hooks/useMessaging.ts +9 -0
- package/src/hooks/useParticipants.ts +92 -0
- package/src/index.ts +26 -0
- package/src/providers/MessagingProvider.tsx +282 -0
- package/src/stories/mocks.tsx +157 -0
- package/src/test/setup.ts +30 -0
- package/src/test/utils.tsx +23 -0
- 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
|
+
)
|