@burdenoff/microfe-conversations 2026.624.1 → 2026.625.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conversations/components/ConversationRow.js +1 -1
- package/dist/conversations/components/ConversationRow.js.map +1 -1
- package/dist/conversations/components/EmptyState.js +15 -9
- package/dist/conversations/components/EmptyState.js.map +1 -1
- package/dist/conversations/components/InboxLayout.js +65 -81
- package/dist/conversations/components/InboxLayout.js.map +1 -1
- package/dist/conversations/pages/AdminConversationsLandingPage.js +56 -46
- package/dist/conversations/pages/AdminConversationsLandingPage.js.map +1 -1
- package/dist/conversations/pages/ConversationAdminPages.js +182 -154
- package/dist/conversations/pages/ConversationAdminPages.js.map +1 -1
- package/dist/conversations/pages/ConversationDetailPage.js +3 -3
- package/dist/conversations/pages/ConversationDetailPage.js.map +1 -1
- package/dist/conversations/pages/ConversationListPage.js +64 -58
- package/dist/conversations/pages/ConversationListPage.js.map +1 -1
- package/dist/conversations/pages/ConversationsSettingsPage.js +87 -83
- package/dist/conversations/pages/ConversationsSettingsPage.js.map +1 -1
- package/package.json +2 -2
|
@@ -2,17 +2,17 @@ import { useConversations as e } from "../providers/ConversationsProvider.js";
|
|
|
2
2
|
import { joinPath as t } from "../utils/navigation.js";
|
|
3
3
|
import { useTr as n } from "../utils/i18n.js";
|
|
4
4
|
import { useConversationsStore as r } from "../store/conversationsStore.js";
|
|
5
|
-
import { useGetConversations as i, useSearchConversations as
|
|
6
|
-
import { useConversationStream as
|
|
5
|
+
import { useGetConversations as i, useSearchConversations as ee } from "../hooks/useConversationQueries.js";
|
|
6
|
+
import { useConversationStream as a } from "../hooks/useMessageSubscriptions.js";
|
|
7
7
|
import { PermissionGuard as o } from "../components/PermissionGuard.js";
|
|
8
8
|
import { CONVERSATIONS_PERMISSIONS as s } from "../constants/permissions.js";
|
|
9
9
|
import { ConversationRow as c } from "../components/ConversationRow.js";
|
|
10
|
-
import { useCallback as te, useDeferredValue as
|
|
11
|
-
import { AlertCircle as
|
|
12
|
-
import { useNavigate as
|
|
13
|
-
import { useEventBus as
|
|
14
|
-
import { Fragment as
|
|
15
|
-
import { EmptyState as S } from "@burdenoff/fe-libs/ui";
|
|
10
|
+
import { useCallback as te, useDeferredValue as l, useEffect as u, useMemo as d, useState as f } from "react";
|
|
11
|
+
import { AlertCircle as p, Archive as m, Hash as h, MessageCircle as g, Pin as ne, Users as re } from "lucide-react";
|
|
12
|
+
import { useNavigate as ie, useSearchParams as ae } from "react-router-dom";
|
|
13
|
+
import { useEventBus as _ } from "@burdenoff/fe-libs/shared/events";
|
|
14
|
+
import { Fragment as v, jsx as y, jsxs as b } from "react/jsx-runtime";
|
|
15
|
+
import { EmptyState as x, IllustratedEmptyState as S } from "@burdenoff/fe-libs/ui";
|
|
16
16
|
//#region src/conversations/pages/ConversationListPage.tsx
|
|
17
17
|
var C = {
|
|
18
18
|
state: "all",
|
|
@@ -23,16 +23,16 @@ var C = {
|
|
|
23
23
|
isMuted: void 0
|
|
24
24
|
};
|
|
25
25
|
function w(e, t) {
|
|
26
|
-
let [n, r] =
|
|
27
|
-
return
|
|
26
|
+
let [n, r] = f(e);
|
|
27
|
+
return u(() => {
|
|
28
28
|
let n = window.setTimeout(() => r(e), t);
|
|
29
29
|
return () => window.clearTimeout(n);
|
|
30
30
|
}, [t, e]), n;
|
|
31
31
|
}
|
|
32
|
-
function T({ forcedFilterParam:
|
|
33
|
-
let E =
|
|
34
|
-
|
|
35
|
-
let e =
|
|
32
|
+
function T({ forcedFilterParam: f, embedded: T = !1 } = {}) {
|
|
33
|
+
let E = ie(), { basePath: D, onConversationSelect: O } = e(), k = _(), A = n(), { searchQuery: oe, conversationFilter: j, updateConversationFilter: M, activeConversationId: N, setActiveConversationId: P } = r(), [F] = ae(), I = l(w(oe, 250)), L = I.trim(), R = L.length >= 2;
|
|
34
|
+
u(() => {
|
|
35
|
+
let e = f ?? F.get("filter");
|
|
36
36
|
if (!e) {
|
|
37
37
|
M(C);
|
|
38
38
|
return;
|
|
@@ -55,11 +55,11 @@ function T({ forcedFilterParam: d, embedded: T = !1 } = {}) {
|
|
|
55
55
|
type: t
|
|
56
56
|
});
|
|
57
57
|
}, [
|
|
58
|
-
|
|
58
|
+
f,
|
|
59
59
|
F,
|
|
60
60
|
M
|
|
61
61
|
]);
|
|
62
|
-
let z =
|
|
62
|
+
let z = d(() => {
|
|
63
63
|
if (!(j.state === "all" || j.state === "ACTIVE")) return [j.state];
|
|
64
64
|
}, [j.state]), { conversations: B, loading: V, error: H } = i({
|
|
65
65
|
first: 50,
|
|
@@ -68,8 +68,8 @@ function T({ forcedFilterParam: d, embedded: T = !1 } = {}) {
|
|
|
68
68
|
hasUnread: j.hasUnread,
|
|
69
69
|
isPinned: j.isPinned
|
|
70
70
|
});
|
|
71
|
-
|
|
72
|
-
let { conversations: U, loading: W, error: G } =
|
|
71
|
+
a();
|
|
72
|
+
let { conversations: U, loading: W, error: G } = ee(I), K = R ? U : B, q = d(() => K.filter((e) => e.isPinned), [K]), J = d(() => K.filter((e) => !e.isPinned), [K]), Y = te((e) => {
|
|
73
73
|
if (P(e.id), O?.(e), k.emit("conversations.conversation.opened", {
|
|
74
74
|
conversationId: e.id,
|
|
75
75
|
conversationType: e.type
|
|
@@ -87,145 +87,151 @@ function T({ forcedFilterParam: d, embedded: T = !1 } = {}) {
|
|
|
87
87
|
E,
|
|
88
88
|
O,
|
|
89
89
|
P
|
|
90
|
-
]), X = V || W, Z = H ?? G, Q =
|
|
90
|
+
]), X = V || W, Z = H ?? G, Q = f === "archived" || j.state === "ARCHIVED", $ = d(() => {
|
|
91
91
|
if (R) return {
|
|
92
92
|
title: A("conversations.empty.search.title", "No matches"),
|
|
93
93
|
description: `${A("conversations.empty.search.description", "No conversations matching")} "${L}"`,
|
|
94
|
-
icon: /* @__PURE__ */
|
|
94
|
+
icon: /* @__PURE__ */ y(g, {
|
|
95
95
|
size: 28,
|
|
96
96
|
className: "text-text-secondary"
|
|
97
|
-
})
|
|
97
|
+
}),
|
|
98
|
+
illustration: "empty-search"
|
|
98
99
|
};
|
|
99
100
|
if (Q) return {
|
|
100
101
|
title: A("conversations.empty.archived.title", "No archived conversations"),
|
|
101
102
|
description: A("conversations.empty.archived.description", "Archived conversations are hidden from your main inbox. Move conversations here from the row actions menu."),
|
|
102
|
-
icon: /* @__PURE__ */
|
|
103
|
+
icon: /* @__PURE__ */ y(m, {
|
|
103
104
|
size: 28,
|
|
104
105
|
className: "text-text-secondary"
|
|
105
|
-
})
|
|
106
|
+
}),
|
|
107
|
+
illustration: "empty-files"
|
|
106
108
|
};
|
|
107
|
-
switch (
|
|
109
|
+
switch (f) {
|
|
108
110
|
case "direct": return {
|
|
109
111
|
title: A("conversations.empty.direct.title", "No direct conversations yet"),
|
|
110
112
|
description: A("conversations.empty.direct.description", "Start one with a workspace member from the New conversation button above."),
|
|
111
|
-
icon: /* @__PURE__ */
|
|
113
|
+
icon: /* @__PURE__ */ y(g, {
|
|
112
114
|
size: 28,
|
|
113
115
|
className: "text-text-secondary"
|
|
114
|
-
})
|
|
116
|
+
}),
|
|
117
|
+
illustration: "empty-vibes"
|
|
115
118
|
};
|
|
116
119
|
case "group": return {
|
|
117
120
|
title: A("conversations.empty.groups.title", "Create a group"),
|
|
118
121
|
description: A("conversations.empty.groups.description", "Groups let you chat with multiple people in one thread. Tap New to start one."),
|
|
119
|
-
icon: /* @__PURE__ */
|
|
122
|
+
icon: /* @__PURE__ */ y(re, {
|
|
120
123
|
size: 28,
|
|
121
124
|
className: "text-text-secondary"
|
|
122
|
-
})
|
|
125
|
+
}),
|
|
126
|
+
illustration: "empty-people"
|
|
123
127
|
};
|
|
124
128
|
case "channel": return {
|
|
125
129
|
title: A("conversations.empty.channels.title", "Subscribe to a channel"),
|
|
126
130
|
description: A("conversations.empty.channels.description", "Channels are topic-based discussions visible to everyone who joins them."),
|
|
127
|
-
icon: /* @__PURE__ */
|
|
131
|
+
icon: /* @__PURE__ */ y(h, {
|
|
128
132
|
size: 28,
|
|
129
133
|
className: "text-text-secondary"
|
|
130
|
-
})
|
|
134
|
+
}),
|
|
135
|
+
illustration: "empty-vibes"
|
|
131
136
|
};
|
|
132
137
|
default: return {
|
|
133
138
|
title: A("conversations.empty.all.title", "No conversations yet"),
|
|
134
139
|
description: A("conversations.empty.all.description", "Start your first conversation with someone in this workspace."),
|
|
135
|
-
icon: /* @__PURE__ */
|
|
140
|
+
icon: /* @__PURE__ */ y(g, {
|
|
136
141
|
size: 28,
|
|
137
142
|
className: "text-text-secondary"
|
|
138
|
-
})
|
|
143
|
+
}),
|
|
144
|
+
illustration: "empty-vibes"
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
}, [
|
|
142
|
-
|
|
148
|
+
f,
|
|
143
149
|
Q,
|
|
144
150
|
R,
|
|
145
151
|
A,
|
|
146
152
|
L
|
|
147
153
|
]);
|
|
148
|
-
return T ? /* @__PURE__ */
|
|
154
|
+
return T ? /* @__PURE__ */ y(o, {
|
|
149
155
|
permission: s.CONVERSATION_READ,
|
|
150
156
|
allowUnavailablePermissions: !0,
|
|
151
|
-
children: /* @__PURE__ */
|
|
157
|
+
children: /* @__PURE__ */ y("div", {
|
|
152
158
|
"data-tour": "conversation-list",
|
|
153
159
|
className: "flex h-full min-h-0 w-full flex-col",
|
|
154
160
|
"aria-live": "polite",
|
|
155
|
-
children: X ? /* @__PURE__ */
|
|
161
|
+
children: X ? /* @__PURE__ */ y("div", {
|
|
156
162
|
role: "status",
|
|
157
163
|
className: "px-4 py-8 text-sm text-text-secondary",
|
|
158
164
|
children: A("conversations.loading", "Loading conversations...")
|
|
159
|
-
}) : Z ? /* @__PURE__ */
|
|
160
|
-
icon: /* @__PURE__ */
|
|
165
|
+
}) : Z ? /* @__PURE__ */ y(x, {
|
|
166
|
+
icon: /* @__PURE__ */ y(p, {
|
|
161
167
|
size: 28,
|
|
162
168
|
className: "text-status-error-text",
|
|
163
169
|
"aria-hidden": "true"
|
|
164
170
|
}),
|
|
165
171
|
title: A("conversations.couldNotLoad", "Could not load conversations"),
|
|
166
172
|
description: Z.message
|
|
167
|
-
}) : K.length === 0 ? /* @__PURE__ */
|
|
168
|
-
|
|
173
|
+
}) : K.length === 0 ? /* @__PURE__ */ y(S, {
|
|
174
|
+
illustration: $.illustration,
|
|
169
175
|
title: $.title,
|
|
170
176
|
description: $.description
|
|
171
|
-
}) : /* @__PURE__ */
|
|
177
|
+
}) : /* @__PURE__ */ b("div", {
|
|
172
178
|
"data-tour": "conversation-list-results",
|
|
173
179
|
className: "flex flex-col gap-0.5 px-2 py-2",
|
|
174
|
-
children: [q.length > 0 && /* @__PURE__ */
|
|
175
|
-
/* @__PURE__ */
|
|
180
|
+
children: [q.length > 0 && /* @__PURE__ */ b(v, { children: [
|
|
181
|
+
/* @__PURE__ */ b("div", {
|
|
176
182
|
className: "flex items-center gap-1.5 px-2 pt-2 pb-1",
|
|
177
|
-
children: [/* @__PURE__ */
|
|
183
|
+
children: [/* @__PURE__ */ y(ne, {
|
|
178
184
|
size: 11,
|
|
179
185
|
className: "text-text-secondary",
|
|
180
186
|
"aria-hidden": "true"
|
|
181
|
-
}), /* @__PURE__ */
|
|
187
|
+
}), /* @__PURE__ */ y("span", {
|
|
182
188
|
className: "text-[10px] font-semibold uppercase tracking-wider text-text-secondary",
|
|
183
189
|
children: A("conversations.pinned", "Pinned")
|
|
184
190
|
})]
|
|
185
191
|
}),
|
|
186
|
-
q.map((e) => /* @__PURE__ */
|
|
192
|
+
q.map((e) => /* @__PURE__ */ y(c, {
|
|
187
193
|
conversation: e,
|
|
188
194
|
isActive: N === e.id,
|
|
189
195
|
onClick: Y
|
|
190
196
|
}, e.id)),
|
|
191
|
-
J.length > 0 && /* @__PURE__ */
|
|
197
|
+
J.length > 0 && /* @__PURE__ */ y("div", {
|
|
192
198
|
className: "flex items-center gap-1.5 px-2 pt-3 pb-1",
|
|
193
|
-
children: /* @__PURE__ */
|
|
199
|
+
children: /* @__PURE__ */ y("span", {
|
|
194
200
|
className: "text-[10px] font-semibold uppercase tracking-wider text-text-secondary",
|
|
195
201
|
children: A("conversations.tabs.all", "All")
|
|
196
202
|
})
|
|
197
203
|
})
|
|
198
|
-
] }), J.map((e) => /* @__PURE__ */
|
|
204
|
+
] }), J.map((e) => /* @__PURE__ */ y(c, {
|
|
199
205
|
conversation: e,
|
|
200
206
|
isActive: N === e.id,
|
|
201
207
|
onClick: Y
|
|
202
208
|
}, e.id))]
|
|
203
209
|
})
|
|
204
210
|
})
|
|
205
|
-
}) : /* @__PURE__ */
|
|
211
|
+
}) : /* @__PURE__ */ y(o, {
|
|
206
212
|
permission: s.CONVERSATION_READ,
|
|
207
213
|
allowUnavailablePermissions: !0,
|
|
208
|
-
children: /* @__PURE__ */
|
|
214
|
+
children: /* @__PURE__ */ y("div", {
|
|
209
215
|
"data-tour": "conversation-list-standalone",
|
|
210
216
|
className: "flex h-full min-h-0 w-full flex-col bg-bg-surface",
|
|
211
|
-
children: X ? /* @__PURE__ */
|
|
217
|
+
children: X ? /* @__PURE__ */ y("div", {
|
|
212
218
|
className: "px-4 py-8 text-sm text-text-secondary",
|
|
213
219
|
children: A("conversations.loading", "Loading conversations...")
|
|
214
|
-
}) : Z ? /* @__PURE__ */
|
|
215
|
-
icon: /* @__PURE__ */
|
|
220
|
+
}) : Z ? /* @__PURE__ */ y(x, {
|
|
221
|
+
icon: /* @__PURE__ */ y(p, {
|
|
216
222
|
size: 28,
|
|
217
223
|
className: "text-status-error-text",
|
|
218
224
|
"aria-hidden": "true"
|
|
219
225
|
}),
|
|
220
226
|
title: A("conversations.couldNotLoad", "Could not load conversations"),
|
|
221
227
|
description: Z.message
|
|
222
|
-
}) : K.length === 0 ? /* @__PURE__ */
|
|
223
|
-
|
|
228
|
+
}) : K.length === 0 ? /* @__PURE__ */ y(S, {
|
|
229
|
+
illustration: $.illustration,
|
|
224
230
|
title: $.title,
|
|
225
231
|
description: $.description
|
|
226
|
-
}) : /* @__PURE__ */
|
|
232
|
+
}) : /* @__PURE__ */ y("div", {
|
|
227
233
|
className: "flex flex-col gap-0.5 px-2 py-2",
|
|
228
|
-
children: K.map((e) => /* @__PURE__ */
|
|
234
|
+
children: K.map((e) => /* @__PURE__ */ y(c, {
|
|
229
235
|
conversation: e,
|
|
230
236
|
isActive: N === e.id,
|
|
231
237
|
onClick: Y
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationListPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationListPage.tsx"],"sourcesContent":["import {\n useDeferredValue,\n useEffect,\n useMemo,\n useState,\n useCallback,\n} from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { useNavigate, useSearchParams } from \"react-router-dom\";\nimport { EmptyState } from \"@burdenoff/fe-libs/ui\";\nimport {\n Archive,\n AlertCircle,\n Hash,\n MessageCircle,\n Pin,\n Users,\n} from \"lucide-react\";\nimport { ConversationRow } from \"../components/ConversationRow\";\nimport {\n useSearchConversations,\n useGetConversations,\n} from \"../hooks/useConversationQueries\";\nimport { useConversationStream } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport type {\n Conversation,\n ConversationFilter,\n ConversationState,\n} from \"../types\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useTr } from \"../utils/i18n\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Default filter shape applied whenever the `?filter=` URL param is absent\n// or unrecognised. Covers every dimension on `ConversationFilter` except\n// `searchQuery` — the text search is independent of URL-driven filters and\n// must not be wiped when the URL flips between filter values.\n//\n// The boolean toggles (hasUnread / isPinned / isMuted) are explicitly set\n// to `undefined` here (rather than omitted). Zustand's `set` merges\n// shallowly — the store's `updateConversationFilter` does\n// `{ ...currentFilter, ...partial }` — so an omitted key would preserve the\n// previous value, leaving a toggle like `hasUnread: true` sticky across a\n// URL that drops the filter param. Typing the constant as\n// `Partial<ConversationFilter>` widens the type so the explicit\n// `undefined` entries are accepted as `boolean | undefined` rather than\n// narrowed to the literal `undefined` under `as const`.\nconst FILTER_DEFAULTS: Partial<ConversationFilter> = {\n state: \"all\",\n type: \"all\",\n visibility: \"all\",\n hasUnread: undefined,\n isPinned: undefined,\n isMuted: undefined,\n};\n\nfunction useDebouncedValue<T>(value: T, delayMs: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n\n useEffect(() => {\n const timeoutId = window.setTimeout(\n () => setDebouncedValue(value),\n delayMs,\n );\n return () => window.clearTimeout(timeoutId);\n }, [delayMs, value]);\n\n return debouncedValue;\n}\n\nexport interface ConversationListPageProps {\n /**\n * Filter dimension forced by the parent inbox shell (driven by the\n * currently active tab). Mutually exclusive with the `?filter=` URL\n * param. When set, the URL param is ignored and the store filter is\n * driven by the prop on every change.\n */\n forcedFilterParam?: \"channel\" | \"direct\" | \"group\" | \"archived\";\n /**\n * When true the page renders bare-list chrome (no own header/search/\n * filter chips) so it can be embedded inside `InboxLayout`. Defaults to\n * false for backwards compatibility with the legacy `/conversations/list`\n * route.\n */\n embedded?: boolean;\n}\n\nexport function ConversationListPage({\n forcedFilterParam,\n embedded = false,\n}: ConversationListPageProps = {}) {\n const navigate = useNavigate();\n const { basePath, onConversationSelect } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const {\n searchQuery,\n conversationFilter,\n updateConversationFilter,\n activeConversationId,\n setActiveConversationId,\n } = useConversationsStore();\n const [searchParams] = useSearchParams();\n const debouncedSearchQuery = useDebouncedValue(searchQuery, 250);\n const deferredSearchQuery = useDeferredValue(debouncedSearchQuery);\n const trimmedSearchQuery = deferredSearchQuery.trim();\n const isSearchActive = trimmedSearchQuery.length >= 2;\n\n // Sync URL filter param (?filter=channel|direct|group|archived) to the store\n // on mount / URL change. When the param is absent or unrecognised, reset\n // every filter dimension to its module-level default (`FILTER_DEFAULTS`) —\n // but deliberately do NOT touch `searchQuery`. The list's text search is\n // independent of URL-driven filters; zeroing it here would wipe in-progress\n // searches every time the URL flips between filter values or drops the\n // param entirely.\n useEffect(() => {\n const filterParam = forcedFilterParam ?? searchParams.get(\"filter\");\n\n if (!filterParam) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n const filterMap: Record<string, string> = {\n channel: \"CHANNEL\",\n direct: \"DIRECT\",\n group: \"GROUP\",\n archived: \"ARCHIVED\",\n };\n\n const mapped = filterMap[filterParam.toLowerCase()];\n if (!mapped) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n if (mapped === \"ARCHIVED\") {\n updateConversationFilter({ ...FILTER_DEFAULTS, state: \"ARCHIVED\" });\n } else {\n updateConversationFilter({\n ...FILTER_DEFAULTS,\n type: mapped as \"CHANNEL\" | \"DIRECT\" | \"GROUP\",\n });\n }\n }, [forcedFilterParam, searchParams, updateConversationFilter]);\n\n // Map filter.state (\"all\" | \"ACTIVE\" | \"ARCHIVED\" | \"DELETED\") to the\n // GraphQL `states: [ConversationState!]` filter. When the user is on the\n // Archived tab we send `states: [ARCHIVED]`; otherwise we omit the filter\n // so the backend default (ACTIVE only) kicks in.\n const stateFilter = useMemo<ConversationState[] | undefined>(() => {\n if (\n conversationFilter.state === \"all\" ||\n conversationFilter.state === \"ACTIVE\"\n ) {\n return undefined;\n }\n return [conversationFilter.state as ConversationState];\n }, [conversationFilter.state]);\n\n const {\n conversations: baseConversations,\n loading,\n error,\n } = useGetConversations({\n first: 50,\n types:\n conversationFilter.type === \"all\" ? undefined : [conversationFilter.type],\n states: stateFilter,\n hasUnread: conversationFilter.hasUnread,\n isPinned: conversationFilter.isPinned,\n });\n\n // Live conversation lifecycle stream — keeps the list current without polling.\n // Apollo cache updates are handled inside the hook (append on add, evict on\n // delete, normalisation handles updates).\n useConversationStream();\n\n const {\n conversations: searchedConversations,\n loading: searchLoading,\n error: searchError,\n } = useSearchConversations(deferredSearchQuery);\n\n const conversations = isSearchActive\n ? searchedConversations\n : baseConversations;\n\n // When the BE is queried with states:[ARCHIVED] the inbox tab is \"archived\",\n // so we skip the client-side state filter to avoid double-filtering.\n // For the inbox view the BE returns ACTIVE by default; no extra filter\n // needed here either.\n const filteredConversations = conversations;\n\n const pinnedConversations = useMemo(\n () => filteredConversations.filter((conversation) => conversation.isPinned),\n [filteredConversations],\n );\n\n const unpinnedConversations = useMemo(\n () =>\n filteredConversations.filter((conversation) => !conversation.isPinned),\n [filteredConversations],\n );\n\n const handleConversationClick = useCallback(\n (conversation: Conversation) => {\n setActiveConversationId(conversation.id);\n onConversationSelect?.(conversation);\n bus.emit(\"conversations.conversation.opened\", {\n conversationId: conversation.id,\n conversationType: conversation.type,\n });\n\n if (conversation.sourceContext?.contextHref) {\n try {\n const url = new URL(conversation.sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall back to conversation detail below.\n }\n }\n\n navigate(joinPath(basePath || \"/conversations\", conversation.id));\n },\n [basePath, bus, navigate, onConversationSelect, setActiveConversationId],\n );\n\n const isLoading = loading || searchLoading;\n const activeError = error ?? searchError;\n const isArchivedTab =\n forcedFilterParam === \"archived\" || conversationFilter.state === \"ARCHIVED\";\n\n // Empty-state copy is tab-aware so users understand why a list is blank.\n const emptyState = useMemo(() => {\n if (isSearchActive) {\n return {\n title: tr(\"conversations.empty.search.title\", \"No matches\"),\n description: `${tr(\n \"conversations.empty.search.description\",\n \"No conversations matching\",\n )} \"${trimmedSearchQuery}\"`,\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n };\n }\n if (isArchivedTab) {\n return {\n title: tr(\n \"conversations.empty.archived.title\",\n \"No archived conversations\",\n ),\n description: tr(\n \"conversations.empty.archived.description\",\n \"Archived conversations are hidden from your main inbox. Move conversations here from the row actions menu.\",\n ),\n icon: <Archive size={28} className=\"text-text-secondary\" />,\n };\n }\n switch (forcedFilterParam) {\n case \"direct\":\n return {\n title: tr(\n \"conversations.empty.direct.title\",\n \"No direct conversations yet\",\n ),\n description: tr(\n \"conversations.empty.direct.description\",\n \"Start one with a workspace member from the New conversation button above.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n };\n case \"group\":\n return {\n title: tr(\"conversations.empty.groups.title\", \"Create a group\"),\n description: tr(\n \"conversations.empty.groups.description\",\n \"Groups let you chat with multiple people in one thread. Tap New to start one.\",\n ),\n icon: <Users size={28} className=\"text-text-secondary\" />,\n };\n case \"channel\":\n return {\n title: tr(\n \"conversations.empty.channels.title\",\n \"Subscribe to a channel\",\n ),\n description: tr(\n \"conversations.empty.channels.description\",\n \"Channels are topic-based discussions visible to everyone who joins them.\",\n ),\n icon: <Hash size={28} className=\"text-text-secondary\" />,\n };\n default:\n return {\n title: tr(\"conversations.empty.all.title\", \"No conversations yet\"),\n description: tr(\n \"conversations.empty.all.description\",\n \"Start your first conversation with someone in this workspace.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n };\n }\n }, [\n forcedFilterParam,\n isArchivedTab,\n isSearchActive,\n tr,\n trimmedSearchQuery,\n ]);\n\n // ─── Embedded mode: bare list, no own chrome (used inside InboxLayout) ───\n if (embedded) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list\"\n className=\"flex h-full min-h-0 w-full flex-col\"\n aria-live=\"polite\"\n >\n {isLoading ? (\n <div\n role=\"status\"\n className=\"px-4 py-8 text-sm text-text-secondary\"\n >\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <EmptyState\n icon={emptyState.icon}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div\n data-tour=\"conversation-list-results\"\n className=\"flex flex-col gap-0.5 px-2 py-2\"\n >\n {pinnedConversations.length > 0 && (\n <>\n <div className=\"flex items-center gap-1.5 px-2 pt-2 pb-1\">\n <Pin\n size={11}\n className=\"text-text-secondary\"\n aria-hidden=\"true\"\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.pinned\", \"Pinned\")}\n </span>\n </div>\n {pinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n {unpinnedConversations.length > 0 && (\n <div className=\"flex items-center gap-1.5 px-2 pt-3 pb-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.tabs.all\", \"All\")}\n </span>\n </div>\n )}\n </>\n )}\n {unpinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n }\n\n // ─── Standalone mode (legacy /conversations/list route) ───\n // Retains the original look but uses semantic tokens.\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list-standalone\"\n className=\"flex h-full min-h-0 w-full flex-col bg-bg-surface\"\n >\n {isLoading ? (\n <div className=\"px-4 py-8 text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <EmptyState\n icon={emptyState.icon}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div className=\"flex flex-col gap-0.5 px-2 py-2\">\n {filteredConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConversationListPage.js","names":[],"sources":["../../../src/conversations/pages/ConversationListPage.tsx"],"sourcesContent":["import {\n useDeferredValue,\n useEffect,\n useMemo,\n useState,\n useCallback,\n} from \"react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport { useNavigate, useSearchParams } from \"react-router-dom\";\nimport { EmptyState, IllustratedEmptyState } from \"@burdenoff/fe-libs/ui\";\nimport {\n Archive,\n AlertCircle,\n Hash,\n MessageCircle,\n Pin,\n Users,\n} from \"lucide-react\";\nimport { ConversationRow } from \"../components/ConversationRow\";\nimport {\n useSearchConversations,\n useGetConversations,\n} from \"../hooks/useConversationQueries\";\nimport { useConversationStream } from \"../hooks/useMessageSubscriptions\";\nimport { useConversations } from \"../providers/ConversationsProvider\";\nimport { useConversationsStore } from \"../store/conversationsStore\";\nimport type {\n Conversation,\n ConversationFilter,\n ConversationState,\n} from \"../types\";\nimport { PermissionGuard } from \"../components/PermissionGuard\";\nimport { CONVERSATIONS_PERMISSIONS } from \"../constants/permissions\";\nimport { useTr } from \"../utils/i18n\";\nimport { joinPath } from \"../utils/navigation\";\n\n// Default filter shape applied whenever the `?filter=` URL param is absent\n// or unrecognised. Covers every dimension on `ConversationFilter` except\n// `searchQuery` — the text search is independent of URL-driven filters and\n// must not be wiped when the URL flips between filter values.\n//\n// The boolean toggles (hasUnread / isPinned / isMuted) are explicitly set\n// to `undefined` here (rather than omitted). Zustand's `set` merges\n// shallowly — the store's `updateConversationFilter` does\n// `{ ...currentFilter, ...partial }` — so an omitted key would preserve the\n// previous value, leaving a toggle like `hasUnread: true` sticky across a\n// URL that drops the filter param. Typing the constant as\n// `Partial<ConversationFilter>` widens the type so the explicit\n// `undefined` entries are accepted as `boolean | undefined` rather than\n// narrowed to the literal `undefined` under `as const`.\nconst FILTER_DEFAULTS: Partial<ConversationFilter> = {\n state: \"all\",\n type: \"all\",\n visibility: \"all\",\n hasUnread: undefined,\n isPinned: undefined,\n isMuted: undefined,\n};\n\nfunction useDebouncedValue<T>(value: T, delayMs: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n\n useEffect(() => {\n const timeoutId = window.setTimeout(\n () => setDebouncedValue(value),\n delayMs,\n );\n return () => window.clearTimeout(timeoutId);\n }, [delayMs, value]);\n\n return debouncedValue;\n}\n\nexport interface ConversationListPageProps {\n /**\n * Filter dimension forced by the parent inbox shell (driven by the\n * currently active tab). Mutually exclusive with the `?filter=` URL\n * param. When set, the URL param is ignored and the store filter is\n * driven by the prop on every change.\n */\n forcedFilterParam?: \"channel\" | \"direct\" | \"group\" | \"archived\";\n /**\n * When true the page renders bare-list chrome (no own header/search/\n * filter chips) so it can be embedded inside `InboxLayout`. Defaults to\n * false for backwards compatibility with the legacy `/conversations/list`\n * route.\n */\n embedded?: boolean;\n}\n\nexport function ConversationListPage({\n forcedFilterParam,\n embedded = false,\n}: ConversationListPageProps = {}) {\n const navigate = useNavigate();\n const { basePath, onConversationSelect } = useConversations();\n const bus = useEventBus();\n const tr = useTr();\n const {\n searchQuery,\n conversationFilter,\n updateConversationFilter,\n activeConversationId,\n setActiveConversationId,\n } = useConversationsStore();\n const [searchParams] = useSearchParams();\n const debouncedSearchQuery = useDebouncedValue(searchQuery, 250);\n const deferredSearchQuery = useDeferredValue(debouncedSearchQuery);\n const trimmedSearchQuery = deferredSearchQuery.trim();\n const isSearchActive = trimmedSearchQuery.length >= 2;\n\n // Sync URL filter param (?filter=channel|direct|group|archived) to the store\n // on mount / URL change. When the param is absent or unrecognised, reset\n // every filter dimension to its module-level default (`FILTER_DEFAULTS`) —\n // but deliberately do NOT touch `searchQuery`. The list's text search is\n // independent of URL-driven filters; zeroing it here would wipe in-progress\n // searches every time the URL flips between filter values or drops the\n // param entirely.\n useEffect(() => {\n const filterParam = forcedFilterParam ?? searchParams.get(\"filter\");\n\n if (!filterParam) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n const filterMap: Record<string, string> = {\n channel: \"CHANNEL\",\n direct: \"DIRECT\",\n group: \"GROUP\",\n archived: \"ARCHIVED\",\n };\n\n const mapped = filterMap[filterParam.toLowerCase()];\n if (!mapped) {\n updateConversationFilter(FILTER_DEFAULTS);\n return;\n }\n\n if (mapped === \"ARCHIVED\") {\n updateConversationFilter({ ...FILTER_DEFAULTS, state: \"ARCHIVED\" });\n } else {\n updateConversationFilter({\n ...FILTER_DEFAULTS,\n type: mapped as \"CHANNEL\" | \"DIRECT\" | \"GROUP\",\n });\n }\n }, [forcedFilterParam, searchParams, updateConversationFilter]);\n\n // Map filter.state (\"all\" | \"ACTIVE\" | \"ARCHIVED\" | \"DELETED\") to the\n // GraphQL `states: [ConversationState!]` filter. When the user is on the\n // Archived tab we send `states: [ARCHIVED]`; otherwise we omit the filter\n // so the backend default (ACTIVE only) kicks in.\n const stateFilter = useMemo<ConversationState[] | undefined>(() => {\n if (\n conversationFilter.state === \"all\" ||\n conversationFilter.state === \"ACTIVE\"\n ) {\n return undefined;\n }\n return [conversationFilter.state as ConversationState];\n }, [conversationFilter.state]);\n\n const {\n conversations: baseConversations,\n loading,\n error,\n } = useGetConversations({\n first: 50,\n types:\n conversationFilter.type === \"all\" ? undefined : [conversationFilter.type],\n states: stateFilter,\n hasUnread: conversationFilter.hasUnread,\n isPinned: conversationFilter.isPinned,\n });\n\n // Live conversation lifecycle stream — keeps the list current without polling.\n // Apollo cache updates are handled inside the hook (append on add, evict on\n // delete, normalisation handles updates).\n useConversationStream();\n\n const {\n conversations: searchedConversations,\n loading: searchLoading,\n error: searchError,\n } = useSearchConversations(deferredSearchQuery);\n\n const conversations = isSearchActive\n ? searchedConversations\n : baseConversations;\n\n // When the BE is queried with states:[ARCHIVED] the inbox tab is \"archived\",\n // so we skip the client-side state filter to avoid double-filtering.\n // For the inbox view the BE returns ACTIVE by default; no extra filter\n // needed here either.\n const filteredConversations = conversations;\n\n const pinnedConversations = useMemo(\n () => filteredConversations.filter((conversation) => conversation.isPinned),\n [filteredConversations],\n );\n\n const unpinnedConversations = useMemo(\n () =>\n filteredConversations.filter((conversation) => !conversation.isPinned),\n [filteredConversations],\n );\n\n const handleConversationClick = useCallback(\n (conversation: Conversation) => {\n setActiveConversationId(conversation.id);\n onConversationSelect?.(conversation);\n bus.emit(\"conversations.conversation.opened\", {\n conversationId: conversation.id,\n conversationType: conversation.type,\n });\n\n if (conversation.sourceContext?.contextHref) {\n try {\n const url = new URL(conversation.sourceContext.contextHref);\n if (url.origin === window.location.origin) {\n navigate(`${url.pathname}${url.search}${url.hash}`);\n return;\n }\n } catch {\n // Fall back to conversation detail below.\n }\n }\n\n navigate(joinPath(basePath || \"/conversations\", conversation.id));\n },\n [basePath, bus, navigate, onConversationSelect, setActiveConversationId],\n );\n\n const isLoading = loading || searchLoading;\n const activeError = error ?? searchError;\n const isArchivedTab =\n forcedFilterParam === \"archived\" || conversationFilter.state === \"ARCHIVED\";\n\n // Empty-state copy is tab-aware so users understand why a list is blank.\n const emptyState = useMemo(() => {\n if (isSearchActive) {\n return {\n title: tr(\"conversations.empty.search.title\", \"No matches\"),\n description: `${tr(\n \"conversations.empty.search.description\",\n \"No conversations matching\",\n )} \"${trimmedSearchQuery}\"`,\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-search\",\n };\n }\n if (isArchivedTab) {\n return {\n title: tr(\n \"conversations.empty.archived.title\",\n \"No archived conversations\",\n ),\n description: tr(\n \"conversations.empty.archived.description\",\n \"Archived conversations are hidden from your main inbox. Move conversations here from the row actions menu.\",\n ),\n icon: <Archive size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-files\",\n };\n }\n switch (forcedFilterParam) {\n case \"direct\":\n return {\n title: tr(\n \"conversations.empty.direct.title\",\n \"No direct conversations yet\",\n ),\n description: tr(\n \"conversations.empty.direct.description\",\n \"Start one with a workspace member from the New conversation button above.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n case \"group\":\n return {\n title: tr(\"conversations.empty.groups.title\", \"Create a group\"),\n description: tr(\n \"conversations.empty.groups.description\",\n \"Groups let you chat with multiple people in one thread. Tap New to start one.\",\n ),\n icon: <Users size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-people\",\n };\n case \"channel\":\n return {\n title: tr(\n \"conversations.empty.channels.title\",\n \"Subscribe to a channel\",\n ),\n description: tr(\n \"conversations.empty.channels.description\",\n \"Channels are topic-based discussions visible to everyone who joins them.\",\n ),\n icon: <Hash size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n default:\n return {\n title: tr(\"conversations.empty.all.title\", \"No conversations yet\"),\n description: tr(\n \"conversations.empty.all.description\",\n \"Start your first conversation with someone in this workspace.\",\n ),\n icon: <MessageCircle size={28} className=\"text-text-secondary\" />,\n illustration: \"empty-vibes\",\n };\n }\n }, [\n forcedFilterParam,\n isArchivedTab,\n isSearchActive,\n tr,\n trimmedSearchQuery,\n ]);\n\n // ─── Embedded mode: bare list, no own chrome (used inside InboxLayout) ───\n if (embedded) {\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list\"\n className=\"flex h-full min-h-0 w-full flex-col\"\n aria-live=\"polite\"\n >\n {isLoading ? (\n <div\n role=\"status\"\n className=\"px-4 py-8 text-sm text-text-secondary\"\n >\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div\n data-tour=\"conversation-list-results\"\n className=\"flex flex-col gap-0.5 px-2 py-2\"\n >\n {pinnedConversations.length > 0 && (\n <>\n <div className=\"flex items-center gap-1.5 px-2 pt-2 pb-1\">\n <Pin\n size={11}\n className=\"text-text-secondary\"\n aria-hidden=\"true\"\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.pinned\", \"Pinned\")}\n </span>\n </div>\n {pinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n {unpinnedConversations.length > 0 && (\n <div className=\"flex items-center gap-1.5 px-2 pt-3 pb-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wider text-text-secondary\">\n {tr(\"conversations.tabs.all\", \"All\")}\n </span>\n </div>\n )}\n </>\n )}\n {unpinnedConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n }\n\n // ─── Standalone mode (legacy /conversations/list route) ───\n // Retains the original look but uses semantic tokens.\n return (\n <PermissionGuard\n permission={CONVERSATIONS_PERMISSIONS.CONVERSATION_READ}\n allowUnavailablePermissions\n >\n <div\n data-tour=\"conversation-list-standalone\"\n className=\"flex h-full min-h-0 w-full flex-col bg-bg-surface\"\n >\n {isLoading ? (\n <div className=\"px-4 py-8 text-sm text-text-secondary\">\n {tr(\"conversations.loading\", \"Loading conversations...\")}\n </div>\n ) : activeError ? (\n <EmptyState\n icon={\n <AlertCircle\n size={28}\n className=\"text-status-error-text\"\n aria-hidden=\"true\"\n />\n }\n title={tr(\n \"conversations.couldNotLoad\",\n \"Could not load conversations\",\n )}\n description={activeError.message}\n />\n ) : filteredConversations.length === 0 ? (\n <IllustratedEmptyState\n illustration={emptyState.illustration}\n title={emptyState.title}\n description={emptyState.description}\n />\n ) : (\n <div className=\"flex flex-col gap-0.5 px-2 py-2\">\n {filteredConversations.map((conversation) => (\n <ConversationRow\n key={conversation.id}\n conversation={conversation}\n isActive={activeConversationId === conversation.id}\n onClick={handleConversationClick}\n />\n ))}\n </div>\n )}\n </div>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|