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

import type { EventReaction } from '@/modules/crm/types';
import { useTRPC } from '@/providers/query-provider';

/**
 * Reactions in the unibox's LinkedIn and WhatsApp chats — the sibling of `useChannelReactions`,
 * which does the same job for Slack.
 *
 * Two providers, one hook, because Unipile gives them the same model: reactions are keyed by the
 * NATIVE EMOJI (there are no custom shortcodes to name), and each person has at most ONE reaction
 * on a message. That second rule is the whole reason this is not simply Slack's hook pointed at
 * another route — clicking 🎉 when you already reacted 👍 REPLACES your reaction rather than
 * adding a second, and the optimistic write has to model that or the chip row lies until the
 * next read.
 */

/**
 * One message as the two chat reads return it — only the fields a reaction write touches.
 *
 * BOTH id spellings, because the reads disagree. LinkedIn's mirrored rows put a Cedar uuid in
 * `id` and Unipile's message id in `providerMessageId`; its live fallback and WhatsApp's read
 * put the provider id straight in `id`. The write path addresses a message by the PROVIDER id
 * (see `ChannelThreadView`), so matching on `id` alone silently missed every mirrored LinkedIn
 * message — which is the common case — and left the chip frozen until a refetch.
 */
export type ReactableMessage = {
  id?: string;
  providerMessageId?: string | null;
  reactions?: EventReaction[];
};

/**
 * Is this cached message the one the reaction was written against?
 *
 * Exported for the test. The whole subtlety is the precedence: `providerMessageId` FIRST, since
 * that is the id `ChannelThreadView` hands the write path, and `id` only as the fallback for the
 * reads that put the provider id there.
 */
export function matchesMessage(m: ReactableMessage, messageId: string): boolean {
  return (m.providerMessageId ?? m.id) === messageId;
}

/**
 * Apply the provider's replace-my-reaction rule locally.
 *
 * Exported for the test: this is the rule that differs from Slack, and the interesting cases
 * (react while already reacted, un-react the last reactor off a shared chip) are pure.
 */
export function applyOwnChatReaction(
  current: EventReaction[],
  emoji: string,
  remove: boolean,
): EventReaction[] {
  const withoutMine = current
    .map((r) => (r.reactedByMe ? { ...r, count: r.count - 1, reactedByMe: false } : r))
    // A chip whose only reactor was me disappears with my reaction; one others also used stays,
    // un-highlighted and one lower.
    .filter((r) => r.count > 0);
  if (remove) return withoutMine;

  const existing = withoutMine.find((r) => r.key === emoji);
  if (existing) {
    return withoutMine.map((r) =>
      r.key === emoji ? { ...r, count: r.count + 1, reactedByMe: true } : r,
    );
  }
  return [...withoutMine, { key=[redacted], emojiUnicode: emoji, count: 1, reactedByMe: true }];
}

export function useChatReactions(input: {
  channel: 'linkedin' | 'whatsapp';
  unipileAccountId: string;
  chatId: string;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const mutation = useMutation(trpc.inbox.reactToChannelMessage.mutationOptions());

  /**
   * Rewrite one message's reactions in the chat read that is on screen.
   *
   * THIS CHANNEL'S key only. Patching both reads meant one click could touch a cached message in
   * the other channel that happened to share a provider id — and, worse, the rollback below
   * captures a single `previous`, so a failed mutation would write one channel's aggregate onto
   * the other's message. The hook already knows which channel it is bound to; there is nothing to
   * gain by guessing. Still matched by PREFIX, so every cached chat of this channel is covered
   * without knowing the rest of the key.
   */
  const writeCache = useCallback(
    (messageId: string, next: (current: EventReaction[]) => EventReaction[]) => {
      const patch = (data: { messages?: ReactableMessage[] } | undefined) =>
        data?.messages
          ? {
              ...data,
              messages: data.messages.map((m) =>
                matchesMessage(m, messageId) ? { ...m, reactions: next(m.reactions ?? []) } : m,
              ),
            }
          : data;

      queryClient.setQueriesData<{ messages?: ReactableMessage[] }>(
        {
          queryKey=[redacted] === 'whatsapp'
              ? trpc.outbound.whatsapp.chatMessages.queryKey()
              : trpc.linkedin.messaging.listChatMessages.queryKey(),
        },
        patch,
      );
    },
    [queryClient, trpc, input.channel],
  );

  const react = useCallback(
    (messageId: string, emoji: string, remove = false) => {
      let previous: EventReaction[] = [];
      writeCache(messageId, (current) => {
        previous = current;
        return applyOwnChatReaction(current, emoji, remove);
      });

      mutation.mutate(
        {
          channel: input.channel,
          unipileAccountId: input.unipileAccountId,
          chatId: input.chatId,
          messageId,
          emoji,
          remove,
        },
        {
          onSuccess: (data) => {
            // Both channels answer with the stored aggregate. Null means the mirror has not
            // caught up to this message yet, and the optimistic write stands until it does.
            if (data?.reactions) writeCache(messageId, () => data.reactions as EventReaction[]);
          },
          onError: () => {
            writeCache(messageId, () => previous);
            toast.error('Failed to update reaction');
          },
        },
      );
    },
    [input.channel, input.chatId, input.unipileAccountId, mutation, writeCache],
  );

  /**
   * A click on an EXISTING chip. Withdraws the reaction when it is already mine, and otherwise
   * joins it — which, under one-reaction-per-person, also moves my reaction off whatever else I
   * had reacted with.
   */
  const toggle = useCallback(
    (messageId: string, key=[redacted], reactedByMe: boolean) => react(messageId, key, reactedByMe),
    [react],
  );

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