slack-channel-manager.tsx24.6 KBView on GitHub
'use client';

import { useState, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Search, Ban, X } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';

const ChannelRow = function ChannelRow({
  channel,
  isSelected,
  isBlocked,
  onToggle,
  onBlockToggle,
}: {
  channel: { id: string; name: string; isPrivate?: boolean };
  isSelected: boolean;
  isBlocked: boolean;
  onToggle: (id: string) => void;
  onBlockToggle: (id: string) => void;
}) {
  return (
    <div
      onClick={(e) => {
        if ((e.target as HTMLElement).closest('button')) return;
        if (!isBlocked) onToggle(channel.id);
      }}
      className={`group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors ${
        isBlocked
          ? 'bg-destructive/10 text-muted-foreground'
          : isSelected
            ? 'bg-accent'
            : 'hover:bg-accent/50'
      }`}
    >
      <div className="flex flex-1 items-center gap-3">
        <Checkbox
          id={channel.id}
          checked={isSelected}
          onCheckedChange={() => !isBlocked && onToggle(channel.id)}
          disabled={isBlocked}
          className="pointer-events-none"
        />
        <div className={`flex flex-1 items-center gap-2 ${isBlocked ? 'line-through' : ''}`}>
          <span className="font-medium">#{channel.name}</span>
          {channel.isPrivate && (
            <Badge variant="outline" className="h-5 px-1 text-[10px]">
              Private
            </Badge>
          )}
        </div>
      </div>
      <div className="flex items-center gap-2">
        <Button
          variant="ghost"
          size="icon"
          className={`h-6 w-6 ${isBlocked ? 'text-destructive hover:text-destructive' : 'text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100'}`}
          onClick={(e) => {
            e.stopPropagation();
            onBlockToggle(channel.id);
          }}
          title={isBlocked ? 'Unblock channel' : 'Block channel'}
        >
          <Ban className="h-3 w-3" />
        </Button>
      </div>
    </div>
  );
};

interface SlackChannelManagerProps {
  workspaceId: string;
  userId?: string; // For admin use - when viewing another user's sync config
}

export interface SlackChannelManagerRef {
  saveChanges: () => Promise<void>;
  hasChanges: boolean;
  getSelectedChannels: () => Set<string>;
  getSelectedChannelObjects: () => Array<{ id: string; name: string }>;
}

