Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import {
type CorrespondenceParty,
CorrespondenceSidePanelSection,
externalParties,
} from '@app/features/correspondence';
import {
EntityPropertiesSection,
EntityTagsSection,
} from '@app/features/property/side-panel/properties';
import { SidePanel } from '@components/app/side-panel';
import { References } from '@core/component/References';
import { useEmail } from '@core/context/user';
import { useAttachmentReferencesQuery } from '@queries/storage/attachment-references';
import type { ItemType } from '@service-storage/client';
import { Show, Suspense } from 'solid-js';
import { createMemo, Show, Suspense } from 'solid-js';
import { useEmailContext } from '../EmailContext';

interface EmailSidePanelSectionsProps {
Expand All @@ -17,6 +23,30 @@ interface EmailSidePanelSectionsProps {
export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) {
const emailCtx = useEmailContext();
const canEdit = () => emailCtx.permissions().isOwner;
const currentUserEmail = useEmail();

// Everyone visibly on the chain — senders and To/Cc recipients across every
// message. Bcc is left out: it is deliberately hidden correspondence and
// doesn't belong in a "who is on this thread" summary.
const externalThreadParties = createMemo<CorrespondenceParty[]>(() => {
const messages = emailCtx.thread()?.messages ?? [];
const participants: CorrespondenceParty[] = [];
for (const message of messages) {
if (message.from?.email) {
participants.push({
email: message.from.email,
name: message.from.name ?? undefined,
});
}
for (const contact of [...message.to, ...message.cc]) {
participants.push({
email: contact.email,
name: contact.name ?? undefined,
});
}
}
return externalParties(participants, currentUserEmail());
});

return (
<>
Expand Down Expand Up @@ -44,6 +74,10 @@ export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) {
</Suspense>
</SidePanel.Section>
<ReferencesSectionConditional threadId={props.threadId} />
<CorrespondenceSidePanelSection
parties={externalThreadParties()}
order={60}
/>
</>
);
}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/features/calendar/CalendarSidePanelSections.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import {
type CorrespondenceParty,
CorrespondenceSidePanelSection,
externalParties,
} from '@app/features/correspondence';
import { SidePanel, useSidePanel } from '@components/app/side-panel/SidePanel';
import { useEmail } from '@core/context/user';
import CloseIcon from '@phosphor/x.svg';
import { Button, Calendar as MiniCalendar } from '@ui';
import { createEffect, createMemo, createSignal, on, Show } from 'solid-js';
Expand Down Expand Up @@ -137,6 +143,42 @@ function CalendarSourcesSidePanelSection() {
);
}

/**
* Correspondence for the selected event. Only present while an event is
* selected, and (via the section itself) only when that event has external
* parties on it.
*/
function CalendarCorrespondenceSidePanelSection() {
const calendarView = useCalendarView();
const currentUserEmail = useEmail();

// The organizer plus every attendee. `isSelf` marks the connected account
// on the event; `externalParties` drops it again by address, so a
// provider that omits the flag still can't leak the user into the panel.
const parties = createMemo<CorrespondenceParty[]>(() => {
const event = calendarView.selectedEvent();
if (!event) return [];

const participants: CorrespondenceParty[] = [];
if (event.organizerEmail) {
participants.push({
email: event.organizerEmail,
name: event.organizerName,
});
}
for (const attendee of event.attendees) {
if (attendee.isSelf) continue;
participants.push({
email: attendee.email,
name: attendee.displayName ?? undefined,
});
}
return externalParties(participants, currentUserEmail());
});

return <CorrespondenceSidePanelSection parties={parties()} order={30} />;
}

/** Registers the calendar's contextual right-side panel sections. */
export function CalendarSidePanelSections() {
const sidePanel = useSidePanel();
Expand All @@ -146,6 +188,7 @@ export function CalendarSidePanelSections() {
<CalendarEventSidePanelSection />
<CalendarMiniCalendarSidePanelSection />
<CalendarSourcesSidePanelSection />
<CalendarCorrespondenceSidePanelSection />
</Show>
);
}
12 changes: 12 additions & 0 deletions apps/web/src/features/companies/Company/emailFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ export function emailFilterForAddress(email: string): unknown {
return anyDirection({ Complete: email });
}

/**
* Any-direction match for any of the given addresses. Returns `undefined`
* when the list is empty so the caller can skip setting `ef` entirely.
*/
export function emailFilterForAddresses(
addresses: string[]
): unknown | undefined {
if (addresses.length === 0) return undefined;
const trees = addresses.map((address) => emailFilterForAddress(address));
return trees.reduce((acc, cur) => ({ '|': [acc, cur] }));
}

/**
* Any-direction match for any of the given domains. Returns `undefined`
* when the list is empty so the caller can skip setting `ef` entirely.
Expand Down
212 changes: 212 additions & 0 deletions apps/web/src/features/correspondence/CorrespondenceSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { openEntityInSplitFromUnifiedList } from '@app/features/next-soup/utils';
import { SidePanel } from '@components/app/side-panel';
import { useSplitLayout } from '@components/app/split-layout/layout';
import {
ListEntity,
ListEntityMetadataQueryProvider,
ListLayoutProvider,
} from '@entity';
import {
createMemo,
createSignal,
For,
type JSX,
type ParentProps,
Show,
Suspense,
} from 'solid-js';
import { type CorrespondenceParty, partyDomains } from './parties';
import {
CORRESPONDENCE_THREAD_LIMIT,
useCorrespondenceThreadsQuery,
} from './use-correspondence-threads';
import {
useCrmCompanyForDomain,
useCrmContactForAddress,
} from './use-crm-records';

/**
* The "Correspondence" side-panel section: who the external parties on the
* current email thread / calendar event are, the company they belong to, and
* the recent email history with them.
*
* Renders nothing when `parties` is empty, which is how the "external parties
* only" rule is enforced — callers pass the output of
* {@link import('./parties').externalParties}.
*/
export function CorrespondenceSidePanelSection(props: {
parties: CorrespondenceParty[];
/** Render order within the panel — lower numbers appear first. */
order?: number;
}) {
return (
<Show when={props.parties.length > 0}>
<SidePanel.Section
id="correspondence"
title="Correspondence"
order={props.order}
defaultOpen
>
<CorrespondenceContent parties={props.parties} />
</SidePanel.Section>
</Show>
);
}

