UNPKG

@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.

727 lines (723 loc) 26.1 kB
"use client"; import { jsxs, jsx, Fragment } from 'react/jsx-runtime'; import { MENTION_CHARACTER, assertNever, Permission } from '@liveblocks/core'; import { useAddRoomCommentReaction, useRemoveRoomCommentReaction, useRoomAttachmentUrl, useMarkRoomThreadAsRead, useDeleteRoomComment, useEditRoomComment, useRoomPermissions } from '@liveblocks/react/_private'; import * as TogglePrimitive from '@radix-ui/react-toggle'; import { forwardRef, useMemo, useCallback, useRef, useState, useEffect } from 'react'; import { useComponents, ComponentsProvider } from '../components.js'; import { CheckIcon } from '../icons/Check.js'; import { CrossIcon } from '../icons/Cross.js'; import { DeleteIcon } from '../icons/Delete.js'; import { EditIcon } from '../icons/Edit.js'; import { EllipsisIcon } from '../icons/Ellipsis.js'; import { EmojiPlusIcon } from '../icons/EmojiPlus.js'; import { useOverrides } from '../overrides.js'; import { Mention as CommentMention$1, Link as CommentLink$1, Body as CommentBody } from '../primitives/Comment/index.js'; import { Submit as ComposerSubmit } from '../primitives/Composer/index.js'; import { Timestamp } from '../primitives/Timestamp.js'; import { useCurrentUserId } from '../shared.js'; import { cn } from '../utils/cn.js'; import { download } from '../utils/download.js'; import { useIsGroupMentionMember } from '../utils/use-group-mention.js'; import { useRefs } from '../utils/use-refs.js'; import { useIntersectionCallback } from '../utils/use-visible.js'; import { useWindowFocus } from '../utils/use-window-focus.js'; import { Composer } from './Composer.js'; import { MediaAttachment, FileAttachment, separateMediaAttachments } from './internal/Attachment.js'; import { Avatar } from './internal/Avatar.js'; import { CustomButton, Button } from './internal/Button.js'; import { DropdownItem, Dropdown } from './internal/Dropdown.js'; import { Emoji } from './internal/Emoji.js'; import { EmojiPicker } from './internal/EmojiPicker.js'; import { Group } from './internal/Group.js'; import { List } from './internal/List.js'; import { Tooltip, ShortcutTooltip } from './internal/Tooltip.js'; import { User } from './internal/User.js'; import { TooltipProvider } from '@radix-ui/react-tooltip'; import { PopoverTrigger } from '@radix-ui/react-popover'; import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; const REACTIONS_TRUNCATE = 5; function CommentUserMention({ mention, className, ...props }) { const currentId = useCurrentUserId(); return /* @__PURE__ */ jsxs( CommentMention$1, { className: cn("lb-mention lb-comment-mention", className), "data-self": mention.id === currentId ? "" : void 0, ...props, children: [ /* @__PURE__ */ jsx("span", { className: "lb-mention-symbol", children: MENTION_CHARACTER }), /* @__PURE__ */ jsx(User, { userId: mention.id }) ] } ); } function CommentGroupMention({ mention, className, ...props }) { const isMember = useIsGroupMentionMember(mention); return /* @__PURE__ */ jsxs( CommentMention$1, { className: cn("lb-mention lb-comment-mention", className), "data-self": isMember ? "" : void 0, ...props, children: [ /* @__PURE__ */ jsx("span", { className: "lb-mention-symbol", children: MENTION_CHARACTER }), /* @__PURE__ */ jsx(Group, { groupId: mention.id }) ] } ); } function CommentMention({ mention, ...props }) { switch (mention.kind) { case "user": return /* @__PURE__ */ jsx(CommentUserMention, { mention, ...props }); case "group": return /* @__PURE__ */ jsx(CommentGroupMention, { mention, ...props }); default: return assertNever(mention, "Unhandled mention kind"); } } function CommentLink({ href, children, className, ...props }) { const { Anchor } = useComponents(); return /* @__PURE__ */ jsx( CommentLink$1, { className: cn("lb-comment-link", className), href, ...props, asChild: true, children: /* @__PURE__ */ jsx(Anchor, { ...props, children }) } ); } function CommentNonInteractiveLink({ href: _href, children, className, ...props }) { return /* @__PURE__ */ jsx("span", { className: cn("lb-comment-link", className), ...props, children }); } const CommentReactionButton = forwardRef(({ reaction, overrides, className, ...props }, forwardedRef) => { const $ = useOverrides(overrides); return /* @__PURE__ */ jsxs( CustomButton, { className: cn("lb-comment-reaction", className), variant: "outline", "aria-label": $.COMMENT_REACTION_DESCRIPTION( reaction.emoji, reaction.users.length ), ...props, ref: forwardedRef, children: [ /* @__PURE__ */ jsx(Emoji, { className: "lb-comment-reaction-emoji", emoji: reaction.emoji }), /* @__PURE__ */ jsx("span", { className: "lb-comment-reaction-count", children: reaction.users.length }) ] } ); }); const CommentReaction = forwardRef(({ comment, reaction, overrides, disabled, ...props }, forwardedRef) => { const addReaction = useAddRoomCommentReaction(comment.roomId); const removeReaction = useRemoveRoomCommentReaction(comment.roomId); const currentId = useCurrentUserId(); const isActive = useMemo(() => { return reaction.users.some((users) => users.id === currentId); }, [currentId, reaction]); const $ = useOverrides(overrides); const tooltipContent = useMemo( () => /* @__PURE__ */ jsx("span", { children: $.COMMENT_REACTION_LIST( /* @__PURE__ */ jsx( List, { values: reaction.users.map((users) => /* @__PURE__ */ jsx(User, { userId: users.id, replaceSelf: true }, users.id)), formatRemaining: $.LIST_REMAINING_USERS, truncate: REACTIONS_TRUNCATE, locale: $.locale } ), reaction.emoji, reaction.users.length ) }), [$, reaction] ); const stopPropagation = useCallback((event) => { event.stopPropagation(); }, []); const handlePressedChange = useCallback( (isPressed) => { if (isPressed) { addReaction({ threadId: comment.threadId, commentId: comment.id, emoji: reaction.emoji }); } else { removeReaction({ threadId: comment.threadId, commentId: comment.id, emoji: reaction.emoji }); } }, [addReaction, comment.threadId, comment.id, reaction.emoji, removeReaction] ); return /* @__PURE__ */ jsx( Tooltip, { content: tooltipContent, multiline: true, className: "lb-comment-reaction-tooltip", children: /* @__PURE__ */ jsx( TogglePrimitive.Root, { asChild: true, pressed: isActive, onPressedChange: handlePressedChange, onClick: stopPropagation, disabled, ref: forwardedRef, children: /* @__PURE__ */ jsx( CommentReactionButton, { "data-self": isActive ? "" : void 0, reaction, overrides, ...props } ) } ) } ); }); const CommentNonInteractiveReaction = forwardRef(({ reaction, overrides, ...props }, forwardedRef) => { const currentId = useCurrentUserId(); const isActive = useMemo(() => { return reaction.users.some((users) => users.id === currentId); }, [currentId, reaction]); return /* @__PURE__ */ jsx( CommentReactionButton, { disableable: false, "data-self": isActive ? "" : void 0, reaction, overrides, ...props, ref: forwardedRef } ); }); function openAttachment({ attachment, url }) { if (attachment.mimeType === "application/pdf" || attachment.mimeType.startsWith("image/") || attachment.mimeType.startsWith("video/") || attachment.mimeType.startsWith("audio/")) { window.open(url, "_blank"); } else { download(url, attachment.name); } } function CommentMediaAttachment({ attachment, onAttachmentClick, roomId, className, overrides, ...props }) { const { url } = useRoomAttachmentUrl(attachment.id, roomId); const handleClick = useCallback( (event) => { if (!url) { return; } const args = { attachment, url }; onAttachmentClick?.(args, event); if (event.isDefaultPrevented()) { return; } openAttachment(args); }, [attachment, onAttachmentClick, url] ); return /* @__PURE__ */ jsx( MediaAttachment, { className: cn("lb-comment-attachment", className), ...props, attachment, overrides, onClick: url ? handleClick : void 0, roomId } ); } function CommentFileAttachment({ attachment, onAttachmentClick, roomId, className, overrides, ...props }) { const { url } = useRoomAttachmentUrl(attachment.id, roomId); const handleClick = useCallback( (event) => { if (!url) { return; } const args = { attachment, url }; onAttachmentClick?.(args, event); if (event.isDefaultPrevented()) { return; } openAttachment(args); }, [attachment, onAttachmentClick, url] ); return /* @__PURE__ */ jsx( FileAttachment, { className: cn("lb-comment-attachment", className), ...props, attachment, overrides, onClick: url ? handleClick : void 0, roomId } ); } function CommentNonInteractiveFileAttachment({ className, ...props }) { return /* @__PURE__ */ jsx( FileAttachment, { className: cn("lb-comment-attachment", className), allowMediaPreview: false, ...props } ); } function AutoMarkReadThreadIdHandler({ threadId, roomId, commentRef }) { const markThreadAsRead = useMarkRoomThreadAsRead(roomId); const isWindowFocused = useWindowFocus(); useIntersectionCallback( commentRef, (isIntersecting) => { if (isIntersecting) { markThreadAsRead(threadId); } }, { // The underlying IntersectionObserver is only enabled when the window is focused enabled: isWindowFocused } ); return null; } const CommentDropdownItem = forwardRef(({ children, icon, onSelect, onClick, ...props }, forwardedRef) => { const handleClick = useCallback( (event) => { onClick?.(event); if (!event.isDefaultPrevented()) { event.stopPropagation(); } }, [onClick] ); return /* @__PURE__ */ jsx( DropdownItem, { icon, onSelect, onClick: handleClick, ...props, ref: forwardedRef, children } ); }); const Comment = Object.assign( forwardRef( ({ comment, indentContent = true, showDeleted, showActions = "hover", showReactions = true, showAttachments = true, showComposerFormattingControls = true, onAuthorClick, onMentionClick, onAttachmentClick, onCommentEdit, onCommentDelete, dropdownItems, overrides, components, className, actions, actionsClassName, autoMarkReadThreadId, ...props }, forwardedRef) => { const ref = useRef(null); const mergedRefs = useRefs(forwardedRef, ref); const currentUserId = useCurrentUserId(); const deleteComment = useDeleteRoomComment(comment.roomId); const editComment = useEditRoomComment(comment.roomId); const addReaction = useAddRoomCommentReaction(comment.roomId); const removeReaction = useRemoveRoomCommentReaction(comment.roomId); const $ = useOverrides(overrides); const [isEditing, setEditing] = useState(false); const [isTarget, setTarget] = useState(false); const [isMoreActionOpen, setMoreActionOpen] = useState(false); const [isReactionActionOpen, setReactionActionOpen] = useState(false); const { mediaAttachments, fileAttachments } = useMemo(() => { return separateMediaAttachments(comment.attachments); }, [comment.attachments]); const permissions = useRoomPermissions(comment.roomId); const canComment = permissions.size > 0 ? permissions.has(Permission.CommentsWrite) || permissions.has(Permission.Write) : true; const stopPropagation = useCallback((event) => { event.stopPropagation(); }, []); const handleEdit = useCallback(() => { setEditing(true); }, []); const handleEditCancel = useCallback( (event) => { event.stopPropagation(); setEditing(false); }, [] ); const handleEditSubmit = useCallback( ({ body, attachments }, event) => { onCommentEdit?.(comment); if (event.isDefaultPrevented()) { return; } event.stopPropagation(); event.preventDefault(); setEditing(false); editComment({ commentId: comment.id, threadId: comment.threadId, body, attachments }); }, [comment, editComment, onCommentEdit] ); const handleDelete = useCallback(() => { onCommentDelete?.(comment); deleteComment({ commentId: comment.id, threadId: comment.threadId }); }, [comment, deleteComment, onCommentDelete]); const handleAuthorClick = useCallback( (event) => { onAuthorClick?.(comment.userId, event); }, [comment.userId, onAuthorClick] ); const handleReactionSelect = useCallback( (emoji) => { const reactionIndex = comment.reactions.findIndex( (reaction) => reaction.emoji === emoji ); if (reactionIndex >= 0 && currentUserId && comment.reactions[reactionIndex]?.users.some( (user) => user.id === currentUserId )) { removeReaction({ threadId: comment.threadId, commentId: comment.id, emoji }); } else { addReaction({ threadId: comment.threadId, commentId: comment.id, emoji }); } }, [ addReaction, comment.id, comment.reactions, comment.threadId, removeReaction, currentUserId ] ); useEffect(() => { const isWindowDefined = typeof window !== "undefined"; if (!isWindowDefined) return; const hash = window.location.hash; const commentId = hash.slice(1); if (commentId === comment.id) { setTarget(true); } }, []); if (!showDeleted && !comment.body) { return null; } const defaultDropdownItems = comment.userId === currentUserId ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(CommentDropdownItem, { onSelect: handleEdit, icon: /* @__PURE__ */ jsx(EditIcon, {}), children: $.COMMENT_EDIT }), /* @__PURE__ */ jsx(CommentDropdownItem, { onSelect: handleDelete, icon: /* @__PURE__ */ jsx(DeleteIcon, {}), children: $.COMMENT_DELETE }) ] }) : null; const dropdownContent = typeof dropdownItems === "function" ? dropdownItems({ children: defaultDropdownItems, comment }) : /* @__PURE__ */ jsxs(Fragment, { children: [ defaultDropdownItems, dropdownItems ] }); return /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsxs(ComponentsProvider, { components, children: [ autoMarkReadThreadId && /* @__PURE__ */ jsx( AutoMarkReadThreadIdHandler, { commentRef: ref, threadId: autoMarkReadThreadId, roomId: comment.roomId } ), /* @__PURE__ */ jsxs( "div", { id: comment.id, className: cn( "lb-root lb-comment", indentContent && "lb-comment:indent-content", showActions === "hover" && "lb-comment:show-actions-hover", (isMoreActionOpen || isReactionActionOpen) && "lb-comment:action-open", className ), "data-deleted": !comment.body ? "" : void 0, "data-editing": isEditing ? "" : void 0, "data-target": isTarget ? "" : void 0, dir: $.dir, ...props, ref: mergedRefs, children: [ /* @__PURE__ */ jsxs("div", { className: "lb-comment-header", children: [ /* @__PURE__ */ jsxs("div", { className: "lb-comment-details", children: [ /* @__PURE__ */ jsx( Avatar, { className: "lb-comment-avatar", userId: comment.userId, onClick: handleAuthorClick } ), /* @__PURE__ */ jsxs("span", { className: "lb-comment-details-labels", children: [ /* @__PURE__ */ jsx( User, { className: "lb-comment-author", userId: comment.userId, onClick: handleAuthorClick } ), /* @__PURE__ */ jsxs("span", { className: "lb-comment-date", children: [ /* @__PURE__ */ jsx( Timestamp, { locale: $.locale, date: comment.createdAt, className: "lb-date lb-comment-date-created" } ), comment.editedAt && comment.body && /* @__PURE__ */ jsxs(Fragment, { children: [ " ", /* @__PURE__ */ jsx("span", { className: "lb-comment-date-edited", children: $.COMMENT_EDITED }) ] }) ] }) ] }) ] }), showActions && !isEditing && /* @__PURE__ */ jsxs("div", { className: cn("lb-comment-actions", actionsClassName), children: [ actions ?? null, showReactions && canComment ? /* @__PURE__ */ jsx( EmojiPicker, { onEmojiSelect: handleReactionSelect, onOpenChange: setReactionActionOpen, children: /* @__PURE__ */ jsx(Tooltip, { content: $.COMMENT_ADD_REACTION, children: /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx( Button, { className: "lb-comment-action", onClick: stopPropagation, "aria-label": $.COMMENT_ADD_REACTION, icon: /* @__PURE__ */ jsx(EmojiPlusIcon, {}) } ) }) }) } ) : null, dropdownContent ? /* @__PURE__ */ jsx( Dropdown, { open: isMoreActionOpen, onOpenChange: setMoreActionOpen, align: "end", content: dropdownContent, children: /* @__PURE__ */ jsx(Tooltip, { content: $.COMMENT_MORE, children: /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx( Button, { className: "lb-comment-action", disabled: !comment.body, onClick: stopPropagation, "aria-label": $.COMMENT_MORE, icon: /* @__PURE__ */ jsx(EllipsisIcon, {}) } ) }) }) } ) : null ] }) ] }), /* @__PURE__ */ jsx("div", { className: "lb-comment-content", children: isEditing ? /* @__PURE__ */ jsx( Composer, { className: "lb-comment-composer", onComposerSubmit: handleEditSubmit, defaultValue: comment.body, defaultAttachments: comment.attachments, autoFocus: true, showAttribution: false, showAttachments, showFormattingControls: showComposerFormattingControls, actions: /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( Tooltip, { content: $.COMMENT_EDIT_COMPOSER_CANCEL, "aria-label": $.COMMENT_EDIT_COMPOSER_CANCEL, children: /* @__PURE__ */ jsx( Button, { className: "lb-composer-action", onClick: handleEditCancel, icon: /* @__PURE__ */ jsx(CrossIcon, {}) } ) } ), /* @__PURE__ */ jsx( ShortcutTooltip, { content: $.COMMENT_EDIT_COMPOSER_SAVE, shortcut: "Enter", children: /* @__PURE__ */ jsx(ComposerSubmit, { asChild: true, children: /* @__PURE__ */ jsx( Button, { variant: "primary", className: "lb-composer-action", onClick: stopPropagation, "aria-label": $.COMMENT_EDIT_COMPOSER_SAVE, icon: /* @__PURE__ */ jsx(CheckIcon, {}) } ) }) } ) ] }), overrides: { COMPOSER_PLACEHOLDER: $.COMMENT_EDIT_COMPOSER_PLACEHOLDER }, roomId: comment.roomId } ) : comment.body ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( CommentBody, { className: "lb-comment-body", body: comment.body, components: { Mention: ({ mention }) => /* @__PURE__ */ jsx( CommentMention, { mention, onClick: (event) => onMentionClick?.(mention, event), overrides } ), Link: CommentLink } } ), showAttachments && (mediaAttachments.length > 0 || fileAttachments.length > 0) ? /* @__PURE__ */ jsxs("div", { className: "lb-comment-attachments", children: [ mediaAttachments.length > 0 ? /* @__PURE__ */ jsx("div", { className: "lb-attachments", children: mediaAttachments.map((attachment) => /* @__PURE__ */ jsx( CommentMediaAttachment, { attachment, overrides, onAttachmentClick, roomId: comment.roomId }, attachment.id )) }) : null, fileAttachments.length > 0 ? /* @__PURE__ */ jsx("div", { className: "lb-attachments", children: fileAttachments.map((attachment) => /* @__PURE__ */ jsx( CommentFileAttachment, { attachment, overrides, onAttachmentClick, roomId: comment.roomId }, attachment.id )) }) : null ] }) : null, showReactions && comment.reactions.length > 0 && /* @__PURE__ */ jsxs("div", { className: "lb-comment-reactions", children: [ comment.reactions.map((reaction) => /* @__PURE__ */ jsx( CommentReaction, { comment, reaction, overrides, disabled: !canComment }, reaction.emoji )), canComment ? /* @__PURE__ */ jsx(EmojiPicker, { onEmojiSelect: handleReactionSelect, children: /* @__PURE__ */ jsx(Tooltip, { content: $.COMMENT_ADD_REACTION, children: /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx( Button, { className: "lb-comment-reaction lb-comment-reaction-add", variant: "outline", onClick: stopPropagation, "aria-label": $.COMMENT_ADD_REACTION, icon: /* @__PURE__ */ jsx(EmojiPlusIcon, {}) } ) }) }) }) : null ] }) ] }) : /* @__PURE__ */ jsx("div", { className: "lb-comment-body", children: /* @__PURE__ */ jsx("p", { className: "lb-comment-deleted", children: $.COMMENT_DELETED }) }) }) ] } ) ] }) }); } ), { /** * Displays a dropdown item in the comment's dropdown. */ DropdownItem: CommentDropdownItem } ); export { Comment, CommentLink, CommentMention, CommentNonInteractiveFileAttachment, CommentNonInteractiveLink, CommentNonInteractiveReaction, CommentReaction }; //# sourceMappingURL=Comment.js.map