use-canvas-conversations.ts15.2 KBView on GitHub /**
* useCanvasConversations Hook
*
* Canvas-scoped data fetching hook that replaces useCRMConversations for canvas components.
* Consumes useCanvasConfiguration as the single source of truth for column state,
* AOP selection, and customFieldDefinitions — no duplicate resolution logic.
*
* Data flow:
* canvasesById[canvasId].viewConfig + AopSlice
* → useCanvasConfiguration (columns, customFieldDefinitions, selectedAopIds)
* → computeBackendSortFromConfig / computeFiltersFromConfig
* → trpc.crm.listConversations
*/
import {
getClientSortColumnsFromConfig,
matchesEnumFilters,
} from '@/modules/crm/utils/compute-canvas-filters';
import { getCustomFieldsRevision } from '@/modules/crm/utils/custom-fields-revision';
import type { ConversationSummary } from '@/modules/conversations';
import { useMemo, useEffect, useRef, useState } from 'react';
import { useCanvasListVariables } from '@/modules/crm/hooks/use-canvas-list-variables';
import { useTRPCClient } from '@/providers/query-provider';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { HydratedConversation } from '../types';
import { useCedarStore } from '@/modules/store';
const MAX_FETCH_ALL_PAGES = 50;
const MAX_FETCH_ALL_ITEMS = 10_000;
export function useCanvasConversations(
canvasId: string | null | undefined,
pinnedConversationIds?: string[],
options?: { limit?: number; fetchAllPages?: boolean; syncCurrentList?: boolean },
) {
// Kanban views have no infinite scroll, so they request a larger first page.
const pageLimit = options?.limit ?? 50;
const fetchAllPages = options?.fetchAllPages ?? false;
// `currentConversationList` is the app's ONE list-selection context: j/k keyboard nav reads
// it, and the chat turns it into the "N Conversations" context chip. A view that is merely
// displaying conversations — rather than being the list the user is navigating — must opt
// out, or it silently repoints that shared state at its own rows. The ordered list is
// returned either way, so an opted-out view renders from the return value instead.
const syncCurrentList = options?.syncCurrentList ?? true;
const trpcClient = useTRPCClient();
// Filter derivation lives in useCanvasListVariables so the metrics bar above the table
// consumes the identical object — see that hook's header for why sharing matters.
const { listVariables, filters, filterSortConfig, dateEpoch, hasRelativeFilters } =
useCanvasListVariables(canvasId, pinnedConversationIds, { limit: pageLimit });
// Post-fetch store sync — owned here, not by the filter-derivation hook.
const setConversations = useCedarStore((state) => state.setConversations);
const setCurrentConversationList = useCedarStore((state) => state.setCurrentConversationList);
const previousSignatureRef = useRef<string | null>(null);
// The fetched+sorted ids, in display order. Always maintained locally so callers can render
// from it directly rather than reading the global list back out of the store.
const [orderedList, setOrderedList] = useState<ConversationSummary[]>([]);
// ── Infinite query ──────────────────────────────────────────────────────
const conversationsQuery = useInfiniteQuery({
queryKey: [
['crm', 'listConversations'],
listVariables,
// dateEpoch busts the cache when the day rolls over for relative date filters
hasRelativeFilters ? dateEpoch : undefined,
],
enabled: !!canvasId,
queryFn: async ({ pageParam }) => {
return await trpcClient.crm.listConversations.query({
...listVariables,
cursor: typeof pageParam === 'number' ? pageParam : undefined,
});
},
initialPageParam: undefined as number | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNextPageParam: (lastPage: any) => {
return lastPage?.nextCursor ?? undefined;
},
staleTime: 30 * 1000,
placeholderData: (previousData) => previousData,
});
const hasNextPage = conversationsQuery.hasNextPage ?? false;
const isFetching = conversationsQuery.isFetching;
const isFetchingNextPage = conversationsQuery.isFetchingNextPage;
const fetchNextPage = conversationsQuery.fetchNextPage;
const loadedPageCount = conversationsQuery.data?.pages.length ?? 0;
// ── Flatten pages ───────────────────────────────────────────────────────
const fetchedConversations = useMemo(() => {
if (!conversationsQuery.data?.pages) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return conversationsQuery.data.pages.flatMap((page: any) => page.conversations || []);
}, [conversationsQuery.data?.pages]);
// ── Optimistic enum membership ──────────────────────────────────────────
// status/priority are filtered SERVER-side, so a page's membership is fixed at fetch time.
// Editing a deal's stage writes to the store, never to the fetched page, so the row kept its
// place in the list under a filter that no longer admits it — mark a deal Closed Lost with
// "Exclude Closed Lost" on and it just sat there. Re-check the two enum filters against the
// CURRENT value so the row leaves in the same frame as the click; the invalidation in
// useOptimisticConversationActions then refetches and the server agrees.
const storeConversations = useCedarStore((state) => state.conversations);
const rawConversations = useMemo(
() =>
fetchedConversations.filter((conv) =>
matchesEnumFilters(storeConversations[conv.id]?.data.conversation ?? conv, filters),
),
[
fetchedConversations,
storeConversations,
filters.status,
filters.excludedStatus,
filters.priority,
filters.excludedPriority,
],
);
// ── Sync to Zustand ─────────────────────────────────────────────────────
useEffect(() => {
const activeSorts = Object.entries(filterSortConfig)
.filter(([, col]) => col.sort?.active)
.map(([id, col]) => ({ id, sort: col.sort }));
const signature = JSON.stringify({
ids: rawConversations.map((conv) => conv.id),
updatedAt: rawConversations.map((conv) => conv.updatedAt),
customFieldsRevision: rawConversations.map((conv) =>
getCustomFieldsRevision(conv.customFields),
),
filters,
activeSorts,
});
if (previousSignatureRef.current === signature) return;
previousSignatureRef.current = signature;
try {
const conversationsMap: Record<string, HydratedConversation> = {};
const conversationIds: string[] = [];
rawConversations.forEach((conv) => {
const people =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conv.conversationUsers?.map((cu: any) => cu.person).filter(Boolean) || [];
const company =
(conv as { primaryCompany?: HydratedConversation['company'] | null }).primaryCompany ??
conv.conversationCompanies?.[0]?.company ??
null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const latestEvent = (conv.events?.[0] as any) || null;
conversationsMap[conv.id] = {
conversation: conv,
people,
company,
userTasks: conv.userTasks || [],
latestEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scheduledCalendarEvents: (conv as any).scheduledCalendarEvents || [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customFields: (conv as any).customFields || [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ownerUser: (conv as any).ownerUser ?? null,
};
conversationIds.push(conv.id);
});
// ── Client-side sorting (optimistic only) ─────────────────────────
const clientSortColumns = getClientSortColumnsFromConfig(filterSortConfig);
if (clientSortColumns.length > 0) {
const getActionDueDate = (
tasks: (typeof conversationsMap)[string]['userTasks'],
isCurrent: boolean,
): number => {
if (!tasks || tasks.length === 0) return Infinity;
const now = new Date();
const endOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
23,
59,
59,
999,
);
const todoTasks = tasks.filter((t) => t.status === 'todo');
const relevantTasks = isCurrent
? todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) <= endOfToday)
: todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) > endOfToday);
if (relevantTasks.length === 0) return Infinity;
return relevantTasks.reduce((earliest, task) => {
const taskDate = new Date(task.dueDate!).getTime();
return taskDate < earliest ? taskDate : earliest;
}, Infinity);
};
conversationIds.sort((aId, bId) => {
for (const column of clientSortColumns) {
let comparison = 0;
const direction = column.sort.direction;
const customOrder = column.sort.order;
if (column.id === 'currentTasks') {
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
const aCount = aTasks.filter((t) => t.status === 'todo').length;
const bCount = bTasks.filter((t) => t.status === 'todo').length;
comparison = aCount - bCount;
} else if (column.id === 'currentAction') {
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
comparison = getActionDueDate(aTasks, true) - getActionDueDate(bTasks, true);
} else if (column.id === 'futureAction') {
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
comparison = getActionDueDate(aTasks, false) - getActionDueDate(bTasks, false);
} else if (column.id === 'history') {
const aEvent = conversationsMap[aId].latestEvent;
const bEvent = conversationsMap[bId].latestEvent;
const aEventType = aEvent
? aEvent.eventType === 'email' && aEvent.direction
? `email_${aEvent.direction}`
: aEvent.eventType
: '';
const bEventType = bEvent
? bEvent.eventType === 'email' && bEvent.direction
? `email_${bEvent.direction}`
: bEvent.eventType
: '';
if (customOrder && customOrder.length > 0) {
const aIndex = customOrder.indexOf(aEventType);
const bIndex = customOrder.indexOf(bEventType);
comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
} else {
comparison = aEventType.localeCompare(bEventType);
}
} else if (column.id === 'hasFutureCalendar') {
const now = new Date();
const getNextMeetingTime = (id: string): number => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const events: any[] = conversationsMap[id].scheduledCalendarEvents || [];
for (const event of events) {
const endTime = new Date(event.endTime);
if (event.isAllDay || endTime <= now) continue;
return new Date(event.startTime).getTime();
}
return Infinity;
};
const aTime = getNextMeetingTime(aId);
const bTime = getNextMeetingTime(bId);
if (aTime === bTime) comparison = 0;
else if (aTime === Infinity) comparison = 1;
else if (bTime === Infinity) comparison = -1;
else comparison = aTime - bTime;
}
const result = direction === 'asc' ? comparison : -comparison;
if (result !== 0) return result;
}
return 0;
});
}
// 'list' — these rows come from crm.listConversations, whose events are the overview
// projection. The flag stops them replacing the full timeline of a conversation that is
// open. See setConversations in conversationsSlice.
setConversations(conversationsMap, { projection: 'list' });
const conversationList: ConversationSummary[] = conversationIds.map((id) => {
const conv = conversationsMap[id];
return { id, name: conv.conversation.name || undefined };
});
setOrderedList(conversationList);
if (syncCurrentList) setCurrentConversationList(conversationList);
} catch (error) {
console.error('[useCanvasConversations] Failed to update state:', error);
}
}, [
rawConversations,
filters,
filterSortConfig,
setConversations,
setCurrentConversationList,
syncCurrentList,
]);
// ── Auto-fetch more when filtered to few results ────────────────────────
useEffect(() => {
if (!fetchAllPages) return;
if (loadedPageCount >= MAX_FETCH_ALL_PAGES) return;
if (rawConversations.length >= MAX_FETCH_ALL_ITEMS) return;
if (
hasNextPage &&
!isFetching &&
!isFetchingNextPage
) {
void fetchNextPage();
}
}, [
fetchAllPages,
loadedPageCount,
rawConversations.length,
hasNextPage,
isFetching,
isFetchingNextPage,
fetchNextPage,
]);
useEffect(() => {
if (fetchAllPages) return;
// Our own ordered list when we don't own the global one — reading the store there would
// measure some other view's rows.
const visibleCount = syncCurrentList
? useCedarStore.getState().getCurrentConversationList().length
: orderedList.length;
if (
visibleCount < 20 &&
hasNextPage &&
!isFetching &&
!isFetchingNextPage
) {
void fetchNextPage();
}
}, [
fetchAllPages,
rawConversations.length,
filters,
hasNextPage,
isFetching,
isFetchingNextPage,
fetchNextPage,
syncCurrentList,
orderedList.length,
]);
// ── Load more ───────────────────────────────────────────────────────────
const loadMore = async () => {
if (
conversationsQuery.isLoading ||
conversationsQuery.isFetching ||
conversationsQuery.isFetchingNextPage
) {
return;
}
if (!conversationsQuery.hasNextPage) return;
await conversationsQuery.fetchNextPage();
};
return [conversationsQuery, conversationsQuery.hasNextPage ?? false, loadMore, orderedList] as const;
}