@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
JavaScript
"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