AttachToConversation.tsx4.8 KBView on GitHub
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link2, Loader2, Search } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { InboxItem } from '@/modules/inbox/types';
import { useTRPC } from '@/providers/query-provider';

interface AttachToConversationProps {
  item: InboxItem;
  onAttached?: () => void;
  /** Custom popover trigger (e.g. the top-row "link here" affordance). Defaults
   *  to the icon button used in the header actions. */
  trigger?: ReactNode;
}

/**
 * Top-bar control to attach / move a channel chat to a CRM conversation (deal).
 * Search conversations, pick one → the channel's attach mutation binds the
 * chat's events to it. LinkedIn/WhatsApp only (Slack channels are already
 * conversation-scoped). See apps/mail/docs/omni-channel-inbox.md Phase 4.13.
 */
export function AttachToConversation({ item, onAttached, trigger }: AttachToConversationProps) {
  const trpc = useTRPC();
  const ref = item.ref;
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');

  const search = useQuery({
    ...trpc.crm.searchConversationsMinimal.queryOptions({ query }),
    enabled: open && query.trim().length >= 2,
  });

  const attachLinkedin = useMutation(trpc.linkedin.messaging.attachToDeal.mutationOptions());
  const attachWhatsapp = useMutation(trpc.outbound.whatsapp.attachToDeal.mutationOptions());
  const attaching = attachLinkedin.isPending || attachWhatsapp.isPending;

  // Only chat channels can be re-homed; Slack is conversation-scoped already.
  if (ref.kind !== 'linkedin' && ref.kind !== 'whatsapp') return null;

  const attach = async (conversationId: string) => {
    try {
      if (ref.kind === 'linkedin') {
        await attachLinkedin.mutateAsync({ chatId: ref.chatId, conversationId });
      } else {
        await attachWhatsapp.mutateAsync({ chatId: ref.chatId, conversationId });
      }
      setOpen(false);
      setQuery('');
      onAttached?.();
    } catch {
      toast.error('Failed to attach');
    }
  };

  const results =
    (search.data as { conversations?: { id: string; name?: string | null }[] } | undefined)
      ?.conversations ?? [];

  return (
    <Popover open={open} onOpenChange={setOpen}>
      {trigger ? (
        <PopoverTrigger asChild>{trigger}</PopoverTrigger>
      ) : (
        <Tooltip>
          <TooltipTrigger asChild>
            <PopoverTrigger asChild>
              <Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer [&_svg]:size-4">
                <Link2 className="text-[#9D9D9D]" />
              </Button>
            </PopoverTrigger>
          </TooltipTrigger>
          <TooltipContent side="bottom">Attach to conversation</TooltipContent>
        </Tooltip>
      )}
      <PopoverContent align="end" className="w-72 p-2">
        <div className="border-border mb-2 flex items-center gap-2 rounded-md border px-2">
          <Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
          <input
            autoFocus
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search conversations…"
            className="h-8 flex-1 bg-transparent text-sm outline-none"
          />
        </div>
        <div className="max-h-64 overflow-y-auto">
          {attaching ? (
            <div className="flex justify-center py-4">
              <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
            </div>
          ) : query.trim().length < 2 ? (
            <p className="text-muted-foreground px-2 py-3 text-center text-xs">
              Type to search conversations.
            </p>
          ) : search.isLoading ? (
            <div className="flex justify-center py-4">
              <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
            </div>
          ) : results.length === 0 ? (
            <p className="text-muted-foreground px-2 py-3 text-center text-xs">No matches.</p>
          ) : (
            <div className="flex flex-col gap-0.5">
              {results.map((c) => (
                <button
                  key=[redacted]
                  type="button"
                  onClick={() => void attach(c.id)}
                  className="hover:bg-subtleWhite flex cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-sm transition-colors dark:hover:bg-[#202020]"
                >
                  <span className="truncate">{c.name || 'Untitled conversation'}</span>
                </button>
              ))}
            </div>
          )}
        </div>
      </PopoverContent>
    </Popover>
  );
}