@connectycube/react-ui-kit 0.0.18 → 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.
Files changed (53) hide show
  1. package/configs/dependencies.json +12 -0
  2. package/configs/imports.json +5 -1
  3. package/dist/index.cjs +1 -27
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +1 -27
  6. package/dist/index.js.map +1 -1
  7. package/dist/types/components/attachment.d.ts +45 -0
  8. package/dist/types/components/attachment.d.ts.map +1 -0
  9. package/dist/types/components/avatar.d.ts +2 -1
  10. package/dist/types/components/avatar.d.ts.map +1 -1
  11. package/dist/types/components/call-message.d.ts +17 -0
  12. package/dist/types/components/call-message.d.ts.map +1 -0
  13. package/dist/types/components/chat-message.d.ts +30 -0
  14. package/dist/types/components/chat-message.d.ts.map +1 -0
  15. package/dist/types/components/dialog-item.d.ts +4 -4
  16. package/dist/types/components/dialog-item.d.ts.map +1 -1
  17. package/dist/types/components/linkify-text.d.ts +6 -1
  18. package/dist/types/components/linkify-text.d.ts.map +1 -1
  19. package/dist/types/components/stream-view.d.ts.map +1 -1
  20. package/dist/types/components/switch.d.ts +6 -0
  21. package/dist/types/components/switch.d.ts.map +1 -0
  22. package/dist/types/components/utils.d.ts +1 -0
  23. package/dist/types/components/utils.d.ts.map +1 -1
  24. package/dist/types/index.d.ts +24 -4
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/gen/components/attachment.jsx +216 -0
  27. package/gen/components/avatar.jsx +14 -2
  28. package/gen/components/call-message.jsx +62 -0
  29. package/gen/components/chat-message.jsx +120 -0
  30. package/gen/components/dialog-item.jsx +11 -8
  31. package/gen/components/dismiss-layer.jsx +1 -1
  32. package/gen/components/file-picker.jsx +2 -2
  33. package/gen/components/linkify-text.jsx +41 -2
  34. package/gen/components/stream-view.jsx +1 -5
  35. package/gen/components/switch.jsx +23 -0
  36. package/gen/components/utils.js +4 -0
  37. package/gen/index.js +50 -0
  38. package/package.json +12 -10
  39. package/src/components/attachment.tsx +269 -0
  40. package/src/components/avatar.tsx +4 -2
  41. package/src/components/call-message.tsx +75 -0
  42. package/src/components/chat-message.tsx +138 -0
  43. package/src/components/connectycube-ui/attachment.tsx +269 -0
  44. package/src/components/connectycube-ui/chat-message.tsx +138 -0
  45. package/src/components/connectycube-ui/link-preview.tsx +149 -0
  46. package/src/components/dialog-item.tsx +13 -10
  47. package/src/components/dismiss-layer.tsx +1 -1
  48. package/src/components/file-picker.tsx +2 -2
  49. package/src/components/linkify-text.tsx +44 -3
  50. package/src/components/stream-view.tsx +1 -5
  51. package/src/components/switch.tsx +25 -0
  52. package/src/components/utils.ts +4 -0
  53. package/src/index.ts +78 -4
@@ -7,7 +7,8 @@ import { cn } from './utils';
7
7
  interface AvatarProps extends AvatarPrimitive.AvatarProps {
8
8
  src?: string | undefined;
9
9
  name?: string | undefined;
10
- online?: boolean;
10
+ fallbackIconElement?: React.ReactNode | undefined;
11
+ online?: boolean | undefined;
11
12
  presence?: PresenceStatus;
12
13
  onlineProps?: React.ComponentProps<'div'>;
13
14
  presenceProps?: PresenceBadgeProps;
@@ -26,6 +27,7 @@ function AvatarBase(
26
27
  {
27
28
  src,
28
29
  name = 'NA',
30
+ fallbackIconElement,
29
31
  online,
30
32
  presence,
31
33
  className,
@@ -50,7 +52,7 @@ function AvatarBase(
50
52
  {...fallbackProps}
51
53
  className={cn('bg-muted size-full rounded-full flex items-center justify-center', fallbackProps?.className)}
52
54
  >
53
- {initials}
55
+ {fallbackIconElement || initials}
54
56
  </AvatarPrimitive.Fallback>
55
57
  {online && (
56
58
  <div
@@ -0,0 +1,75 @@
1
+ import type React from 'react';
2
+ import { forwardRef, memo } from 'react';
3
+ import { Phone, PhoneIncoming, PhoneMissed, PhoneOutgoing, type LucideProps } from 'lucide-react';
4
+ import { FormattedDate, type FormattedDateProps } from './formatted-date';
5
+ import { cn } from './utils';
6
+
7
+ interface CallMessageProps extends React.ComponentProps<'div'> {
8
+ signal?: 'reject' | 'notAnswer' | 'hungUp' | 'cancel' | undefined;
9
+ info?: string | undefined;
10
+ duration?: number | undefined;
11
+ fromMe: boolean;
12
+ isLast: boolean;
13
+ iconElement?: React.ReactNode;
14
+ iconProps?: LucideProps;
15
+ infoProps?: React.ComponentProps<'span'>;
16
+ formattedDateProps?: FormattedDateProps;
17
+ }
18
+
19
+ function formatDuration(seconds: number): string {
20
+ const h = Math.floor(seconds / 3600);
21
+ const m = Math.floor((seconds % 3600) / 60);
22
+ const s = seconds % 60;
23
+ const pad = (num: number) => String(num).padStart(2, '0');
24
+
25
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
26
+ }
27
+
28
+ function CallMessageBase(
29
+ {
30
+ signal,
31
+ info,
32
+ duration = 0,
33
+ fromMe = false,
34
+ isLast = false,
35
+ iconElement,
36
+ iconProps,
37
+ infoProps,
38
+ formattedDateProps,
39
+ ...props
40
+ }: CallMessageProps,
41
+ ref: React.ForwardedRef<HTMLDivElement>
42
+ ) {
43
+ const CallIcon =
44
+ signal === 'hungUp' ? Phone : fromMe ? (signal === 'reject' ? PhoneIncoming : PhoneOutgoing) : PhoneMissed;
45
+
46
+ return (
47
+ <div
48
+ ref={ref}
49
+ {...props}
50
+ className={cn(
51
+ 'flex items-center justify-center gap-2 rounded-full w-fit bg-ring/20 mx-auto px-3 py-1.5',
52
+ isLast ? 'my-2' : 'mt-2',
53
+ props?.className
54
+ )}
55
+ >
56
+ {iconElement || (
57
+ <CallIcon
58
+ {...iconProps}
59
+ className={cn('size-4', signal === 'hungUp' ? 'text-green-500' : 'text-red-500', iconProps?.className)}
60
+ />
61
+ )}
62
+ <span
63
+ {...infoProps}
64
+ className={cn('text-sm mb-px', infoProps?.className)}
65
+ >{`${info}${duration ? ` - ${formatDuration(duration)}` : ''}`}</span>
66
+ <FormattedDate distanceToNow {...formattedDateProps} />
67
+ </div>
68
+ );
69
+ }
70
+
71
+ const CallMessage = memo(forwardRef<HTMLDivElement, CallMessageProps>(CallMessageBase));
72
+
73
+ CallMessage.displayName = 'CallMessage';
74
+
75
+ export { CallMessage, type CallMessageProps };
@@ -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,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 };