Skip to content

Commit f6786c5

Browse files
authored
Merge pull request #865 from microboxlabs/based/852-conversation-group-by
feat(whatsapp): group-by control for the conversation list
2 parents 2d0c93a + d9d1968 commit f6786c5

5 files changed

Lines changed: 193 additions & 56 deletions

File tree

turbo-repo/apps/app/src/features/whatsapp-inbox/components/conversation-list.tsx

Lines changed: 114 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,38 @@ import type { I18nRecord } from "@/features/i18n/i18n.service.types";
55
import { tr } from "@/features/i18n/tr.service";
66
import type { Conversation } from "../conversation.types";
77
import { conversationName, formatListTime } from "../format";
8+
import { groupConversations, type GroupBy } from "../grouping";
89

910
interface ConversationListProps {
1011
readonly conversations: Conversation[];
1112
readonly isLoading: boolean;
1213
readonly selectedId: string | null;
1314
readonly onSelect: (id: string) => void;
15+
readonly groupBy: GroupBy;
16+
readonly onGroupByChange: (groupBy: GroupBy) => void;
1417
readonly dict: I18nRecord;
1518
readonly locale: string;
1619
}
1720

21+
const GROUP_OPTIONS: readonly GroupBy[] = ["recent", "service", "driver"];
22+
const GROUP_LABEL_KEY: Record<GroupBy, string> = {
23+
recent: "groupByRecent",
24+
service: "groupByService",
25+
driver: "groupByDriver",
26+
};
27+
1828
/**
1929
* Left column: one row per conversation (driver name / phone, last-message preview, time, and an
20-
* unread badge), most-recently-active first. Mirrors the org-switcher list styling.
30+
* unread badge), most-recently-active first. A "group by" control pivots the list flat, by service,
31+
* or by driver — a view over the per-phone threads, computed client-side.
2132
*/
2233
export default function ConversationList({
2334
conversations,
2435
isLoading,
2536
selectedId,
2637
onSelect,
38+
groupBy,
39+
onGroupByChange,
2740
dict,
2841
locale,
2942
}: ConversationListProps) {
@@ -46,70 +59,115 @@ export default function ConversationList({
4659
);
4760
}
4861

62+
const groups = groupConversations(conversations, groupBy, dict);
63+
4964
return (
5065
<aside className={`${shell} self-start`}>
51-
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
66+
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
5267
<h2 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wide">
5368
{tr("listTitle", dict)}
5469
</h2>
70+
<label className="flex items-center text-xs text-gray-500 dark:text-gray-400">
71+
{/* Label kept for screen readers only — the select value already reads the current grouping,
72+
and an inline label wraps/cramps the 340px list header. */}
73+
<span className="sr-only">{tr("groupBy", dict)}</span>
74+
<select
75+
value={groupBy}
76+
onChange={(e) => onGroupByChange(e.target.value as GroupBy)}
77+
className="rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 py-1 pl-2 pr-6 text-xs text-gray-700 dark:text-gray-200 cursor-pointer"
78+
aria-label={tr("groupBy", dict)}
79+
>
80+
{GROUP_OPTIONS.map((option) => (
81+
<option key={option} value={option}>
82+
{tr(GROUP_LABEL_KEY[option], dict)}
83+
</option>
84+
))}
85+
</select>
86+
</label>
5587
</div>
5688
<ul>
57-
{conversations.map((conversation, idx) => {
58-
const isActive = conversation.id === selectedId;
59-
const isLast = idx === conversations.length - 1;
60-
const hasUnread = conversation.unreadCount > 0;
61-
return (
62-
<li key={conversation.id}>
63-
<button
64-
type="button"
65-
onClick={() => onSelect(conversation.id)}
66-
className={`w-full text-left flex items-center gap-3 px-4 h-16 cursor-pointer transition-all duration-300 ${
67-
isActive
68-
? "bg-blue-50/50 dark:bg-blue-900/20"
69-
: "hover:bg-gray-100 dark:hover:bg-gray-700"
70-
} ${isLast ? "" : "border-b border-gray-200 dark:border-gray-700"}`}
71-
>
72-
<HiUserCircle
73-
className={`h-7 w-7 shrink-0 ${
74-
isActive
75-
? "text-blue-500 dark:text-blue-400"
76-
: "text-gray-400 dark:text-gray-500"
77-
}`}
89+
{groups.map((group) => (
90+
<li key={group.key}>
91+
{group.label !== null && (
92+
<div className="px-4 py-1.5 bg-gray-50 dark:bg-gray-900/40 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
93+
{group.label}
94+
</div>
95+
)}
96+
<ul>
97+
{group.items.map((conversation) => (
98+
<ConversationRow
99+
key={conversation.id}
100+
conversation={conversation}
101+
isActive={conversation.id === selectedId}
102+
onSelect={onSelect}
103+
dict={dict}
104+
locale={locale}
78105
/>
79-
<span className="min-w-0 flex-1">
80-
<span className="flex items-center justify-between gap-2">
81-
<span
82-
className={`block text-sm truncate ${
83-
isActive
84-
? "font-semibold text-blue-700 dark:text-blue-300"
85-
: "font-medium text-gray-900 dark:text-white"
86-
}`}
87-
>
88-
{conversationName(conversation)}
89-
</span>
90-
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
91-
{formatListTime(conversation.lastMessageAt, locale)}
92-
</span>
93-
</span>
94-
<span className="flex items-center justify-between gap-2">
95-
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">
96-
{conversation.lastMessagePreview ?? ""}
97-
</span>
98-
{hasUnread && (
99-
<span
100-
className="shrink-0 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-green-500 text-white text-xs font-semibold"
101-
aria-label={tr("unread", dict)}
102-
>
103-
{conversation.unreadCount}
104-
</span>
105-
)}
106-
</span>
107-
</span>
108-
</button>
109-
</li>
110-
);
111-
})}
106+
))}
107+
</ul>
108+
</li>
109+
))}
112110
</ul>
113111
</aside>
114112
);
115113
}
114+
115+
function ConversationRow({
116+
conversation,
117+
isActive,
118+
onSelect,
119+
dict,
120+
locale,
121+
}: {
122+
readonly conversation: Conversation;
123+
readonly isActive: boolean;
124+
readonly onSelect: (id: string) => void;
125+
readonly dict: I18nRecord;
126+
readonly locale: string;
127+
}) {
128+
const hasUnread = conversation.unreadCount > 0;
129+
return (
130+
<button
131+
type="button"
132+
onClick={() => onSelect(conversation.id)}
133+
className={`w-full text-left flex items-center gap-3 px-4 h-16 cursor-pointer transition-all duration-300 border-b border-gray-200 dark:border-gray-700 ${
134+
isActive ? "bg-blue-50/50 dark:bg-blue-900/20" : "hover:bg-gray-100 dark:hover:bg-gray-700"
135+
}`}
136+
>
137+
<HiUserCircle
138+
className={`h-7 w-7 shrink-0 ${
139+
isActive ? "text-blue-500 dark:text-blue-400" : "text-gray-400 dark:text-gray-500"
140+
}`}
141+
/>
142+
<span className="min-w-0 flex-1">
143+
<span className="flex items-center justify-between gap-2">
144+
<span
145+
className={`block text-sm truncate ${
146+
isActive
147+
? "font-semibold text-blue-700 dark:text-blue-300"
148+
: "font-medium text-gray-900 dark:text-white"
149+
}`}
150+
>
151+
{conversationName(conversation)}
152+
</span>
153+
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
154+
{formatListTime(conversation.lastMessageAt, locale)}
155+
</span>
156+
</span>
157+
<span className="flex items-center justify-between gap-2">
158+
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">
159+
{conversation.lastMessagePreview ?? ""}
160+
</span>
161+
{hasUnread && (
162+
<span
163+
className="shrink-0 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-green-500 text-white text-xs font-semibold"
164+
aria-label={tr("unread", dict)}
165+
>
166+
{conversation.unreadCount}
167+
</span>
168+
)}
169+
</span>
170+
</span>
171+
</button>
172+
);
173+
}

