use-setup-splits.ts4.0 KBView on GitHub import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import {
SUPERHUMAN_TEMPLATES,
type SplitTemplate,
} from '@/modules/threads/components/SplitInboxTabs';
import { useInboxes, type InboxConfig } from '@/modules/threads/hooks/use-inboxes';
/**
* The templates onboarding offers.
*
* Only ones that apply in a single click: a concrete query, no placeholders to
* fill (`<your-domain>`), no follow-up form, and no AI labels to provision
* first. The full gallery — VIP lists, pipeline presets, AI-label templates —
* stays in the inbox's own Edit dialog, where there is room to ask the follow-up
* question. Setup is not the place to open a second form.
*/
export const SETUP_SPLIT_TEMPLATES: SplitTemplate[] = SUPERHUMAN_TEMPLATES.filter(
(template) =>
!!template.query &&
!template.query.includes('<') &&
!template.requiresUserInput &&
!template.action &&
!template.aiLabels?.length,
);
/** The inbox a template produced, matched on query first and name as a fallback. */
function findMatch(inboxes: InboxConfig[], template: SplitTemplate): InboxConfig | undefined {
return (
inboxes.find((inbox) => inbox.query?.trim() === template.query && inbox.enabled !== false) ??
inboxes.find(
(inbox) =>
inbox.name.toLowerCase() === template.name.toLowerCase() && inbox.enabled !== false,
)
);
}
/**
* Select/deselect sub-inbox templates.
*
* `pending` exists because a card has to commit the instant it is clicked, and
* the server row it will match on does not exist yet. It holds only the names
* whose write is still in flight — once the row lands, the derived state takes
* over and `pending` is dropped, so a rejected write cannot leave a card stuck
* looking applied.
*/
export function useSetupSplits(onAdded?: (inboxId: string) => void) {
const { inboxes, addInbox, removeInbox, assertCanAddInbox } = useInboxes();
const [pending, setPending] = useState<Record<string, 'adding' | 'removing'>>({});
const clearPending = useCallback((name: string) => {
setPending((current) =>
Object.fromEntries(Object.entries(current).filter(([key]) => key !== name)),
);
}, []);
const isApplied = useCallback(
(template: SplitTemplate) => {
const state = pending[template.name];
if (state) return state === 'adding';
return !!findMatch(inboxes, template);
},
[inboxes, pending],
);
/** Returns the created inbox's id when the click ADDED one, else null. */
const toggle = useCallback(
async (template: SplitTemplate): Promise<string | null> => {
const match = findMatch(inboxes, template);
if (match) {
setPending((current) => ({ ...current, [template.name]: 'removing' }));
try {
await removeInbox(match.id);
} catch (error) {
toast.error(
error instanceof Error ? error.message : `Couldn't remove ${template.name}`,
);
} finally {
clearPending(template.name);
}
return null;
}
try {
assertCanAddInbox(template.name);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Cannot add another inbox');
return null;
}
setPending((current) => ({ ...current, [template.name]: 'adding' }));
try {
return await addInbox({
name: template.name,
rule: { kind: 'all_of', clauses: [] },
lookbackDays: 30,
query: template.query,
alsoShowInImportant: template.alsoShowInImportantOrOther ?? false,
// Select the new tab on the click, not on the round-trip.
onOptimisticId: onAdded,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : `Couldn't add ${template.name}`);
return null;
} finally {
clearPending(template.name);
}
},
[inboxes, addInbox, removeInbox, assertCanAddInbox, clearPending, onAdded],
);
return { templates: SETUP_SPLIT_TEMPLATES, isApplied, toggle, inboxes };
}