export const SlackChannelManager = forwardRef<SlackChannelManagerRef, SlackChannelManagerProps>(
  ({ workspaceId, userId }, ref) => {
    const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
    const [blockedChannels, setBlockedChannels] = useState<Set<string>>(new Set());
    const [isConnectingChannels, setIsConnectingChannels] = useState(false);
    const [searchQuery, setSearchQuery] = useState('');
    const [startsWith, setStartsWith] = useState('');
    const [endsWith, setEndsWith] = useState('');
    const [autoSyncNewChannels, setAutoSyncNewChannels] = useState(false);
    // Tracks which channel IDs were selected by the current pattern so they can be
    // removed when the pattern changes (without touching manually-selected channels).
    const patternSelectedIdsRef = useRef<Set<string>>(new Set());

    const trpc = useTRPC();
    const queryClient = useQueryClient();

    // Fetch available channels
    const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
      ...trpc.integrations.slack.listChannels.queryOptions({
        workspaceId: workspaceId || '',
        ...(userId && { userId }),
      }),
      enabled: !!workspaceId,
    });

    // Fetch connected channels
    const { data: connectedChannelsData } = useQuery({
      ...trpc.integrations.slack.getConnectedChannels.queryOptions({
        workspaceId: workspaceId || '',
        ...(userId && { userId }),
      }),
      enabled: !!workspaceId,
    });

    // const { mutateAsync: connectChannels } = useMutation(
    //   trpc.integrations.slack.connectChannels.mutationOptions(),
    // );

    // const { mutateAsync: disconnectChannel } = useMutation(
    //   trpc.integrations.slack.disconnectChannel.mutationOptions(),
    // );

    const { mutateAsync: updateConfig } = useMutation(
      trpc.integrations.slack.updateConfiguration.mutationOptions(),
    );

    const { data: configData } = useQuery({
      ...trpc.integrations.slack.getConfiguration.queryOptions({
        workspaceId: workspaceId || '',
        ...(userId && { userId }),
      }),
      enabled: !!workspaceId,
    });

    const availableChannels = useMemo(
      () =>
        (channelsData?.channels || []) as Array<{ id: string; name: string; isPrivate?: boolean }>,
      [channelsData?.channels],
    );
    const connectedChannelIds = useMemo(
      () => new Set<string>(connectedChannelsData?.channelIds || []),
      [connectedChannelsData?.channelIds],
    );

    // Load saved configuration and sync with connected channels
    // Priority: config > connected channels (if config is empty)
    useEffect(() => {
      if (!configData?.config && !connectedChannelsData) {
        // Neither loaded yet, wait
        return;
      }

      const config = configData?.config;

      // Load config values first
      const configSelectedIds = config?.selectedChannelIds || [];
      const configBlockedIds = config?.blockedChannelIds || [];

      // If config has selected channels, use them; otherwise fall back to connected channels
      const initialSelectedIds =
        configSelectedIds.length > 0 ? configSelectedIds : Array.from(connectedChannelIds);

      setSelectedChannels(new Set(initialSelectedIds));
      setBlockedChannels(new Set(configBlockedIds || []));

      // The stored config holds a list of patterns; this UI edits a single pair.
      const pattern = config?.autoSelectPatterns?.[0];
      if (pattern) {
        setStartsWith(pattern.startsWith || '');
        setEndsWith(pattern.endsWith || '');
      }
      setAutoSyncNewChannels(config?.autoSyncNewChannels ?? false);
    }, [configData, connectedChannelsData, connectedChannelIds]);

    // Filter channels based on search query, split into linked vs unlinked
    const { linkedChannels, unlinkedChannels } = useMemo(() => {
      const query = searchQuery.toLowerCase().trim();
      const filtered = query
        ? availableChannels.filter(
            (channel: { id: string; name: string }) =>
              channel.name.toLowerCase().includes(query) || channel.id.toLowerCase().includes(query),
          )
        : availableChannels;
      return {
        linkedChannels: filtered.filter((c) => connectedChannelIds.has(c.id)),
        unlinkedChannels: filtered.filter((c) => !connectedChannelIds.has(c.id)),
      };
    }, [availableChannels, searchQuery, connectedChannelIds]);

    const filteredChannels = useMemo(
      () => [...linkedChannels, ...unlinkedChannels],
      [linkedChannels, unlinkedChannels],
    );

    // Auto-select channels based on criteria.
    // When the pattern changes, channels that matched the OLD pattern are removed
    // (unless they were also manually selected) and channels matching the NEW pattern
    // are added. patternSelectedIdsRef tracks which IDs the current pattern owns.
    useEffect(() => {
      const startStr = startsWith.toLowerCase();
      const endStr = endsWith.toLowerCase();

      const newPatternMatches = new Set<string>();
      if (startStr || endStr) {
        availableChannels.forEach((channel: { id: string; name: string }) => {
          if (blockedChannels.has(channel.id)) return;
          const name = channel.name.toLowerCase();
          // Both fields filled → AND (must match both); single field → that field alone.
          const matchesStart = startStr ? name.startsWith(startStr) : true;
          const matchesEnd = endStr ? name.endsWith(endStr) : true;
          if (matchesStart && matchesEnd) newPatternMatches.add(channel.id);
        });
      }

      setSelectedChannels((prev) => {
        const next = new Set(prev);
        // Remove channels that the previous pattern selected but the new one doesn't
        patternSelectedIdsRef.current.forEach((id) => {
          if (!newPatternMatches.has(id)) next.delete(id);
        });
        // Add channels the new pattern selects
        newPatternMatches.forEach((id) => next.add(id));
        return next;
      });

      patternSelectedIdsRef.current = newPatternMatches;
    }, [startsWith, endsWith, blockedChannels, availableChannels]);

    const handleChannelToggle = (channelId: string) => {
      setSelectedChannels((prev) => {
        const next = new Set(prev);
        if (next.has(channelId)) {
          next.delete(channelId);
        } else {
          next.add(channelId);
        }
        return next;
      });
    };

    const handleBlockToggle = (channelId: string) => {
      setBlockedChannels((prev) => {
        const next = new Set(prev);
        if (next.has(channelId)) {
          next.delete(channelId);
        } else {
          next.add(channelId);
          // If blocking, remove from selected
          setSelectedChannels((selected) => {
            const newSelected = new Set(selected);
            newSelected.delete(channelId);
            return newSelected;
          });
        }
        return next;
      });
    };

    const handleSaveConfiguration = async () => {
      try {
        setIsConnectingChannels(true);

        // Save configuration including selected channels
        await updateConfig({
          workspaceId,
          config: {
            selectedChannelIds: Array.from(selectedChannels),
            blockedChannelIds: Array.from(blockedChannels),
            autoSelectPatterns:
              startsWith || endsWith
                ? [{ startsWith: startsWith || undefined, endsWith: endsWith || undefined }]
                : [],
            autoSyncNewChannels,
          },
          ...(userId && { userId }),
        });

        // Invalidate and refetch queries to ensure fresh data
        await queryClient.invalidateQueries({
          queryKey=[redacted]
            workspaceId,
            ...(userId && { userId }),
          }),
        });
        await queryClient.invalidateQueries({
          queryKey=[redacted]
            workspaceId,
            ...(userId && { userId }),
          }),
        });

      } catch (error) {
        console.error('[SlackChannelManager] Error saving configuration:', error);
        toast.error(
          `Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`,
        );
        throw error;
      } finally {
        setIsConnectingChannels(false);
      }
    };

    // Calculate changes by comparing current state with saved config
    const hasChanges = useMemo(() => {
      if (!configData?.config) {
        // No config yet, compare with connected channels
        return (
          selectedChannels.size !== connectedChannelIds.size ||
          Array.from(selectedChannels).some((id) => !connectedChannelIds.has(id)) ||
          Array.from(connectedChannelIds).some((id) => !selectedChannels.has(id))
        );
      }

      const config = configData.config;

      const configSelected = new Set(config.selectedChannelIds || []);
      const configBlocked = new Set(config.blockedChannelIds || []);
      const configStartsWith = config.autoSelectPatterns?.[0]?.startsWith || '';
      const configEndsWith = config.autoSelectPatterns?.[0]?.endsWith || '';

      // Check if selected channels changed
      const selectedChanged =
        selectedChannels.size !== configSelected.size ||
        Array.from(selectedChannels).some((id) => !configSelected.has(id)) ||
        Array.from(configSelected).some((id) => !selectedChannels.has(id));

      // Check if blocked channels changed
      const blockedChanged =
        blockedChannels.size !== configBlocked.size ||
        Array.from(blockedChannels).some((id) => !configBlocked.has(id)) ||
        Array.from(configBlocked).some((id) => !blockedChannels.has(id));

      // Check if patterns changed
      const patternsChanged = startsWith !== configStartsWith || endsWith !== configEndsWith;

      // Check if autoSyncNewChannels changed
      const autoSyncChanged = autoSyncNewChannels !== (config.autoSyncNewChannels ?? false);

      return selectedChanged || blockedChanged || patternsChanged || autoSyncChanged;
    }, [
      selectedChannels,
      blockedChannels,
      startsWith,
      endsWith,
      autoSyncNewChannels,
      configData?.config,
      connectedChannelIds,
    ]);

    useImperativeHandle(ref, () => ({
      saveChanges: async () => {
        await handleSaveConfiguration();
      },
      hasChanges,
      getSelectedChannels: () => new Set(selectedChannels),
      getSelectedChannelObjects: () =>
        availableChannels.filter((ch: { id: string; name: string }) => selectedChannels.has(ch.id)),
    }));

    const selectedChannelsList = useMemo(() => {
      return availableChannels.filter((ch: { id: string; name: string }) =>
        selectedChannels.has(ch.id),
      );
    }, [availableChannels, selectedChannels]);

    const blockedChannelsList = useMemo(() => {
      return availableChannels.filter((ch: { id: string; name: string }) =>
        blockedChannels.has(ch.id),
      );
    }, [availableChannels, blockedChannels]);

    if (isLoadingChannels) {
      return (
        <div className="flex items-center justify-center py-4">
          <Loader2 className="h-5 w-5 animate-spin" />
          <span className="text-muted-foreground ml-2 text-sm">Loading channels...</span>
        </div>
      );
    }

    if (availableChannels.length === 0) {
      return (
        <div className="text-muted-foreground py-4 text-center text-sm">
          No channels found. Make sure you have access to at least one Slack channel.
        </div>
      );
    }

    return (
      <div className="flex h-[520px] gap-6">
        {/* Left Column: Selection & Rules */}
        <div className="flex flex-1 flex-col gap-4 overflow-hidden">
          {/* Smart Selection */}
          <div className="bg-muted/20 grid gap-4 rounded-lg border p-4">
            <div className="grid grid-cols-2 gap-4">
              <div className="space-y-2">
                <Label>Starts with</Label>
                <Input
                  value={startsWith}
                  onChange={(e) => setStartsWith(e.target.value)}
                  placeholder="e.g. cedar-"
                />
                <p className="text-muted-foreground text-xs">
                  Will also automatically add future channels that start with this
                </p>
              </div>
              <div className="space-y-2">
                <Label>Ends with</Label>
                <Input
                  value={endsWith}
                  onChange={(e) => setEndsWith(e.target.value)}
                  placeholder="e.g. -cedar"
                />
                <p className="text-muted-foreground text-xs">
                  Will also automatically add future channels that end with this
                </p>
              </div>
            </div>
            <div className="flex items-center gap-3 border-t pt-3">
              <Switch
                id="auto-sync-new"
                checked={autoSyncNewChannels}
                onCheckedChange={setAutoSyncNewChannels}
                disabled={!startsWith && !endsWith}
              />
              <div>
                <Label htmlFor="auto-sync-new" className="cursor-pointer text-sm font-medium">
                  Automatically sync new channels matching these patterns
                </Label>
                <p className="text-muted-foreground text-xs">
                  During each periodic sync, Cedar will discover and sync any new channels matching
                  the patterns above
                </p>
              </div>
            </div>
          </div>

          {/* Search Bar */}
          <div className="relative">
            <Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
            <Input
              type="text"
              placeholder="Search channels..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              className="pl-9"
            />
          </div>

          <ScrollArea className="flex-1">
            <div className="">
              {filteredChannels.length === 0 ? (
                <div className="text-muted-foreground py-4 text-center text-sm">
                  {searchQuery.trim()
                    ? `No channels found matching "${searchQuery}"`
                    : 'No channels available'}
                </div>
              ) : (
                <div className="space-y-3">
                  {/* Already-linked channels */}
                  {linkedChannels.length > 0 && (
                    <div>
                      <div className="mb-1 flex items-center justify-between px-1">
                        <span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
                          Already linked ({linkedChannels.length})
                        </span>
                        <button
                          className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
                          onClick={() => {
                            setSelectedChannels((prev) => {
                              const next = new Set(prev);
                              linkedChannels.forEach((c) => next.delete(c.id));
                              return next;
                            });
                          }}
                        >
                          Exclude all
                        </button>
                      </div>
                      <div className="space-y-1">
                        {linkedChannels.map((channel) => {
                          const isSelected = selectedChannels.has(channel.id);
                          const isBlocked = blockedChannels.has(channel.id);
                          return (
                            <ChannelRow
                              key=[redacted]
                              channel={channel}
                              isSelected={isSelected}
                              isBlocked={isBlocked}
                              onToggle={handleChannelToggle}
                              onBlockToggle={handleBlockToggle}
                            />
                          );
                        })}
                      </div>
                    </div>
                  )}

                  {/* Separator */}
                  {linkedChannels.length > 0 && unlinkedChannels.length > 0 && (
                    <div className="border-t" />
                  )}

                  {/* Other channels */}
                  {unlinkedChannels.length > 0 && (
                    <div>
                      {linkedChannels.length > 0 && (
                        <div className="mb-1 px-1">
                          <span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
                            Other channels ({unlinkedChannels.length})
                          </span>
                        </div>
                      )}
                      <div className="space-y-1">
                        {unlinkedChannels.map((channel) => {
                          const isSelected = selectedChannels.has(channel.id);
                          const isBlocked = blockedChannels.has(channel.id);
                          return (
                            <ChannelRow
                              key=[redacted]
                              channel={channel}
                              isSelected={isSelected}
                              isBlocked={isBlocked}
                              onToggle={handleChannelToggle}
                              onBlockToggle={handleBlockToggle}
                            />
                          );
                        })}
                      </div>
                    </div>
                  )}
                </div>
              )}
            </div>
          </ScrollArea>
        </div>

        {/* Right Column: Summary */}
        <div className="flex w-[300px] flex-col gap-6 border-l pl-6">
          {/* Selected Channels Summary */}
          <div className="flex flex-1 flex-col gap-2 overflow-hidden">
            <div className="space-y-1">
              <div className="flex items-center justify-between">
                <h4 className="font-medium">Selected</h4>
                <Badge variant="secondary">{selectedChannels.size}</Badge>
              </div>
              <p className="text-muted-foreground text-xs">Currently selected channels</p>
            </div>
            <ScrollArea className="flex-1">
              {selectedChannelsList.length === 0 ? (
                <p className="text-muted-foreground text-sm italic">No channels selected</p>
              ) : (
                <div className="space-y-2 pr-4">
                  {selectedChannelsList.map((channel: { id: string; name: string }) => (
                    <div
                      key=[redacted]
                      className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
                    >
                      <span className="truncate">#{channel.name}</span>
                      <Button
                        variant="ghost"
                        size="icon"
                        className="text-muted-foreground hover:text-foreground h-6 w-6"
                        onClick={() => handleChannelToggle(channel.id)}
                      >
                        <X className="h-3 w-3" />
                      </Button>
                    </div>
                  ))}
                </div>
              )}
            </ScrollArea>
          </div>

          {/* Save button */}
          {hasChanges && (
            <div className="border-t pt-3">
              <Button
                size="sm"
                className="w-full"
                onClick={handleSaveConfiguration}
                disabled={isConnectingChannels}
              >
                {isConnectingChannels ? (
                  <>
                    <Loader2 className="mr-2 h-3 w-3 animate-spin" />
                    Saving...
                  </>
                ) : (
                  'Save'
                )}
              </Button>
            </div>
          )}

          {/* Blocked Channels Summary */}
          {blockedChannels.size > 0 && (
            <div className="flex flex-1 flex-col gap-2 overflow-hidden border-t pt-4">
              <div className="space-y-1">
                <div className="flex items-center justify-between">
                  <h4 className="text-destructive font-medium">Blocked</h4>
                  <Badge variant="outline" className="border-destructive/50 text-destructive">
                    {blockedChannels.size}
                  </Badge>
                </div>
                <p className="text-muted-foreground text-xs">
                  Explicitly blocks Cedar from accessing your mail
                </p>
              </div>
              <ScrollArea className="flex-1">
                <div className="space-y-2 pr-4">
                  {blockedChannelsList.map((channel: { id: string; name: string }) => (
                    <div
                      key=[redacted]
                      className="bg-destructive/5 border-destructive/20 flex items-center justify-between rounded-md border px-3 py-2 text-sm"
                    >
                      <span className="text-muted-foreground line-through">#{channel.name}</span>
                      <Button
                        variant="ghost"
                        size="icon"
                        className="text-destructive/50 hover:text-destructive h-6 w-6"
                        onClick={() => handleBlockToggle(channel.id)}
                      >
                        <X className="h-3 w-3" />
                      </Button>
                    </div>
                  ))}
                </div>
              </ScrollArea>
            </div>
          )}
        </div>
      </div>
    );
  },
);

SlackChannelManager.displayName = 'SlackChannelManager';