turbo-repo/apps/app/src/features/whatsapp-inbox/components/conversations-page-content.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FaWhatsapp } from "react-icons/fa";
55
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
66
import { tr } from "@/features/i18n/tr.service";
77
import { useConversations } from "../use-conversations";
8+
import type { GroupBy } from "../grouping";
89
import ConversationList from "./conversation-list";
910
import MessageThread from "./message-thread";
1011

@@ -21,6 +22,7 @@ interface ConversationsPageContentProps {
2122
export default function ConversationsPageContent({ dict, locale }: ConversationsPageContentProps) {
2223
const { conversations, isLoading, error, refresh } = useConversations();
2324
const [selectedId, setSelectedId] = useState<string | null>(null);
25+
const [groupBy, setGroupBy] = useState<GroupBy>("recent");
2426

2527
const selected = conversations.find((c) => c.id === selectedId) ?? null;
2628

@@ -48,6 +50,8 @@ export default function ConversationsPageContent({ dict, locale }: Conversations
4850
isLoading={isLoading}
4951
selectedId={selectedId}
5052
onSelect={setSelectedId}
53+
groupBy={groupBy}
54+
onGroupByChange={setGroupBy}
5155
dict={dict}
5256
locale={locale}
5357
/>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
2+
import { tr } from "@/features/i18n/tr.service";
3+
import type { Conversation } from "./conversation.types";
4+
5+
/**
6+
* How the conversation list is pivoted. Driver phone numbers are reused across services over time,
7+
* so the same thread can be viewed flat (most recent first), bucketed by the trip/service it's
8+
* currently tied to, or bucketed by driver. Grouping is a VIEW over the per-phone threads — we never
9+
* fork a conversation per trip, so an inbound reply always lands on one place.
10+
*/
11+
export type GroupBy = "recent" | "service" | "driver";
12+
13+
export interface ConversationGroup {
14+
readonly key: string;
15+
/** Section header text; {@code null} renders a flat list with no header (the "recent" view). */
16+
readonly label: string | null;
17+
readonly items: Conversation[];
18+
}
19+
20+
/** Most-recently-active first; missing timestamps sort last. ISO strings compare chronologically. */
21+
function byRecent(a: Conversation | undefined, b: Conversation | undefined): number {
22+
return (b?.lastMessageAt ?? "").localeCompare(a?.lastMessageAt ?? "");
23+
}
24+
25+
export function groupConversations(
26+
conversations: Conversation[],
27+
groupBy: GroupBy,
28+
dict: I18nRecord,
29+
): ConversationGroup[] {
30+
const sorted = [...conversations].sort(byRecent);
31+
32+
if (groupBy === "recent") {
33+
return [{ key: "all", label: null, items: sorted }];
34+
}
35+
36+
const keyOf = (c: Conversation): string =>
37+
groupBy === "service"
38+
? (c.contextServiceCode ?? "")
39+
: (c.driverId ?? c.waContactName ?? c.phoneE164);
40+
41+
const buckets = new Map<string, Conversation[]>();
42+
for (const conversation of sorted) {
43+
const key = keyOf(conversation);
44+
const bucket = buckets.get(key);
45+
if (bucket) {
46+
bucket.push(conversation);
47+
} else {
48+
buckets.set(key, [conversation]);
49+
}
50+
}
51+
52+
const groups: ConversationGroup[] = [];
53+
for (const [key, items] of buckets) {
54+
const label =
55+
groupBy === "service"
56+
? key || tr("noService", dict)
57+
: (items[0]?.waContactName ?? items[0]?.phoneE164 ?? tr("noDriver", dict));
58+
groups.push({ key: key || "__none__", label, items });
59+
}
60+
61+
// Order groups by their most-recent conversation (items are already recency-sorted).
62+
return groups.sort((g1, g2) => byRecent(g1.items[0], g2.items[0]));
63+
}

turbo-repo/apps/app/src/lang/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4002,6 +4002,12 @@
40024002
"title": "WhatsApp Conversations",
40034003
"description": "Driver replies and message delivery status for your organization.",
40044004
"listTitle": "Conversations",
4005+
"groupBy": "Group by",
4006+
"groupByRecent": "Recent",
4007+
"groupByService": "Service",
4008+
"groupByDriver": "Driver",
4009+
"noService": "No service",
4010+
"noDriver": "Unknown driver",
40054011
"loading": "Loading conversations…",
40064012
"empty": "No conversations yet.",
40074013
"noSelection": "Select a conversation to view the thread.",

turbo-repo/apps/app/src/lang/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4002,6 +4002,12 @@
40024002
"title": "Conversaciones de WhatsApp",
40034003
"description": "Respuestas de conductores y estado de entrega de los mensajes de tu organización.",
40044004
"listTitle": "Conversaciones",
4005+
"groupBy": "Agrupar por",
4006+
"groupByRecent": "Recientes",
4007+
"groupByService": "Servicio",
4008+
"groupByDriver": "Conductor",
4009+
"noService": "Sin servicio",
4010+
"noDriver": "Conductor desconocido",
40054011
"loading": "Cargando conversaciones…",
40064012
"empty": "Aún no hay conversaciones.",
40074013
"noSelection": "Selecciona una conversación para ver los mensajes.",

0 commit comments

Comments
 (0)