@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
@@ -0,0 +1,120 @@
1
+ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
2
+ import { useInView } from 'react-intersection-observer';
3
+ import { Attachment } from './attachment';
4
+ import { Avatar } from './avatar';
5
+ import { FormattedDate } from './formatted-date';
6
+ import { LinkifyText } from './linkify-text';
7
+ import { LinkPreview } from './link-preview';
8
+ import { StatusSent } from './status-sent';
9
+ import { cn } from './utils';
10
+
11
+ function ChatMessageBase(
12
+ {
13
+ isLast,
14
+ fromMe,
15
+ sameSenderAbove,
16
+ title,
17
+ senderName,
18
+ senderAvatar,
19
+ attachmentElement,
20
+ linkifyTextElement,
21
+ linkPreviewElement,
22
+ onView = () => {},
23
+ avatarProps,
24
+ bubbleProps,
25
+ titleProps,
26
+ formattedDateProps,
27
+ statusSentProps,
28
+ attachmentProps,
29
+ linkifyTextProps,
30
+ linkPreviewProps,
31
+ children,
32
+ ...props
33
+ },
34
+ ref
35
+ ) {
36
+ const [setRef, inView] = useInView();
37
+ const messageRef = useRef(null);
38
+ const setRefs = useCallback(
39
+ (node) => {
40
+ messageRef.current = node;
41
+ setRef(node);
42
+ },
43
+ [setRef]
44
+ );
45
+ const hasAvatar = Boolean((avatarProps || senderAvatar) && !sameSenderAbove);
46
+ const hasAvatarMargin = Boolean((avatarProps || senderAvatar) && sameSenderAbove);
47
+ const hasThinMarginTop = hasAvatarMargin || Boolean(fromMe && sameSenderAbove);
48
+
49
+ useEffect(() => {
50
+ if (inView) {
51
+ onView();
52
+ }
53
+ }, [inView, onView]);
54
+
55
+ useImperativeHandle(ref, () => messageRef.current, []);
56
+
57
+ return (
58
+ <div
59
+ ref={setRefs}
60
+ {...props}
61
+ className={cn(
62
+ `flex relative text-left whitespace-pre-wrap`,
63
+ fromMe ? 'self-end flex-row-reverse ml-12' : `self-start mr-12`,
64
+ isLast && 'mb-2',
65
+ hasThinMarginTop ? 'mt-1' : 'mt-2',
66
+ inView && 'view',
67
+ props?.className
68
+ )}
69
+ >
70
+ {hasAvatar && (
71
+ <Avatar
72
+ name={senderName}
73
+ src={senderAvatar}
74
+ imageProps={{
75
+ className: 'bg-ring/30',
76
+ }}
77
+ fallbackProps={{
78
+ className: 'bg-ring/30',
79
+ }}
80
+ {...avatarProps}
81
+ className={cn('mt-1 mr-1', avatarProps?.className)}
82
+ />
83
+ )}
84
+
85
+ <div
86
+ className={cn(
87
+ 'relative flex flex-col min-w-42 max-w-120 rounded-xl px-2 pt-2 pb-6',
88
+ fromMe ? 'bg-blue-200' : 'bg-gray-200',
89
+ hasAvatarMargin && 'ml-9',
90
+ bubbleProps?.className
91
+ )}
92
+ >
93
+ {(title || senderName) && (
94
+ <span
95
+ {...titleProps}
96
+ className={cn(
97
+ 'font-semibold',
98
+ title && 'mb-1 py-1.5 text-xs text-muted-foreground italic border-b',
99
+ titleProps?.className
100
+ )}
101
+ >
102
+ {title || senderName}
103
+ </span>
104
+ )}
105
+ {children}
106
+ {attachmentElement || (attachmentProps ? <Attachment {...attachmentProps} /> : null)}
107
+ {linkifyTextElement || (linkifyTextProps ? <LinkifyText {...linkifyTextProps} /> : null)}
108
+ {linkPreviewElement || (linkPreviewProps ? <LinkPreview {...linkPreviewProps} /> : null)}
109
+ <div className="absolute bottom-1 right-2 flex items-center gap-1 italic">
110
+ <FormattedDate distanceToNow {...formattedDateProps} />
111
+ <StatusSent {...statusSentProps} />
112
+ </div>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ const ChatMessage = memo(forwardRef(ChatMessageBase));
119
+
120
+ export { ChatMessage };
@@ -1,9 +1,9 @@
1
- import { forwardRef, memo } from 'react';
2
- import { Users } from 'lucide-react';
3
- import { cn } from './utils';
4
1
  import { Avatar } from './avatar';
5
- import { StatusSent } from './status-sent';
2
+ import { Users } from 'lucide-react';
3
+ import { forwardRef, memo } from 'react';
6
4
  import { FormattedDate } from './formatted-date';
5
+ import { StatusSent } from './status-sent';
6
+ import { cn } from './utils';
7
7
  import { Badge } from './badge';
8
8
 
9
9
  function DialogItemBase(
@@ -46,9 +46,9 @@ function DialogItemBase(
46
46
  },
47
47
  ref
48
48
  ) {
49
- const avatarSource = photo || avatarProps?.src || undefined;
50
- const avatarFallback = name || avatarProps?.name || undefined;
51
- const avatarOnline = isPrivateDialog ? userOnline || avatarProps?.online || false : false;
49
+ const avatarSource = photo || avatarProps?.src;
50
+ const avatarFallback = name || avatarProps?.name;
51
+ const avatarOnline = isPrivateDialog ? userOnline || avatarProps?.online : false;
52
52
  const avatarPresence = isPrivateDialog ? userPresence || avatarProps?.presence : undefined;
53
53
  const showTopDivider = divider === 'top' || (divider === 'both' && index === 0);
54
54
  const showBottomDivider = divider === 'bottom' || divider === 'both';
@@ -98,7 +98,10 @@ function DialogItemBase(
98
98
  <div {...footerProps} className={cn('flex items-start justify-between gap-1', footerProps?.className)}>
99
99
  <span
100
100
  {...lastMessageProps}
101
- className={cn('text-sm text-left text-muted-foreground line-clamp-2', lastMessageProps?.className)}
101
+ className={cn(
102
+ 'text-sm text-left text-muted-foreground break-all line-clamp-2',
103
+ lastMessageProps?.className
104
+ )}
102
105
  >
103
106
  {typingStatusText ||
104
107
  (draft ? (
@@ -7,7 +7,7 @@ function DismissLayerBase(
7
7
  ) {
8
8
  const innerRef = useRef(null);
9
9
 
10
- useImperativeHandle(ref, () => innerRef.current);
10
+ useImperativeHandle(ref, () => innerRef.current, []);
11
11
 
12
12
  const handleClickOrTouch = useCallback(
13
13
  (e) => {
@@ -152,14 +152,14 @@ function FilePickerDropzoneBase(
152
152
  onDragLeave={handleDragLeave}
153
153
  onDragOver={handleDragEvent}
154
154
  {...props}
155
- className={cn('size-full relative', props?.className)}
155
+ className={cn('relative size-full min-h-0', props?.className)}
156
156
  >
157
157
  {children}
158
158
  <div
159
159
  onDrop={handleDrop}
160
160
  {...dropZoneProps}
161
161
  className={cn(
162
- 'group absolute top-0 left-0 size-full',
162
+ 'absolute top-0 left-0 size-full',
163
163
  'flex items-center justify-center',
164
164
  'transition-all duration-300 ease-out',
165
165
  'border-2 border-dashed border-ring bg-ring/25 rounded-md',
@@ -1,4 +1,4 @@
1
- import { forwardRef, memo, useMemo } from 'react';
1
+ import { forwardRef, memo, useEffect, useMemo, useRef } from 'react';
2
2
  import Linkify from 'linkify-react';
3
3
  import { cn } from './utils';
4
4
 
@@ -6,8 +6,22 @@ const DEFAULT_LINKIFY_OPTIONS = {
6
6
  target: '_blank',
7
7
  rel: 'noopener noreferrer',
8
8
  };
9
+ const DEFAULT_SKELETON_LINES_CLASS_NAMES = ['w-full', 'w-3/4', 'w-1/2'];
9
10
 
10
- function LinkifyTextBase({ text, linkifyProps, ...props }, ref) {
11
+ function LinkifyTextBase(
12
+ {
13
+ text,
14
+ pending = false,
15
+ onReady = () => {},
16
+ linkifyProps,
17
+ skeletonContainerProps,
18
+ skeletonLineProps,
19
+ skeletonLinesClassNames = DEFAULT_SKELETON_LINES_CLASS_NAMES,
20
+ ...props
21
+ },
22
+ ref
23
+ ) {
24
+ const pendingRef = useRef(pending);
11
25
  const options = useMemo(
12
26
  () => ({
13
27
  ...DEFAULT_LINKIFY_OPTIONS,
@@ -17,6 +31,31 @@ function LinkifyTextBase({ text, linkifyProps, ...props }, ref) {
17
31
  [linkifyProps]
18
32
  );
19
33
 
34
+ useEffect(() => {
35
+ if (pendingRef.current && !pending) {
36
+ onReady();
37
+ }
38
+
39
+ pendingRef.current = pending;
40
+ }, [pending, onReady]);
41
+
42
+ if (pending) {
43
+ return (
44
+ <div
45
+ {...skeletonContainerProps}
46
+ className={cn('flex items-start flex-col size-full', skeletonContainerProps?.className)}
47
+ >
48
+ {skeletonLinesClassNames.map((width, index) => (
49
+ <span
50
+ {...skeletonLineProps}
51
+ key={`${width}_${index}`}
52
+ className={cn('bg-muted-foreground animate-pulse rounded-md my-1 h-4', skeletonLineProps?.className, width)}
53
+ ></span>
54
+ ))}
55
+ </div>
56
+ );
57
+ }
58
+
20
59
  return (
21
60
  <p ref={ref} {...props} className={cn('wrap-break-word text-base', props?.className)}>
22
61
  <Linkify options={options}>{text}</Linkify>
@@ -1,10 +1,6 @@
1
1
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
2
2
  import { Maximize, Minimize, PictureInPicture2 } from 'lucide-react';
3
- import { cn } from './utils';
4
-
5
- function getRandomString(length = 8) {
6
- return (Date.now() / Math.random()).toString(36).replace('.', '').slice(0, length);
7
- }
3
+ import { cn, getRandomString } from './utils';
8
4
 
9
5
  function StreamViewBase({ id, stream, mirror, className, muted, ...props }, ref) {
10
6
  const innerRef = useRef(null);
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+ import * as SwitchPrimitive from '@radix-ui/react-switch';
3
+ import { cn } from './utils';
4
+
5
+ function Switch(props) {
6
+ return (
7
+ <SwitchPrimitive.Root
8
+ {...props}
9
+ className={cn(
10
+ 'peer data-[state=checked]:bg-ring data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15em] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring disabled:cursor-not-allowed disabled:opacity-50',
11
+ props?.className
12
+ )}
13
+ >
14
+ <SwitchPrimitive.Thumb
15
+ className={cn(
16
+ 'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
17
+ )}
18
+ />
19
+ </SwitchPrimitive.Root>
20
+ );
21
+ }
22
+
23
+ export { Switch };
@@ -5,6 +5,10 @@ export function cn(...inputs) {
5
5
  return twMerge(clsx(inputs));
6
6
  }
7
7
 
8
+ export function getRandomString(length = 8) {
9
+ return (Date.now() / Math.random()).toString(36).replace('.', '').slice(0, length);
10
+ }
11
+
8
12
  export function capitalize(str) {
9
13
  return typeof str === 'string' && str.length > 0 ? `${str[0]?.toUpperCase()}${str.slice(1)}` : '';
10
14
  }
package/gen/index.js CHANGED
@@ -1,3 +1,53 @@
1
+ export {
2
+ Attachment,
3
+ AttachmentLink,
4
+ AttachmentImage,
5
+ AttachmentAudio,
6
+ AttachmentVideo,
7
+ AttachmentFile,
8
+ AttachmentFailed,
9
+ } from './components/attachment';
10
+
11
+ export { Avatar } from './components/avatar';
12
+
13
+ export { Badge } from './components/badge';
14
+
15
+ export { Button } from './components/button';
16
+
17
+ export { CallMessage } from './components/call-message';
18
+
19
+ export { ChatMessage } from './components/chat-message';
20
+
21
+ export { DialogItem } from './components/dialog-item';
22
+
1
23
  export { DismissLayer } from './components/dismiss-layer';
2
24
 
25
+ export { FilePickerInput, FilePickerDropzone } from './components/file-picker';
26
+
27
+ export { FormattedDate } from './components/formatted-date';
28
+
29
+ export { Input } from './components/input';
30
+
31
+ export { Label } from './components/label';
32
+
33
+ export { LinkPreview } from './components/link-preview';
34
+
35
+ export { LinkifyText } from './components/linkify-text';
36
+
37
+ export { PlaceholderText } from './components/placeholder-text';
38
+
39
+ export { Presence, PresenceBadge } from './components/presence';
40
+
41
+ export { Search } from './components/search';
42
+
43
+ export { Spinner } from './components/spinner';
44
+
45
+ export { StatusIndicator } from './components/status-indicator';
46
+
47
+ export { StatusSent } from './components/status-sent';
48
+
3
49
  export { StreamView, LocalStreamView, RemoteStreamView, FullscreenStreamView } from './components/stream-view';
50
+
51
+ export { Switch } from './components/switch';
52
+
53
+ export * from './components/utils';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connectycube/react-ui-kit",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Simple React UI Kit generator with TSX/JSX",
5
5
  "homepage": "https://github.com/ConnectyCube/react-ui-kit#readme",
6
6
  "bugs": {
@@ -66,15 +66,14 @@
66
66
  "@radix-ui/react-avatar": "^1.1.11",
67
67
  "@radix-ui/react-label": "^2.1.8",
68
68
  "@radix-ui/react-slot": "^1.2.4",
69
+ "@radix-ui/react-switch": "^1.2.6",
69
70
  "@radix-ui/react-tooltip": "^1.2.8",
70
71
  "class-variance-authority": "^0.7.1",
71
72
  "clsx": "^2.1.1",
72
73
  "date-fns": "^4.1.0",
73
- "execa": "^9.6.0",
74
- "fs-extra": "^11.3.2",
75
74
  "linkify-react": "^4.3.2",
76
- "lucide-react": "^0.555.0",
77
- "prompts": "^2.4.2",
75
+ "lucide-react": "^0.561.0",
76
+ "react-intersection-observer": "^10.0.0",
78
77
  "tailwind-merge": "^3.4.0"
79
78
  },
80
79
  "peerDependencies": {
@@ -84,25 +83,28 @@
84
83
  "devDependencies": {
85
84
  "@babel/core": "^7.28.5",
86
85
  "@babel/preset-typescript": "^7.28.5",
87
- "@eslint/js": "^9.39.1",
86
+ "@eslint/js": "^9.39.2",
88
87
  "@rollup/plugin-commonjs": "^29.0.0",
89
88
  "@rollup/plugin-node-resolve": "^16.0.3",
90
89
  "@rollup/plugin-terser": "^0.4.4",
91
90
  "@rollup/plugin-typescript": "^12.3.0",
92
91
  "@stylistic/eslint-plugin": "^5.6.1",
93
- "@types/node": "^24.10.1",
92
+ "@types/node": "^25.0.1",
94
93
  "@types/react": "^19.2.7",
95
- "eslint": "^9.39.1",
94
+ "eslint": "^9.39.2",
96
95
  "eslint-plugin-react-hooks": "^7.0.1",
97
96
  "eslint-plugin-react-refresh": "^0.4.24",
97
+ "execa": "^9.6.1",
98
98
  "fast-glob": "^3.3.3",
99
+ "fs-extra": "^11.3.2",
99
100
  "globals": "^16.5.0",
100
- "prettier": "^3.6.2",
101
+ "prettier": "^3.7.4",
102
+ "prompts": "^2.4.2",
101
103
  "rollup": "^4.53.3",
102
104
  "rollup-plugin-peer-deps-external": "^2.2.4",
103
105
  "tslib": "^2.8.1",
104
106
  "typescript": "^5.9.3",
105
- "typescript-eslint": "^8.48.0"
107
+ "typescript-eslint": "^8.49.0"
106
108
  },
107
109
  "engines": {
108
110
  "node": ">=18"
@@ -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
+ };