slack-inbox-read-and-empty-channels.md9.4 KBView on GitHub # Slack inbox: unread dots + empty linked channels
The conversation inbox ([InboxTab.tsx](../modules/conversations/components/timeline/InboxTab.tsx)) lists
each Slack thread as a row built purely from synced events. Two gaps:
1. **No unread indicator.** Slack rows reserve an *invisible* dot slot
([SlackThreadRow.tsx](../modules/conversations/components/timeline/SlackThreadRow.tsx) line ~120) but
nothing populates it — there is no Slack read/unread state anywhere (not on the client
`SlackMessageEvent` type, not in the DB). Email rows get their blue dot from
`crm_email_events.is_read`; Slack has no equivalent.
2. **Empty linked channels are hidden.** A Slack channel linked to a conversation lives in
`crm_conversations.integrationMetadata` as `{ type:'slack', channelId, workspaceId, channelName }`.
The redesigned inbox derives rows only from events, so a linked channel with zero synced
messages never appears. (The older [ConversationInbox.tsx](../modules/conversations/components/ConversationInbox.tsx)
*does* show these as "No messages synced" and syncs on click — the pattern to port.)
This doc adds channel-grain Slack read state (→ blue dot) and surfaces empty linked channels as
rows that auto-sync when opened.
## Current state
### Read state
- `crm_email_events.is_read` (boolean) — [crm-schema.ts:710](../../server/src/db/crm-schema.ts) — is the
email read model. Surfaced in `getConversation` via `'isRead', ee.is_read`
([conversations.ts:2798](../../server/src/services/crm/conversations.ts)).
- `crm_slack_messages` ([crm-schema.ts:827-877](../../server/src/db/crm-schema.ts)) has **no** read column.
It is already per-user (unique on `userId, slackWorkspaceId, slackChannelId, slackMessageTs`), so a
read flag added here is naturally per-user.
- The big `getConversationsSingleQuery` builds `slackMessage` jsonb at
[conversations.ts:2804-2826](../../server/src/services/crm/conversations.ts).
- Client `SlackMessageEvent` type: [types/index.ts:488-507](../modules/crm/types/index.ts). No read field.
- Inbox grouping key=[redacted] ?? 'root'}`
([inboxThreadKey.ts](../modules/conversations/components/timeline/inboxThreadKey.ts)). All non-threaded
channel messages collapse into one `root` row; each Slack thread (`threadTs` set) is its own row.
### Empty channels
- Linked channels stored in `integrationMetadata` by `linkSlackChannelToConversation`
([slack-events.ts:707-795](../../server/src/services/crm/slack-events.ts)).
- `trpc.crm.syncSlackChannel({ conversationId, channelId, workspaceId, channelName? })` exists
([crm.ts:3926](../../server/src/trpc/routes/crm.ts)) — fetches recent messages and processes them.
- `SlackThreadDisplay` ([SlackThreadDisplay.tsx](../modules/conversations/components/timeline/SlackThreadDisplay.tsx))
already renders a "No messages" empty state + composer, but derives `channelId`/`workspaceId` from the
*first event* — so for a channel with zero events it can't identify the channel.
## Proposed changes
### Read state (blue dot)
Read state is CHANNEL-grain: one flag, `slack_channels.is_read`, per (owner, workspace, channel). A
channel is the unit a rep reads, so there is no per-thread or per-message flag to drift.
- Ingest sets it from the **newest** message: your own send means you've read the channel, a newer
inbound lights it. Guarded by the same out-of-order check as the rest of the channel envelope, so a
backfilled older message can't re-light a channel whose last message is your reply.
- `getConversation` returns a `slackChannelUnread` map (`channelId → unread`) read from the container.
- Every Slack row of a channel shows that one dot, lit via the email row's blue-dot classes.
- `trpc.crm.markSlackChannelRead({ conversationId, channelId })` flips the caller's container row, then
invalidates `getConversation`. Fired from `SlackThreadDisplay` on open (and when new messages arrive
while open) — opening any thread clears the channel.
### Empty channels (auto-sync on open)
- In `InboxThreadList`, synthesize a row for each `integrationMetadata` Slack channel that has no
event-derived group. Render via a new `EmptySlackChannelRow` (channel name + "No messages synced",
Slack-badge avatar), keyed `slack:${workspaceId}:${channelId}:root`.
- In `SlackThreadDisplay`, when the event list is empty, parse `workspaceId`/`channelId` from the key so
the header, composer, and sync know the channel. On open of an empty channel, fire `syncSlackChannel`
(loading state), then the invalidated `getConversation` fills in any messages.
## Critical files
- [apps/server/src/db/crm-schema.ts](../../server/src/db/crm-schema.ts) — `slack_channels.is_read` column.
- [apps/server/src/services/crm/conversations.ts](../../server/src/services/crm/conversations.ts) — `slackChannelUnread` map.
- [apps/server/src/services/crm/slack-events.ts](../../server/src/services/crm/slack-events.ts) — channel rollup; newest message owns read state.
- [apps/server/src/trpc/routes/crm.ts](../../server/src/trpc/routes/crm.ts) — `markSlackChannelRead` mutation.
- [apps/mail/modules/crm/types/index.ts](../modules/crm/types/index.ts) — `HydratedConversation.slackChannelUnread`.
- [apps/mail/modules/conversations/components/timeline/SlackThreadRow.tsx](../modules/conversations/components/timeline/SlackThreadRow.tsx) — unread dot.
- [apps/mail/modules/conversations/components/timeline/InboxThreadList.tsx](../modules/conversations/components/timeline/InboxThreadList.tsx) — empty-channel rows.
- [apps/mail/modules/conversations/components/timeline/EmptySlackChannelRow.tsx](../modules/conversations/components/timeline/EmptySlackChannelRow.tsx) — new.
- [apps/mail/modules/conversations/components/timeline/SlackThreadDisplay.tsx](../modules/conversations/components/timeline/SlackThreadDisplay.tsx) — key parsing + auto-sync.
## Phase 1 — Slack read state: backend
- [x] `is_read` lives on `slack_channels` (`crm_slack_messages.is_read` and the `slack_threads` index that
also carried one are deleted — [slack_read_state_channel_only.sql](../../server/src/db/migrations/slack_read_state_channel_only.sql)).
- [x] `ingestSlackMessages` sets it in the channel rollup from the newest message's direction, under the
envelope's out-of-order guard.
- [x] Return `slackChannelUnread` (`channelId → unread`) from `getConversation`, scoped to the
conversation owner so a shared channel shows each rep their own unread.
- [x] Add `markSlackChannelRead` to `crm.ts`: input `{ conversationId: uuid, channelId: string }`, sets
`slack_channels.is_read = true` for `ownerUserId = caller` + channel, so clearing a channel never
touches another org member's unread. Guard with `conversationOwnerCondition`.
- [x] Direction ("is this mine?") matches the connected account's Slack user id (`authed_user.id`) first,
email second — `users.info` omits the email for guests and across Slack Connect, which had made the
user's own sends look inbound.
- **Test:** `pnpm --filter @cedar/server types`; `slack-events.ingest.test.ts` covers direction + the
computed container row. Read-only DB check: a fresh inbound message leaves its channel `is_read = false`;
replying from Slack flips it true.
## Phase 2 — Slack read state: frontend (blue dot)
- [x] Add `slackChannelUnread?: Record<string, boolean>` to the hydrated conversation type in types/index.ts.
- [x] `InboxThreadList` passes each row its channel's unread; `SlackThreadRow` takes `isUnread` as a prop
and renders the dot slot with the email row's blue-dot classes (`#006FFE`) (drop `invisible`).
- [x] In `SlackThreadDisplay`, call `markSlackChannelRead` on mount (and when the latest event ts changes
while open), invalidating `trpc.crm.getConversation.queryKey({ id: conversationId })`.
- **Test:** `pnpm --filter @cedar/mail types`. Manually: an unsynced/new Slack thread shows a blue dot;
opening it clears the dot after refetch.
## Phase 3 — Empty linked channels + auto-sync on open
- [x] Create `EmptySlackChannelRow` mirroring `SlackThreadRow` layout: Slack-badge avatar, `#channelName`,
muted "No messages synced", no date; `onClick` opens key `slack:${workspaceId}:${channelId}:root`.
- [x] In `InboxThreadList`, after building `slackGroups`, add a row for each `integrationMetadata` Slack
entry whose `slack:${workspaceId}:${channelId}:` prefix is absent from `slackGroups`. Sorted to the
bottom (`latestTime: 0`).
- [x] In `SlackThreadDisplay`, when `events.length === 0`, parse `workspaceId`/`channelId` from the key;
use them for the header (channelName via `integrationMetadata`) and pass `channelId` to the composer.
- [x] On open of an empty channel, fire `trpc.crm.syncSlackChannel` (with `isSyncing` state) and invalidate
`getConversation` + `listConversations` on settle (mirror ConversationInbox.tsx:85-92).
- **Test:** `pnpm --filter @cedar/mail types`. Manually: link a channel with no messages → it appears as a
row; opening it shows "Syncing…", then any messages load.
## Verification steps
- `pnpm types` clean for touched files.
- Migration applies cleanly; existing Slack channels are not all blue post-deploy.
- New inbound Slack message → blue dot on its channel's rows; opening any of them clears it and persists
across refetch. Replying from Slack clears it without opening Cedar at all.
- Empty linked channel shows as a row and syncs on open.
- The `-` separator removal and unread dot leave email/Slack row alignment intact.