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 };
}