InboxList.tsx14.4 KBView on GitHub import { Loader2, TriangleAlert } from 'lucide-react';
import { useCallback, useMemo, useRef } from 'react';
import { ChannelConnectPrompt } from '@/modules/inbox/components/ChannelConnectPrompt';
import { InboxRow } from '@/modules/inbox/components/InboxRow';
import { Thread } from '@/modules/threads/threadList/threadItem/components/thread';
import { useMailNavigation } from '@/modules/threads/threadList/hooks/use-mail-navigation';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { useInboxChannelConnect } from '@/modules/inbox/hooks/use-inbox-channel-connect';
import { useInboxItems } from '@/modules/inbox/hooks/use-inbox-items';
import { useInboxItemActions } from '@/modules/inbox/hooks/use-inbox-item-actions';
import { useSlackItemPrincipals } from '@/modules/inbox/hooks/use-slack-item-avatars';
import { useLinkedinSyncOnLoad } from '@/modules/inbox/hooks/use-linkedin-sync-on-load';
import { useWhatsappSyncOnLoad } from '@/modules/inbox/hooks/use-whatsapp-sync-on-load';
import { useSlackSyncOnLoad } from '@/modules/inbox/hooks/use-slack-sync-on-load';
import type { InboxChannelFilter, InboxItem } from '@/modules/inbox/types';
import {
DATE_GROUP_ORDER,
getDateGroup,
useLocalDayStart,
type DateGroup,
} from '@/modules/threads/threadList/utils/date-groups';
import { useCedarStore } from '@/modules/store';
import {
selectMergedFeed,
selectUnifiedInboxList,
} from '@/modules/inbox/store/inboxSlice';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
const CHANNEL_LABELS: Record<string, string> = {
email: 'email',
slack: 'Slack',
linkedin: 'LinkedIn',
whatsapp: 'WhatsApp',
};
/**
* Names the channels that failed, above the rows the healthy ones still produced.
*
* The wording is "new messages" on purpose. A channel that had already loaded a page keeps
* showing it when a later refetch fails — react-query retains the last good data, and dropping
* rows we successfully fetched would be a worse answer than holding them. So this notice cannot
* claim the channel is missing; what is missing is anything NEWER than what is on screen, which
* is equally true of a channel that never loaded at all.
*/
function DegradedChannelsNotice({
channels,
onRetry,
}: {
channels: string[];
onRetry: () => void;
}) {
const names = channels.map((c) => CHANNEL_LABELS[c] ?? c);
const listed =
names.length === 1
? names[0]
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
return (
<div className="text-muted-foreground flex items-center gap-2 px-16 py-2 text-xs">
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">
Couldn't load new {listed} messages. Everything else is up to date.
</span>
<button
type="button"
onClick={onRetry}
className="hover:text-foreground shrink-0 cursor-pointer underline underline-offset-2"
>
Retry
</button>
</div>
);
}
/** Same date-group header as MailList (px-16, subtle first/rest spacing). */
function DateGroupHeader({ label, isFirst }: { label: string; isFirst: boolean }) {
return (
<div className={cn('text-foreground px-16 pb-2 text-sm font-medium', isFirst ? 'pt-3' : 'pt-6')}>
{label}
</div>
);
}
interface InboxListProps {
channel: InboxChannelFilter;
folder?: string;
/**
* The active custom inbox, when the user is on one of its tabs. Without these the feed is
* scoped by `folder` alone — and a custom inbox's slug is not a real folder, so the email
* source matched nothing while the chat sources matched everything. `inboxId` is how the
* server looks up its CRM rule. See apps/mail/docs/pipeline-inbox-crm-stage-filter.md.
*/
compiledQuery?: string;
queryHash?: string;
inboxId?: string;
/** ⇧U — show only unread across every channel in the feed. */
unreadOnly?: boolean;
/**
* False while the inbox half of the scope is still resolving — see `useInboxItems`.
* The list renders its loading state rather than a feed answered for the wrong tab.
*/
scopeReady?: boolean;
/** Open a non-email chat full-screen (handled by the parent, same as a thread). */
onOpenChannel: (item: InboxItem) => void;
}
/**
* The unibox list. Email rows open in the existing full-screen thread view
* (via the store); LinkedIn/WhatsApp/Slack rows open a local `ChannelThreadView`
* overlay — no core-store changes. Empty-safe at every branch. See
* apps/mail/docs/omni-channel-inbox.md Phase 3/4.
*/
export function InboxList({
channel,
folder,
compiledQuery,
queryHash,
inboxId,
unreadOnly = false,
scopeReady = true,
onOpenChannel,
}: InboxListProps) {
// The query drives fetch/pagination and mirrors its pages into the inbox slice
// (see use-inbox-items); the slice is the render + selection source of truth.
const {
isLoading,
isError,
failedChannels,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
refetch,
isPlaceholderData,
} = useInboxItems({
channel,
folder,
compiledQuery,
queryHash,
inboxId,
unreadOnly,
enabled: scopeReady,
});
// The merged feed, not the hook's copy of it: the store is the render + selection source,
// and the selector is memoized on the committed `channelFeeds` so this costs one identity
// check per render rather than a re-sort.
const items = useCedarStore(selectMergedFeed).items;
// Live reconcile LinkedIn on load (mirrors email's on-load sync), then refetch.
useLinkedinSyncOnLoad({
enabled: channel === 'all' || channel === 'linkedin',
onSynced: () => void refetch(),
});
// Same for WhatsApp: mirror the live Unipile chat list into whatsapp_chats,
// then refetch the feed off the mirror.
useWhatsappSyncOnLoad({
enabled: channel === 'all' || channel === 'whatsapp',
onSynced: () => void refetch(),
});
// Same for Slack — the only channel with no client-side catch-up until now, which
// is why the on-open catch-up had to carry the whole burden. There is no
// account-wide Slack sync procedure, so this walks the visible Slack rows and
// fires the per-channel `channels.catchUp` for each. See
// apps/server/docs/channel-sync-architecture.md Phase 6.
useSlackSyncOnLoad({
enabled: channel === 'all' || channel === 'slack',
items,
onSynced: () => void refetch(),
});
// Resolve Slack principals (avatars + mention display names) for visible rows.
const { resolveAvatar: resolveSlackAvatar, resolveName: resolveSlackName } =
useSlackItemPrincipals(items);
const { markDone, snooze, toggleStar } = useInboxItemActions();
// Default snooze target: tomorrow, 9am local. (A preset menu can refine later.)
const snoozeItem = useCallback(
(item: InboxItem) => {
const t = new Date();
t.setDate(t.getDate() + 1);
t.setHours(9, 0, 0, 0);
snooze(item, t);
},
[snooze],
);
// LinkedIn/WhatsApp filters gate on a Unipile connection: if the channel isn't
// connected, show a connect prompt in place of the (necessarily empty) feed.
const channelConnect = useInboxChannelConnect(channel);
const showConnectPrompt =
!!channelConnect && channelConnect.accountsLoaded && channelConnect.connectedCount === 0;
const selectThreadId = useCedarStore((state) => state.selectThreadId);
const setIsThreadOpen = useCedarStore((state) => state.setIsThreadOpen);
// Same date-split as the mail list: Today / Yesterday / Last 7 days / Older.
const todayStart = useLocalDayStart();
const grouped = useMemo(() => {
const buckets: Record<DateGroup, InboxItem[]> = {
Today: [],
Yesterday: [],
'Last 7 days': [],
Older: [],
};
for (const item of items) buckets[getDateGroup(item.sortedAt, todayStart)].push(item);
return DATE_GROUP_ORDER.map((label) => ({ label, items: buckets[label] })).filter(
(g) => g.items.length > 0,
);
}, [items, todayStart]);
const handleOpen = useCallback(
(item: InboxItem) => {
if (item.channel === 'email' && item.ref.kind === 'email') {
selectThreadId(item.ref.threadId);
setIsThreadOpen(true);
return;
}
// Channel chats open full-screen via the parent (same as an email thread). Select
// FIRST so the row highlights and the chat's ambient conversation follows it —
// `selectThreadId` resolves a channel row's conversation off the unified feed.
selectThreadId(item.id);
onOpenChannel(item);
},
[selectThreadId, setIsThreadOpen, onOpenChannel],
);
// `<Thread>`'s onClick contract: (message) => () => void. Opens the thread view.
const openThreadOnClick = useCallback(
(message: ParsedMessage) => () => {
const tid = message.threadId ?? message.id;
selectThreadId(tid);
setIsThreadOpen(true);
},
[selectThreadId, setIsThreadOpen],
);
// Same list navigation the email inbox has — j/k, arrows, Enter, Escape. Without this the
// unibox had no keyboard behaviour at all (the hook lives in MailList, which isn't mounted
// here), so Escape never cleared the selection on ?channel=all. Enter routes through
// `handleOpen` so a Slack/LinkedIn/WhatsApp row opens its chat rather than a mail thread.
const scrollRef = useRef<HTMLDivElement>(null);
// Navigation walks SELECTION ids, not `InboxItem.id` — an email row's
// selection id is the bare Gmail threadId while its item id is `email:<threadId>`, and
// selectedThreadId / bulkSelected / data-thread-id all speak the selection id.
const navItems = useCedarStore(selectUnifiedInboxList);
const openById = useCallback(
(id: string) => {
const item = useCedarStore.getState().getInboxItem(id);
if (item) handleOpen(item);
},
[handleOpen],
);
useMailNavigation({ items: navItems, containerRef: scrollRef, onOpen: openById });
return (
<div className="relative flex h-full min-h-0 w-full flex-col">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{/* A degraded channel is reported ABOVE the rows rather than instead of them — the
other channels loaded fine and the rep should still get them. `isError` means every
channel failed, and that renders the full error state below instead. */}
{scopeReady && !isLoading && !isError && failedChannels.length > 0 && (
<DegradedChannelsNotice channels={failedChannels} onRetry={() => void refetch()} />
)}
{showConnectPrompt && channelConnect ? (
<ChannelConnectPrompt
channel={channelConnect.channel}
isConnecting={channelConnect.isConnecting}
onConnect={channelConnect.connect}
/>
) : // An empty placeholder means "nothing unread among the rows we happened to be
// holding" — a fact about the cache, not about the inbox. Wait for the real page
// rather than announce an emptiness that is about to be contradicted.
// `!scopeReady` counts as loading: a DISABLED react-query reports isLoading false
// (pending but not fetching), which would render "Nothing here yet" for the frame
// before the tab resolves — an empty inbox, not a loading one.
!scopeReady || isLoading || (isPlaceholderData && items.length === 0) ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : isError ? (
<div className="text-muted-foreground flex h-full items-center justify-center px-6 text-center text-sm">
Couldn't load the inbox. Try refreshing.
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 px-6 text-center text-sm">
{/* An unread-filtered empty list is not an empty inbox — say which one the user
is looking at, and how to get the rest of it back. */}
<span className="font-medium">{unreadOnly ? 'No unread here' : 'Nothing here yet'}</span>
<span className="text-xs">
{unreadOnly
? 'Press ⇧U to show everything again.'
: channel === 'all'
? 'No messages across your channels.'
: `No ${channel} messages.`}
</span>
</div>
) : (
<div className="flex flex-col">
{grouped.map((group, gi) => (
<div key=[redacted] className="flex flex-col">
<DateGroupHeader label={group.label} isFirst={gi === 0} />
{group.items.map((item) =>
// Email rows render through the full `<Thread>` (labels, AOP,
// tracking, opens) off threadSlice, seeded from the item's
// threadMeta; channel rows render the InboxRow. Both select into
// the same `bulkSelected` (Thread by threadId, InboxRow by id).
item.channel === 'email' && item.ref.kind === 'email' ? (
<Thread
key=[redacted]
message={{ id: item.ref.threadId }}
onClick={openThreadOnClick}
/>
) : (
<InboxRow
key=[redacted]
item={item}
onOpen={handleOpen}
avatarUrl={
item.ref.kind === 'slack'
? resolveSlackAvatar(item.ref.workspaceId, item.ref.slackUserId)
: undefined
}
resolveSlackName={resolveSlackName}
onMarkDone={markDone}
onSnooze={snoozeItem}
onToggleStar={toggleStar}
/>
),
)}
</div>
))}
{hasNextPage && (
<div className="flex justify-center py-3">
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Load more'
)}
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}