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