function CorrespondenceContent(props: { parties: CorrespondenceParty[] }) {
const domains = createMemo(() => partyDomains(props.parties));
const addresses = createMemo(() => props.parties.map((p) => p.email));

return (
<div class="flex flex-col gap-3 py-1">
<Block label={props.parties.length === 1 ? 'Contact' : 'Contacts'}>
<For each={props.parties}>
{(party) => <ContactRow party={party} />}
</For>
</Block>

<Block label={domains().length === 1 ? 'Company' : 'Companies'}>
<For each={domains()}>{(domain) => <CompanyRow domain={domain} />}</For>
</Block>

<Block label="Recent emails">
<RecentThreads addresses={addresses()} />
</Block>
</div>
);
}

function Block(props: ParentProps<{ label: JSX.Element }>) {
return (
<div class="flex min-w-0 flex-col gap-1">
<div class="px-1 text-[0.6875rem] uppercase tracking-wide text-ink-extra-muted">
{props.label}
</div>
{props.children}
</div>
);
}

/**
* One external party. Opens their CRM contact record when the team tracks
* one; otherwise it is a plain, inert row showing what the thread/event knows
* about them.
*/
function ContactRow(props: { party: CorrespondenceParty }) {
const { replaceOrInsertSplit } = useSplitLayout();
const { contact } = useCrmContactForAddress(() => props.party.email);

const label = () => contact()?.name ?? props.party.name ?? props.party.email;

return (
<button
type="button"
disabled={!contact()}
onClick={() => {
const id = contact()?.id;
if (id) replaceOrInsertSplit({ type: 'contact', id });
}}
class="flex min-w-0 flex-col gap-0.5 rounded-md px-1 py-0.5 text-left"
classList={{
'hover:bg-ink-muted/[0.06]': !!contact(),
'cursor-default': !contact(),
}}
>
<span class="truncate text-xs text-ink">{label()}</span>
<Show when={label() !== props.party.email}>
<span class="truncate text-[0.6875rem] text-ink-extra-muted">
{props.party.email}
</span>
</Show>
</button>
);
}

/**
* The CRM company behind an external domain. Falls back to the bare domain
* once the lookup settles without a match — the team simply doesn't track a
* company for it yet.
*/
function CompanyRow(props: { domain: string }) {
const { replaceOrInsertSplit } = useSplitLayout();
const { company, isLoading } = useCrmCompanyForDomain(() => props.domain);

return (
<Show
when={company()}
fallback={
<Show when={!isLoading()} fallback={<SidePanel.Loading />}>
<div class="px-1 py-0.5 text-xs text-ink-muted">{props.domain}</div>
</Show>
}
>
{(record) => (
<button
type="button"
onClick={() =>
replaceOrInsertSplit({ type: 'company', id: record().id })
}
class="flex min-w-0 flex-col gap-0.5 rounded-md px-1 py-0.5 text-left hover:bg-ink-muted/[0.06]"
>
<span class="truncate text-xs text-ink">
{record().name || props.domain}
</span>
<Show when={record().name && record().name !== props.domain}>
<span class="truncate text-[0.6875rem] text-ink-extra-muted">
{props.domain}
</span>
</Show>
</button>
)}
</Show>
);
}

/**
* The most recent shared email threads, capped at
* {@link CORRESPONDENCE_THREAD_LIMIT} and scrolled inside a fixed-height box
* so a chatty correspondent can't stretch the panel.
*/
function RecentThreads(props: { addresses: string[] }) {
const threadsQuery = useCorrespondenceThreadsQuery(() => props.addresses);
const threads = createMemo(() =>
(threadsQuery.data?.entities ?? []).slice(0, CORRESPONDENCE_THREAD_LIMIT)
);

const [listRef, setListRef] = createSignal<HTMLElement>();

return (
<Suspense fallback={<SidePanel.Loading />}>
<Show when={!threadsQuery.isLoading} fallback={<SidePanel.Loading />}>
<Show
when={threads().length > 0}
fallback={
<div class="px-1 py-0.5 text-xs text-ink-muted">
No emails with these contacts yet.
</div>
}
>
<div class="max-h-64 overflow-y-auto text-xs">
<ListEntityMetadataQueryProvider>
<ListLayoutProvider ref={listRef}>
<div ref={setListRef} class="flex flex-col">
<For each={threads()}>
{(entity) => (
<ListEntity
entity={entity}
timestamp={entity.updatedAt}
onClick={() =>
openEntityInSplitFromUnifiedList(entity, {})
}
/>
)}
</For>
</div>
</ListLayoutProvider>
</ListEntityMetadataQueryProvider>
</div>
</Show>
</Show>
</Suspense>
);
}
7 changes: 7 additions & 0 deletions apps/web/src/features/correspondence/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { CorrespondenceSidePanelSection } from './CorrespondenceSection';
export {
addressDomain,
type CorrespondenceParty,
externalParties,
partyDomains,
} from './parties';
Loading
Loading