use-channel-reactions.ts6.1 KBView on GitHub
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';

import { emojiToSlackName } from '@/modules/conversations/components/timeline/emoji-shortcodes';
import type { EventReaction } from '@/modules/crm/types';
import { useTRPC } from '@/providers/query-provider';

/**
 * Reactions for the unibox's Slack channel view (design: slack-parity.md Phase 6).
 *
 * The write path is the SAME `crm.toggleReaction` the conversation timeline uses — it already
 * resolves the reactor's Slack identity, posts to `reactions.add`/`remove` as the user, and
 * upserts against the webhook echo of the same reaction. None of that is rebuilt here.
 *
 * What differs is where the optimistic state lives. `useToggleReaction` writes through
 * `setEventReactions(conversationId, …)` on the conversation store, and the unibox has no
 * conversation to key on — a Slack channel need not be linked to a deal at all, which is the
 * whole point of Phase 1. So this updates the react-query caches for the two reads that actually
 * render here instead.
 */

/** Mirrors SLACK_REAUTH_MARKER on the server — errors that need a Slack reconnect. */
const SLACK_REAUTH_MARKER = 'SLACK_REAUTH_REQUIRED';

type ReactableMessage = { eventId: string; reactions: EventReaction[] };

/** Apply a local toggle so the chip moves before the server replies. */
export function applyToggle(
  current: EventReaction[],
  key=[redacted],
  emojiUnicode: string | null,
): EventReaction[] {
  const existing = current.find((r) => r.key === key);
  if (!existing) return [...current, { key, emojiUnicode, count: 1, reactedByMe: true }];
  if (existing.reactedByMe) {
    const count = existing.count - 1;
    // The last reactor removing theirs removes the chip; otherwise it stays with a lower count
    // and un-highlights, which is what "someone else still reacted" should look like.
    return count <= 0
      ? current.filter((r) => r.key !== key)
      : current.map((r) => (r.key === key ? { ...r, count, reactedByMe: false } : r));
  }
  return current.map((r) => (r.key === key ? { ...r, count: r.count + 1, reactedByMe: true } : r));
}

export function useChannelReactions(input: { workspaceId: string; channelId: string }) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  const mutation = useMutation(trpc.crm.toggleReaction.mutationOptions());

  /**
   * Rewrite one message's reactions everywhere it is currently rendered.
   *
   * Both caches, unconditionally: a root message appears in the channel stream AND inside its own
   * thread panel, so updating only the one that happened to be clicked leaves the other showing
   * the pre-click state until something refetches it. `queryKey` is matched by prefix
   * (`filters.queryKey` without `exact`) because the thread read is keyed by `threadTs` and the
   * caller reacting from the stream does not know which thread the message belongs to.
   */
  const writeCaches = useCallback(
    (eventId: string, next: (current: EventReaction[]) => EventReaction[]) => {
      const patch = (messages: ReactableMessage[] | undefined) =>
        messages?.map((m) => (m.eventId === eventId ? { ...m, reactions: next(m.reactions) } : m));

      queryClient.setQueriesData<ReactableMessage[]>(
        {
          queryKey=[redacted]
            workspaceId: input.workspaceId,
            channelId: input.channelId,
          }),
        },
        patch,
      );
      queryClient.setQueriesData<ReactableMessage[]>(
        { queryKey=[redacted] },
        patch,
      );
    },
    [queryClient, trpc, input.workspaceId, input.channelId],
  );

  /** Toggle a SPECIFIC aggregate — a chip click, where the Slack shortcode is already known. */
  const toggle = useCallback(
    (eventId: string, key=[redacted], emojiUnicode: string | null) => {
      // A live-filled thread message has no `crm_events` row to hang a reaction on (its id is
      // `live:<channel>:<ts>`), so there is nothing to toggle. Silent rather than a toast: the
      // chips are absent on those rows anyway, so this is only reachable by a race.
      if (eventId.startsWith('live:')) return;

      // Snapshot for the rollback BEFORE the optimistic write, per message rather than per cache:
      // the same event may sit in two caches and both must go back to the same value.
      let previous: EventReaction[] = [];
      writeCaches(eventId, (current) => {
        previous = current;
        return applyToggle(current, key, emojiUnicode);
      });

      mutation.mutate(
        { eventId, reactionKey: key, emojiUnicode: emojiUnicode ?? undefined },
        {
          onSuccess: (data) => writeCaches(eventId, () => data.reactions),
          onError: (error) => {
            writeCaches(eventId, () => previous);
            if (error instanceof Error && error.message.includes(SLACK_REAUTH_MARKER)) {
              toast.error('Reconnect Slack to use reactions', {
                description: 'Cedar needs the reactions permission on your Slack workspace.',
                action: { label: 'Reconnect', onClick: () => navigate('/settings/connections') },
                duration: 8000,
              });
            } else {
              toast.error('Failed to update reaction');
            }
          },
        },
      );
    },
    [mutation, navigate, writeCaches],
  );

  /**
   * React with a unicode glyph from the picker or the quick row.
   *
   * Slack's API keys reactions by SHORTCODE (`white_check_mark`), not by the character, so a glyph
   * with no shortcode cannot be sent at all — saying so beats posting something the workspace will
   * reject and leaving a chip that vanishes on the next refetch.
   */
  const react = useCallback(
    (eventId: string, emoji: string) => {
      const name = emojiToSlackName(emoji);
      if (!name) {
        toast.error('That emoji can’t be used as a Slack reaction');
        return;
      }
      toggle(eventId, name, emoji);
    },
    [toggle],
  );

  return { toggle, react, isPending: mutation.isPending };
}