message-actions-and-reactions.md15.5 KBView on GitHub # Message hover actions + reactions
## Context
The inbox/timeline message rows currently expose only a bare "move" and "delete"
affordance (`EventHoverActions`), and there is no way to react to a message or to
reply to a Slack message inline. We want each message (email **and** Slack) to get
a Slack-style floating action toolbar in its top-right on hover, containing:
1. **Reactions** — quick-reaction buttons + an emoji picker. Reactions persist in
Cedar and, for Slack messages, post to Slack via `reactions.add`/`reactions.remove`.
Reactions added by anyone directly in Slack flow back into Cedar live via the
Slack Events API (two-way sync).
2. **Reply** — Slack: opens the full thread overlay. Email: opens the email thread.
3. **A three-dot overflow menu** — holds **Move** (move event to another
conversation) and **Delete**. Delete opens an "Are you sure?" confirmation modal.
This replaces the always-inline move/delete buttons with a cleaner, denser toolbar
and adds a real reactions feature across the stack (DB → server → Slack API → webhook
→ tRPC → frontend).
## Current state
- `apps/mail/.../timeline/SlackTimelineEvent.tsx` renders every event row (email,
Slack, meeting, note…) in the timeline and thread views. On hover it renders
`EventHoverActions` in a third grid column.
- `EventHoverActions.tsx` renders, inline: an "open thread" button (email only),
`MoveEventPopover`, and a delete button that fires `crm.deleteEvent` with no
confirmation.
- `MoveEventPopover.tsx` is a self-contained Popover + `Command` search that calls
`crm.moveEvent`. Used here with `alwaysVisible`.
- Slack threads open via the `onOpenSlackThread(threadKey)` callback threaded
`InboxTab → InboxThreadList → SlackThreadRow`. `threadKey(event)` (in
`inboxThreadKey.ts`) yields `slack:{workspace}:{channel}:{threadTs ?? 'root'}`.
`SlackTimeline` (the timeline view) does **not** currently receive
`onOpenSlackThread`.
- No reactions exist anywhere: no DB column/table, no tRPC route, no Slack API call,
no webhook handling, nothing on `SlackMessageEvent`/`ConversationEvent`.
- Slack send path: `UniversalComposer` → `trpc.integrations.slack.sendMessage` →
`sendSlackMessage()` in `services/integrations/slack/slack-api.ts`, which runs a
**Superglue** workflow. The raw Slack access token is available via
`getUserAuth({ userId, serverName: 'slack' })` (prefers the `xoxp-*` user token).
- Slack inbound events: `POST /slack/events` in `apps/server/src/http/app.ts`
(signature-verified, dedup'd) dispatches by `payload.event.type`
(`member_joined_channel`, `app_mention`, `message`). Handlers live in
`services/integrations/slack/slack-events-webhook.ts`.
- `getConversation()` in `services/crm/conversations.ts` hydrates events via
`db.query.crmEvents.findMany({ with: { slackMessage: true, ... } })` (drizzle
relations) and ships them as `EventWithTypeData` → frontend `ConversationEvent`.
## Proposed changes
### Data model — generic per-event reactions
New table `crm_event_reactions` (in `apps/server/src/db/crm-schema.ts`), keyed by
`eventId` so it works for any event type (Slack today, email internal-only, others
later). One row = one (event, emoji, reactor) tuple.
| column | type | notes |
|---|---|---|
| `id` | uuid pk | `defaultRandom()` |
| `eventId` | uuid | → `crm_events.id` `onDelete: cascade` |
| `ownerUserId` | text | → `user.id` cascade. Cedar account that owns this event's data (denormalized like `crm_slack_messages.userId`); scopes the row. |
| `reactionKey` | text | canonical grouping key. Slack → shortcode name (e.g. `white_check_mark`). Email → the unicode char. |
| `emojiUnicode` | text (nullable) | unicode glyph for display, resolved from shortcode where possible. |
| `reactorId` | text | who reacted: Slack reactor user id for Slack-origin; Cedar user id for email-origin. |
| `reactorIsSlack` | boolean | distinguishes the namespace of `reactorId`. |
| `source` | text | `'cedar'` or `'slack'` (provenance). |
| `createdAt` | timestamp | `defaultNow()` |
Constraints/indexes:
- `unique(eventId, reactionKey, reactorId)` — idempotent upsert; the webhook echo of
our own optimistic insert collides on this key instead of duplicating.
- `index(eventId)` for the per-conversation aggregate fetch.
A drizzle relation `crmEventReactionsRelations` + `reactions: many(crmEventReactions)`
added to `crmEventsRelations`, so `getConversation` can hydrate with
`with: { reactions: true }`.
Migration: hand-write `apps/server/src/db/migrations/00XX_crm_event_reactions.sql`
(next free number) creating the table + constraints, mirroring the style of
`0034_crm_slack_messages.sql`. (Generate via `pnpm --filter @cedar/db db:generate`
if drizzle-kit picks it up, else hand-write + update `meta`.)
### Slack reactions API helpers
In `slack-api.ts`, add `addSlackReaction` / `removeSlackReaction(channelId, messageTs,
reactionName, workspaceId, userId)`. These call Slack's Web API **directly via `fetch`**
(`https://slack.com/api/reactions.add` / `.remove`, `Authorization: Bearer <user
token>`, form-encoded `channel`, `timestamp`, `name`) — no new Superglue workflow
needed. Reuse the `getUserAuth` token extraction from `sendSlackMessage`, and guard
with `assertProviderSideEffectsAllowed('slack.reactions.add' | '.remove')`. Treat
`already_reacted` / `no_reaction` as success (idempotent). **Requires the Slack app to
hold the `reactions:write` scope** — surfaced as an error toast if missing.
### tRPC: `crm.toggleReaction`
In `apps/server/src/trpc/routes/crm.ts` (pattern = `deleteEvent`/`moveEvent`):
- Input: `{ eventId: uuid, reactionKey=[redacted], emojiUnicode?: string }`.
- Load the event + `crmSlackMessages` row. Determine my reactor identity:
Slack message → my Slack user id (from `getUserAuth` `authed_user.id`, cached);
email/other → `ctx.sessionUser.id` (`reactorIsSlack=false`).
- If a `(eventId, reactionKey, reactorId)` row exists → delete it and, for Slack,
call `removeSlackReaction`. Else insert it (`onConflictDoNothing`) and, for Slack,
call `addSlackReaction`. Slack API failure rolls back the local change + throws.
- Output: the event's full reaction aggregate (so the client can reconcile).
### getConversation payload
In `conversations.ts`, add `reactions: true` to the `crmEvents.findMany` `with`, then
fold each event's rows into an aggregate `EventReaction[]`:
`{ key, emojiUnicode, count, reactedByMe }` (grouped by `reactionKey`, `count` =
distinct reactors, `reactedByMe` = a row whose `reactorId` matches my Slack/Cedar id).
Attach as `event.reactions`. Extend `EventWithTypeData` and the frontend
`ConversationEvent` type (`apps/mail/modules/crm/types/index.ts`) with
`reactions?: EventReaction[]`.
### Two-way sync webhook
- Add `SlackReactionEvent` type + `handleSlackReactionEvent()` in
`slack-events-webhook.ts`: from `event.item.channel` + `event.item.ts`, find the
matching `crm_slack_messages` row(s) (there may be one per owning Cedar user) to get
`eventId` + `ownerUserId`; `event.user` = reactor Slack id, `event.reaction` =
shortcode. `reaction_added` → upsert row (`source:'slack'`, `reactorIsSlack:true`,
resolve `emojiUnicode` from the shortcode); `reaction_removed` → delete the matching
row.
- Dispatch it from `POST /slack/events` in `app.ts` with a new
`else if (type === 'reaction_added' || type === 'reaction_removed')` branch
(fire-and-forget, like the others).
- **External config:** the Slack app must subscribe to the `reaction_added` /
`reaction_removed` events and hold `reactions:read` (read) + `reactions:write`
(post). Documented here; not code.
### Frontend — toolbar + reaction display
- **`EventHoverActions.tsx`** becomes the floating toolbar (absolute top-right,
`bg-popover` + `border` + `shadow-sm` + rounded, shown on hover). Contents:
- Quick-reaction buttons (`✅ 👀 🙌`) + an "add reaction" button opening the
existing `EmojiPicker` (`components/ui/emoji-picker.tsx`). All call a
`toggleReaction` handler.
- Reply button: Slack → `onReply()` (= `onOpenSlackThread(threadKey(event))`);
email → `openThread(emailThreadId)` (Zustand `openThread`, already used here).
- Three-dot `DropdownMenu` (`components/ui/dropdown-menu.tsx`): **Move** opens a
controlled `Dialog` containing the move search (see refactor below); **Delete**
opens an `AlertDialog` confirm that fires `crm.deleteEvent`.
- **Refactor `MoveEventPopover`**: extract the inner `Command` + queries + `handleSelect`
into a presentational `MoveEventCommand`. `MoveEventPopover` keeps wrapping it in a
Popover (existing call sites unchanged); the new menu renders `MoveEventCommand`
inside a `Dialog` to avoid Radix nested-popover focus issues.
- **`MessageReactions` component** (new): renders the reaction chips beneath the
message body in `SlackTimelineEvent` (emoji + count, highlighted when `reactedByMe`,
click toggles). Uses a shared `useToggleReaction` hook with optimistic update +
`crm.getConversation` invalidation.
- **Shortcode ⇄ unicode** helper: reuse the `emojis` dataset from
`@tiptap/extension-emoji` (already used by `EmojiPicker`) to map a Slack shortcode
→ unicode (`slackNameToEmoji`) and a picked unicode → its Slack shortcode
(`emojiToSlackName`) for the API call.
- **Thread plumbing for reply**: thread `onOpenSlackThread` from `InboxTab` →
`SlackTimeline` → `SlackTimelineEvent` → `EventHoverActions` (new optional
`onReply`/`onOpenSlackThread` prop). In `SlackThreadDisplay` the thread is already
open, so reply there is a no-op/omitted.
## Critical files
Server:
- `apps/server/src/db/crm-schema.ts` — new table + relations.
- `apps/server/src/db/migrations/00XX_crm_event_reactions.sql` — migration.
- `apps/server/src/services/integrations/slack/slack-api.ts` — add/remove reaction.
- `apps/server/src/services/integrations/slack/slack-events-webhook.ts` — webhook handler.
- `apps/server/src/http/app.ts` — dispatch reaction events.
- `apps/server/src/trpc/routes/crm.ts` — `toggleReaction` mutation.
- `apps/server/src/services/crm/conversations.ts` — hydrate + aggregate reactions.
Frontend:
- `apps/mail/modules/crm/types/index.ts` — `EventReaction`, extend `ConversationEvent`.
- `apps/mail/modules/conversations/components/timeline/EventHoverActions.tsx` — toolbar.
- `apps/mail/modules/conversations/components/timeline/MoveEventPopover.tsx` — extract `MoveEventCommand`.
- `apps/mail/modules/conversations/components/timeline/MessageReactions.tsx` — new chips.
- `apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx` — render chips + pass reply.
- `apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx` + `InboxTab.tsx` — thread reply plumbing.
- `apps/mail/modules/conversations/components/timeline/emoji-shortcodes.ts` — new map helpers.
- Reuse: `components/ui/{dropdown-menu,alert-dialog,dialog,emoji-picker}.tsx`.
## Phased implementation plan
### Phase 1 — Hover toolbar restructure (UI only, no persistence)
- [x] Refactor `MoveEventPopover` to extract `MoveEventCommand`.
- [x] Rebuild `EventHoverActions` as the floating top-right toolbar: reply button,
three-dot `DropdownMenu` with **Move** (Dialog + `MoveEventCommand`) and
**Delete** (`AlertDialog` confirm → `crm.deleteEvent`). Reaction buttons added
in Phase 4.
- [x] Thread `onOpenSlackThread` from `InboxTab` → `SlackTimeline` →
`SlackTimelineEvent` → toolbar; wire reply (Slack→thread, email→`openThread`).
- [x] Restyle the toolbar container to match the Slack-style floating chip.
- Test: hover an email and a Slack row in the timeline; confirm toolbar appears,
reply opens the right thread, move works, delete prompts then deletes. `pnpm types`.
### Phase 2 — Reactions schema + migration
- [x] Add `crmEventReactions` table + `crmEventReactionsRelations`; add
`reactions: many(...)` to `crmEventsRelations`; export both from `schema.ts`.
- [x] Write migration `0045_crm_event_reactions.sql` (hand-written, mirroring
`0034_crm_slack_messages.sql`). Added `reactor_cedar_user_id` so `reactedByMe`
needs no Slack identity lookup at read time.
- Test: `pnpm deps:check` ✓. **Migration applied** to the dev DB in `apps/server/.env`
(via `postgres.js`, since the `DATABASE_URL` carries `?pgbouncer=true` which `psql`
rejects and drizzle's journal is out of sync). Re-run the same SQL against other
environments (prod) as part of deploy — it is idempotent on a fresh DB and the
feature code no-ops gracefully until the table exists everywhere.
### Phase 3 — Slack API + tRPC + payload
- [x] `addSlackReaction` / `removeSlackReaction` + `getSlackUserIdentity` in
`slack-api.ts` (direct `fetch` to the Slack Web API; idempotent on
`already_reacted` / `no_reaction`).
- [x] `crm.toggleReaction` mutation (Slack call first, then DB toggle; returns the
event's reaction aggregate).
- [x] Hydrate `reactions` per event in `getConversationsSingleQuery` (SQL aggregate
with `reactedByMe`); extend `HydratedConversation` event shapes.
- [x] Shared `aggregateEventReactions` + `EventReaction` in `services/crm/reactions.ts`;
frontend `ConversationEvent`/`EventReaction` types.
- Test: `pnpm types` clean for touched files.
### Phase 4 — Frontend reaction display + wiring
- [x] `emoji-shortcodes.ts` (`slackNameToEmoji`/`emojiToSlackName`).
- [x] `MessageReactions` chips under the message body in `SlackTimelineEvent`.
- [x] `useToggleReaction` hook (optimistic store update via new
`setEventReactions` slice action + server reconcile) wired into toolbar quick
reactions, `EmojiPicker`, and chips.
- Test: `pnpm types` clean for touched files.
### Phase 5 — Two-way sync webhook
- [x] `SlackReactionEvent` type + `handleSlackReactionEvent` in the webhook service
(matches message rows by channel+ts, upsert/delete per owner; strips
`::skin-tone-N`).
- [x] Dispatch branch in `POST /slack/events` (+ event union type).
- [x] Slack app config requirement (below).
- Test: `pnpm types` clean for touched files.
## Slack app configuration (external, required for reactions)
The connected Slack app must be granted and re-authorized with:
- **Scopes:** `reactions:write` (post reactions from Cedar) and `reactions:read`
(receive reaction events).
- **Event subscriptions:** `reaction_added` and `reaction_removed`.
**Rollout to users.** Slack scopes are not declared in this repo — they live in the
Slack app manifest (managed via Slack/Klavis), so granting them is a dashboard change,
not a deploy. Once the manifest has the new scopes:
- **New connections** request them automatically on first authorize.
- **Existing users** must re-authorize. The connections page already has a "Reinstall
Slack app" button (`apps/mail/modules/integrations/slack-integration-card.tsx`,
`handleReinstall`) that runs a fresh OAuth grant.
**Self-service reconnect on failure.** `addSlackReaction`/`removeSlackReaction` tag
reauth-class Slack errors (`missing_scope`, `invalid_auth`, `token_revoked`, …) with
the `SLACK_REAUTH_MARKER` prefix. `useToggleReaction` detects it and shows a toast with
a **Reconnect** action that routes to `/settings/connections`, where the Reinstall flow
re-grants the scope. So a user who reacts before re-authorizing gets a direct path to
fix it rather than a dead-end error.
## Verification
- `pnpm types` clean for touched files; `pnpm deps:check` after schema/service edits.
- Manual end-to-end in the running app (`pnpm dev`): timeline hover → toolbar; reply
routing; move + delete-confirm; react (Cedar→Slack) and observe in Slack; react in
Slack (Slack→Cedar) and observe in Cedar.
- Confirm reaction rows are scoped per `ownerUserId` and cascade-delete with their
event.