@connectycube/react-ui-kit 0.0.19 → 0.0.20
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/configs/dependencies.json +6 -0
- package/configs/imports.json +2 -0
- package/dist/index.cjs +1 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -35
- package/dist/index.js.map +1 -1
- package/dist/types/components/attachment.d.ts +3 -3
- package/dist/types/components/attachment.d.ts.map +1 -1
- package/dist/types/components/avatar.d.ts +1 -0
- package/dist/types/components/avatar.d.ts.map +1 -1
- package/dist/types/components/badge.d.ts +1 -1
- package/dist/types/components/button.d.ts +2 -2
- package/dist/types/components/call-message.d.ts +17 -0
- package/dist/types/components/call-message.d.ts.map +1 -0
- package/dist/types/components/chat-message.d.ts +30 -0
- package/dist/types/components/chat-message.d.ts.map +1 -0
- package/dist/types/components/dialog-item.d.ts.map +1 -1
- package/dist/types/components/linkify-text.d.ts +6 -1
- package/dist/types/components/linkify-text.d.ts.map +1 -1
- package/dist/types/components/switch.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/gen/components/attachment.jsx +21 -19
- package/gen/components/avatar.jsx +14 -2
- package/gen/components/call-message.jsx +62 -0
- package/gen/components/chat-message.jsx +120 -0
- package/gen/components/dialog-item.jsx +4 -1
- package/gen/components/linkify-text.jsx +41 -2
- package/gen/components/switch.jsx +0 -2
- package/gen/index.js +4 -0
- package/package.json +11 -10
- package/src/components/attachment.tsx +25 -26
- package/src/components/avatar.tsx +3 -1
- package/src/components/call-message.tsx +75 -0
- package/src/components/chat-message.tsx +138 -0
- package/src/components/connectycube-ui/attachment.tsx +269 -0
- package/src/components/connectycube-ui/chat-message.tsx +138 -0
- package/src/components/connectycube-ui/link-preview.tsx +149 -0
- package/src/components/dialog-item.tsx +4 -1
- package/src/components/linkify-text.tsx +44 -3
- package/src/components/switch.tsx +0 -2
- package/src/index.ts +6 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { forwardRef, memo, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import { File, FileXCorner, type LucideProps } from 'lucide-react';
|
|
4
|
+
import { Spinner } from './spinner';
|
|
5
|
+
import { cn, getRandomString } from './utils';
|
|
6
|
+
|
|
7
|
+
interface AttachmentProps {
|
|
8
|
+
uid?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
mimeType?: string;
|
|
11
|
+
pending?: boolean;
|
|
12
|
+
onReady?: (skipOnce?: boolean) => void;
|
|
13
|
+
linkProps?: AttachmentLinkProps;
|
|
14
|
+
containerProps?: React.ComponentProps<'div'>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AttachmentLinkProps
|
|
18
|
+
extends React.ComponentProps<'a'>, Omit<AttachmentProps, 'containerProps' & 'mimeType' & 'onReady'> {
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AttachmentImageProps
|
|
23
|
+
extends React.ComponentProps<'img'>, Omit<AttachmentProps, 'containerProps' & 'mimeType'> {}
|
|
24
|
+
|
|
25
|
+
interface AttachmentAudioProps
|
|
26
|
+
extends React.ComponentProps<'audio'>, Omit<AttachmentProps, 'linkProps' & 'mimeType' & 'onReady'> {}
|
|
27
|
+
|
|
28
|
+
interface AttachmentVideoProps extends React.ComponentProps<'video'>, Omit<AttachmentProps, 'linkProps' & 'mimeType'> {
|
|
29
|
+
maxSize?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface AttachmentFileProps extends LucideProps, Omit<AttachmentProps, 'containerProps' & 'mimeType'> {
|
|
33
|
+
name?: string | undefined;
|
|
34
|
+
iconElement?: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AttachmentFailedProps extends LucideProps, Omit<AttachmentProps, 'linkProps' & 'mimeType'> {
|
|
38
|
+
name?: string | undefined;
|
|
39
|
+
iconElement?: React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function AttachmentLinkBase(
|
|
43
|
+
{ url, pending = false, children, ...props }: AttachmentLinkProps,
|
|
44
|
+
ref: React.ForwardedRef<HTMLAnchorElement>
|
|
45
|
+
) {
|
|
46
|
+
return (
|
|
47
|
+
<a
|
|
48
|
+
ref={ref}
|
|
49
|
+
target="_blank"
|
|
50
|
+
rel="noopener noreferrer"
|
|
51
|
+
{...props}
|
|
52
|
+
href={url}
|
|
53
|
+
className={cn(
|
|
54
|
+
'group relative min-h-8 min-w-8 w-full flex items-center justify-center rounded-md overflow-hidden bg-ring/10 hover:bg-ring/20 transition-color duration-300 ease-out cursor-pointer',
|
|
55
|
+
props?.className
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
<Spinner loading={pending} layout="overlay" />
|
|
60
|
+
</a>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const AttachmentLink = forwardRef<HTMLAnchorElement, AttachmentLinkProps>(AttachmentLinkBase);
|
|
65
|
+
|
|
66
|
+
AttachmentLink.displayName = 'AttachmentLink';
|
|
67
|
+
|
|
68
|
+
function AttachmentAudioBase(
|
|
69
|
+
{ uid, url, pending = false, containerProps, ...props }: AttachmentAudioProps,
|
|
70
|
+
ref: React.ForwardedRef<HTMLAudioElement>
|
|
71
|
+
) {
|
|
72
|
+
const audioId = `attachment_audio_${uid || getRandomString()}`;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
{...containerProps}
|
|
77
|
+
className={cn('relative min-h-8 min-w-8 w-full rounded-md overflow-hidden', containerProps?.className)}
|
|
78
|
+
>
|
|
79
|
+
<audio ref={ref} src={url} id={audioId} controls {...props} />
|
|
80
|
+
<Spinner loading={pending} layout="overlay" />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const AttachmentAudio = forwardRef<HTMLAudioElement, AttachmentAudioProps>(AttachmentAudioBase);
|
|
86
|
+
|
|
87
|
+
function AttachmentVideoBase(
|
|
88
|
+
{ uid, url, maxSize = 360, pending = false, onReady = () => {}, containerProps, ...props }: AttachmentVideoProps,
|
|
89
|
+
ref: React.ForwardedRef<HTMLVideoElement>
|
|
90
|
+
) {
|
|
91
|
+
const videoId = `attachment_video_${uid || getRandomString()}`;
|
|
92
|
+
const videoMaxSize = `${maxSize}px`;
|
|
93
|
+
const playerRef = useRef<HTMLVideoElement>(null);
|
|
94
|
+
const [style, setStyle] = useState<React.CSSProperties>({
|
|
95
|
+
maxHeight: videoMaxSize,
|
|
96
|
+
maxWidth: videoMaxSize,
|
|
97
|
+
});
|
|
98
|
+
const handleCanPlay = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
|
99
|
+
const player = playerRef.current;
|
|
100
|
+
|
|
101
|
+
if (player) {
|
|
102
|
+
const { videoWidth, videoHeight } = player;
|
|
103
|
+
const ratio = videoWidth / videoHeight || 1;
|
|
104
|
+
const height = ratio < 1 ? videoMaxSize : `${Math.round(maxSize / ratio)}px`;
|
|
105
|
+
const width = ratio < 1 ? `${Math.round(maxSize * ratio)}px` : videoMaxSize;
|
|
106
|
+
|
|
107
|
+
setStyle({ height, width, maxHeight: height, maxWidth: width });
|
|
108
|
+
props.onCanPlay?.(event);
|
|
109
|
+
onReady();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
useImperativeHandle(ref, () => playerRef.current!, []);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
{...containerProps}
|
|
118
|
+
className={cn('relative min-h-20 w-full rounded-md overflow-hidden', containerProps?.className)}
|
|
119
|
+
>
|
|
120
|
+
<video
|
|
121
|
+
id={videoId}
|
|
122
|
+
ref={playerRef}
|
|
123
|
+
src={url}
|
|
124
|
+
controls
|
|
125
|
+
preload="metadata"
|
|
126
|
+
style={style}
|
|
127
|
+
{...props}
|
|
128
|
+
onCanPlay={handleCanPlay}
|
|
129
|
+
className={cn('size-full', props?.className)}
|
|
130
|
+
/>
|
|
131
|
+
<Spinner loading={pending} layout="overlay" />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const AttachmentVideo = forwardRef<HTMLVideoElement, AttachmentVideoProps>(AttachmentVideoBase);
|
|
137
|
+
|
|
138
|
+
AttachmentVideo.displayName = 'AttachmentVideo';
|
|
139
|
+
|
|
140
|
+
function AttachmentImageBase(
|
|
141
|
+
{ uid, url, pending = false, onReady = () => {}, linkProps, ...props }: AttachmentImageProps,
|
|
142
|
+
ref: React.ForwardedRef<HTMLImageElement>
|
|
143
|
+
) {
|
|
144
|
+
const imageId = `attachment_image_${uid || getRandomString()}`;
|
|
145
|
+
const handleLoad = (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
146
|
+
props.onLoad?.(event);
|
|
147
|
+
onReady();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<AttachmentLink href={url} pending={pending} {...linkProps}>
|
|
152
|
+
<img
|
|
153
|
+
ref={ref}
|
|
154
|
+
src={url}
|
|
155
|
+
id={imageId}
|
|
156
|
+
alt="attachment"
|
|
157
|
+
{...props}
|
|
158
|
+
className={cn(
|
|
159
|
+
'rounded-md object-cover min-h-8 min-w-8 max-h-[360px] group-hover:scale-102 transition-transform duration-300 ease-out',
|
|
160
|
+
props?.className
|
|
161
|
+
)}
|
|
162
|
+
onLoad={handleLoad}
|
|
163
|
+
/>
|
|
164
|
+
</AttachmentLink>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const AttachmentImage = forwardRef<HTMLImageElement, AttachmentImageProps>(AttachmentImageBase);
|
|
169
|
+
|
|
170
|
+
AttachmentImage.displayName = 'AttachmentImage';
|
|
171
|
+
|
|
172
|
+
function AttachmentFile({ url, name, pending = false, iconElement, linkProps, ...props }: AttachmentFileProps) {
|
|
173
|
+
const fileId = `attachment_file_${props.id || getRandomString()}`;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<AttachmentLink
|
|
177
|
+
href={url}
|
|
178
|
+
pending={pending}
|
|
179
|
+
{...linkProps}
|
|
180
|
+
className={cn('flex-row gap-1.5 p-2 hover:shadow', linkProps?.className)}
|
|
181
|
+
>
|
|
182
|
+
{iconElement || (
|
|
183
|
+
<File
|
|
184
|
+
id={fileId}
|
|
185
|
+
{...props}
|
|
186
|
+
className={cn(
|
|
187
|
+
'size-5 shrink-0 text-foreground/80 group-hover:text-foreground duration-300 ease-out',
|
|
188
|
+
props?.className
|
|
189
|
+
)}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
{name && (
|
|
193
|
+
<span className="font-medium line-clamp-1 break-all text-foreground/80 group-hover:text-foreground duration-300 ease-out">
|
|
194
|
+
{name}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
</AttachmentLink>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
AttachmentFile.displayName = 'AttachmentFile';
|
|
202
|
+
|
|
203
|
+
function AttachmentFailed({
|
|
204
|
+
name = 'Unknown file',
|
|
205
|
+
pending = false,
|
|
206
|
+
iconElement,
|
|
207
|
+
containerProps,
|
|
208
|
+
...props
|
|
209
|
+
}: AttachmentFailedProps) {
|
|
210
|
+
const failedId = `attachment_failed_${props.id || getRandomString()}`;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
{...containerProps}
|
|
215
|
+
className={cn(
|
|
216
|
+
'relative min-h-8 min-w-8 w-full flex flex-row items-center justify-center gap-2 px-2 bg-red-600/10 rounded-md overflow-hidden',
|
|
217
|
+
containerProps?.className
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{iconElement || (
|
|
221
|
+
<FileXCorner id={failedId} {...props} className={cn('size-6 shrink-0 text-red-600', props?.className)} />
|
|
222
|
+
)}
|
|
223
|
+
<span className="font-medium line-clamp-1 break-all text-red-600">{name}</span>
|
|
224
|
+
<Spinner loading={pending} layout="overlay" />
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
AttachmentFailed.displayName = 'AttachmentFailed';
|
|
230
|
+
|
|
231
|
+
function AttachmentBase({ mimeType, ...props }: AttachmentProps) {
|
|
232
|
+
const [type = ''] = mimeType?.split('/') || [];
|
|
233
|
+
|
|
234
|
+
if (!props.url) {
|
|
235
|
+
return <AttachmentFailed {...props} />;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
switch (type) {
|
|
239
|
+
case 'image':
|
|
240
|
+
return <AttachmentImage {...props} />;
|
|
241
|
+
case 'video':
|
|
242
|
+
return <AttachmentVideo {...props} />;
|
|
243
|
+
case 'audio':
|
|
244
|
+
return <AttachmentAudio {...props} />;
|
|
245
|
+
default:
|
|
246
|
+
return <AttachmentFile name={mimeType} {...props} />;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const Attachment = memo(AttachmentBase);
|
|
251
|
+
|
|
252
|
+
Attachment.displayName = 'Attachment';
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
Attachment,
|
|
256
|
+
AttachmentLink,
|
|
257
|
+
AttachmentImage,
|
|
258
|
+
AttachmentAudio,
|
|
259
|
+
AttachmentVideo,
|
|
260
|
+
AttachmentFile,
|
|
261
|
+
AttachmentFailed,
|
|
262
|
+
type AttachmentLinkProps,
|
|
263
|
+
type AttachmentImageProps,
|
|
264
|
+
type AttachmentAudioProps,
|
|
265
|
+
type AttachmentVideoProps,
|
|
266
|
+
type AttachmentFileProps,
|
|
267
|
+
type AttachmentFailedProps,
|
|
268
|
+
type AttachmentProps,
|
|
269
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
|
3
|
+
import { useInView } from 'react-intersection-observer';
|
|
4
|
+
import { Attachment, type AttachmentProps } from './attachment';
|
|
5
|
+
import { Avatar, type AvatarProps } from './avatar';
|
|
6
|
+
import { FormattedDate, type FormattedDateProps } from './formatted-date';
|
|
7
|
+
import { LinkifyText, type LinkifyTextProps } from './linkify-text';
|
|
8
|
+
import { LinkPreview, type LinkPreviewProps } from './link-preview';
|
|
9
|
+
import { StatusSent, type StatusSentProps } from './status-sent';
|
|
10
|
+
import { cn } from './utils';
|
|
11
|
+
|
|
12
|
+
interface ChatMessageProps extends React.ComponentProps<'div'> {
|
|
13
|
+
isLast: boolean;
|
|
14
|
+
fromMe: boolean;
|
|
15
|
+
sameSenderAbove: boolean;
|
|
16
|
+
title?: string;
|
|
17
|
+
senderName?: string;
|
|
18
|
+
senderAvatar?: string;
|
|
19
|
+
attachmentElement?: React.ReactNode;
|
|
20
|
+
linkifyTextElement?: React.ReactNode;
|
|
21
|
+
linkPreviewElement?: React.ReactNode;
|
|
22
|
+
onView?: () => void;
|
|
23
|
+
avatarProps?: AvatarProps;
|
|
24
|
+
bubbleProps?: React.ComponentProps<'div'>;
|
|
25
|
+
titleProps?: React.ComponentProps<'span'>;
|
|
26
|
+
formattedDateProps?: FormattedDateProps;
|
|
27
|
+
statusSentProps?: StatusSentProps;
|
|
28
|
+
attachmentProps?: AttachmentProps;
|
|
29
|
+
linkifyTextProps?: LinkifyTextProps;
|
|
30
|
+
linkPreviewProps?: LinkPreviewProps;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ChatMessageBase(
|
|
34
|
+
{
|
|
35
|
+
isLast,
|
|
36
|
+
fromMe,
|
|
37
|
+
sameSenderAbove,
|
|
38
|
+
title,
|
|
39
|
+
senderName,
|
|
40
|
+
senderAvatar,
|
|
41
|
+
attachmentElement,
|
|
42
|
+
linkifyTextElement,
|
|
43
|
+
linkPreviewElement,
|
|
44
|
+
onView = () => {},
|
|
45
|
+
avatarProps,
|
|
46
|
+
bubbleProps,
|
|
47
|
+
titleProps,
|
|
48
|
+
formattedDateProps,
|
|
49
|
+
statusSentProps,
|
|
50
|
+
attachmentProps,
|
|
51
|
+
linkifyTextProps,
|
|
52
|
+
linkPreviewProps,
|
|
53
|
+
children,
|
|
54
|
+
...props
|
|
55
|
+
}: ChatMessageProps,
|
|
56
|
+
ref: React.ForwardedRef<HTMLDivElement>
|
|
57
|
+
) {
|
|
58
|
+
const [setRef, inView] = useInView();
|
|
59
|
+
const messageRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const setRefs = useCallback(
|
|
61
|
+
(node: HTMLDivElement) => {
|
|
62
|
+
messageRef.current = node;
|
|
63
|
+
setRef(node);
|
|
64
|
+
},
|
|
65
|
+
[setRef]
|
|
66
|
+
);
|
|
67
|
+
const hasAvatar = Boolean((avatarProps || senderAvatar) && !sameSenderAbove);
|
|
68
|
+
const hasAvatarMargin = Boolean((avatarProps || senderAvatar) && sameSenderAbove);
|
|
69
|
+
const hasThinMarginTop = hasAvatarMargin || Boolean(fromMe && sameSenderAbove);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (inView) {
|
|
73
|
+
onView();
|
|
74
|
+
}
|
|
75
|
+
}, [inView, onView]);
|
|
76
|
+
|
|
77
|
+
useImperativeHandle(ref, () => messageRef.current!, []);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={setRefs}
|
|
82
|
+
{...props}
|
|
83
|
+
className={cn(
|
|
84
|
+
`flex relative text-left whitespace-pre-wrap`,
|
|
85
|
+
fromMe ? 'self-end flex-row-reverse ml-12' : `self-start mr-12`,
|
|
86
|
+
isLast && 'mb-2',
|
|
87
|
+
hasThinMarginTop ? 'mt-1' : 'mt-2',
|
|
88
|
+
inView && 'view',
|
|
89
|
+
props?.className
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{hasAvatar && (
|
|
93
|
+
<Avatar
|
|
94
|
+
name={senderName}
|
|
95
|
+
src={senderAvatar}
|
|
96
|
+
imageProps={{ className: 'bg-ring/30' }}
|
|
97
|
+
fallbackProps={{ className: 'bg-ring/30' }}
|
|
98
|
+
{...avatarProps}
|
|
99
|
+
className={cn('mt-1 mr-1', avatarProps?.className)}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<div
|
|
104
|
+
className={cn(
|
|
105
|
+
'relative flex flex-col min-w-42 max-w-120 rounded-xl px-2 pt-2 pb-6',
|
|
106
|
+
fromMe ? 'bg-blue-200' : 'bg-gray-200',
|
|
107
|
+
hasAvatarMargin && 'ml-9',
|
|
108
|
+
bubbleProps?.className
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{(title || senderName) && (
|
|
112
|
+
<span
|
|
113
|
+
{...titleProps}
|
|
114
|
+
className={cn(
|
|
115
|
+
'font-semibold',
|
|
116
|
+
title && 'mb-1 py-1.5 text-xs text-muted-foreground italic border-b',
|
|
117
|
+
titleProps?.className
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{title || senderName}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
{children}
|
|
124
|
+
{attachmentElement || (attachmentProps ? <Attachment {...attachmentProps} /> : null)}
|
|
125
|
+
{linkifyTextElement || (linkifyTextProps ? <LinkifyText {...linkifyTextProps} /> : null)}
|
|
126
|
+
{linkPreviewElement || (linkPreviewProps ? <LinkPreview {...linkPreviewProps} /> : null)}
|
|
127
|
+
<div className="absolute bottom-1 right-2 flex items-center gap-1 italic">
|
|
128
|
+
<FormattedDate distanceToNow {...formattedDateProps} />
|
|
129
|
+
<StatusSent {...statusSentProps} />
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ChatMessage = memo(forwardRef<HTMLDivElement, ChatMessageProps>(ChatMessageBase));
|
|
137
|
+
|
|
138
|
+
export { ChatMessage, type ChatMessageProps };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { forwardRef, memo, useCallback, useState } from 'react';
|
|
3
|
+
import { Globe, type LucideProps } from 'lucide-react';
|
|
4
|
+
import { cn } from './utils';
|
|
5
|
+
|
|
6
|
+
const decodeHtmlEntities = (text: string) => {
|
|
7
|
+
if (text.length === 0) {
|
|
8
|
+
return text;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const element = document.createElement('div');
|
|
12
|
+
|
|
13
|
+
element.innerHTML = text;
|
|
14
|
+
|
|
15
|
+
return element.textContent.trim();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface LinkPreviewProps extends React.ComponentProps<'a'> {
|
|
19
|
+
thin?: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
icon?: React.ComponentProps<'img'>['src'];
|
|
23
|
+
image?: React.ComponentProps<'img'>['src'];
|
|
24
|
+
onReady?: () => void;
|
|
25
|
+
iconFallbackElement?: React.ReactElement;
|
|
26
|
+
titleContainerProps?: React.ComponentProps<'div'>;
|
|
27
|
+
iconProps?: React.ComponentProps<'img'>;
|
|
28
|
+
iconFallbackProps?: LucideProps;
|
|
29
|
+
titleProps?: React.ComponentProps<'span'>;
|
|
30
|
+
descriptionProps?: React.ComponentProps<'div'>;
|
|
31
|
+
imageContainerProps?: React.ComponentProps<'div'>;
|
|
32
|
+
imageProps?: React.ComponentProps<'img'>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function LinkPreviewBase(
|
|
36
|
+
{
|
|
37
|
+
thin = false,
|
|
38
|
+
title = '',
|
|
39
|
+
description = '',
|
|
40
|
+
icon,
|
|
41
|
+
iconFallbackElement,
|
|
42
|
+
image,
|
|
43
|
+
onReady = () => {},
|
|
44
|
+
titleContainerProps,
|
|
45
|
+
iconProps,
|
|
46
|
+
iconFallbackProps,
|
|
47
|
+
titleProps,
|
|
48
|
+
descriptionProps,
|
|
49
|
+
imageContainerProps,
|
|
50
|
+
imageProps,
|
|
51
|
+
...props
|
|
52
|
+
}: LinkPreviewProps,
|
|
53
|
+
ref: React.ForwardedRef<HTMLAnchorElement>
|
|
54
|
+
) {
|
|
55
|
+
const [iconSrc, setIconSrc] = useState<React.ComponentProps<'img'>['src']>(icon);
|
|
56
|
+
const [imageSrc, setImageSrc] = useState<React.ComponentProps<'img'>['src']>(image);
|
|
57
|
+
const handleOnLoad = useCallback(
|
|
58
|
+
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
59
|
+
imageProps?.onLoad?.(event);
|
|
60
|
+
onReady();
|
|
61
|
+
},
|
|
62
|
+
[onReady, imageProps]
|
|
63
|
+
);
|
|
64
|
+
const handleIconOnError = useCallback(
|
|
65
|
+
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
66
|
+
iconProps?.onError?.(event);
|
|
67
|
+
setIconSrc(undefined);
|
|
68
|
+
},
|
|
69
|
+
[iconProps]
|
|
70
|
+
);
|
|
71
|
+
const handleImageOnError = useCallback(
|
|
72
|
+
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
73
|
+
imageProps?.onError?.(event);
|
|
74
|
+
setImageSrc(undefined);
|
|
75
|
+
},
|
|
76
|
+
[imageProps]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<a
|
|
81
|
+
ref={ref}
|
|
82
|
+
target="_blank"
|
|
83
|
+
rel="noopener noreferrer"
|
|
84
|
+
{...props}
|
|
85
|
+
className={cn(
|
|
86
|
+
'transition-color duration-300 ease-out',
|
|
87
|
+
'grid items-start gap-2 p-2 mt-1 overflow-hidden rounded border-l-4 border-l-ring bg-white hover:bg-ring/20',
|
|
88
|
+
thin ? 'grid-cols-3' : 'grid-cols-1',
|
|
89
|
+
props?.className
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
{...titleContainerProps}
|
|
94
|
+
className={cn('flex items-start gap-2', thin ? 'col-span-3' : 'col-span-1', titleContainerProps?.className)}
|
|
95
|
+
>
|
|
96
|
+
{iconSrc ? (
|
|
97
|
+
<img
|
|
98
|
+
alt="icon"
|
|
99
|
+
src={iconSrc}
|
|
100
|
+
{...iconProps}
|
|
101
|
+
onError={handleIconOnError}
|
|
102
|
+
className={cn('size-5', iconProps?.className)}
|
|
103
|
+
/>
|
|
104
|
+
) : (
|
|
105
|
+
iconFallbackElement || <Globe {...iconFallbackProps} className={cn('size-5', iconFallbackProps?.className)} />
|
|
106
|
+
)}
|
|
107
|
+
<span
|
|
108
|
+
{...titleProps}
|
|
109
|
+
className={cn('text-sm font-bold', thin ? 'line-clamp-1' : 'line-clamp-2', titleProps?.className)}
|
|
110
|
+
>
|
|
111
|
+
{decodeHtmlEntities(title)}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
{description && (
|
|
115
|
+
<span
|
|
116
|
+
{...descriptionProps}
|
|
117
|
+
className={cn(
|
|
118
|
+
'text-sm line-clamp-5',
|
|
119
|
+
thin ? (imageSrc ? 'col-span-2' : 'col-span-3') : 'col-span-1',
|
|
120
|
+
descriptionProps?.className
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{decodeHtmlEntities(description)}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
{imageSrc && (
|
|
127
|
+
<div
|
|
128
|
+
{...imageContainerProps}
|
|
129
|
+
className={cn('flex items-center justify-center', imageContainerProps?.className)}
|
|
130
|
+
>
|
|
131
|
+
<img
|
|
132
|
+
alt="banner"
|
|
133
|
+
src={imageSrc}
|
|
134
|
+
{...imageProps}
|
|
135
|
+
onLoad={handleOnLoad}
|
|
136
|
+
onError={handleImageOnError}
|
|
137
|
+
className={cn('rounded max-w-full object-contain', thin ? 'max-h-25' : 'max-h-60', imageProps?.className)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</a>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const LinkPreview = memo(forwardRef<HTMLAnchorElement, LinkPreviewProps>(LinkPreviewBase));
|
|
146
|
+
|
|
147
|
+
LinkPreview.displayName = 'LinkPreview';
|
|
148
|
+
|
|
149
|
+
export { LinkPreview, type LinkPreviewProps };
|
|
@@ -137,7 +137,10 @@ function DialogItemBase(
|
|
|
137
137
|
<div {...footerProps} className={cn('flex items-start justify-between gap-1', footerProps?.className)}>
|
|
138
138
|
<span
|
|
139
139
|
{...lastMessageProps}
|
|
140
|
-
className={cn(
|
|
140
|
+
className={cn(
|
|
141
|
+
'text-sm text-left text-muted-foreground break-all line-clamp-2',
|
|
142
|
+
lastMessageProps?.className
|
|
143
|
+
)}
|
|
141
144
|
>
|
|
142
145
|
{typingStatusText ||
|
|
143
146
|
(draft ? (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
import type { Opts } from 'linkifyjs';
|
|
3
|
-
import { forwardRef, memo, useMemo } from 'react';
|
|
3
|
+
import { forwardRef, memo, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
import Linkify from 'linkify-react';
|
|
5
5
|
import { cn } from './utils';
|
|
6
6
|
|
|
@@ -8,16 +8,32 @@ const DEFAULT_LINKIFY_OPTIONS: Opts = {
|
|
|
8
8
|
target: '_blank',
|
|
9
9
|
rel: 'noopener noreferrer',
|
|
10
10
|
};
|
|
11
|
+
const DEFAULT_SKELETON_LINES_CLASS_NAMES = ['w-full', 'w-3/4', 'w-1/2'];
|
|
11
12
|
|
|
12
13
|
interface LinkifyTextProps extends React.ComponentProps<'p'> {
|
|
13
|
-
text
|
|
14
|
+
text?: string;
|
|
15
|
+
pending?: boolean;
|
|
16
|
+
onReady?: () => void;
|
|
14
17
|
linkifyProps?: Opts;
|
|
18
|
+
skeletonContainerProps?: React.ComponentProps<'div'>;
|
|
19
|
+
skeletonLineProps?: React.ComponentProps<'span'>;
|
|
20
|
+
skeletonLinesClassNames?: string[];
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
function LinkifyTextBase(
|
|
18
|
-
{
|
|
24
|
+
{
|
|
25
|
+
text,
|
|
26
|
+
pending = false,
|
|
27
|
+
onReady = () => {},
|
|
28
|
+
linkifyProps,
|
|
29
|
+
skeletonContainerProps,
|
|
30
|
+
skeletonLineProps,
|
|
31
|
+
skeletonLinesClassNames = DEFAULT_SKELETON_LINES_CLASS_NAMES,
|
|
32
|
+
...props
|
|
33
|
+
}: LinkifyTextProps,
|
|
19
34
|
ref: React.ForwardedRef<HTMLParagraphElement>
|
|
20
35
|
) {
|
|
36
|
+
const pendingRef = useRef(pending);
|
|
21
37
|
const options = useMemo(
|
|
22
38
|
() => ({
|
|
23
39
|
...DEFAULT_LINKIFY_OPTIONS,
|
|
@@ -27,6 +43,31 @@ function LinkifyTextBase(
|
|
|
27
43
|
[linkifyProps]
|
|
28
44
|
);
|
|
29
45
|
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (pendingRef.current && !pending) {
|
|
48
|
+
onReady();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pendingRef.current = pending;
|
|
52
|
+
}, [pending, onReady]);
|
|
53
|
+
|
|
54
|
+
if (pending) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
{...skeletonContainerProps}
|
|
58
|
+
className={cn('flex items-start flex-col size-full', skeletonContainerProps?.className)}
|
|
59
|
+
>
|
|
60
|
+
{skeletonLinesClassNames.map((width, index) => (
|
|
61
|
+
<span
|
|
62
|
+
{...skeletonLineProps}
|
|
63
|
+
key={`${width}_${index}`}
|
|
64
|
+
className={cn('bg-muted-foreground animate-pulse rounded-md my-1 h-4', skeletonLineProps?.className, width)}
|
|
65
|
+
></span>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
30
71
|
return (
|
|
31
72
|
<p ref={ref} {...props} className={cn('wrap-break-word text-base', props?.className)}>
|
|
32
73
|
<Linkify options={options}>{text}</Linkify>
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { AlertDialog, type AlertDialogProps } from './components/alert-dialog';
|
|
2
|
+
|
|
1
3
|
export {
|
|
2
4
|
Attachment,
|
|
3
5
|
AttachmentLink,
|
|
@@ -21,6 +23,10 @@ export { Badge, type BadgeProps } from './components/badge';
|
|
|
21
23
|
|
|
22
24
|
export { Button, type ButtonProps } from './components/button';
|
|
23
25
|
|
|
26
|
+
export { CallMessage, type CallMessageProps } from './components/call-message';
|
|
27
|
+
|
|
28
|
+
export { ChatMessage, type ChatMessageProps } from './components/chat-message';
|
|
29
|
+
|
|
24
30
|
export { DialogItem, type DialogItemProps } from './components/dialog-item';
|
|
25
31
|
|
|
26
32
|
export { DismissLayer, type DismissLayerProps } from './components/dismiss-layer';
|