@connectycube/react-ui-kit 0.0.19 → 0.0.22

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 (78) hide show
  1. package/configs/dependencies.json +21 -0
  2. package/configs/imports.json +7 -0
  3. package/dist/index.cjs +1 -36
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +1 -35
  6. package/dist/index.js.map +1 -1
  7. package/dist/types/components/attachment.d.ts +7 -8
  8. package/dist/types/components/attachment.d.ts.map +1 -1
  9. package/dist/types/components/avatar.d.ts +1 -0
  10. package/dist/types/components/avatar.d.ts.map +1 -1
  11. package/dist/types/components/badge.d.ts +1 -1
  12. package/dist/types/components/button.d.ts +2 -2
  13. package/dist/types/components/chat-bubble.d.ts +32 -0
  14. package/dist/types/components/chat-bubble.d.ts.map +1 -0
  15. package/dist/types/components/chat-input.d.ts +27 -0
  16. package/dist/types/components/chat-input.d.ts.map +1 -0
  17. package/dist/types/components/chat-list.d.ts +30 -0
  18. package/dist/types/components/chat-list.d.ts.map +1 -0
  19. package/dist/types/components/checkbox.d.ts +11 -0
  20. package/dist/types/components/checkbox.d.ts.map +1 -0
  21. package/dist/types/components/dialog-item.d.ts.map +1 -1
  22. package/dist/types/components/dialogs-list.d.ts +14 -0
  23. package/dist/types/components/dialogs-list.d.ts.map +1 -0
  24. package/dist/types/components/file-picker.d.ts +1 -1
  25. package/dist/types/components/file-picker.d.ts.map +1 -1
  26. package/dist/types/components/linkify-text.d.ts +6 -1
  27. package/dist/types/components/linkify-text.d.ts.map +1 -1
  28. package/dist/types/components/placeholder-text.d.ts.map +1 -1
  29. package/dist/types/components/quick-actions.d.ts +14 -0
  30. package/dist/types/components/quick-actions.d.ts.map +1 -0
  31. package/dist/types/components/status-call.d.ts +8 -0
  32. package/dist/types/components/status-call.d.ts.map +1 -0
  33. package/dist/types/components/switch.d.ts.map +1 -1
  34. package/dist/types/index.d.ts +8 -0
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/gen/components/attachment.jsx +27 -25
  37. package/gen/components/avatar.jsx +14 -2
  38. package/gen/components/button.jsx +1 -1
  39. package/gen/components/chat-bubble.jsx +141 -0
  40. package/gen/components/chat-input.jsx +152 -0
  41. package/gen/components/chat-list.jsx +151 -0
  42. package/gen/components/checkbox.jsx +30 -0
  43. package/gen/components/dialog-item.jsx +5 -2
  44. package/gen/components/dialogs-list.jsx +73 -0
  45. package/gen/components/dismiss-layer.jsx +1 -1
  46. package/gen/components/file-picker.jsx +2 -2
  47. package/gen/components/linkify-text.jsx +41 -2
  48. package/gen/components/placeholder-text.jsx +5 -1
  49. package/gen/components/quick-actions.jsx +62 -0
  50. package/gen/components/search.jsx +1 -1
  51. package/gen/components/status-call.jsx +18 -0
  52. package/gen/components/stream-view.jsx +8 -8
  53. package/gen/components/switch.jsx +0 -2
  54. package/gen/index.js +16 -0
  55. package/package.json +17 -13
  56. package/src/components/attachment.tsx +38 -37
  57. package/src/components/avatar.tsx +3 -1
  58. package/src/components/button.tsx +1 -1
  59. package/src/components/chat-bubble.tsx +176 -0
  60. package/src/components/chat-input.tsx +172 -0
  61. package/src/components/chat-list.tsx +164 -0
  62. package/src/components/checkbox.tsx +40 -0
  63. package/src/components/connectycube-ui/attachment.tsx +269 -0
  64. package/src/components/connectycube-ui/chat-input.tsx +174 -0
  65. package/src/components/connectycube-ui/chat-message.tsx +138 -0
  66. package/src/components/connectycube-ui/link-preview.tsx +149 -0
  67. package/src/components/dialog-item.tsx +5 -2
  68. package/src/components/dialogs-list.tsx +84 -0
  69. package/src/components/dismiss-layer.tsx +1 -1
  70. package/src/components/file-picker.tsx +3 -3
  71. package/src/components/linkify-text.tsx +44 -3
  72. package/src/components/placeholder-text.tsx +5 -1
  73. package/src/components/quick-actions.tsx +74 -0
  74. package/src/components/search.tsx +1 -1
  75. package/src/components/status-call.tsx +23 -0
  76. package/src/components/stream-view.tsx +8 -8
  77. package/src/components/switch.tsx +0 -2
  78. package/src/index.ts +21 -0
