import { useMemo } from 'react' import { Editor, TLComment, TLCommentReaction, createCommentReaction, createCommentReactionId, useEditor, useValue, } from 'tldraw' import { RenderReaction } from '../ui/reaction' import { ReactionPicker } from '../ui/reaction-picker' import { Reactions, ReactionSummary } from '../ui/reactions' import { commitCommentMutation } from './comment-mutations' import { UNKNOWN_AUTHOR } from './comment-render' import { getCommentReactions } from './comment-store' import { getCommentingOptions, useCommentingOptions } from './options' /** * One comment's reactions, oldest first, reactively. * * @public */ export function useCommentReactions( editor: Editor, commentId: TLComment['id'] ): TLCommentReaction[] { return useValue( 'comment reactions', () => getCommentReactions(editor) .filter((reaction) => reaction.commentId === commentId) .sort((a, b) => a.createdAt - b.createdAt), [editor, commentId] ) } /** * The reaction fields {@link summarizeReactions} needs — a structural subset of * {@link tldraw#TLCommentReaction}, so tallies can also be built from rows synced outside the * editor store. * * @public */ export interface ReactionSummaryInput { userId: string emoji: string createdAt: number } /** * Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so * the row stays stable as later reactions arrive. `active` marks the current user's emoji and * `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't * name falls back to a generic "Someone", never the raw user id. * * @public */ export function summarizeReactions( reactions: readonly ReactionSummaryInput[], currentUserId?: string | null, resolveName?: (userId: string) => string | undefined ): ReactionSummary[] { const groups = new Map< string, { count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] } >() for (const reaction of reactions) { const mine = currentUserId != null && reaction.userId === currentUserId // Fall back to a generic name, never the raw user id, when the id can't be resolved. const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine } const group = groups.get(reaction.emoji) if (group) { group.count++ group.active ||= mine group.firstAt = Math.min(group.firstAt, reaction.createdAt) group.reactors.push(reactor) } else { groups.set(reaction.emoji, { count: 1, active: mine, firstAt: reaction.createdAt, reactors: [reactor], }) } } return [...groups] .sort(([, a], [, b]) => a.firstAt - b.firstAt) .map(([emoji, group]) => ({ emoji, count: group.count, active: group.active, reactors: group.reactors, })) } /** * Toggle one user's reaction with a given emoji on a comment. * * Each reaction is its own record keyed by (comment, user, emoji), so this only touches that user's * own records and two people reacting at once never conflict. Behaviour depends on * `allowMultipleReactions`: * * - **multiple** (default): the emoji toggles independently, leaving other reactions alone. * - **single**: a new emoji replaces the user's existing reaction; the same one removes it. * * @public */ export function toggleCommentReaction( editor: Editor, comment: TLComment, userId: string, emoji: string, now = Date.now() ): void { const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor) const targetId = createCommentReactionId(comment.id, userId, emoji) const mine = getCommentReactions(editor).filter( (reaction) => reaction.commentId === comment.id && reaction.userId === userId ) const removing = mine.some((reaction) => reaction.id === targetId) // Only a token the configured palette allows may be added. Removals always go through — if a // reaction somehow carries an off-palette token, the user must still be able to clear it. if (!removing && !isAllowedReaction(emoji)) return commitCommentMutation(editor, ({ put, remove }) => { if (removing) { remove([targetId]) return } // Single-select: a new emoji replaces the user's existing reaction(s) on this comment. if (!allowMultipleReactions && mine.length > 0) { remove(mine.map((reaction) => reaction.id)) } put([ createCommentReaction({ commentId: comment.id, threadId: comment.threadId, pageId: comment.pageId, userId, emoji, now, }), ]) }) } /** @public */ export interface CommentReactionsProps { comment: TLComment /** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the * pills don't toggle. */ currentUserId?: string | null /** Names a reactor id for the hover list. Ids it can't name fall back to a generic "Someone". */ resolveName?(userId: string): string | undefined } /** * Adapts the `ReactionContent` override into a `renderReaction` function for the presentational * components. Undefined when no override is set, so they fall back to drawing the token string. */ function useReactionRenderer(): RenderReaction | undefined { const { components } = useCommentingOptions() const ReactionContent = components.ReactionContent return useMemo( () => (ReactionContent ? (token: string) => : undefined), [ReactionContent] ) } /** * The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what * adds a reaction. * @public @react */ export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) { const editor = useEditor() const renderReaction = useReactionRenderer() const { components } = useCommentingOptions() const reactions = useCommentReactions(editor, comment.id) const summaries = useMemo( () => summarizeReactions(reactions, currentUserId, resolveName), [reactions, currentUserId, resolveName] ) // Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it // doesn't compete with the menu the user is actually working in. Edit mode already hides the // pills entirely (the card becomes a composer), so it needs no special case here. const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [ editor, ]) return ( { if (currentUserId == null) return toggleCommentReaction(editor, comment, currentUserId, value) }} /> ) } /** @public */ export interface CommentReactionPickerProps { comment: TLComment /** The reacting user. Null/omitted renders nothing — there's nobody to react as. */ currentUserId?: string | null /** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */ emoji?: string[] } /** * The add-reaction button for one comment. Belongs with the comment card's hover actions rather * than in the reaction row, so opening it doesn't chase the row as reactions are added. * @public @react */ export function CommentReactionPicker({ comment, currentUserId, emoji, }: CommentReactionPickerProps) { const editor = useEditor() const { components } = useCommentingOptions() const renderReaction = useReactionRenderer() const reactions = useCommentReactions(editor, comment.id) const selected = useMemo( () => reactions .filter((reaction) => currentUserId != null && reaction.userId === currentUserId) .map((reaction) => reaction.emoji), [reactions, currentUserId] ) if (currentUserId == null) return null return ( toggleCommentReaction(editor, comment, currentUserId, value)} /> ) }