mail.tsx22.9 KBView on GitHub

Introduced 1 production defect in 180 days, median 48 days to fix.

import {
  PencilCompose,
  Search,
} from '@/components/icons/icons';
import { useThreadsOperations } from '@/modules/threads/threadList/hooks/use-threads-operations';
import { SplitInboxTabs } from '@/modules/threads/components/SplitInboxTabs';
import { BulkActionsBar } from '@/modules/threads/components/bulk-actions-bar';
import { StackedInboxView } from '@/modules/threads/components/stacked-inbox-view';
import { useInboxes } from '@/modules/threads/hooks/use-inboxes';
import { useRouteInbox } from '@/modules/threads/hooks/use-route-inbox';
import { MailList } from '@/modules/threads/threadList/components/mail-list';
import { ScheduledMailView } from '@/modules/drafting/components/scheduled-mail-view';
import { useMail, useMailActions, useCedarStore } from '@/modules/store';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import { ChannelSelector } from '@/modules/inbox/components/ChannelSelector';
import { ChannelThreadView } from '@/modules/inbox/components/ChannelThreadView';
import { InboxList } from '@/modules/inbox/components/InboxList';
import { useUnreadFilter } from '@/modules/threads/hooks/use-unread-filter';
import { useSearchQuerySync } from '@/modules/threads/hooks/use-search-query-sync';
import { CHANNEL_FILTERS, type InboxChannelFilter } from '@/modules/inbox/types';
import { useOpenChannelItem } from '@/modules/inbox/hooks/use-open-channel-item';
import { resolveChannelFilter } from '@/modules/inbox/lib/channel-availability';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { INBOX_REFRESH_EVENT } from '@/modules/inbox/hooks/use-inbox-items';
import { useActiveConnection } from '@/hooks/use-connections';
import { SearchInput } from '@/components/ui/search-input';
import { perfSessionLog } from '@/lib/performance-logger';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { RefreshCcw } from 'lucide-react';
import { useParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';



export function MailLayout() {
  perfSessionLog('[MailLayout] Render', 'color: #ff00ff');

  const params = useParams<{ folder: string }>();
  const folder = params?.folder ?? 'inbox';


  const [mail] = useMail();
  const { clearBulkSelection } = useMailActions();
  const prevFolderRef = useRef(folder);
  useActiveConnection();

  // Landing on the inbox starts clean: no highlighted row, no thread, and — critically —
  // no conversation carried in from wherever the user just was. `activeConversationId`
  // and `conversationSelection` are what the chat input turns into context badges, so a
  // leftover selection means the agent silently inherits a deal the user never opened here.
  //
  // Skipped when the URL names an artifact (`?threadOpen` / `?conversationId` / any of the three
  // chat params) — that is an explicit deep-link that LayoutUrlSync is about to restore, and
  // clearing would race it. All three chat channels, not just Slack: `?linkedin=` and
  // `?whatsapp=` restore through the same hook and are what a table's output cell navigates to.
  // Mount-only: it must not re-fire on folder switches, which are their own navigation.
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const named = ['threadOpen', 'conversationId', 'slack', 'linkedin', 'whatsapp'];
    if (named.some((param) => params.has(param))) return;
    const store = useCedarStore.getState();
    store.selectThreadId(null);
    store.setFocusedIndex(null);
    store.clearBulkSelection();
    store.clearConversationSelection();
    store.setActiveConversationId(null);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const { inboxLayout, scopeReady: feedScopeReady } = useInboxes();

  // The unified feed is scoped by `folder`, but a custom inbox's slug
  // (`/mail/active-pipeline`) is not a real Gmail folder — so on a custom inbox
  // tab the email source resolved `in:active-pipeline`, matched nothing, and the
  // chat sources (which never read `folder` at all) returned everything. Hand the
  // feed the inbox's own rule instead, through the SAME resolver `useThreads` uses:
  // one route, one compiled query, so `All` and `Email` cannot disagree about which
  // tab is open. What the tab MEANS, not what its record happens to store — the
  // system tabs (Important / Other / Inbox) are local stubs with no `compiledQuery`
  // of their own, so reading it off the record handed the feed `undefined` and let
  // the server fall back to `in:<folder>`, which for Important skipped the split
  // subtraction (a Calendar split's mail reappeared) and for Other named a Gmail
  // label that does not exist (no email in the tab at all).
  const routeInbox = useRouteInbox({ folder });
  const activeFeedCompiled = routeInbox.compiled;
  // The channel badge and the inbox tab are orthogonal filters over the same feed, and both
  // have to be known before it can be asked. While settings/inboxes load, `inboxLayout` reads
  // as the default `inbox` — under which no stub owns the `important`/`other` slug, so
  // `findInboxBySlug` misses, `activeFeedCompiled` is undefined, and the server falls back to
  // a folder mapping that cannot express the tab. `scopeReady` (not `isLoading`) holds the
  // feed for that window — see the note on it in `useInboxes`.
  // ⇧U — the unread-only filter for the inbox on screen. The email list reads the same flag
  // inside `useThreads`; the unibox has to be handed it, since its feed is a different query.
  const { unreadOnly } = useUnreadFilter();

  const useStackedView = inboxLayout === 'stacked' && folder === 'inbox';
  // Virtual folder — scheduled ("send later") sends live in the server KV, so this
  // view replaces the thread list while keeping the surrounding mail chrome.
  const isScheduledFolder = folder === 'scheduled';
  const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
  const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
  const isTaskOutputOpen = useCedarStore((state) => state.isTaskOutputOpen);
  const newEmail = useCedarStore((state) => state.newEmail);
  const openNewEmail = useCedarStore((state) => state.openNewEmail);

  // Omni-channel: the top-row channel badge. Defaults to Email so the existing
  // mail experience is unchanged; picking All/LinkedIn/WhatsApp/Slack swaps the
  // list to the unified feed. See apps/mail/docs/omni-channel-inbox.md.
  const [channelParam, setChannelParam] = useQueryState('channel');
  const channel = (channelParam as InboxChannelFilter | null) ?? 'email';
  const showUnifiedFeed = channel !== 'email';

  // Remember the last-picked channel locally so /inbox restores it instead of always
  // defaulting to Email. This reconciles on EVERY render where the URL has no ?channel —
  // not once on mount — because navigations that drop the query (the /inbox → /inbox/inbox
  // redirect, folder-tab clicks) leave mail.tsx mounted, and a one-shot restore would never
  // re-fire, silently reverting the inbox to Email. `saved !== 'email'` means an explicit
  // Email pick (which writes 'email' + clears the param) stays on Email without a loop.
  // See omni-channel-inbox.md.
  useEffect(() => {
    if (channelParam) return; // explicit URL wins
    const saved = typeof window !== 'undefined' ? window.localStorage.getItem('inbox:channel') : null;
    if (saved && saved !== 'email') void setChannelParam(saved);
  }, [channelParam, setChannelParam]);

  // A `?slack=` / `?linkedin=` / `?whatsapp=` deep-link names a chat that only the UNIFIED feed
  // carries, so arriving on the email list would set the artifact and open nothing. Adopt that
  // channel's filter on the way in — once, so a later channel pick isn't dragged back. Links
  // copied out of the app already carry `?channel=` (you can only open a chat row from the
  // unibox); this is for the hand-written / server-generated shape, and for the one an output
  // cell in a table navigates to (see `open-output-in-channel.ts`).
  const [slackDeepLink] = useQueryState('slack');
  const [linkedinDeepLink] = useQueryState('linkedin');
  const [whatsappDeepLink] = useQueryState('whatsapp');
  const adoptedChatDeepLink = useRef(false);
  useEffect(() => {
    if (adoptedChatDeepLink.current) return;
    const channel = slackDeepLink
      ? 'slack'
      : linkedinDeepLink
        ? 'linkedin'
        : whatsappDeepLink
          ? 'whatsapp'
          : null;
    if (!channel) return;
    adoptedChatDeepLink.current = true;
    if (channelParam !== channel && channelParam !== 'all') void setChannelParam(channel);
  }, [slackDeepLink, linkedinDeepLink, whatsappDeepLink, channelParam, setChannelParam]);

  // Tell the store which ordered list is on screen. The unibox and the email list share
  // selectedThreadId / bulkSelected / focusedIndex (the unibox just holds unified ids), so
  // every keyboard action — j/k, Enter, Escape — has to walk the list the user can see.
  // Switching back to Email must restore 'threads', hence the else branch rather than a
  // mount-only write.
  const setActiveListSource = useCedarStore((state) => state.setActiveListSource);
  useEffect(() => {
    setActiveListSource(showUnifiedFeed ? 'unified' : 'threads');
    return () => setActiveListSource('threads');
  }, [showUnifiedFeed, setActiveListSource]);

  const pickChannel = useCallback(
    (v: InboxChannelFilter) => {
      if (typeof window !== 'undefined') window.localStorage.setItem('inbox:channel', v);
      void setChannelParam(v === 'email' ? null : v);
    },
    [setChannelParam],
  );

  // Opening a LinkedIn/WhatsApp/Slack chat takes over the full content area —
  // the SAME mechanism email threads use: the header+list block hides and the
  // channel view renders where ActiveViewDisplay does. No folder tabs behind it.
  // A Slack channel additionally rides the display artifact → `?slack=<channelId>`,
  // so it is deep-linkable and back-restorable like a thread (see the hook).
  const { openChannelItem, openChannel, closeChannel } = useOpenChannelItem();

  useEffect(() => {
    if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) {
      clearBulkSelection();
    }
    prevFolderRef.current = folder;
  }, [folder, mail.bulkSelected.length, clearBulkSelection]);

  const { isFetching, refetch: refetchThreads } = useThreadsOperations();
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  // Which channels the ACTIVE TAB can answer. The same `inbox.getFeedScope` read the feed makes
  // (same key, same input), so react-query serves both from one request — and it is read here
  // rather than out of `useInboxItems` because the badge menu needs it on the Email badge too,
  // where the unified feed is not mounted at all.
  const feedScope = useQuery(
    trpc.inbox.getFeedScope.queryOptions(
      { inboxId: routeInbox.record?.id },
      { enabled: feedScopeReady },
    ),
  );
  const tabChannels = feedScope.data?.channels;

  // A badge this tab cannot answer resolves to `all`.
  //
  // Disabling the menu item closes the front door; this closes the others. On a Gmail-query tab
  // the chat sources are gated out — correctly, they cannot evaluate a Gmail query — so a Slack
  // badge there asks for an intersection that is empty, and the feed rendered that as a
  // confident "No slack messages." A badge still arrives around the menu: `?channel=slack` in a
  // shared link, or the remembered pick restored below, followed by a click onto such a tab.
  //
  // The URL is written, never `localStorage`: the remembered pick is the user's standing choice,
  // and a tab that happens to be unable to honour it should not erase it. Returning to a tab
  // that CAN answer Slack restores Slack. `all` is answerable everywhere, so this converges on
  // the next render rather than fighting the restore effect above.
  useEffect(() => {
    const resolved = resolveChannelFilter(channel, tabChannels);
    if (resolved !== channel) void setChannelParam(resolved);
  }, [channel, tabChannels, setChannelParam]);

  // Local state for refresh button (like tasks-view.tsx pattern)
  const [isRefreshing, setIsRefreshing] = useState(false);
  const isRefreshingOrFetching = isRefreshing || isFetching;

  const [isSearchOpen, setIsSearchOpen] = useState(false);
  // While a search query is active in the URL, keep the search bar expanded —
  // collapsing it to an icon while results stay filtered hides any way to clear it.
  const [searchQuery] = useQueryState('search');
  const isSearchActive = isSearchOpen || !!searchQuery;

  // The `?search=` → search-slice mirror, mounted with the LIST rather than with the input.
  // `SearchInput` used to own it, and it renders only while `isSearchActive` — so an input that
  // unmounted with a query still in the store left the list filtered by a search with no box on
  // screen to show it or clear it. See use-search-query-sync.ts.
  useSearchQuerySync();

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== '/') return;
      const activeElement = document.activeElement as HTMLElement | null;
      const isInteractive =
        activeElement?.closest('.ProseMirror') ||
        activeElement?.isContentEditable ||
        activeElement?.tagName === 'INPUT' ||
        activeElement?.tagName === 'TEXTAREA';
      if (isInteractive) return;
      e.preventDefault();
      setIsSearchOpen(true);
    };
    window.addEventListener('keydown', handleKeyDown, { capture: true });
    return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
  }, []);

  // Backtick cycles the omni-channel filter (All · Email · LinkedIn · WhatsApp · Slack).
  // `c` is already the global "compose" shortcut, so the channel switch rides `` ` `` instead.
  // Only fires while the list is on screen (no thread / compose / channel chat open) and the
  // focus isn't in an editor, mirroring the `/` search handler above.
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== '`') return;
      const activeElement = document.activeElement as HTMLElement | null;
      const isInteractive =
        activeElement?.closest('.ProseMirror') ||
        activeElement?.isContentEditable ||
        activeElement?.tagName === 'INPUT' ||
        activeElement?.tagName === 'TEXTAREA';
      if (isInteractive) return;
      if (isThreadOpen || isConversationOpen || isTaskOutputOpen || newEmail || openChannelItem)
        return;
      e.preventDefault();
      const index = CHANNEL_FILTERS.findIndex((c) => c.value === channel);
      const next = CHANNEL_FILTERS[(index + 1) % CHANNEL_FILTERS.length];
      pickChannel(next.value);
    };
    window.addEventListener('keydown', handleKeyDown, { capture: true });
    return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
  }, [
    channel,
    pickChannel,
    isThreadOpen,
    isConversationOpen,
    isTaskOutputOpen,
    newEmail,
    openChannelItem,
  ]);

  // Add mailto protocol handler registration
  useEffect(() => {
    // Register as a mailto protocol handler if browser supports it
    if (typeof window !== 'undefined' && 'registerProtocolHandler' in navigator) {
      try {
        // Register the mailto protocol handler
        // When a user clicks a mailto: link, it will be passed to our dedicated handler
        // which will:
        // 1. Parse the mailto URL to extract email, subject and body
        // 2. Create a draft with these values
        // 3. Redirect to the compose page with just the draft ID
        // This ensures we don't keep the email content in the URL
        navigator.registerProtocolHandler('mailto', `/api/mailto-handler?mailto=%s`);
      } catch (error) {
        console.error('Failed to register protocol handler:', error);
      }
    }
  }, []);

  const handleExitBulkSelection = useCallback(() => {
    clearBulkSelection();
  }, [clearBulkSelection]);

  const handleRefetchThreads = useCallback(async () => {
    setIsRefreshing(true);
    try {
      if (showUnifiedFeed) {
        // The unibox is composed from one query per channel — email through
        // `mail.listThreads`, the chats through `inbox.listChannelItems` — so a refresh
        // has to reach both. Ping the channel sync hooks too, so re-pulling Unipile
        // surfaces new LinkedIn/WhatsApp messages rather than re-reading the mirror.
        window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
        await Promise.all([
          queryClient.invalidateQueries({
            queryKey=[redacted],
          }),
          queryClient.invalidateQueries({
            queryKey=[redacted],
          }),
        ]);
      } else {
        await refetchThreads();
      }
    } finally {
      setIsRefreshing(false);
    }
  }, [showUnifiedFeed, refetchThreads, queryClient, trpc.mail.listThreads, trpc.inbox.listChannelItems]);

  return (
    <>
      {/* URL⇄store sync is now a single root-level LayoutUrlSync (url-driven-layout Phase 3). */}
      <div className="relative flex h-full w-full flex-col">
        {/* Main content area - takes remaining space */}
        <div className="min-h-0 flex-1">
          {/* Always render mail list - hide when thread/conversation is open OR compose is open */}
          <div
            className={cn(
              'flex h-full w-full',
              (isThreadOpen || isConversationOpen || isTaskOutputOpen || newEmail || openChannelItem) &&
                'hidden',
            )}
          >
            <div className="flex h-full w-full max-w-full flex-col">
              {/* Header — compact title bar with actions */}
              <div className="z-15 sticky top-0">
                <div className="flex h-[44px] items-center gap-2 pl-4 pr-[3.375rem]">
                  {/* Middle: bulk actions, or split tabs (tabs stay visible while
                      searching). The channel badge leads the tab row — positioned
                      exactly where the first inbox tab sits. */}
                  {mail.bulkSelected.length > 0 && !isSearchActive ? (
                    <BulkActionsBar
                      selectedThreadIds={mail.bulkSelected}
                      currentFolder={folder}
                      onExitSelection={handleExitBulkSelection}
                    />
                  ) : (
                    <SplitInboxTabs
                      grow={!isSearchActive}
                      leading={
                        <ChannelSelector
                          value={channel}
                          onChange={pickChannel}
                          participating={tabChannels}
                        />
                      }
                    />
                  )}

                  <AnimatePresence>
                    {isSearchActive && (
                      <motion.div
                        key=[redacted]
                        initial={{ clipPath: 'inset(0 0 0 100%)' }}
                        animate={{ clipPath: 'inset(0 0 0 0%)' }}
                        exit={{ clipPath: 'inset(0 0 0 100%)' }}
                        transition={{ duration: 0.2, ease: 'easeInOut' }}
                        className="min-w-64 flex-1 overflow-hidden pl-8"
                      >
                        <SearchInput autoFocus onClose={() => setIsSearchOpen(false)} />
                      </motion.div>
                    )}
                  </AnimatePresence>

                  {/* Search icon button */}
                  {!isSearchActive && mail.bulkSelected.length === 0 && (
                    <Button
                      onClick={() => setIsSearchOpen(true)}
                      variant="ghost"
                      size="icon"
                      className="hover:bg-accent/50 h-7 w-7 rounded-full border border-transparent bg-transparent hover:border-border/50"
                    >
                      <Search className="fill-muted-foreground h-4 w-4" />
                    </Button>
                  )}

                  <button
                    onClick={() => openNewEmail()}
                    className="text-muted-foreground hover:text-foreground mx-1 shrink-0 transition-colors"
                  >
                    <PencilCompose className="fill-muted-foreground h-3.5 w-3.5" />
                  </button>

                  <Button
                    onClick={handleRefetchThreads}
                    variant="ghost"
                    size="icon"
                    disabled={isRefreshingOrFetching}
                    className="hover:bg-accent/50 h-7 w-7 rounded-full border-none bg-transparent"
                  >
                    <RefreshCcw
                      className={cn(
                        'text-muted-foreground h-4 w-4',
                        isRefreshingOrFetching && 'animate-spin',
                      )}
                    />
                  </Button>
                </div>

              </div>

              {/* Mail list section */}
              <div className={cn('z-1 relative flex h-full flex-col overflow-hidden pt-0')}>
                <div className="h-full overflow-hidden">
                  {showUnifiedFeed ? (
                    <InboxList
                      channel={channel}
                      folder={folder}
                      compiledQuery={activeFeedCompiled?.compiledQuery}
                      queryHash={activeFeedCompiled?.queryHash}
                      inboxId={routeInbox.record?.id}
                      unreadOnly={unreadOnly}
                      scopeReady={feedScopeReady}
                      onOpenChannel={openChannel}
                    />
                  ) : isScheduledFolder ? (
                    <ScheduledMailView />
                  ) : useStackedView && !isSearchActive ? (
                    <StackedInboxView />
                  ) : (
                    <MailList />
                  )}
                </div>
              </div>
            </div>
          </div>

          {/* Active view — top of view stack determines what to show. `isChannelChatOpen` is
              deliberately NOT here: this route opens a channel through `openChannelItem` below,
              so listing it would mount a second ChannelThreadView over the first. Every OTHER
              artifact kind has to be listed, or it is set and never drawn — `?task=` is routed
              from the root layout and reaches here. */}
          {(isConversationOpen || isThreadOpen || isTaskOutputOpen || newEmail) && (
            <ActiveViewDisplay />
          )}

          {/* Channel chat — full content area, folder tabs hidden (same as a thread).
              LinkedIn/WhatsApp/Slack all render through the one ChannelThreadView. */}
          {openChannelItem && (
            <div className="bg-surface flex h-full w-full">
              <ChannelThreadView item={openChannelItem} onClose={closeChannel} />
            </div>
          )}
        </div>
      </div>
    </>
  );
}