@@ -0,0 +1,174 @@
1
+ import type React from 'react';
2
+ import { ChangeEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
+ import TextareaAutosize, { type TextareaAutosizeProps, type TextareaHeightChangeMeta } from 'react-textarea-autosize';
4
+ import { SendHorizontal, type LucideProps } from 'lucide-react';
5
+ import { Label, type LabelProps } from './label';
6
+ import { cn } from './utils';
7
+
8
+ interface ChatInputSendProps extends LabelProps {
9
+ onSend: () => void;
10
+ iconElement?: React.ReactNode;
11
+ iconProps?: LucideProps;
12
+ }
13
+
14
+ interface ChatInputProps extends TextareaAutosizeProps, React.ComponentProps<'textarea'> {
15
+ key?: string;
16
+ children?: React.ReactNode;
17
+ pending?: boolean;
18
+ draft?: string;
19
+ onSend?: (value?: string) => void;
20
+ onDraft?: (value?: string) => void;
21
+ onTyping?: (typing?: boolean) => void;
22
+ onHeightGrow?: (data: { height: number; shift: number }) => void;
23
+ hideChatInputSend?: boolean;
24
+ chatInputSendProps?: ChatInputSendProps;
25
+ containerProps?: React.ComponentProps<'div'>;
26
+ }
27
+
28
+ function ChatInputSendBase(
29
+ { onSend = () => {}, iconElement, iconProps, ...props }: ChatInputSendProps,
30
+ ref: React.ForwardedRef<HTMLLabelElement>
31
+ ) {
32
+ return (
33
+ <Label
34
+ ref={ref}
35
+ {...props}
36
+ onClick={onSend}
37
+ className={cn('group rounded-full py-2 pl-2.5 pr-1.5 cursor-pointer bg-ring/90 hover:bg-ring', props?.className)}
38
+ >
39
+ {iconElement || (
40
+ <SendHorizontal
41
+ {...iconProps}
42
+ className={cn(
43
+ 'size-7 text-background group-hover:scale-110 transition-all duration-200 ease-out',
44
+ iconProps?.className
45
+ )}
46
+ />
47
+ )}
48
+ </Label>
49
+ );
50
+ }
51
+
52
+ const ChatInputSend = forwardRef<HTMLLabelElement, ChatInputSendProps>(ChatInputSendBase);
53
+
54
+ ChatInputSend.displayName = 'ChatInputSend';
55
+
56
+ function ChatInputBase(
57
+ {
58
+ key,
59
+ pending = false,
60
+ draft,
61
+ onSend = () => {},
62
+ onDraft = () => {},
63
+ onTyping = () => {},
64
+ onHeightGrow = () => {},
65
+ hideChatInputSend = false,
66
+ chatInputSendProps,
67
+ containerProps,
68
+ children,
69
+ ...props
70
+ }: ChatInputProps,
71
+ ref: React.ForwardedRef<HTMLTextAreaElement>
72
+ ) {
73
+ const [value, setValue] = useState<string>();
74
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
75
+ const textareaHeightRef = useRef<number>(0);
76
+ const typingRef = useRef<boolean>(false);
77
+ const typingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
78
+ const handleStopTyping = () => {
79
+ typingRef.current = false;
80
+ onTyping(false);
81
+
82
+ if (typingTimeoutRef.current) {
83
+ clearTimeout(typingTimeoutRef.current);
84
+ typingTimeoutRef.current = undefined;
85
+ }
86
+ };
87
+ const handleOnChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
88
+ const { data } = event.nativeEvent as InputEvent;
89
+
90
+ setValue(event.target.value);
91
+
92
+ if (!typingRef.current && typeof data === 'string' && data.length > 0) {
93
+ typingRef.current = true;
94
+ onTyping(true);
95
+ }
96
+
97
+ clearTimeout(typingTimeoutRef.current);
98
+ typingTimeoutRef.current = setTimeout(handleStopTyping, 5000);
99
+ };
100
+ const handleOnSend = async () => {
101
+ handleStopTyping();
102
+
103
+ if (typeof value === 'string' && value.length > 0) {
104
+ onSend(value.trim());
105
+ setValue('');
106
+ textareaRef.current?.focus();
107
+ }
108
+ };
109
+ const handleOnHeightChange = (height: number, meta: TextareaHeightChangeMeta) => {
110
+ if (!height && !meta) return;
111
+
112
+ if (height !== textareaHeightRef.current) {
113
+ const shift = height > textareaHeightRef.current ? meta.rowHeight : -meta.rowHeight;
114
+
115
+ onHeightGrow({ height, shift });
116
+ }
117
+
118
+ textareaHeightRef.current = height;
119
+ };
120
+ const handleOnKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
121
+ if (event.key === 'Enter' && event.shiftKey === false) {
122
+ event.preventDefault();
123
+ props.onKeyDown?.(event);
124
+ handleOnSend();
125
+ }
126
+ };
127
+
128
+ useEffect(() => {
129
+ const textarea = textareaRef.current;
130
+
131
+ handleStopTyping();
132
+ queueMicrotask(() => setValue(draft));
133
+ textarea?.focus();
134
+
135
+ return () => {
136
+ onDraft(textarea?.value);
137
+ };
138
+ }, [key]);
139
+
140
+ useImperativeHandle(ref, () => textareaRef.current!, [textareaRef]);
141
+
142
+ return (
143
+ <div {...containerProps} className={cn('flex items-end gap-2', containerProps?.className)}>
144
+ <TextareaAutosize
145
+ key={key}
146
+ ref={textareaRef}
147
+ autoFocus
148
+ minRows={1}
149
+ maxRows={8}
150
+ {...props}
151
+ value={value}
152
+ onChange={handleOnChange}
153
+ onKeyDown={handleOnKeyDown}
154
+ onHeightChange={handleOnHeightChange}
155
+ className={cn(
156
+ 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
157
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring',
158
+ 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
159
+ 'resize-none py-2.5',
160
+ pending && 'pointer-events-none bg-muted-foreground border-ring ring-ring/50 animate-pulse',
161
+ props?.className
162
+ )}
163
+ />
164
+ {!hideChatInputSend && <ChatInputSend onSend={handleOnSend} {...chatInputSendProps} />}
165
+ {children}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(ChatInputBase);
171
+
172
+ ChatInput.displayName = 'ChatInput';
173
+
174
+ export { ChatInput };
@@ -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 };
@@ -99,7 +99,7 @@ function DialogItemBase(
99
99
  onClick={onSelect}
100
100
  className={cn(
101
101
  'flex items-start gap-2 px-2 flex-1 cursor-pointer',
102
- 'transition-colors duration-200 ease-linear',
102
+ 'transition-colors duration-200 ease-out',
103
103
  `${selected ? 'border-l-[0.25em] pl-1 border-l-ring bg-ring/20' : 'hover:bg-ring/5'}`,
104
104
  props?.className
105
105
  )}
@@ -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('text-sm text-left text-muted-foreground line-clamp-2', lastMessageProps?.className)}
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 ? (
@@ -0,0 +1,84 @@
1
+ import type React from 'react';
2
+ import { forwardRef, useImperativeHandle, useRef } from 'react';
3
+ import { VList, type VListHandle, type VListProps } from 'virtua';
4
+ import { PlaceholderText } from './placeholder-text';
5
+
6
+ interface DialogsListProps extends VListProps {
7
+ pending?: boolean;
8
+ pendingListLength?: number;
9
+ offsetToReach?: number;
10
+ placeholderVisible?: boolean;
11
+ placeholderTitles?: string[];
12
+ onScrollStartReached?: () => void;
13
+ onScrollEndReached?: () => void;
14
+ }
15
+
16
+ const PendingItem = () => (
17
+ <div className="flex flex-row gap-2 mx-2 border-b">
18
+ <div className="size-13 my-2 rounded-full bg-muted animate-pulse" />
19
+ <div className="flex-1 text-muted">
20
+ <div className="flex flex-row items-center justify-between h-6">
21
+ <div className="w-2/3 h-4 rounded-full bg-muted animate-pulse" />
22
+ <div className="w-1/6 h-3.5 rounded-full bg-muted animate-pulse" />
23
+ </div>
24
+ <div className="flex flex-col items-start justify-around h-10">
25
+ <div className="w-7/8 h-3.5 rounded-full bg-muted animate-pulse" />
26
+ <div className="w-5/6 h-3.5 rounded-full bg-muted animate-pulse" />
27
+ </div>
28
+ </div>
29
+ </div>
30
+ );
31
+
32
+ function DialogsListBase(
33
+ {
34
+ children,
35
+ pending,
36
+ pendingListLength = 5,
37
+ offsetToReach = 50,
38
+ placeholderVisible,
39
+ placeholderTitles = ['No dialogs yet.', 'Start a conversation!'],
40
+ onScrollStartReached,
41
+ onScrollEndReached,
42
+ ...props
43
+ }: DialogsListProps,
44
+ ref: React.ForwardedRef<VListHandle>
45
+ ) {
46
+ const vListRef = useRef<VListHandle>(null);
47
+ const skeletonList = Array.from({ length: pendingListLength }).map((_, i) => (
48
+ <PendingItem key={`pending_dialog_item_${i}`} />
49
+ ));
50
+ const handleOnScroll = async (offset: number) => {
51
+ props.onScroll?.(offset);
52
+
53
+ if (!vListRef.current) return;
54
+
55
+ if (typeof onScrollStartReached === 'function' && offset < offsetToReach) {
56
+ onScrollStartReached();
57
+ }
58
+
59
+ if (
60
+ typeof onScrollEndReached === 'function' &&
61
+ vListRef.current.viewportSize + offset + offsetToReach > vListRef.current.scrollSize
62
+ ) {
63
+ onScrollEndReached();
64
+ }
65
+ };
66
+
67
+ useImperativeHandle(ref, () => vListRef.current || ({} as VListHandle), []);
68
+
69
+ if (placeholderVisible) {
70
+ return <PlaceholderText titles={placeholderTitles} className="text-base text-muted" />;
71
+ }
72
+
73
+ return (
74
+ <VList ref={vListRef} onScroll={handleOnScroll} className="size-full overflow-y-scroll" {...props}>
75
+ {pending ? skeletonList : children}
76
+ </VList>
77
+ );
78
+ }
79
+
80
+ const DialogsList = forwardRef<VListHandle, DialogsListProps>(DialogsListBase);
81
+
82
+ DialogsList.displayName = 'DialogsList';
83
+
84
+ export { DialogsList, type DialogsListProps };
@@ -16,7 +16,7 @@ function DismissLayerBase(
16
16
  ) {
17
17
  const innerRef = useRef<HTMLDivElement>(null);
18
18
 
19
- useImperativeHandle(ref, () => innerRef.current!, []);
19
+ useImperativeHandle(ref, () => innerRef.current || ({} as HTMLDivElement), []);
20
20
 
21
21
  const handleClickOrTouch = useCallback(
22
22
  (e: React.MouseEvent | React.TouchEvent) => {
@@ -58,7 +58,7 @@ const handleFiles = (
58
58
  };
59
59
 
60
60
  interface FilePickerInputProps extends React.ComponentProps<'input'> {
61
- onSelectFile: (files: File[]) => void;
61
+ onSelectFile?: (files: File[]) => void;
62
62
  onInvalidFile?: () => void;
63
63
  iconElement?: React.ReactNode;
64
64
  labelProps?: LabelProps;
@@ -97,7 +97,7 @@ function FilePickerInputBase(
97
97
  {...labelProps}
98
98
  htmlFor="file-uploader"
99
99
  className={cn(
100
- 'group p-2 rounded-full hover:bg-ring/10 transition-all duration-200 ease-in-out cursor-pointer',
100
+ 'group p-2 rounded-full hover:bg-ring/10 transition-all duration-200 ease-out cursor-pointer',
101
101
  labelProps?.className
102
102
  )}
103
103
  >
@@ -105,7 +105,7 @@ function FilePickerInputBase(
105
105
  <Paperclip
106
106
  {...iconProps}
107
107
  className={cn(
108
- 'text-foreground group-hover:scale-110 transition-all duration-200 ease-in-out',
108
+ 'text-foreground group-hover:scale-110 transition-all duration-200 ease-out',
109
109
  iconProps?.className
110
110
  )}
111
111
  />
@@ -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: string;
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
- { text, linkifyProps, ...props }: LinkifyTextProps,
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>
@@ -21,7 +21,11 @@ function PlaceholderTextBase(
21
21
  className={cn('absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2', props?.className)}
22
22
  >
23
23
  {rows.map((row, index) => (
24
- <div key={`placeholder-text-${index}`} {...rowProps} className={cn('text-center', rowProps?.className)}>
24
+ <div
25
+ key={`placeholder-text-${index}`}
26
+ {...rowProps}
27
+ className={cn('text-center text-muted-foreground', rowProps?.className)}
28
+ >
25
29
  {row}
26
30
  </div>
27
31
  ))}