@liveblocks/react-ui
Version:
A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.
1 lines • 16.8 kB
Source Map (JSON)
{"version":3,"file":"Attachment.cjs","sources":["../../../src/components/internal/Attachment.tsx"],"sourcesContent":["\"use client\";\n\nimport type { CommentMixedAttachment } from \"@liveblocks/core\";\nimport { useRoomAttachmentUrl } from \"@liveblocks/react/_private\";\nimport type {\n ComponentPropsWithoutRef,\n KeyboardEvent,\n MouseEventHandler,\n PointerEvent,\n} from \"react\";\nimport { memo, useCallback, useMemo, useState } from \"react\";\n\nimport { CrossIcon } from \"../../icons/Cross\";\nimport { SpinnerIcon } from \"../../icons/Spinner\";\nimport { WarningIcon } from \"../../icons/Warning\";\nimport type { Overrides } from \"../../overrides\";\nimport { useOverrides } from \"../../overrides\";\nimport { AttachmentTooLargeError } from \"../../primitives\";\nimport { useComposerAttachmentsContextOrNull } from \"../../primitives/Composer/contexts\";\nimport { classNames } from \"../../utils/class-names\";\nimport { formatFileSize } from \"../../utils/format-file-size\";\nimport { Tooltip } from \"./Tooltip\";\n\nconst MAX_DISPLAYED_MEDIA_SIZE = 60 * 1024 * 1024; // 60 MB\n\ninterface AttachmentProps extends ComponentPropsWithoutRef<\"div\"> {\n attachment: CommentMixedAttachment;\n onDeleteClick?: MouseEventHandler<HTMLButtonElement>;\n preventFocusOnDelete?: boolean;\n roomId: string;\n overrides?: Partial<Overrides>;\n allowMediaPreview?: boolean;\n}\n\nconst fileExtensionRegex = /^(.+?)(\\.[^.]+)?$/;\n\nfunction splitFileName(name: string) {\n const match = name.match(fileExtensionRegex);\n\n return { base: match?.[1] ?? name, extension: match?.[2] };\n}\n\nfunction getAttachmentIconGlyph(mimeType: string) {\n if (\n mimeType === \"application/zip\" ||\n mimeType === \"application/gzip\" ||\n mimeType === \"application/vnd.rar\" ||\n mimeType === \"application/x-rar-compressed\" ||\n mimeType === \"application/x-7z-compressed\" ||\n mimeType === \"application/x-zip-compressed\" ||\n mimeType === \"application/x-tar\" ||\n mimeType === \"application/x-bzip\" ||\n mimeType === \"application/x-bzip2\"\n ) {\n return (\n <path d=\"M13 15h2v1h-1.5a.5.5 0 0 0 0 1H15v1h-1.5a.5.5 0 0 0 0 1H15v1h-1.5a.5.5 0 0 0 0 1h1a.5.5 0 0 0 .5-.5V20h1.5a.5.5 0 0 0 0-1H15v-1h1.5a.5.5 0 0 0 0-1H15v-1h1.5a.5.5 0 0 0 .5-.5V15a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Z\" />\n );\n }\n\n if (\n mimeType.startsWith(\"text/\") ||\n mimeType.startsWith(\"font/\") ||\n mimeType.startsWith(\"application/\")\n ) {\n return (\n <path d=\"M10 16a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 0 1h-8a.5.5 0 0 1-.5-.5Z\" />\n );\n }\n\n if (mimeType.startsWith(\"image/\")) {\n return (\n <path d=\"M12 16h6a1 1 0 0 1 1 1v3l-1.293-1.293a1 1 0 0 0-1.414 0L14.09 20.91l-.464-.386a1 1 0 0 0-1.265-.013l-1.231.985A.995.995 0 0 1 11 21v-4a1 1 0 0 1 1-1Zm-2 1a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-6a2 2 0 0 1-2-2v-4Zm3 2a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z\" />\n );\n }\n\n if (mimeType.startsWith(\"video/\")) {\n return (\n <path d=\"M12 15.71a1 1 0 0 1 1.49-.872l4.96 2.79a1 1 0 0 1 0 1.744l-4.96 2.79A1 1 0 0 1 12 21.29v-5.58Z\" />\n );\n }\n\n if (mimeType.startsWith(\"audio/\")) {\n return (\n <path d=\"M15 15a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 1 0v-7a.5.5 0 0 0-.5-.5Zm-2.5 2.5a.5.5 0 0 1 1 0v3a.5.5 0 0 1-1 0v-3Zm-2 1a.5.5 0 0 1 1 0v1a.5.5 0 0 1-1 0v-1Zm6-1a.5.5 0 0 1 1 0v3a.5.5 0 0 1-1 0v-3ZM19 16a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.5-.5Z\" />\n );\n }\n\n return null;\n}\n\nconst AttachmentFileIcon = memo(({ mimeType }: { mimeType: string }) => {\n const iconGlyph = useMemo(() => getAttachmentIconGlyph(mimeType), [mimeType]);\n\n return (\n <svg\n className=\"lb-attachment-icon\"\n width={30}\n height={30}\n viewBox=\"0 0 30 30\"\n fill=\"currentColor\"\n fillRule=\"evenodd\"\n clipRule=\"evenodd\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M6 5a2 2 0 0 1 2-2h5.843a4 4 0 0 1 2.829 1.172l6.156 6.156A4 4 0 0 1 24 13.157V25a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V5Z\"\n className=\"lb-attachment-icon-shadow\"\n />\n <path\n d=\"M6 5a2 2 0 0 1 2-2h5.843a4 4 0 0 1 2.829 1.172l6.156 6.156A4 4 0 0 1 24 13.157V25a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V5Z\"\n className=\"lb-attachment-icon-background\"\n />\n <path\n d=\"M14.382 3.037a4 4 0 0 1 2.29 1.135l6.156 6.157a4 4 0 0 1 1.136 2.289A2 2 0 0 0 22 11h-4a2 2 0 0 1-2-2V5a2 2 0 0 0-1.618-1.963Z\"\n className=\"lb-attachment-icon-fold\"\n />\n\n {iconGlyph && <g className=\"lb-attachment-icon-glyph\">{iconGlyph}</g>}\n </svg>\n );\n});\n\nfunction AttachmentImagePreview({\n attachment,\n markPreviewAsUnsupported,\n roomId,\n}: {\n attachment: CommentMixedAttachment;\n markPreviewAsUnsupported: () => void;\n roomId: string;\n}) {\n const { url } = useRoomAttachmentUrl(attachment.id, roomId);\n const [isLoaded, setLoaded] = useState(false);\n\n const handleLoad = useCallback(() => {\n setLoaded(true);\n }, []);\n\n return (\n <>\n {!isLoaded ? <SpinnerIcon /> : null}\n {url ? (\n <div\n className=\"lb-attachment-preview-media\"\n data-hidden={!isLoaded ? \"\" : undefined}\n >\n <img\n src={url}\n loading=\"lazy\"\n onLoad={handleLoad}\n onError={markPreviewAsUnsupported}\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction AttachmentVideoPreview({\n attachment,\n markPreviewAsUnsupported,\n roomId,\n}: {\n attachment: CommentMixedAttachment;\n markPreviewAsUnsupported: () => void;\n roomId: string;\n}) {\n const { url } = useRoomAttachmentUrl(attachment.id, roomId);\n const [isLoaded, setLoaded] = useState(false);\n\n const handleLoad = useCallback(() => {\n setLoaded(true);\n }, []);\n\n return (\n <>\n {!isLoaded ? <SpinnerIcon /> : null}\n {url ? (\n <div\n className=\"lb-attachment-preview-media\"\n data-hidden={!isLoaded ? \"\" : undefined}\n >\n <video\n src={url}\n onLoadedData={handleLoad}\n onError={markPreviewAsUnsupported}\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction AttachmentPreview({\n attachment,\n allowMediaPreview = true,\n roomId,\n}: {\n attachment: CommentMixedAttachment;\n allowMediaPreview?: boolean;\n roomId: string;\n}) {\n const [isUnsupportedPreview, setUnsupportedPreview] = useState(false);\n const isUploaded =\n attachment.type === \"attachment\" || attachment.status === \"uploaded\";\n\n function markPreviewAsUnsupported() {\n setUnsupportedPreview(true);\n }\n\n if (\n !isUnsupportedPreview &&\n allowMediaPreview &&\n isUploaded &&\n attachment.size <= MAX_DISPLAYED_MEDIA_SIZE\n ) {\n if (attachment.mimeType.startsWith(\"image/\")) {\n return (\n <AttachmentImagePreview\n attachment={attachment}\n markPreviewAsUnsupported={markPreviewAsUnsupported}\n roomId={roomId}\n />\n );\n }\n\n if (attachment.mimeType.startsWith(\"video/\")) {\n return (\n <AttachmentVideoPreview\n attachment={attachment}\n markPreviewAsUnsupported={markPreviewAsUnsupported}\n roomId={roomId}\n />\n );\n }\n }\n\n return <AttachmentFileIcon mimeType={attachment.mimeType} />;\n}\n\nfunction AttachmentName({\n attachment,\n}: {\n attachment: CommentMixedAttachment;\n}) {\n const { base: fileBaseName, extension: fileExtension } = useMemo(() => {\n return splitFileName(attachment.name);\n }, [attachment.name]);\n\n return (\n <span className=\"lb-attachment-name\" title={attachment.name}>\n <span className=\"lb-attachment-name-base\">{fileBaseName}</span>\n {fileExtension && (\n <span className=\"lb-attachment-name-extension\">{fileExtension}</span>\n )}\n </span>\n );\n}\n\nfunction useClickOnKeyDown(\n onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void\n) {\n const handleKeyDown = useCallback(\n (event: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event);\n\n if (event.isDefaultPrevented()) {\n return;\n }\n\n // Simulate a click event on Enter or Space because it's a div\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n\n const clickEvent = new MouseEvent(\"click\", {\n bubbles: true,\n cancelable: true,\n view: window,\n });\n event.target.dispatchEvent(clickEvent);\n }\n },\n [onKeyDown]\n );\n\n return handleKeyDown;\n}\n\nfunction useAttachmentContent(\n attachment: CommentMixedAttachment,\n overrides?: Partial<Overrides>\n) {\n const $ = useOverrides(overrides);\n const composerAttachmentsContext = useComposerAttachmentsContextOrNull();\n const isInComposer = Boolean(composerAttachmentsContext);\n const maxAttachmentSize = composerAttachmentsContext?.maxAttachmentSize;\n\n const status =\n attachment.type === \"localAttachment\" ? attachment.status : undefined;\n const isUploading = status === \"uploading\";\n const isError = status === \"error\";\n\n let description: string;\n\n if (attachment.type === \"localAttachment\" && attachment.status === \"error\") {\n if (attachment.error instanceof AttachmentTooLargeError) {\n if (attachment.error.origin === \"server\") {\n description = $.ATTACHMENT_TOO_LARGE();\n } else {\n description = $.ATTACHMENT_TOO_LARGE(\n maxAttachmentSize\n ? formatFileSize(maxAttachmentSize, $.locale)\n : undefined\n );\n }\n } else {\n description = $.ATTACHMENT_ERROR(attachment.error);\n }\n } else {\n description = formatFileSize(attachment.size, $.locale);\n }\n\n const deleteLabel = isInComposer\n ? $.COMPOSER_REMOVE_ATTACHMENT\n : $.COMMENT_DELETE_ATTACHMENT;\n\n return {\n isUploading,\n isError,\n description,\n deleteLabel,\n };\n}\n\nexport function MediaAttachment({\n attachment,\n overrides,\n onClick,\n onDeleteClick,\n preventFocusOnDelete,\n allowMediaPreview = true,\n roomId,\n className,\n onKeyDown,\n ...props\n}: AttachmentProps) {\n const { isUploading, isError, description, deleteLabel } =\n useAttachmentContent(attachment, overrides);\n\n const handleDeletePointerDown = useCallback(\n (event: PointerEvent<HTMLButtonElement>) => {\n if (preventFocusOnDelete) {\n event.preventDefault();\n }\n },\n [preventFocusOnDelete]\n );\n\n const handleKeyDown = useClickOnKeyDown(onKeyDown);\n\n return (\n <div\n className={classNames(\"lb-attachment lb-media-attachment\", className)}\n data-error={isError ? \"\" : undefined}\n {...props}\n role={onClick ? \"button\" : undefined}\n onClick={onClick}\n tabIndex={onClick ? 0 : -1}\n onKeyDown={onClick ? handleKeyDown : undefined}\n >\n <div className=\"lb-attachment-preview\">\n {isUploading ? (\n <SpinnerIcon />\n ) : isError ? (\n <WarningIcon />\n ) : (\n <AttachmentPreview\n attachment={attachment}\n allowMediaPreview={allowMediaPreview}\n roomId={roomId}\n />\n )}\n </div>\n <div className=\"lb-attachment-details\">\n <AttachmentName attachment={attachment} />\n <span className=\"lb-attachment-description\" title={description}>\n {description}\n </span>\n </div>\n {onDeleteClick && (\n <Tooltip content={deleteLabel}>\n <button\n type=\"button\"\n className=\"lb-attachment-delete\"\n onClick={onDeleteClick}\n onPointerDown={handleDeletePointerDown}\n aria-label={deleteLabel}\n >\n <CrossIcon />\n </button>\n </Tooltip>\n )}\n </div>\n );\n}\n\nexport function FileAttachment({\n attachment,\n overrides,\n onClick,\n onDeleteClick,\n preventFocusOnDelete,\n allowMediaPreview = true,\n roomId,\n className,\n onKeyDown,\n ...props\n}: AttachmentProps) {\n const { isUploading, isError, description, deleteLabel } =\n useAttachmentContent(attachment, overrides);\n\n const handleDeletePointerDown = useCallback(\n (event: PointerEvent<HTMLButtonElement>) => {\n if (preventFocusOnDelete) {\n event.preventDefault();\n }\n },\n [preventFocusOnDelete]\n );\n\n const handleKeyDown = useClickOnKeyDown(onKeyDown);\n\n return (\n <div\n className={classNames(\"lb-attachment lb-file-attachment\", className)}\n data-error={isError ? \"\" : undefined}\n {...props}\n role={onClick ? \"button\" : undefined}\n onClick={onClick}\n tabIndex={onClick ? 0 : -1}\n onKeyDown={onClick ? handleKeyDown : undefined}\n >\n <div className=\"lb-attachment-preview\">\n {isUploading ? (\n <SpinnerIcon />\n ) : isError ? (\n <WarningIcon />\n ) : (\n <AttachmentPreview\n attachment={attachment}\n allowMediaPreview={allowMediaPreview}\n roomId={roomId}\n />\n )}\n </div>\n <div className=\"lb-attachment-details\">\n <AttachmentName attachment={attachment} />\n <span className=\"lb-attachment-description\" title={description}>\n {description}\n </span>\n </div>\n {onDeleteClick && (\n <Tooltip content={deleteLabel}>\n <button\n type=\"button\"\n className=\"lb-attachment-delete\"\n onClick={onDeleteClick}\n onPointerDown={handleDeletePointerDown}\n aria-label={deleteLabel}\n >\n <CrossIcon />\n </button>\n </Tooltip>\n )}\n </div>\n );\n}\n\nexport function separateMediaAttachments<T extends CommentMixedAttachment>(\n attachments: T[]\n) {\n const mediaAttachments: T[] = [];\n const fileAttachments: T[] = [];\n\n for (const attachment of attachments) {\n if (\n (attachment.mimeType.startsWith(\"image/\") ||\n attachment.mimeType.startsWith(\"video/\")) &&\n attachment.size <= MAX_DISPLAYED_MEDIA_SIZE\n ) {\n mediaAttachments.push(attachment);\n } else {\n fileAttachments.push(attachment);\n }\n }\n\n return {\n mediaAttachments,\n fileAttachments,\n };\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAuBA;AAWA;AAEA;AACE;AAEA;AACF;AAEA;AACE;AAWE;AACG;AAAO;AAAoP;AAIhQ;AAKE;AACG;AAAO;AAAiP;AAI7P;AACE;AACG;AAAO;AAAiQ;AAI7Q;AACE;AACG;AAAO;AAAiG;AAI7G;AACE;AACG;AAAO;AAAyP;AAIrQ;AACF;AAEA;AACE;AAEA;AACG;AACW;AACH;AACC;AACA;AACH;AACI;AACA;AACH;AAEN;AAAC;AACG;AACQ;AACZ;AACC;AACG;AACQ;AACZ;AACC;AACG;AACQ;AACZ;AAEe;AAAY;AAA4B;AAAU;AAAA;AAGvE;AAEA;AAAgC;AAC9B;AACA;AAEF;AAKE;AACA;AAEA;AACE;AAAc;AAGhB;AACE;AACG;AAA8B;AAE5B;AACW;AACoB;AAE7B;AACM;AACG;AACA;AACC;AACX;AAEA;AAAA;AAGV;AAEA;AAAgC;AAC9B;AACA;AAEF;AAKE;AACA;AAEA;AACE;AAAc;AAGhB;AACE;AACG;AAA8B;AAE5B;AACW;AACoB;AAE7B;AACM;AACS;AACL;AACX;AAEA;AAAA;AAGV;AAEA;AAA2B;AACzB;AACoB;AAEtB;AAKE;AACA;AAGA;AACE;AAA0B;AAG5B;AAME;AACE;AACG;AACC;AACA;AACA;AACF;AAIJ;AACE;AACG;AACC;AACA;AACA;AACF;AAEJ;AAGF;AAAQ;AAAwC;AAClD;AAEA;AAAwB;AAExB;AAGE;AACE;AAAoC;AAGtC;AACG;AAAe;AAAuC;AACrD;AAAC;AAAe;AAA2B;AAAa;AAErD;AAAe;AAAgC;AAAc;AAAA;AAItE;AAEA;AAGE;AAAsB;AAElB;AAEA;AACE;AAAA;AAIF;AACE;AAEA;AAA2C;AAChC;AACG;AACN;AAER;AAAqC;AACvC;AACF;AACU;AAGZ;AACF;AAEA;AAIE;AACA;AACA;AACA;AAEA;AAEA;AACA;AAEA;AAEA;AACE;AACE;AACE;AAAqC;AAErC;AAAgB;AAGV;AACN;AACF;AAEA;AAAiD;AACnD;AAEA;AAAsD;AAGxD;AAIA;AAAO;AACL;AACA;AACA;AACA;AAEJ;AAEO;AAAyB;AAC9B;AACA;AACA;AACA;AACA;AACoB;AACpB;AACA;AACA;AAEF;AACE;AAGA;AAAgC;AAE5B;AACE;AAAqB;AACvB;AACF;AACqB;AAGvB;AAEA;AACG;AACqE;AACzC;AACvB;AACuB;AAC3B;AACwB;AACa;AAErC;AAAC;AAAc;AAMV;AACC;AACA;AACA;AACF;AAEJ;AACC;AAAc;AACb;AAAC;AAAe;AAAwB;AACvC;AAAe;AAAmC;AAChD;AACH;AAAA;AACF;AAEG;AAAiB;AACf;AACM;AACK;AACD;AACM;AACH;AAED;AACb;AACF;AAAA;AAIR;AAEO;AAAwB;AAC7B;AACA;AACA;AACA;AACA;AACoB;AACpB;AACA;AACA;AAEF;AACE;AAGA;AAAgC;AAE5B;AACE;AAAqB;AACvB;AACF;AACqB;AAGvB;AAEA;AACG;AACoE;AACxC;AACvB;AACuB;AAC3B;AACwB;AACa;AAErC;AAAC;AAAc;AAMV;AACC;AACA;AACA;AACF;AAEJ;AACC;AAAc;AACb;AAAC;AAAe;AAAwB;AACvC;AAAe;AAAmC;AAChD;AACH;AAAA;AACF;AAEG;AAAiB;AACf;AACM;AACK;AACD;AACM;AACH;AAED;AACb;AACF;AAAA;AAIR;AAEO;AAGL;AACA;AAEA;AACE;AAKE;AAAgC;AAEhC;AAA+B;AACjC;AAGF;AAAO;AACL;AACA;AAEJ;;;;"}