feat(inbox): filter conversations by contact tags and company

Adds contact-based filters to the Inbox conversation list (issue #272):

- Multi-select tag filter with OR logic (a conversation matches if its
  contact has any selected tag), consistent with Broadcast audiences.
- Company filter (exact match) over companies present in the inbox.
- Filters stack with the existing status filter and search; active
  selections show as removable chips with a clear-all control.

Tags are hydrated via a shared CONVERSATION_SELECT embed and normalized
onto contact.tags, so the realtime hydrate path keeps new conversations
matching an active filter. Filtering logic lives in a pure, unit-tested
matchesContactFilters helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-01 13:03:53 +03:00
parent 65b677181e
commit f8aaac131b
5 changed files with 445 additions and 29 deletions

View File

@ -3,6 +3,10 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
normalizeConversation,
} from "@/lib/inbox/conversations";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import { useRealtime } from "@/hooks/use-realtime";
import { ConversationList } from "@/components/inbox/conversation-list";
@ -118,7 +122,7 @@ export default function InboxPage() {
const supabase = createClient();
const { data, error } = await supabase
.from("conversations")
.select("*, contact:contacts(*)")
.select(CONVERSATION_SELECT)
.eq("id", convId)
.maybeSingle();
if (error) {
@ -133,7 +137,7 @@ export default function InboxPage() {
return;
}
if (!data) return;
const fetched = data as Conversation;
const fetched = normalizeConversation(data);
setConversations((prev) => {
const existing = prev.find((c) => c.id === fetched.id);
if (existing) {

View File

@ -2,18 +2,23 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
matchesContactFilters,
normalizeConversations,
} from "@/lib/inbox/conversations";
import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus } from "@/types";
import { Search, ChevronDown } from "lucide-react";
import type { Conversation, ConversationStatus, Tag } from "@/types";
import { Search, ChevronDown, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConversationListProps {
@ -56,6 +61,12 @@ export function ConversationList({
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true);
// Contact-based filters (issue #272). Tags use OR logic (a conversation
// matches if its contact carries any selected tag), consistent with
// Broadcast audience filtering. Company is an exact match on the field.
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
// Keep the latest callback in a ref so the fetch effect below can
// have a stable, empty-dep identity. Previously the fetch useCallback
@ -81,7 +92,7 @@ export function ConversationList({
(async () => {
const { data, error } = await supabase
.from("conversations")
.select("*, contact:contacts(*)")
.select(CONVERSATION_SELECT)
.order("last_message_at", { ascending: false });
if (cancelled) return;
@ -98,7 +109,7 @@ export function ConversationList({
return;
}
onConversationsLoadedRef.current(data ?? []);
onConversationsLoadedRef.current(normalizeConversations(data ?? []));
setLoading(false);
})();
@ -110,6 +121,38 @@ export function ConversationList({
// up on any events sent while the WS was disconnected or throttled.
}, [resyncToken]);
// Tag definitions for the filter picker — loaded once so labels/colours
// stay stable regardless of which conversations happen to be loaded.
useEffect(() => {
const supabase = createClient();
let cancelled = false;
(async () => {
const { data } = await supabase.from("tags").select("*").order("name");
if (!cancelled && data) setTags(data as Tag[]);
})();
return () => {
cancelled = true;
};
}, []);
// Company options are derived from the loaded conversations — there's no
// separate companies table, and only companies with a live conversation
// are worth offering as an inbox filter.
const companies = useMemo(() => {
const set = new Set<string>();
for (const c of conversations) {
const co = c.contact?.company?.trim();
if (co) set.add(co);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [conversations]);
const tagsById = useMemo(() => {
const m = new Map<string, Tag>();
for (const t of tags) m.set(t.id, t);
return m;
}, [tags]);
const filtered = useMemo(() => {
let result = conversations;
@ -119,6 +162,16 @@ export function ConversationList({
result = result.filter((c) => c.status === filter);
}
// Contact-based filters (tags via OR logic, exact company match).
if (selectedTagIds.length > 0 || selectedCompany !== null) {
result = result.filter((c) =>
matchesContactFilters(c, {
tagIds: selectedTagIds,
company: selectedCompany,
})
);
}
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((c) => {
@ -130,7 +183,20 @@ export function ConversationList({
}
return result;
}, [conversations, filter, search]);
}, [conversations, filter, search, selectedTagIds, selectedCompany]);
const toggleTag = useCallback((id: string) => {
setSelectedTagIds((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
);
}, []);
const clearContactFilters = useCallback(() => {
setSelectedTagIds([]);
setSelectedCompany(null);
}, []);
const hasContactFilters = selectedTagIds.length > 0 || selectedCompany !== null;
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@ -165,6 +231,7 @@ export function ConversationList({
/>
</div>
<div className="flex flex-wrap items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
{activeFilter?.label ?? "All"}
@ -190,6 +257,132 @@ export function ConversationList({
))}
</DropdownMenuContent>
</DropdownMenu>
{tags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
selectedTagIds.length > 0
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Tags
{selectedTagIds.length > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{selectedTagIds.length}
</span>
)}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 w-56 border-border bg-popover"
>
{tags.map((t) => (
<DropdownMenuCheckboxItem
key={t.id}
checked={selectedTagIds.includes(t.id)}
onCheckedChange={() => toggleTag(t.id)}
className="text-sm text-popover-foreground"
>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: t.color }}
/>
<span className="truncate">{t.name}</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{companies.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex max-w-40 items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
selectedCompany
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="truncate">{selectedCompany ?? "Company"}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 w-56 border-border bg-popover"
>
<DropdownMenuItem
onClick={() => setSelectedCompany(null)}
className={cn(
"text-sm",
selectedCompany === null
? "text-primary"
: "text-popover-foreground"
)}
>
All companies
</DropdownMenuItem>
{companies.map((co) => (
<DropdownMenuItem
key={co}
onClick={() => setSelectedCompany(co)}
className={cn(
"text-sm",
selectedCompany === co
? "text-primary"
: "text-popover-foreground"
)}
>
<span className="truncate">{co}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{hasContactFilters && (
<div className="flex flex-wrap items-center gap-1">
{selectedTagIds.map((id) => {
const tag = tagsById.get(id);
return (
<button
key={id}
onClick={() => toggleTag(id)}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }}
/>
<span className="max-w-24 truncate">{tag?.name ?? "Tag"}</span>
<X className="h-3 w-3" />
</button>
);
})}
{selectedCompany && (
<button
onClick={() => setSelectedCompany(null)}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
>
<span className="max-w-24 truncate">{selectedCompany}</span>
<X className="h-3 w-3" />
</button>
)}
<button
onClick={clearContactFilters}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
)}
</div>
{/* Conversation Items.

View File

@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import {
matchesContactFilters,
normalizeConversation,
} from "./conversations";
import type { Conversation } from "@/types";
function makeConversation(
contact: Partial<Conversation["contact"]> | null,
): Conversation {
return {
id: "c1",
user_id: "u1",
contact_id: "ct1",
status: "open",
unread_count: 0,
created_at: "",
updated_at: "",
contact: contact
? {
id: "ct1",
user_id: "u1",
account_id: "a1",
phone: "123",
created_at: "",
updated_at: "",
...contact,
}
: undefined,
};
}
const tag = (id: string, name = id) => ({
id,
user_id: "u1",
name,
color: "#fff",
created_at: "",
});
describe("matchesContactFilters", () => {
it("matches everything when no filters are set", () => {
const conv = makeConversation({ company: "Acme", tags: [tag("t1")] });
expect(matchesContactFilters(conv, { tagIds: [], company: null })).toBe(
true,
);
expect(makeConversation(null)).toBeDefined();
expect(
matchesContactFilters(makeConversation(null), {
tagIds: [],
company: null,
}),
).toBe(true);
});
it("uses OR logic across tags", () => {
const conv = makeConversation({ tags: [tag("t1"), tag("t2")] });
expect(
matchesContactFilters(conv, { tagIds: ["t2", "t9"], company: null }),
).toBe(true);
expect(
matchesContactFilters(conv, { tagIds: ["t9"], company: null }),
).toBe(false);
});
it("excludes conversations whose contact has no tags when a tag filter is active", () => {
const conv = makeConversation({ tags: [] });
expect(
matchesContactFilters(conv, { tagIds: ["t1"], company: null }),
).toBe(false);
expect(
matchesContactFilters(makeConversation(null), {
tagIds: ["t1"],
company: null,
}),
).toBe(false);
});
it("matches company exactly, trimming whitespace", () => {
const conv = makeConversation({ company: " Acme " });
expect(
matchesContactFilters(conv, { tagIds: [], company: "Acme" }),
).toBe(true);
expect(
matchesContactFilters(conv, { tagIds: [], company: "Other" }),
).toBe(false);
});
it("requires both tag and company to match when both are set (AND across facets)", () => {
const conv = makeConversation({ company: "Acme", tags: [tag("t1")] });
expect(
matchesContactFilters(conv, { tagIds: ["t1"], company: "Acme" }),
).toBe(true);
expect(
matchesContactFilters(conv, { tagIds: ["t1"], company: "Other" }),
).toBe(false);
expect(
matchesContactFilters(conv, { tagIds: ["tX"], company: "Acme" }),
).toBe(false);
});
});
describe("normalizeConversation", () => {
it("flattens embedded contact_tags into contact.tags", () => {
const raw = {
id: "c1",
user_id: "u1",
contact_id: "ct1",
status: "open" as const,
unread_count: 0,
created_at: "",
updated_at: "",
contact: {
id: "ct1",
user_id: "u1",
account_id: "a1",
phone: "123",
created_at: "",
updated_at: "",
contact_tags: [{ tags: tag("t1", "VIP") }, { tags: null }],
},
};
const normalized = normalizeConversation(raw);
expect(normalized.contact?.tags).toEqual([tag("t1", "VIP")]);
// The raw join key is dropped from the flattened contact.
expect(
(normalized.contact as unknown as Record<string, unknown>).contact_tags,
).toBeUndefined();
});
it("passes through a conversation with no contact", () => {
const raw = {
id: "c1",
user_id: "u1",
contact_id: "ct1",
status: "open" as const,
unread_count: 0,
created_at: "",
updated_at: "",
contact: null,
};
// A contactless row passes through untouched (consumers use `?.`).
expect(normalizeConversation(raw).contact).toBeNull();
});
});

View File

@ -0,0 +1,71 @@
import type { Conversation, Contact, Tag } from "@/types";
/**
* Conversation select that embeds the contact plus its tags, so the Inbox
* can filter conversations by contact tag without a second round-trip.
* `contact_tags(tags(*))` returns the join rows; {@link normalizeConversation}
* flattens them onto `contact.tags`.
*/
export const CONVERSATION_SELECT =
"*, contact:contacts(*, contact_tags(tags(*)))";
/** Raw shape returned by {@link CONVERSATION_SELECT} before flattening. */
type RawContact = Contact & { contact_tags?: { tags: Tag | null }[] };
type RawConversation = Omit<Conversation, "contact"> & {
contact?: RawContact | null;
};
/**
* Flatten the embedded `contact_tags(tags(*))` join into `contact.tags`.
* Safe to call on rows fetched with {@link CONVERSATION_SELECT}; a row with
* no contact (e.g. a freshly-inserted conversation) passes through untouched.
*/
export function normalizeConversation(raw: RawConversation): Conversation {
const rawContact = raw.contact;
if (!rawContact) return raw as Conversation;
const { contact_tags, ...contact } = rawContact;
return {
...raw,
contact: {
...contact,
tags: (contact_tags ?? [])
.map((ct) => ct.tags)
.filter((t): t is Tag => t != null),
},
};
}
export function normalizeConversations(
rows: RawConversation[],
): Conversation[] {
return rows.map(normalizeConversation);
}
export interface ContactFilters {
/** Tag ids; a conversation matches if its contact has ANY of them (OR). */
tagIds: string[];
/** Exact company match, or null for no company filter. */
company: string | null;
}
/**
* Whether a conversation passes the contact-based Inbox filters (issue #272).
* Empty `tagIds` and null `company` are no-ops, so the default (no filters)
* always matches. Tags use OR logic, consistent with Broadcast audiences.
*/
export function matchesContactFilters(
conversation: Conversation,
{ tagIds, company }: ContactFilters,
): boolean {
if (tagIds.length > 0) {
const contactTagIds = conversation.contact?.tags ?? [];
if (!contactTagIds.some((t) => tagIds.includes(t.id))) return false;
}
if (company !== null && conversation.contact?.company?.trim() !== company) {
return false;
}
return true;
}

View File

@ -100,6 +100,9 @@ export interface Contact {
avatar_url?: string;
created_at: string;
updated_at: string;
/** Hydrated by queries that embed `contact_tags(tags(*))` (e.g. the
* Inbox conversation list, for tag filtering). Absent otherwise. */
tags?: Tag[];
}
export interface Tag {