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&apos;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&apos;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>
  );
}