@firedrill-tools/slack 0.1.1

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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +369 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounds.scenario.json +112 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/history-unavailable.scenario.json +11 -0
  8. package/firedrill/open-long-handles.scenario.json +263 -0
  9. package/firedrill/open-member-bound.scenario.json +24 -0
  10. package/firedrill/post-rate-limited.scenario.json +11 -0
  11. package/firedrill/response-budget.scenario.json +24 -0
  12. package/firedrill/slack-bounds.drill.json +428 -0
  13. package/firedrill/slack-denied.drill.json +74 -0
  14. package/firedrill/slack-fresh-install.drill.json +132 -0
  15. package/firedrill/slack-history-unavailable.drill.json +83 -0
  16. package/firedrill/slack-invalid-auth.drill.json +408 -0
  17. package/firedrill/slack-mcp-aliases.drill.json +219 -0
  18. package/firedrill/slack-open-long-handles.drill.json +82 -0
  19. package/firedrill/slack-open-member-bound.drill.json +135 -0
  20. package/firedrill/slack-post-rate-limited.drill.json +96 -0
  21. package/firedrill/slack-response-budget.drill.json +118 -0
  22. package/firedrill/slack-thread-many-repliers.drill.json +100 -0
  23. package/firedrill/slack-ts-sequence-full.drill.json +165 -0
  24. package/firedrill/slack-visibility.drill.json +110 -0
  25. package/firedrill/slack-web-api-flow.drill.json +896 -0
  26. package/firedrill/thread-many-repliers.scenario.json +79 -0
  27. package/firedrill/tools/slack/app/assets/ATTRIBUTION.md +40 -0
  28. package/firedrill/tools/slack/app/assets/fonts/OFL.txt +93 -0
  29. package/firedrill/tools/slack/app/assets/fonts/lato-latin-400.woff2 +0 -0
  30. package/firedrill/tools/slack/app/assets/fonts/lato-latin-700.woff2 +0 -0
  31. package/firedrill/tools/slack/app/assets/fonts/lato-latin-900.woff2 +0 -0
  32. package/firedrill/tools/slack/app/assets/slack-wordmark.svg +1 -0
  33. package/firedrill/tools/slack/app/assets/slack.svg +1 -0
  34. package/firedrill/tools/slack/app/site/app.js +2202 -0
  35. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-400.woff2 +0 -0
  36. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-700.woff2 +0 -0
  37. package/firedrill/tools/slack/app/site/assets/fonts/lato-latin-900.woff2 +0 -0
  38. package/firedrill/tools/slack/app/site/assets/slack-wordmark.svg +1 -0
  39. package/firedrill/tools/slack/app/site/assets/slack.svg +1 -0
  40. package/firedrill/tools/slack/app/site/chrome.css +73 -0
  41. package/firedrill/tools/slack/app/site/chrome.js +96 -0
  42. package/firedrill/tools/slack/app/site/icons.js +96 -0
  43. package/firedrill/tools/slack/app/site/index.html +253 -0
  44. package/firedrill/tools/slack/app/site/styles.css +2719 -0
  45. package/firedrill/tools/slack/app/site/ui.js +491 -0
  46. package/firedrill/tools/slack/behavior.mjs +1171 -0
  47. package/firedrill/tools/slack/lib/access.mjs +138 -0
  48. package/firedrill/tools/slack/lib/budget.mjs +67 -0
  49. package/firedrill/tools/slack/lib/ids.mjs +146 -0
  50. package/firedrill/tools/slack/lib/search.mjs +172 -0
  51. package/firedrill/tools/slack/lib/wire.mjs +144 -0
  52. package/firedrill/tools/slack/slack.tool.json +6114 -0
  53. package/firedrill/ts-sequence-full.scenario.json +38 -0
  54. package/firedrill/world.json +2008 -0
  55. package/firedrill.json +5 -0
  56. package/package.json +62 -0
  57. package/starter.json +1562 -0
  58. package/test/conformance.mjs +1166 -0
@@ -0,0 +1,2202 @@
1
+ // Slack Tool browser app. Every control calls the Tool's own operations through /_firedrill/client.js; nothing on this
2
+ // page is authoritative — records are re-read after each write and whenever the world revision moves.
3
+ import { hydrateIcons, icon } from "./icons.js";
4
+ import {
5
+ $,
6
+ $$,
7
+ PICKER_EMOJI,
8
+ QUICK_REACTIONS,
9
+ ToolError,
10
+ action,
11
+ avatar,
12
+ call,
13
+ clockTime,
14
+ closeMenus,
15
+ confirmDialog,
16
+ dayLabel,
17
+ describe,
18
+ el,
19
+ emoji,
20
+ emojiLabel,
21
+ fullDate,
22
+ iconButton,
23
+ isPending,
24
+ key,
25
+ localParts,
26
+ longDate,
27
+ menuOpen,
28
+ openMenu,
29
+ plainText,
30
+ readPreference,
31
+ relative,
32
+ renderText,
33
+ shortDate,
34
+ textButton,
35
+ toast,
36
+ tsSeconds,
37
+ watchWorld,
38
+ writePreference,
39
+ } from "./ui.js";
40
+
41
+ // ---------------------------------------------------------------------------------------------
42
+ // State
43
+ // ---------------------------------------------------------------------------------------------
44
+
45
+ const PAGE = 50;
46
+ const SEARCH_PAGE = 20;
47
+ const state = {
48
+ identity: undefined, // auth.test
49
+ me: undefined, // users.info of the acting member
50
+ users: new Map(),
51
+ conversations: [], // every conversation visible to the actor (all four types, archived included)
52
+ latest: new Map(), // conversation id → newest top-level ts (sidebar activity probes)
53
+ lastSeen: readLastSeen(),
54
+ channelsOpen: readPreference("channelsOpen", "true") === "true",
55
+ dmsOpen: readPreference("dmsOpen", "true") === "true",
56
+ appsOpen: readPreference("appsOpen", "true") === "true",
57
+ toolbar: readPreference("toolbar", "true") === "true",
58
+ unreadOnly: false,
59
+ railView: "home",
60
+ view: "messages", // messages | pins | search | browse | people
61
+ current: undefined, // conversation id
62
+ info: undefined, // conversations.info view of the current conversation
63
+ messages: [],
64
+ hasMore: false,
65
+ nextCursor: "",
66
+ pinned: new Set(),
67
+ pinCount: 0,
68
+ loading: false,
69
+ paneError: undefined,
70
+ thread: undefined, // { channel, ts, parent, replies, hasMore, cursor }
71
+ panel: undefined, // "thread" | "profile"
72
+ profile: undefined,
73
+ search: { query: "", sort: "score", page: 1, result: undefined, error: undefined, loading: false },
74
+ browse: { filter: "" },
75
+ people: { filter: "" },
76
+ editing: undefined, // ts of the own message being edited in the main composer
77
+ highlight: undefined,
78
+ fatal: undefined, // { title, text }
79
+ sidebarDenied: false,
80
+ };
81
+ const nav = { stack: [], index: -1, silent: false };
82
+
83
+ const me = () => state.identity?.user_id;
84
+ const tz = () => state.me?.tz_offset ?? 0;
85
+ const userOf = (id) => state.users.get(id);
86
+ const userName = (id) => userOf(id)?.real_name || userOf(id)?.name || id;
87
+ const conversationOf = (id) => state.conversations.find((conversation) => conversation.id === id);
88
+ const resolve = { user: (id) => userOf(id)?.name, channel: (id) => conversationOf(id)?.name };
89
+ const isDm = (conversation) => conversation?.is_im === true || conversation?.is_mpim === true;
90
+
91
+ /** The newest virtual instant the page knows about — the world's "now" for relative labels (never the browser clock). */
92
+ function nowTs() {
93
+ let newest = "0";
94
+ for (const ts of state.latest.values()) if (tsSeconds(ts) > tsSeconds(newest)) newest = ts;
95
+ for (const message of state.messages) if (tsSeconds(message.ts) > tsSeconds(newest)) newest = message.ts;
96
+ for (const reply of state.thread?.replies ?? []) if (tsSeconds(reply.ts) > tsSeconds(newest)) newest = reply.ts;
97
+ if (newest === "0") newest = String(Math.max(0, ...state.conversations.map((c) => c.created)));
98
+ return newest;
99
+ }
100
+
101
+ /** Display name of a conversation the way the sidebar shows it. */
102
+ function conversationTitle(conversation) {
103
+ if (conversation.is_im) {
104
+ const partner = conversation.user && conversation.user !== me() ? conversation.user : undefined;
105
+ return partner ? userName(partner) : userName(me());
106
+ }
107
+ if (conversation.is_mpim) {
108
+ const others = mpimMembers(conversation);
109
+ return others.length > 0 ? others.map(userName).join(", ") : conversation.name;
110
+ }
111
+ return conversation.name;
112
+ }
113
+ function mpimMembers(conversation) {
114
+ const match = /^mpdm-(.+)-\d+$/.exec(conversation.name ?? "");
115
+ if (!match) return [];
116
+ return match[1]
117
+ .split("--")
118
+ .map((handle) => [...state.users.values()].find((user) => user.name === handle)?.id)
119
+ .filter((id) => id && id !== me());
120
+ }
121
+ function conversationGlyph(conversation, size = "sm") {
122
+ const glyph = el("span", { class: "sb-glyph" });
123
+ if (conversation.is_im) {
124
+ const partner = conversation.user && conversation.user !== me() ? userOf(conversation.user) : state.me;
125
+ glyph.append(avatar(partner ?? { id: conversation.user }, size), el("span", { class: `presence ${partner?.id === me() ? "active" : ""}`.trim(), attrs: { "aria-hidden": "true" } }));
126
+ } else if (conversation.is_mpim) {
127
+ const count = el("span", { class: "avatar sm", text: String(mpimMembers(conversation).length || "·") });
128
+ count.style.background = "rgba(255,255,255,0.18)";
129
+ glyph.append(count);
130
+ } else glyph.append(icon(conversation.is_private ? "lock" : "hash"));
131
+ return glyph;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------------------------
135
+ // Navigation (hash routes: #C…, #C…/thread/<ts>, #search/<query>, #browse, #people)
136
+ // ---------------------------------------------------------------------------------------------
137
+
138
+ /** The stored last-seen map; a corrupted or hand-edited preference value starts empty instead of crashing the app. */
139
+ function readLastSeen() {
140
+ try {
141
+ const entries = JSON.parse(readPreference("lastSeen", "[]"));
142
+ return new Map(Array.isArray(entries) ? entries.filter((entry) => Array.isArray(entry) && entry.length === 2) : []);
143
+ } catch {
144
+ return new Map();
145
+ }
146
+ }
147
+
148
+ function currentHash() {
149
+ const raw = location.hash.replace(/^#/, "");
150
+ try {
151
+ return decodeURIComponent(raw);
152
+ } catch {
153
+ // A pasted or typed link with a malformed percent escape (e.g. #%E0%A4%A) cannot be decoded; treat it as the
154
+ // default route so in-app navigation keeps working instead of throwing on every hashchange and click.
155
+ return "";
156
+ }
157
+ }
158
+ function navigate(hash) {
159
+ if (currentHash() === hash) {
160
+ void openRoute(hash);
161
+ return;
162
+ }
163
+ nav.stack = nav.stack.slice(0, nav.index + 1);
164
+ nav.stack.push(hash);
165
+ nav.index = nav.stack.length - 1;
166
+ nav.silent = true;
167
+ location.hash = hash;
168
+ }
169
+ window.addEventListener("hashchange", () => {
170
+ const hash = currentHash();
171
+ if (!nav.silent) {
172
+ if (nav.stack[nav.index - 1] === hash) nav.index -= 1;
173
+ else if (nav.stack[nav.index + 1] === hash) nav.index += 1;
174
+ else {
175
+ nav.stack = nav.stack.slice(0, nav.index + 1);
176
+ nav.stack.push(hash);
177
+ nav.index = nav.stack.length - 1;
178
+ }
179
+ }
180
+ nav.silent = false;
181
+ renderNavButtons();
182
+ void openRoute(hash);
183
+ });
184
+ function renderNavButtons() {
185
+ $("#nav-back").disabled = nav.index <= 0;
186
+ $("#nav-forward").disabled = nav.index >= nav.stack.length - 1;
187
+ }
188
+ $("#nav-back").addEventListener("click", () => {
189
+ if (nav.index > 0) {
190
+ nav.index -= 1;
191
+ nav.silent = true;
192
+ location.hash = nav.stack[nav.index];
193
+ }
194
+ });
195
+ $("#nav-forward").addEventListener("click", () => {
196
+ if (nav.index < nav.stack.length - 1) {
197
+ nav.index += 1;
198
+ nav.silent = true;
199
+ location.hash = nav.stack[nav.index];
200
+ }
201
+ });
202
+ $("#nav-history").addEventListener("click", (event) => {
203
+ const seen = [];
204
+ for (let index = nav.stack.length - 1; index >= 0 && seen.length < 10; index -= 1) {
205
+ const hash = nav.stack[index];
206
+ const conversation = conversationOf(hash.split("/")[0]);
207
+ if (conversation && !seen.some((entry) => entry.id === conversation.id)) seen.push(conversation);
208
+ }
209
+ const items = seen.map((conversation) => ({ label: isDm(conversation) ? conversationTitle(conversation) : `#${conversation.name}`, icon: isDm(conversation) ? "dm" : conversation.is_private ? "lock" : "hash", onSelect: () => navigate(conversation.id) }));
210
+ openMenu(event.currentTarget, items.length > 0 ? items : [{ label: "No recent conversations", disabled: true }], { header: "Recent" });
211
+ });
212
+
213
+ async function openRoute(hash) {
214
+ closeMenus();
215
+ $("#shell").classList.remove("sidebar-open");
216
+ const [head, ...rest] = hash.split("/");
217
+ if (head === "search") {
218
+ const query = rest.join("/");
219
+ $("#search").value = query;
220
+ $("#search-form").classList.toggle("has-value", query.length > 0);
221
+ $("#search-clear").hidden = query.length === 0;
222
+ await runSearch(query, { page: 1 });
223
+ return;
224
+ }
225
+ // Any other route clears the top search field so a stale query never sits over a conversation.
226
+ $("#search").value = "";
227
+ $("#search-form").classList.remove("has-value");
228
+ $("#search-clear").hidden = true;
229
+ if (head === "browse") {
230
+ showView("browse");
231
+ await renderBrowse();
232
+ return;
233
+ }
234
+ if (head === "people") {
235
+ showView("people");
236
+ await renderPeople();
237
+ return;
238
+ }
239
+ if (head === "pins" && state.current) {
240
+ showView("pins");
241
+ await renderPins();
242
+ return;
243
+ }
244
+ const id = head || defaultConversation()?.id;
245
+ if (!id) {
246
+ renderEmptyWorkspace();
247
+ return;
248
+ }
249
+ const changed = id !== state.current;
250
+ if (changed) state.editing = undefined;
251
+ await loadConversation(id, { keepScroll: !changed });
252
+ if (rest[0] === "thread" && rest[1]) await openThread(id, rest[1]);
253
+ else if (state.panel === "thread" && changed) closePanel();
254
+ }
255
+
256
+ function defaultConversation() {
257
+ const member = state.conversations.filter((conversation) => conversation.is_member && !conversation.is_archived);
258
+ return member.find((conversation) => conversation.is_general) ?? member.find((conversation) => conversation.is_channel) ?? member[0] ?? state.conversations[0];
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------------------------
262
+ // Identity, users and sidebar
263
+ // ---------------------------------------------------------------------------------------------
264
+
265
+ async function loadIdentity() {
266
+ state.identity = await call("auth.test");
267
+ const users = [];
268
+ let cursor;
269
+ for (let page = 0; page < 20; page += 1) {
270
+ const result = await call("users.list", { limit: 200, ...(cursor ? { cursor } : {}) });
271
+ users.push(...result.members);
272
+ cursor = result.response_metadata.next_cursor;
273
+ if (!cursor) break;
274
+ }
275
+ state.users = new Map(users.map((user) => [user.id, user]));
276
+ state.me = state.users.get(state.identity.user_id) ?? (await call("users.info", { user: state.identity.user_id })).user;
277
+ $("#workspace-name").textContent = state.identity.team;
278
+ $("#workspace-initial").textContent = state.identity.team.trim().charAt(0).toUpperCase() || "W";
279
+ document.title = `${state.identity.team} - Slack (synthetic)`;
280
+ const meAvatar = avatar(state.me, "md");
281
+ meAvatar.id = "me-avatar";
282
+ $("#me-avatar").replaceWith(meAvatar);
283
+ $("#search").placeholder = `Search ${state.identity.team}`;
284
+ }
285
+
286
+ async function loadConversations() {
287
+ const conversations = [];
288
+ let cursor;
289
+ try {
290
+ for (let page = 0; page < 20; page += 1) {
291
+ const result = await call("conversations.list", { types: "public_channel,private_channel,mpim,im", limit: 200, ...(cursor ? { cursor } : {}) });
292
+ conversations.push(...result.channels);
293
+ cursor = result.response_metadata.next_cursor;
294
+ if (!cursor) break;
295
+ }
296
+ state.sidebarDenied = false;
297
+ } catch (error) {
298
+ if (error instanceof ToolError && error.denied) {
299
+ state.sidebarDenied = true;
300
+ state.conversations = [];
301
+ renderSidebar();
302
+ return;
303
+ }
304
+ throw error;
305
+ }
306
+ state.conversations = conversations;
307
+ // Activity probes: the newest top-level message of every joined, active conversation (limit 1 each).
308
+ const probes = conversations.filter((conversation) => conversation.is_member && !conversation.is_archived);
309
+ const results = await Promise.all(
310
+ probes.map((conversation) =>
311
+ call("conversations.history", { channel: conversation.id, limit: 1 })
312
+ .then((result) => result.messages[0]?.ts)
313
+ .catch(() => undefined),
314
+ ),
315
+ );
316
+ const firstRun = state.latest.size === 0 && state.lastSeen.size === 0;
317
+ probes.forEach((conversation, index) => {
318
+ const ts = results[index];
319
+ if (!ts) return;
320
+ state.latest.set(conversation.id, ts);
321
+ if (firstRun) state.lastSeen.set(conversation.id, ts);
322
+ });
323
+ if (firstRun) saveLastSeen();
324
+ renderSidebar();
325
+ }
326
+
327
+ function saveLastSeen() {
328
+ writePreference("lastSeen", JSON.stringify([...state.lastSeen.entries()]));
329
+ }
330
+ function markSeen(id) {
331
+ const latest = state.latest.get(id);
332
+ const newest = state.messages.length > 0 ? state.messages[state.messages.length - 1].ts : latest;
333
+ const value = [latest, newest].filter(Boolean).sort((a, b) => tsSeconds(b) - tsSeconds(a) || (b > a ? 1 : -1))[0];
334
+ if (value) {
335
+ state.lastSeen.set(id, value);
336
+ saveLastSeen();
337
+ }
338
+ }
339
+ function isUnread(conversation) {
340
+ const latest = state.latest.get(conversation.id);
341
+ const seen = state.lastSeen.get(conversation.id);
342
+ if (!latest) return false;
343
+ if (!seen) return true;
344
+ return tsSeconds(latest) > tsSeconds(seen) || (tsSeconds(latest) === tsSeconds(seen) && latest > seen);
345
+ }
346
+
347
+ function sidebarItem(conversation) {
348
+ const item = el("li", { class: "sb-item" });
349
+ const link = el("button", { class: "sb-link", attrs: { type: "button", "data-id": conversation.id, title: isDm(conversation) ? conversationTitle(conversation) : `#${conversation.name}` } });
350
+ link.append(conversationGlyph(conversation), el("span", { class: "sb-name", text: conversationTitle(conversation) }));
351
+ if (conversation.is_mpim) link.append(el("span", { class: "sb-meta", text: String(mpimMembers(conversation).length + 1) }));
352
+ if (conversation.id === state.current && (state.view === "messages" || state.view === "pins")) link.setAttribute("aria-current", "page");
353
+ if (isUnread(conversation)) link.classList.add("unread");
354
+ link.addEventListener("click", () => navigate(conversation.id));
355
+ item.append(link);
356
+ return item;
357
+ }
358
+
359
+ function renderSidebar() {
360
+ const denied = $("#sidebar-denied");
361
+ denied.hidden = !state.sidebarDenied;
362
+ if (state.sidebarDenied) {
363
+ denied.replaceChildren(el("strong", { text: "Channels can't be listed" }), el("span", { text: "This actor has no grant for conversations.list (missing_scope), so the sidebar stays empty. Grant the operation in the world's actor definition to see conversations here." }));
364
+ }
365
+ const dmsOnly = state.railView === "dms";
366
+ $("#section-channels").hidden = dmsOnly || state.sidebarDenied;
367
+ $("#section-dms").hidden = state.sidebarDenied;
368
+ const channelList = $("#channel-list");
369
+ const dmList = $("#dm-list");
370
+ channelList.replaceChildren();
371
+ dmList.replaceChildren();
372
+ const visible = (conversation) => !state.unreadOnly || isUnread(conversation) || conversation.id === state.current;
373
+ const channels = state.conversations
374
+ .filter((conversation) => conversation.is_channel && conversation.is_member && !conversation.is_archived && visible(conversation))
375
+ .sort((left, right) => left.name.localeCompare(right.name));
376
+ const dms = state.conversations
377
+ .filter((conversation) => isDm(conversation) && conversation.is_member && visible(conversation))
378
+ .sort((left, right) => tsSeconds(state.latest.get(right.id) ?? "0") - tsSeconds(state.latest.get(left.id) ?? "0"));
379
+ $("#channels-toggle").setAttribute("aria-expanded", String(state.channelsOpen));
380
+ $("#dms-toggle").setAttribute("aria-expanded", String(state.dmsOpen));
381
+ channelList.hidden = !state.channelsOpen;
382
+ dmList.hidden = !state.dmsOpen;
383
+ $("#add-channels").hidden = !state.channelsOpen;
384
+ $("#add-dm").hidden = !state.dmsOpen;
385
+ if (state.channelsOpen) {
386
+ for (const conversation of channels) channelList.append(sidebarItem(conversation));
387
+ if (channels.length === 0 && state.conversations.length > 0) channelList.append(el("li", { class: "sidebar-empty", text: state.unreadOnly ? "No unread channels." : "You're not in any channels yet." }));
388
+ } else {
389
+ // Collapsed sections still show the selected and unread conversations, the way the client does.
390
+ for (const conversation of channels.filter((entry) => entry.id === state.current || isUnread(entry))) channelList.append(sidebarItem(conversation));
391
+ channelList.hidden = channelList.childElementCount === 0;
392
+ }
393
+ if (state.dmsOpen) {
394
+ for (const conversation of dms) dmList.append(sidebarItem(conversation));
395
+ if (dms.length === 0 && state.conversations.length > 0) dmList.append(el("li", { class: "sidebar-empty", text: state.unreadOnly ? "No unread direct messages." : "No direct messages yet." }));
396
+ } else {
397
+ for (const conversation of dms.filter((entry) => entry.id === state.current || isUnread(entry))) dmList.append(sidebarItem(conversation));
398
+ dmList.hidden = dmList.childElementCount === 0;
399
+ }
400
+ const bots = [...state.users.values()].filter((user) => user.is_bot && !user.deleted).sort((left, right) => (left.real_name || left.name).localeCompare(right.real_name || right.name));
401
+ $("#section-apps").hidden = state.sidebarDenied || dmsOnly || bots.length === 0;
402
+ $("#apps-toggle").setAttribute("aria-expanded", String(state.appsOpen));
403
+ const appList = $("#app-list");
404
+ appList.replaceChildren();
405
+ appList.hidden = !state.appsOpen;
406
+ if (state.appsOpen) {
407
+ for (const bot of bots) {
408
+ const link = el("button", { class: "sb-link", title: bot.real_name || bot.name, attrs: { type: "button", "data-user": bot.id } });
409
+ link.append(el("span", { class: "sb-glyph" }, avatar(bot, "sm")), el("span", { class: "sb-name", text: bot.real_name || bot.name }));
410
+ link.addEventListener("click", () => openProfile(bot.id));
411
+ appList.append(el("li", { class: "sb-item" }, link));
412
+ }
413
+ }
414
+ $$(".rail-item").forEach((button) => {
415
+ if (button.dataset.view === state.railView) button.setAttribute("aria-current", "page");
416
+ else button.removeAttribute("aria-current");
417
+ });
418
+ }
419
+
420
+ function renderSidebarSkeleton() {
421
+ for (const list of [$("#channel-list"), $("#dm-list")]) {
422
+ list.replaceChildren();
423
+ for (let index = 0; index < 4; index += 1) {
424
+ const bar = el("li", { class: "sk-side" });
425
+ bar.style.width = `${45 + ((index * 17) % 40)}%`;
426
+ list.append(bar);
427
+ }
428
+ }
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------------------------
432
+ // Conversation pane
433
+ // ---------------------------------------------------------------------------------------------
434
+
435
+ function showView(view) {
436
+ state.view = view;
437
+ const isConversation = view === "messages" || view === "pins";
438
+ $("#pane-tabs").hidden = !isConversation || !state.current;
439
+ $("#pane-footer").hidden = view !== "messages";
440
+ $$(".pane-tab").forEach((tab) => {
441
+ if (tab.dataset.tab === view) tab.setAttribute("aria-current", "page");
442
+ else tab.removeAttribute("aria-current");
443
+ });
444
+ if (!isConversation) {
445
+ $("#pane-topic").hidden = true;
446
+ $("#members-pill").hidden = true;
447
+ $("#pane-details").hidden = true;
448
+ $("#huddle-split").hidden = true;
449
+ $("#pane-more").hidden = true;
450
+ $("#pane-title-glyph").replaceChildren();
451
+ $("#pane-title").classList.add("static");
452
+ $(".pane-title-chevron").toggleAttribute("hidden", true);
453
+ } else $(".pane-title-chevron").toggleAttribute("hidden", false);
454
+ renderSidebar();
455
+ }
456
+
457
+ function paneBody() {
458
+ const body = $("#pane-body");
459
+ body.replaceChildren();
460
+ return body;
461
+ }
462
+
463
+ function skeleton(rows = 6) {
464
+ const host = el("div", { class: "skeleton", attrs: { "aria-busy": "true", "aria-label": "Loading" } });
465
+ for (let index = 0; index < rows; index += 1) {
466
+ const lines = el("div", { class: "sk-lines" }, [el("div", { class: "sk-line short" }), el("div", { class: `sk-line ${index % 2 ? "medium" : ""}`.trim() })]);
467
+ host.append(el("div", { class: "sk-row" }, [el("div", { class: "sk-avatar" }), lines]));
468
+ }
469
+ return host;
470
+ }
471
+
472
+ function stateCard(title, text, { error = false, actions = [] } = {}) {
473
+ const card = el("div", { class: `state-card ${error ? "error" : ""}`.trim() }, [el("h2", { class: "state-title", text }), el("p", { class: "state-text", text })]);
474
+ card.firstChild.textContent = title;
475
+ if (actions.length > 0) card.append(el("div", { class: "state-actions" }, actions));
476
+ return card;
477
+ }
478
+
479
+ async function loadConversation(id, { keepScroll = false } = {}) {
480
+ state.current = id;
481
+ state.paneError = undefined;
482
+ state.view = "messages";
483
+ showView("messages");
484
+ const known = conversationOf(id);
485
+ renderHeader(known ?? { id, name: id, is_channel: true });
486
+ if (!keepScroll) {
487
+ const body = paneBody();
488
+ body.append(el("div", { class: "message-scroll", attrs: { id: "message-scroll" } }, [el("div", { class: "message-list", attrs: { id: "messages", role: "log", "aria-live": "polite", "aria-label": "Messages" } }, skeleton())]));
489
+ }
490
+ try {
491
+ const info = (await call("conversations.info", { channel: id, include_num_members: true })).channel;
492
+ if (!info.is_im) {
493
+ // The header pill shows the first few member avatars the way the client does (one bounded page).
494
+ info.member_preview = await call("conversations.members", { channel: id, limit: 3 })
495
+ .then((result) => result.members)
496
+ .catch(() => [me()]);
497
+ }
498
+ state.info = info;
499
+ if (!known) state.conversations.push(info);
500
+ else Object.assign(known, info);
501
+ renderHeader(info);
502
+ if (!info.is_member) {
503
+ state.messages = [];
504
+ state.hasMore = false;
505
+ state.pinned = new Set();
506
+ renderMessages({ keepScroll });
507
+ renderFooter();
508
+ return;
509
+ }
510
+ const [history, pins] = await Promise.all([call("conversations.history", { channel: id, limit: PAGE }), call("pins.list", { channel: id }).catch(() => ({ items: [] }))]);
511
+ if (state.current !== id) return;
512
+ state.messages = [...history.messages].reverse();
513
+ state.hasMore = history.has_more;
514
+ state.nextCursor = history.response_metadata.next_cursor;
515
+ state.pinCount = history.pin_count;
516
+ state.pinned = new Set(pins.items.map((item) => item.message.ts));
517
+ if (state.messages.length > 0) state.latest.set(id, state.messages[state.messages.length - 1].ts);
518
+ markSeen(id);
519
+ renderSidebar();
520
+ renderMessages({ keepScroll });
521
+ renderFooter();
522
+ } catch (error) {
523
+ if (state.current !== id) return;
524
+ state.paneError = error;
525
+ renderPaneError(error, id);
526
+ }
527
+ }
528
+
529
+ function renderHeader(conversation) {
530
+ const glyph = $("#pane-title-glyph");
531
+ glyph.replaceChildren();
532
+ glyph.classList.remove("with-avatar");
533
+ const title = $("#pane-title-text");
534
+ const topic = $("#pane-topic");
535
+ if (conversation.is_im) {
536
+ const partner = conversation.user && conversation.user !== me() ? userOf(conversation.user) : state.me;
537
+ glyph.classList.add("with-avatar");
538
+ glyph.append(avatar(partner ?? { id: conversation.user }, "md"), el("span", { class: `presence ${partner?.id === me() ? "active" : ""}`.trim(), attrs: { "aria-hidden": "true" } }));
539
+ title.textContent = conversationTitle(conversation);
540
+ topic.hidden = true;
541
+ } else if (conversation.is_mpim) {
542
+ title.textContent = conversationTitle(conversation);
543
+ topic.hidden = true;
544
+ } else {
545
+ glyph.append(icon(conversation.is_private ? "lock" : "hash"));
546
+ title.textContent = conversation.name;
547
+ const value = conversation.topic?.value ?? "";
548
+ topic.hidden = false;
549
+ topic.textContent = value || (conversation.is_member && !conversation.is_archived ? "Add a topic" : "");
550
+ topic.hidden = topic.textContent.length === 0;
551
+ topic.classList.toggle("placeholder", value.length === 0);
552
+ topic.title = value || "Add a topic";
553
+ }
554
+ const pill = $("#members-pill");
555
+ const count = conversation.num_members;
556
+ pill.hidden = count === undefined || conversation.is_im === true;
557
+ if (count !== undefined) {
558
+ $("#members-count").textContent = String(count);
559
+ pill.setAttribute("aria-label", `View ${count} member${count === 1 ? "" : "s"}`);
560
+ const stack = $("#members-stack");
561
+ stack.replaceChildren();
562
+ const ids = conversation.is_mpim ? [me(), ...mpimMembers(conversation)] : [...(conversation.member_preview ?? [me()])];
563
+ for (const id of ids.filter(Boolean).slice(0, 3)) stack.append(avatar(userOf(id) ?? { id }, "sm"));
564
+ }
565
+ $("#pane-details").hidden = true;
566
+ $("#huddle-split").hidden = false;
567
+ $("#pane-more").hidden = false;
568
+ $("#pins-count").textContent = "";
569
+ $("#pane-title").classList.remove("static");
570
+ }
571
+
572
+ function renderPaneError(error, id) {
573
+ const body = paneBody();
574
+ const retry = textButton("Try again", { onClick: () => action(() => loadConversation(id)) });
575
+ if (error instanceof ToolError && error.is("CHANNEL_NOT_FOUND")) {
576
+ body.append(stateCard("This conversation isn't available", "It doesn't exist or isn't visible to you. It may have been removed when the world was reset.", { actions: [textButton("Back to your channels", { class: "primary", onClick: () => navigate(defaultConversation()?.id ?? "") })] }));
577
+ } else if (error instanceof ToolError && error.is("SERVICE_UNAVAILABLE")) {
578
+ body.append(stateCard("History is temporarily unavailable", "The workspace answered 503 service_unavailable for this channel's history. Nothing was changed.", { error: true, actions: [retry] }));
579
+ } else if (error instanceof ToolError && error.denied) {
580
+ body.append(stateCard("You don't have permission to view this", "This actor has no grant for conversations.history or conversations.info (missing_scope).", { error: true }));
581
+ } else {
582
+ body.append(stateCard("Something went wrong", describe(error), { error: true, actions: [retry] }));
583
+ }
584
+ $("#pane-footer").hidden = true;
585
+ $("#pane-tabs").hidden = true;
586
+ }
587
+
588
+ /** Group top-level messages into day sections and render them oldest-first with the composer anchored below. */
589
+ function renderMessages({ keepScroll = false } = {}) {
590
+ const info = state.info;
591
+ let scroll = $("#message-scroll");
592
+ const previousBottom = scroll ? scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight : 0;
593
+ const previousTop = scroll?.scrollTop ?? 0;
594
+ if (!scroll) {
595
+ const body = paneBody();
596
+ scroll = el("div", { class: "message-scroll", attrs: { id: "message-scroll" } });
597
+ body.append(scroll);
598
+ }
599
+ const list = el("div", { class: "message-list", attrs: { id: "messages", role: "log", "aria-live": "polite", "aria-label": "Messages" } });
600
+ scroll.replaceChildren(list);
601
+ $("#pins-count").textContent = state.pinCount > 0 ? String(state.pinCount) : "";
602
+ if (!info.is_member) {
603
+ list.append(previewCard(info));
604
+ return;
605
+ }
606
+ if (state.hasMore) {
607
+ list.append(textButton("Load older messages", { class: "load-more", onClick: () => action(loadOlder) }));
608
+ } else list.append(channelIntro(info));
609
+ const now = nowTs();
610
+ let previous;
611
+ let previousDay;
612
+ for (const message of state.messages) {
613
+ const day = localParts(message.ts, tz()).dayIndex;
614
+ if (day !== previousDay) {
615
+ list.append(dayDivider(dayLabel(message.ts, now, tz())));
616
+ previous = undefined;
617
+ previousDay = day;
618
+ }
619
+ const continued = previous !== undefined && previous.user === message.user && !message.subtype && !previous.subtype && tsSeconds(message.ts) - tsSeconds(previous.ts) < 300;
620
+ list.append(messageRow(message, { continued, channel: info.id }));
621
+ previous = message;
622
+ }
623
+ if (keepScroll) scroll.scrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight - previousBottom);
624
+ else scroll.scrollTop = scroll.scrollHeight;
625
+ if (state.highlight) {
626
+ const target = list.querySelector(`[data-ts="${state.highlight}"]`);
627
+ if (target) {
628
+ target.classList.add("highlight");
629
+ target.scrollIntoView({ block: "center" });
630
+ setTimeout(() => target.classList.remove("highlight"), 4000);
631
+ }
632
+ state.highlight = undefined;
633
+ } else if (keepScroll && previousTop === 0 && !state.hasMore) scroll.scrollTop = scroll.scrollHeight;
634
+ }
635
+
636
+ function dayDivider(label) {
637
+ const pill = el("button", { class: "day-pill", attrs: { type: "button", "aria-label": `${label} — jump to date`, title: label } }, [el("span", { text: label }), icon("chevron_down")]);
638
+ pill.addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Jump to the most recent message", onSelect: () => ($("#message-scroll").scrollTop = $("#message-scroll").scrollHeight) }, { label: "Load older messages", disabled: !state.hasMore, onSelect: () => action(loadOlder) }], { align: "start" }));
639
+ return el("div", { class: "day-divider", attrs: { role: "separator" } }, pill);
640
+ }
641
+
642
+ function channelIntro(info) {
643
+ const host = el("div", { class: "channel-intro" });
644
+ if (info.is_im) {
645
+ const partner = info.user && info.user !== me() ? userOf(info.user) : state.me;
646
+ host.append(avatar(partner ?? { id: info.user }, "xl"));
647
+ host.append(el("h2", { class: "intro-title", text: partner?.id === me() ? "This is your space" : partner?.real_name ?? info.user }));
648
+ host.append(el("p", { class: "intro-text", text: partner?.id === me() ? "Draft messages, list your to-dos, or keep links and files handy. You can also talk to yourself here, but please bear in mind you'll have to supply both sides of the conversation." : `This conversation is just between ${partner?.real_name ?? "the two of you"} and you. ${partner?.profile?.title ? `${partner.real_name.split(" ")[0]} is ${partner.profile.title}.` : ""}`.trim() }));
649
+ if (partner && partner.id !== me()) host.append(el("div", { class: "intro-actions" }, textButton("View profile", { onClick: () => openProfile(partner.id) })));
650
+ return host;
651
+ }
652
+ if (info.is_mpim) {
653
+ host.append(el("h2", { class: "intro-title", text: conversationTitle(info) }));
654
+ host.append(el("p", { class: "intro-text", text: `This is the very beginning of your group conversation with ${mpimMembers(info).map(userName).join(", ")}.` }));
655
+ return host;
656
+ }
657
+ host.append(el("div", { class: "intro-icon" }, icon(info.is_private ? "lock" : "hash")));
658
+ host.append(el("h2", { class: "intro-title", text: state.messages.length === 0 ? `You're looking at the #${info.name} channel` : `This is the very beginning of the #${info.name} channel` }));
659
+ const purpose = info.purpose?.value;
660
+ const creator = info.creator ? userName(info.creator) : undefined;
661
+ host.append(el("p", { class: "intro-text", text: purpose ? `${purpose} ${creator ? `Created by ${creator} on ${longDate(String(info.created), tz())}.` : ""}`.trim() : creator ? `Created by ${creator} on ${longDate(String(info.created), tz())}. ${info.is_private ? "This channel is private — only invited members can see it." : "Anyone in the workspace can join."}` : "" }));
662
+ if (!info.is_archived) {
663
+ const actions = el("div", { class: "intro-actions" });
664
+ actions.append(textButton("Add people", { onClick: () => openPeopleDialog("invite"), class: "" }));
665
+ actions.firstChild.prepend(icon("user_add"));
666
+ actions.append(textButton(info.topic?.value ? "Edit topic" : "Set a topic", { onClick: () => openTopicDialog() }));
667
+ host.append(actions);
668
+ }
669
+ return host;
670
+ }
671
+
672
+ function previewCard(info) {
673
+ const host = el("div", { class: "channel-intro" });
674
+ host.append(el("div", { class: "intro-icon" }, icon(info.is_private ? "lock" : "hash")));
675
+ host.append(el("h2", { class: "intro-title", text: `#${info.name}` }));
676
+ const purpose = info.purpose?.value;
677
+ host.append(el("p", { class: "intro-text", text: `${purpose ? `${purpose} ` : ""}${info.num_members ?? 0} member${info.num_members === 1 ? "" : "s"}${info.is_archived ? " · archived" : ""}. You're not a member of this channel yet, so its history isn't shown here — join to read and send messages.` }));
678
+ return host;
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------------------------
682
+ // Message rows
683
+ // ---------------------------------------------------------------------------------------------
684
+
685
+ function messageRow(message, { continued = false, channel, inThread = false, compact = false } = {}) {
686
+ const author = userOf(message.user);
687
+ const row = el("article", { class: "msg", attrs: { "data-ts": message.ts, "data-channel": channel, tabindex: "-1" } });
688
+ if (message.subtype === "channel_join") row.classList.add("system");
689
+ if (message.subtype === "tombstone") row.classList.add("tombstone");
690
+ if (continued) row.classList.add("continued");
691
+ const gutter = el("div", { class: "msg-gutter" });
692
+ if (continued) gutter.append(el("span", { class: "msg-hover-time", text: clockTime(message.ts, tz()).replace(/ [AP]M$/, "") }));
693
+ else {
694
+ const picture = avatar(author ?? { id: message.user }, "lg");
695
+ picture.setAttribute("role", "button");
696
+ picture.setAttribute("tabindex", "0");
697
+ picture.setAttribute("aria-label", `View profile of ${userName(message.user)}`);
698
+ picture.addEventListener("click", () => openProfile(message.user));
699
+ picture.addEventListener("keydown", (event) => {
700
+ if (event.key === "Enter" || event.key === " ") openProfile(message.user);
701
+ });
702
+ gutter.append(picture);
703
+ }
704
+ const main = el("div", { class: "msg-main" });
705
+ if (!continued) {
706
+ const head = el("div", { class: "msg-head" });
707
+ const name = el("button", { class: "msg-author", text: userName(message.user), attrs: { type: "button" } });
708
+ name.addEventListener("click", () => openProfile(message.user));
709
+ head.append(name);
710
+ if (author?.is_bot || message.subtype === "bot_message") head.append(el("span", { class: "app-badge", text: "APP" }));
711
+ if (author?.deleted) head.append(el("span", { class: "deactivated-badge", text: "Deactivated" }));
712
+ head.append(el("button", { class: "msg-time", text: clockTime(message.ts, tz()), title: fullDate(message.ts, tz()), attrs: { type: "button", "aria-label": fullDate(message.ts, tz()) } }));
713
+ main.append(head);
714
+ }
715
+ if (message.subtype === "thread_broadcast") {
716
+ const parent = state.messages.find((entry) => entry.ts === message.thread_ts);
717
+ if (inThread) main.append(el("div", { class: "msg-broadcast", text: "Also sent to the channel" }));
718
+ else main.append(el("div", { class: "msg-broadcast", text: parent ? `replied to a thread: ${plainText(parent.text, resolve).slice(0, 80)}` : "replied to a thread" }));
719
+ }
720
+ const text = el("div", { class: "msg-text" });
721
+ renderText(text, message.text, { resolve, onMention: openProfile, onChannel: (id) => navigate(id) });
722
+ // Jumbo emoji only when every shortcode resolved to a known emoji (unknown names stay literal text at normal size).
723
+ const shortcodes = message.text.trim().match(/:[a-z0-9_+-]+:/g) ?? [];
724
+ if (/^(?::[a-z0-9_+-]+:\s*){1,3}$/.test(message.text.trim()) && text.querySelectorAll(".msg-emoji").length === shortcodes.length) text.classList.add("emoji-only");
725
+ if (message.edited) text.append(el("span", { class: "msg-edited", text: "(edited)" }));
726
+ main.append(text);
727
+ if (Array.isArray(message.reactions) && message.reactions.length > 0) main.append(reactionsRow(message, channel));
728
+ if (!inThread && message.reply_count > 0 && message.thread_ts === message.ts) main.append(threadFooter(message, channel));
729
+ row.append(gutter, main);
730
+ if (!compact && message.subtype !== "channel_join" && message.subtype !== "tombstone") row.append(messageActions(message, channel, { inThread }));
731
+ return row;
732
+ }
733
+
734
+ function reactionsRow(message, channel) {
735
+ const host = el("div", { class: "msg-reactions" });
736
+ for (const reaction of message.reactions) {
737
+ const mine = reaction.users.includes(me());
738
+ const names = reaction.users.map(userName).join(", ");
739
+ const chip = el("button", { class: "reaction", title: `${names} reacted with :${reaction.name}:`, attrs: { type: "button", "aria-pressed": String(mine), "aria-label": `${emojiLabel(reaction.name)} ${reaction.count}, ${mine ? "remove your reaction" : "react"}` } }, [el("span", { class: "reaction-emoji", text: emoji(reaction.name) }), el("span", { text: String(reaction.count) })]);
740
+ chip.addEventListener("click", () => action(() => toggleReaction(channel, message, reaction.name, mine)));
741
+ host.append(chip);
742
+ }
743
+ const add = el("button", { class: "reaction reaction-add", attrs: { type: "button", "aria-label": "Add reaction", "aria-expanded": "false" }, title: "Add reaction" }, icon("emoji_add"));
744
+ add.addEventListener("click", (event) => openEmojiPicker(event.currentTarget, (name) => action(() => toggleReaction(channel, message, name, false))));
745
+ host.append(add);
746
+ return host;
747
+ }
748
+
749
+ function threadFooter(message, channel) {
750
+ const host = el("button", { class: "msg-thread", attrs: { type: "button", "aria-label": `Open thread, ${message.reply_count} replies` } });
751
+ const avatars = el("span", { class: "thread-avatars" });
752
+ for (const id of (message.reply_users ?? []).slice(0, 3)) avatars.append(avatar(userOf(id) ?? { id }, "md"));
753
+ host.append(avatars, el("span", { class: "thread-link", text: `${message.reply_count} repl${message.reply_count === 1 ? "y" : "ies"}` }));
754
+ if (message.latest_reply) host.append(el("span", { class: "thread-last", text: `Last reply ${relative(message.latest_reply, nowTs())}` }));
755
+ host.append(el("span", { class: "thread-view" }, [el("span", { text: "View thread" }), icon("chevron_right")]));
756
+ host.addEventListener("click", () => navigate(`${channel}/thread/${message.ts}`));
757
+ return host;
758
+ }
759
+
760
+ function messageActions(message, channel, { inThread }) {
761
+ const bar = el("div", { class: "msg-actions", attrs: { role: "toolbar", "aria-label": "Message actions" } });
762
+ const info = channel === state.current ? state.info : conversationOf(channel);
763
+ const writable = info?.is_member && !info?.is_archived;
764
+ for (const name of QUICK_REACTIONS) {
765
+ const mine = (message.reactions ?? []).some((reaction) => reaction.name === name && reaction.users.includes(me()));
766
+ const button = el("button", { class: "quick-reaction", text: emoji(name), title: `${emojiLabel(name)}`, attrs: { type: "button", "aria-label": `React with ${emojiLabel(name)}` } });
767
+ button.disabled = !writable;
768
+ button.addEventListener("click", () => action(() => toggleReaction(channel, message, name, mine)));
769
+ bar.append(button);
770
+ }
771
+ const react = iconButton("emoji_add", "Find another reaction", { onClick: (event) => openEmojiPicker(event.currentTarget, (name) => action(() => toggleReaction(channel, message, name, false))) });
772
+ react.setAttribute("aria-expanded", "false");
773
+ react.disabled = !writable;
774
+ bar.append(react);
775
+ if (!inThread && message.subtype !== "channel_join") {
776
+ bar.append(iconButton("thread", "Reply in thread", { onClick: () => navigate(`${channel}/thread/${message.thread_ts && message.thread_ts !== message.ts ? message.thread_ts : message.ts}`) }));
777
+ }
778
+ const forward = iconButton("forward", "Forward message");
779
+ forward.dataset.ns = "forward";
780
+ const later = iconButton("bookmark", "Save for later");
781
+ later.dataset.ns = "save";
782
+ bar.append(forward, later);
783
+ const pinned = state.pinned.has(message.ts) || (Array.isArray(message.pinned_to) && message.pinned_to.includes(channel));
784
+ const more = iconButton("more_v", "More actions", {
785
+ onClick: (event) => {
786
+ const own = message.user === me();
787
+ const items = [];
788
+ items.push({ label: pinned ? "Unpin from channel" : "Pin to channel", icon: "pin", disabled: !writable, onSelect: () => action(() => togglePin(channel, message, pinned)) });
789
+ items.push({ label: "Copy link to message", icon: "link", onSelect: () => copyPermalink(message) });
790
+ if (own || state.me?.is_admin) items.push("divider");
791
+ if (own) items.push({ label: "Edit message", icon: "pencil", disabled: !writable || inThread || message.subtype === "thread_broadcast", onSelect: () => beginEdit(message) });
792
+ if (own || state.me?.is_admin) items.push({ label: "Delete message…", icon: "trash", danger: true, disabled: !writable, onSelect: () => action(() => deleteMessage(channel, message)) });
793
+ const row = event.currentTarget.closest(".msg");
794
+ row.classList.add("menu-open");
795
+ const menu = openMenu(event.currentTarget, items, { align: "end" });
796
+ const observer = new MutationObserver(() => {
797
+ if (!menu.isConnected) {
798
+ row.classList.remove("menu-open");
799
+ observer.disconnect();
800
+ }
801
+ });
802
+ observer.observe(document.body, { childList: true });
803
+ },
804
+ });
805
+ more.setAttribute("aria-expanded", "false");
806
+ bar.append(more);
807
+ return bar;
808
+ }
809
+
810
+ async function copyPermalink(message) {
811
+ try {
812
+ await navigator.clipboard.writeText(message.permalink ?? `${state.identity.url}archives/${state.current}/p${message.ts.replace(".", "")}`);
813
+ toast("Link copied (it points at the synthetic workspace URL and resolves nowhere)");
814
+ } catch {
815
+ toast("Couldn't copy the link in this browser.", { error: true });
816
+ }
817
+ }
818
+
819
+ async function toggleReaction(channel, message, name, mine) {
820
+ try {
821
+ await call(mine ? "reactions.remove" : "reactions.add", { channel, timestamp: message.ts, name }, key());
822
+ } catch (error) {
823
+ if (error instanceof ToolError && (error.is("ALREADY_REACTED") || error.is("NO_REACTION"))) {
824
+ // Someone (or another tab) changed it first; the reload below shows the real state.
825
+ } else throw error;
826
+ }
827
+ await reloadAfterWrite(channel, message.thread_ts && message.thread_ts !== message.ts ? message.thread_ts : undefined);
828
+ }
829
+
830
+ async function togglePin(channel, message, pinned) {
831
+ await call(pinned ? "pins.remove" : "pins.add", { channel, timestamp: message.ts }, key());
832
+ toast(pinned ? "Unpinned from channel" : "Pinned to channel");
833
+ await reloadAfterWrite(channel);
834
+ if (state.view === "pins") await renderPins();
835
+ }
836
+
837
+ async function deleteMessage(channel, message) {
838
+ const preview = messageRow(message, { channel, compact: true });
839
+ const ok = await confirmDialog("Delete message", "Are you sure you want to delete this message? This cannot be undone.", "Delete", { danger: true, preview });
840
+ if (!ok) return;
841
+ await call("chat.delete", { channel, ts: message.ts }, key());
842
+ if (state.editing === message.ts) cancelEdit();
843
+ await reloadAfterWrite(channel, message.thread_ts && message.thread_ts !== message.ts ? message.thread_ts : undefined);
844
+ }
845
+
846
+ /** Re-read the current conversation (and the open thread) after a write, preserving scroll and composer text. */
847
+ async function reloadAfterWrite(channel, threadTs) {
848
+ if (state.current === channel) await loadConversation(channel, { keepScroll: true });
849
+ else await loadConversations();
850
+ if (state.thread && state.thread.channel === channel && (threadTs === undefined || state.thread.ts === threadTs || state.thread.ts !== threadTs)) await loadThread({ keepScroll: true });
851
+ }
852
+
853
+ async function loadOlder() {
854
+ if (!state.hasMore || !state.nextCursor) return;
855
+ const result = await call("conversations.history", { channel: state.current, limit: PAGE, cursor: state.nextCursor });
856
+ state.messages = [...[...result.messages].reverse(), ...state.messages];
857
+ state.hasMore = result.has_more;
858
+ state.nextCursor = result.response_metadata.next_cursor;
859
+ renderMessages({ keepScroll: true });
860
+ }
861
+
862
+ // ---------------------------------------------------------------------------------------------
863
+ // Footer: composer, join bar, archived banner
864
+ // ---------------------------------------------------------------------------------------------
865
+
866
+ const composerInput = $("#composer-input");
867
+ function renderFooter() {
868
+ const info = state.info;
869
+ const footer = $("#pane-footer");
870
+ const composer = $("#composer");
871
+ const banner = $("#composer-banner");
872
+ const note = $("#composer-note");
873
+ footer.hidden = state.view !== "messages";
874
+ note.hidden = true;
875
+ banner.hidden = true;
876
+ composer.hidden = false;
877
+ if (!info) return;
878
+ if (!info.is_member) {
879
+ composer.hidden = true;
880
+ banner.hidden = false;
881
+ banner.replaceChildren();
882
+ if (info.is_archived) {
883
+ banner.append(el("p", { class: "banner-title", text: `#${info.name} has been archived` }), el("p", { text: "Archived channels can't be joined. You can still browse the channel list." }));
884
+ } else if (info.is_channel && !info.is_private) {
885
+ banner.className = "join-bar";
886
+ banner.append(el("div", { class: "join-title", text: `#${info.name}` }), el("div", { class: "join-actions" }, [textButton("Details", { onClick: () => openDetails("about") }), textButton("Join channel", { class: "primary", onClick: () => action(() => joinChannel(info.id)) })]));
887
+ return;
888
+ } else banner.append(el("p", { text: "You're not a member of this conversation." }));
889
+ banner.className = "composer-banner";
890
+ return;
891
+ }
892
+ if (info.is_archived) {
893
+ composer.hidden = true;
894
+ banner.hidden = false;
895
+ banner.className = "composer-banner";
896
+ banner.replaceChildren(el("p", { class: "banner-title", text: `You are viewing #${info.name}` }), el("p", { text: "This channel has been archived. Messages can be read but no longer sent, edited or reacted to." }), el("div", { class: "banner-actions" }, textButton("Browse channels", { onClick: () => navigate("browse") })));
897
+ return;
898
+ }
899
+ const label = info.is_im ? `Message ${conversationTitle(info)}` : info.is_mpim ? `Message ${conversationTitle(info)}` : `Message #${info.name}`;
900
+ composerInput.placeholder = label;
901
+ $("#composer-label").textContent = label;
902
+ composer.classList.toggle("hide-toolbar", !state.toolbar);
903
+ $("#composer-format").setAttribute("aria-pressed", String(state.toolbar));
904
+ $("#composer-format").setAttribute("aria-label", state.toolbar ? "Hide formatting" : "Show formatting");
905
+ updateSendState();
906
+ }
907
+
908
+ function updateSendState() {
909
+ $("#send").disabled = composerInput.value.trim().length === 0;
910
+ composerInput.style.height = "auto";
911
+ composerInput.style.height = `${Math.min(composerInput.scrollHeight, window.innerHeight * 0.4)}px`;
912
+ }
913
+ composerInput.addEventListener("input", updateSendState);
914
+ composerInput.addEventListener("keydown", (event) => {
915
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
916
+ event.preventDefault();
917
+ void action(sendMessage);
918
+ } else if (event.key === "Escape" && state.editing) {
919
+ event.preventDefault();
920
+ cancelEdit();
921
+ } else if (event.key === "ArrowUp" && composerInput.value.length === 0 && !state.editing) {
922
+ const own = [...state.messages].reverse().find((message) => message.user === me() && !message.subtype);
923
+ if (own) {
924
+ event.preventDefault();
925
+ beginEdit(own);
926
+ }
927
+ }
928
+ });
929
+ $("#send").addEventListener("click", () => action(sendMessage));
930
+
931
+ /** Turn typed `@handle` mentions into the wire form the workspace stores (`<@Uxxx>`). */
932
+ function encodeMentions(text) {
933
+ return text.replace(/(^|[\s(])@([a-z0-9._-]{1,21})\b/g, (whole, lead, handle) => {
934
+ const user = [...state.users.values()].find((entry) => entry.name === handle);
935
+ return user ? `${lead}<@${user.id}>` : whole;
936
+ });
937
+ }
938
+ function decodeMentions(text) {
939
+ return String(text).replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (whole, id) => (userOf(id) ? `@${userOf(id).name}` : whole));
940
+ }
941
+
942
+ async function sendMessage() {
943
+ const raw = composerInput.value;
944
+ if (raw.trim().length === 0 || !state.info?.is_member || state.info?.is_archived) return;
945
+ const text = encodeMentions(raw);
946
+ const note = $("#composer-note");
947
+ note.hidden = true;
948
+ const channel = state.current;
949
+ try {
950
+ if (state.editing) {
951
+ await call("chat.update", { channel, ts: state.editing, text }, key());
952
+ cancelEdit();
953
+ } else {
954
+ await call("chat.post-message", { channel, text }, key());
955
+ composerInput.value = "";
956
+ }
957
+ updateSendState();
958
+ await loadConversation(channel, { keepScroll: false });
959
+ } catch (error) {
960
+ if (error instanceof ToolError && error.is("RATELIMITED")) {
961
+ note.hidden = false;
962
+ note.replaceChildren(el("span", { text: "Slack is rate-limiting posts right now (429 ratelimited, Retry-After 30 s). Your message wasn't sent and is kept here — " }), textButton("try again", { class: "link-btn", onClick: () => action(sendMessage) }));
963
+ note.lastChild.classList.remove("btn");
964
+ return;
965
+ }
966
+ if (error instanceof ToolError && (error.is("IS_ARCHIVED") || error.is("NOT_IN_CHANNEL") || error.is("CHANNEL_NOT_FOUND"))) {
967
+ await loadConversation(channel, { keepScroll: true });
968
+ }
969
+ throw error;
970
+ }
971
+ }
972
+
973
+ function beginEdit(message) {
974
+ if (!state.info?.is_member || state.info?.is_archived || message.user !== me()) return;
975
+ state.editing = message.ts;
976
+ composerInput.value = decodeMentions(message.text);
977
+ composerInput.focus();
978
+ composerInput.setSelectionRange(composerInput.value.length, composerInput.value.length);
979
+ updateSendState();
980
+ const note = $("#composer-note");
981
+ note.hidden = false;
982
+ note.style.color = "";
983
+ note.replaceChildren(el("span", { class: "composer-hint", text: "Editing message · Enter to save · Esc to cancel" }), textButton("Cancel", { class: "link-btn", onClick: cancelEdit }));
984
+ note.lastChild.classList.remove("btn");
985
+ $("#composer").classList.add("editing");
986
+ }
987
+ function cancelEdit() {
988
+ state.editing = undefined;
989
+ composerInput.value = "";
990
+ updateSendState();
991
+ $("#composer-note").hidden = true;
992
+ $("#composer").classList.remove("editing");
993
+ }
994
+
995
+ // Formatting toolbar: wraps the selection with mrkdwn markers (the workspace stores text; the client renders the markers).
996
+ function wrapSelection(before, after = before, placeholder = "text", input = composerInput) {
997
+ const start = input.selectionStart ?? input.value.length;
998
+ const end = input.selectionEnd ?? start;
999
+ const selected = input.value.slice(start, end) || placeholder;
1000
+ input.setRangeText(`${before}${selected}${after}`, start, end, "select");
1001
+ input.setSelectionRange(start + before.length, start + before.length + selected.length);
1002
+ input.focus();
1003
+ input.dispatchEvent(new Event("input"));
1004
+ }
1005
+ function prefixLines(prefix, numbered = false, input = composerInput) {
1006
+ const start = input.selectionStart ?? 0;
1007
+ const end = input.selectionEnd ?? start;
1008
+ const value = input.value;
1009
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1;
1010
+ const lineEnd = value.indexOf("\n", end) === -1 ? value.length : value.indexOf("\n", end);
1011
+ const lines = value.slice(lineStart, lineEnd).split("\n");
1012
+ const replaced = lines.map((line, index) => `${numbered ? `${index + 1}. ` : prefix}${line}`).join("\n");
1013
+ input.setRangeText(replaced, lineStart, lineEnd, "end");
1014
+ input.focus();
1015
+ input.dispatchEvent(new Event("input"));
1016
+ }
1017
+ function applyFormat(format, input = composerInput) {
1018
+ if (format === "link") wrapSelection("<https://", "|link text>", "example.test", input);
1019
+ else if (format === "ol") prefixLines("", true, input);
1020
+ else if (format === "ul") prefixLines("• ", false, input);
1021
+ else if (format === "quote") prefixLines("> ", false, input);
1022
+ else if (format === "```") wrapSelection("```\n", "\n```", "code", input);
1023
+ else wrapSelection(format, format, "text", input);
1024
+ }
1025
+ for (const button of $$("#composer-toolbar .fmt-btn")) button.addEventListener("click", () => applyFormat(button.dataset.fmt));
1026
+ $("#composer-format").addEventListener("click", () => {
1027
+ state.toolbar = !state.toolbar;
1028
+ writePreference("toolbar", String(state.toolbar));
1029
+ renderFooter();
1030
+ });
1031
+ $("#composer-emoji").addEventListener("click", (event) => openEmojiPicker(event.currentTarget, (name) => wrapSelection(`:${name}: `, "", ""), { above: true }));
1032
+ $("#composer-mention").addEventListener("click", (event) => {
1033
+ const items = [...state.users.values()]
1034
+ .filter((user) => !user.deleted && user.id !== me())
1035
+ .sort((left, right) => left.real_name.localeCompare(right.real_name))
1036
+ .map((user) => ({ label: user.real_name, detail: `@${user.name}`, onSelect: () => wrapSelection(`@${user.name} `, "", "") }));
1037
+ openMenu(event.currentTarget, items.length > 0 ? items : [{ label: "No one else to mention", disabled: true }], { above: true, header: "Mention someone" });
1038
+ });
1039
+ $("#composer-plus").addEventListener("click", (event) => {
1040
+ openMenu(event.currentTarget, [{ label: "Attach files isn't available", detail: "synthetic workspace", disabled: true }, { label: "Post a code block", icon: "code_block", onSelect: () => wrapSelection("```\n", "\n```", "code") }, { label: "Mention someone", icon: "at", onSelect: () => $("#composer-mention").click() }], { above: true });
1041
+ });
1042
+
1043
+ async function joinChannel(id) {
1044
+ await call("conversations.join", { channel: id }, key());
1045
+ toast(`You joined #${conversationOf(id)?.name ?? id}`);
1046
+ await loadConversations();
1047
+ await loadConversation(id);
1048
+ }
1049
+
1050
+ // ---------------------------------------------------------------------------------------------
1051
+ // Emoji picker
1052
+ // ---------------------------------------------------------------------------------------------
1053
+
1054
+ function openEmojiPicker(anchor, onPick, { above = false } = {}) {
1055
+ const picker = el("div", { class: "emoji-picker" });
1056
+ const search = el("input", { class: "emoji-search", attrs: { type: "text", placeholder: "Search all emoji", "aria-label": "Search emoji" } });
1057
+ const grid = el("div", { class: "emoji-grid", attrs: { role: "listbox", "aria-label": "Emoji" } });
1058
+ const custom = el("div", { class: "emoji-custom" });
1059
+ const customInput = el("input", { attrs: { type: "text", placeholder: "or type a :name:", "aria-label": "Emoji name" } });
1060
+ const customButton = textButton("Add", { class: "small", onClick: () => pick(customInput.value.trim().replace(/^:|:$/g, "")) });
1061
+ custom.append(customInput, customButton);
1062
+ const pick = (name) => {
1063
+ if (!name) return;
1064
+ closeMenus();
1065
+ onPick(name);
1066
+ };
1067
+ const fill = (filter) => {
1068
+ grid.replaceChildren();
1069
+ const names = PICKER_EMOJI.filter((name) => name.includes(filter.toLowerCase().replaceAll(" ", "_")));
1070
+ for (const name of names) {
1071
+ const cell = el("button", { class: "emoji-cell", text: emoji(name), title: `:${name}:`, attrs: { type: "button", role: "option", "aria-label": emojiLabel(name) } });
1072
+ cell.addEventListener("click", () => pick(name));
1073
+ grid.append(cell);
1074
+ }
1075
+ if (names.length === 0) grid.append(el("div", { class: "emoji-empty", text: "No emoji found — type its :name: below" }));
1076
+ };
1077
+ search.addEventListener("input", () => fill(search.value));
1078
+ customInput.addEventListener("keydown", (event) => {
1079
+ if (event.key === "Enter") {
1080
+ event.preventDefault();
1081
+ pick(customInput.value.trim().replace(/^:|:$/g, ""));
1082
+ }
1083
+ });
1084
+ fill("");
1085
+ picker.append(search, grid, custom);
1086
+ openMenu(anchor, picker, { align: "end", above, className: "emoji-menu" });
1087
+ }
1088
+
1089
+ // ---------------------------------------------------------------------------------------------
1090
+ // Right panel: thread and profile
1091
+ // ---------------------------------------------------------------------------------------------
1092
+
1093
+ function openPanel(kind, title, subtitle = "") {
1094
+ state.panel = kind;
1095
+ $("#side-title").textContent = title;
1096
+ $("#side-subtitle").textContent = subtitle;
1097
+ $("#side-panel").hidden = false;
1098
+ }
1099
+ function closePanel() {
1100
+ state.panel = undefined;
1101
+ state.thread = undefined;
1102
+ state.profile = undefined;
1103
+ $("#side-panel").hidden = true;
1104
+ $("#side-body").replaceChildren();
1105
+ if (currentHash().includes("/thread/")) navigate(currentHash().split("/")[0]);
1106
+ }
1107
+ $("#side-close").addEventListener("click", closePanel);
1108
+
1109
+ async function openThread(channel, ts) {
1110
+ const conversation = conversationOf(channel) ?? state.info;
1111
+ state.thread = { channel, ts, parent: undefined, replies: [], hasMore: false, cursor: "" };
1112
+ openPanel("thread", "Thread", conversation ? (isDm(conversation) ? conversationTitle(conversation) : `#${conversation.name}`) : "");
1113
+ $("#side-body").replaceChildren(skeleton(3));
1114
+ await loadThread();
1115
+ }
1116
+
1117
+ async function loadThread({ keepScroll = false } = {}) {
1118
+ const thread = state.thread;
1119
+ if (!thread) return;
1120
+ const body = $("#side-body");
1121
+ const previous = body.scrollTop;
1122
+ try {
1123
+ const result = await call("conversations.replies", { channel: thread.channel, ts: thread.ts, limit: PAGE });
1124
+ if (state.thread !== thread) return;
1125
+ thread.parent = result.messages[0];
1126
+ thread.replies = result.messages.slice(1);
1127
+ thread.hasMore = result.has_more;
1128
+ thread.cursor = result.response_metadata.next_cursor;
1129
+ } catch (error) {
1130
+ if (state.thread !== thread) return;
1131
+ body.replaceChildren();
1132
+ if (error instanceof ToolError && error.is("THREAD_NOT_FOUND")) body.append(stateCard("This thread no longer exists", "The message was deleted or the world was reset.", { actions: [textButton("Close", { onClick: closePanel })] }));
1133
+ else if (error instanceof ToolError && error.denied) body.append(stateCard("You don't have permission to view threads", "This actor has no grant for conversations.replies (missing_scope).", { error: true }));
1134
+ else body.append(stateCard("Couldn't load this thread", describe(error), { error: true, actions: [textButton("Try again", { onClick: () => action(() => loadThread()) })] }));
1135
+ return;
1136
+ }
1137
+ renderThread();
1138
+ if (keepScroll) body.scrollTop = previous;
1139
+ else body.scrollTop = body.scrollHeight;
1140
+ }
1141
+
1142
+ function renderThread() {
1143
+ const thread = state.thread;
1144
+ const body = $("#side-body");
1145
+ const draft = $("#thread-input")?.value ?? "";
1146
+ const broadcast = $("#thread-broadcast")?.checked ?? false;
1147
+ body.replaceChildren();
1148
+ const messages = el("div", { class: "thread-messages" });
1149
+ messages.append(messageRow(thread.parent, { channel: thread.channel, inThread: true }));
1150
+ const count = thread.parent.reply_count ?? thread.replies.length;
1151
+ if (count > 0 || thread.replies.length > 0) messages.append(el("div", { class: "replies-divider", text: `${count} repl${count === 1 ? "y" : "ies"}` }));
1152
+ let previous;
1153
+ for (const reply of thread.replies) {
1154
+ const continued = previous !== undefined && previous.user === reply.user && !reply.subtype && !previous.subtype && tsSeconds(reply.ts) - tsSeconds(previous.ts) < 300;
1155
+ messages.append(messageRow(reply, { channel: thread.channel, inThread: true, continued }));
1156
+ previous = reply;
1157
+ }
1158
+ if (thread.hasMore) messages.append(textButton("Load more replies", { class: "load-more", onClick: () => action(loadMoreReplies) }));
1159
+ body.append(messages);
1160
+ const info = thread.channel === state.current ? state.info : conversationOf(thread.channel);
1161
+ const composerHost = el("div", { class: "thread-composer" });
1162
+ if (info && info.is_member && !info.is_archived && thread.parent.subtype !== "tombstone") {
1163
+ // The thread composer is the same composer as the channel one: formatting toolbar, +, format toggle, emoji,
1164
+ // mention, clips, shortcuts and a send button with its schedule caret.
1165
+ const composer = el("div", { class: `composer thread-composer-box${state.toolbar ? "" : " hide-toolbar"}` });
1166
+ const input = el("textarea", { attrs: { id: "thread-input", rows: "1", placeholder: "Reply…", "aria-label": "Reply in thread", spellcheck: "true" } });
1167
+ input.value = draft;
1168
+ const fmt = (format, iconName, label) => {
1169
+ const button = el("button", { class: "fmt-btn", attrs: { type: "button", "aria-label": label, title: label } }, icon(iconName));
1170
+ button.addEventListener("click", () => applyFormat(format, input));
1171
+ return button;
1172
+ };
1173
+ const divider = () => el("span", { class: "fmt-divider", attrs: { role: "separator" } });
1174
+ const toolbar = el("div", { class: "composer-toolbar" }, [
1175
+ fmt("*", "bold", "Bold"), fmt("_", "italic", "Italic"), fmt("~", "strike", "Strikethrough"), divider(),
1176
+ fmt("link", "link", "Link"), divider(), fmt("ol", "list_ol", "Ordered list"), fmt("ul", "list_ul", "Bulleted list"), divider(),
1177
+ fmt("quote", "quote", "Blockquote"), divider(), fmt("`", "code", "Code"), fmt("```", "code_block", "Code block"),
1178
+ ]);
1179
+ const plain = (iconName, label, attrs = {}) => el("button", { class: "fmt-btn", attrs: { type: "button", "aria-label": label, title: label, ...attrs } }, icon(iconName));
1180
+ const plus = el("button", { class: "composer-plus", attrs: { type: "button", "aria-label": "Attach", title: "Attach", "aria-expanded": "false" } }, icon("plus"));
1181
+ const formatToggle = plain("format", state.toolbar ? "Hide formatting" : "Show formatting", { "aria-pressed": String(state.toolbar) });
1182
+ const emojiButton = plain("emoji", "Emoji", { "aria-expanded": "false" });
1183
+ const mentionButton = plain("at", "Mention someone", { "aria-expanded": "false" });
1184
+ const mention = (anchor) => openMenu(anchor, [...state.users.values()].filter((user) => !user.deleted && user.id !== me()).sort((left, right) => left.real_name.localeCompare(right.real_name)).map((user) => ({ label: user.real_name, detail: `@${user.name}`, onSelect: () => insertAt(input, `@${user.name} `) })), { above: true, header: "Mention someone" });
1185
+ emojiButton.addEventListener("click", (event) => openEmojiPicker(event.currentTarget, (name) => insertAt(input, `:${name}: `), { above: true }));
1186
+ mentionButton.addEventListener("click", (event) => mention(event.currentTarget));
1187
+ plus.addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Attach files isn't available", detail: "synthetic workspace", disabled: true }, { label: "Post a code block", icon: "code_block", onSelect: () => applyFormat("```", input) }, { label: "Mention someone", icon: "at", onSelect: () => mention(mentionButton) }], { above: true }));
1188
+ formatToggle.addEventListener("click", () => {
1189
+ state.toolbar = !state.toolbar;
1190
+ writePreference("toolbar", String(state.toolbar));
1191
+ composer.classList.toggle("hide-toolbar", !state.toolbar);
1192
+ formatToggle.setAttribute("aria-pressed", String(state.toolbar));
1193
+ formatToggle.setAttribute("aria-label", state.toolbar ? "Hide formatting" : "Show formatting");
1194
+ formatToggle.title = state.toolbar ? "Hide formatting" : "Show formatting";
1195
+ renderFooter();
1196
+ });
1197
+ const actions = el("div", { class: "composer-actions" });
1198
+ const start = el("div", { class: "composer-actions-start" }, [
1199
+ plus, formatToggle, emojiButton, mentionButton, divider(),
1200
+ plain("video", "Record video clip", { "data-ns": "video" }), plain("mic", "Record audio clip", { "data-ns": "audio" }), divider(),
1201
+ plain("slash", "Run shortcut", { "data-ns": "shortcuts", "aria-expanded": "false" }),
1202
+ ]);
1203
+ const send = el("button", { class: "send-btn", attrs: { type: "button", "aria-label": "Send now", title: "Send now" } }, icon("send"));
1204
+ send.disabled = draft.trim().length === 0;
1205
+ const caret = el("button", { class: "send-caret", attrs: { type: "button", "data-ns": "schedule", "aria-label": "Schedule for later", title: "Schedule for later", "aria-expanded": "false" } }, icon("chevron_down"));
1206
+ actions.append(start, el("div", { class: "composer-actions-end" }, [send, el("span", { class: "send-divider", attrs: { "aria-hidden": "true" } }), caret]));
1207
+ const check = el("label", { class: "broadcast-check" }, [el("input", { attrs: { type: "checkbox", id: "thread-broadcast" } }), el("span", { text: `Also send to ${isDm(info) ? conversationTitle(info) : `#${info.name}`}` })]);
1208
+ check.firstChild.checked = broadcast;
1209
+ composer.append(toolbar, input, actions, check);
1210
+ const note = el("p", { class: "composer-note", attrs: { role: "alert" } });
1211
+ note.hidden = true;
1212
+ const submit = () =>
1213
+ action(async () => {
1214
+ const text = input.value.trim();
1215
+ if (text.length === 0) return;
1216
+ note.hidden = true;
1217
+ try {
1218
+ if (check.firstChild.checked) await call("chat.post-message", { channel: thread.channel, text: encodeMentions(text), thread_ts: thread.ts, reply_broadcast: true }, key());
1219
+ else await call("chat.reply", { channel: thread.channel, thread_ts: thread.ts, text: encodeMentions(text) }, key());
1220
+ input.value = "";
1221
+ check.firstChild.checked = false;
1222
+ await loadThread();
1223
+ if (state.current === thread.channel) await loadConversation(thread.channel, { keepScroll: true });
1224
+ else await loadConversations();
1225
+ } catch (error) {
1226
+ if (error instanceof ToolError && error.is("RATELIMITED")) {
1227
+ note.hidden = false;
1228
+ note.textContent = "Slack is rate-limiting posts right now (429). Your reply wasn't sent and is kept here — try again in 30 seconds.";
1229
+ return;
1230
+ }
1231
+ throw error;
1232
+ }
1233
+ });
1234
+ input.addEventListener("input", () => {
1235
+ send.disabled = input.value.trim().length === 0;
1236
+ input.style.height = "auto";
1237
+ input.style.height = `${Math.min(input.scrollHeight, 200)}px`;
1238
+ });
1239
+ input.addEventListener("keydown", (event) => {
1240
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1241
+ event.preventDefault();
1242
+ void submit();
1243
+ }
1244
+ });
1245
+ send.addEventListener("click", () => void submit());
1246
+ composerHost.append(composer, note);
1247
+ } else if (info?.is_archived) composerHost.append(el("div", { class: "composer-banner" }, el("p", { text: "This channel is archived — replies can't be added." })));
1248
+ else if (thread.parent.subtype === "tombstone") composerHost.append(el("div", { class: "composer-banner" }, el("p", { text: "The original message was deleted; the thread stays readable." })));
1249
+ body.append(composerHost);
1250
+ }
1251
+
1252
+ function insertAt(input, text) {
1253
+ const start = input.selectionStart ?? input.value.length;
1254
+ input.setRangeText(text, start, input.selectionEnd ?? start, "end");
1255
+ input.focus();
1256
+ input.dispatchEvent(new Event("input"));
1257
+ }
1258
+
1259
+ async function loadMoreReplies() {
1260
+ const thread = state.thread;
1261
+ if (!thread?.hasMore) return;
1262
+ const result = await call("conversations.replies", { channel: thread.channel, ts: thread.ts, limit: PAGE, cursor: thread.cursor });
1263
+ thread.replies.push(...result.messages);
1264
+ thread.hasMore = result.has_more;
1265
+ thread.cursor = result.response_metadata.next_cursor;
1266
+ renderThread();
1267
+ }
1268
+
1269
+ async function openProfile(userId) {
1270
+ state.thread = undefined;
1271
+ state.profile = userId;
1272
+ openPanel("profile", "Profile");
1273
+ const body = $("#side-body");
1274
+ body.replaceChildren(skeleton(2));
1275
+ let user;
1276
+ try {
1277
+ user = (await call("users.info", { user: userId })).user;
1278
+ } catch (error) {
1279
+ if (state.profile !== userId) return;
1280
+ body.replaceChildren(stateCard("Profile unavailable", error instanceof ToolError && error.is("USER_NOT_FOUND") ? "This person doesn't exist in the workspace." : describe(error), { error: !(error instanceof ToolError && error.is("USER_NOT_FOUND")) }));
1281
+ return;
1282
+ }
1283
+ if (state.profile !== userId) return;
1284
+ state.users.set(user.id, user);
1285
+ body.replaceChildren();
1286
+ body.append(el("div", { class: "profile-avatar-wrap" }, avatar(user, "xxl")));
1287
+ const profile = el("div", { class: "profile" });
1288
+ const name = el("h3", { class: "profile-name", text: user.real_name });
1289
+ if (user.is_bot) name.append(el("span", { class: "app-badge", text: "APP" }));
1290
+ if (user.deleted) name.append(el("span", { class: "deactivated-badge", text: "Deactivated" }));
1291
+ if (user.id === me()) name.append(el("span", { class: "presence active", attrs: { "aria-label": "Active" } }));
1292
+ profile.append(name);
1293
+ if (user.profile.display_name && user.profile.display_name !== user.real_name) profile.append(el("div", { class: "profile-handle", text: `@${user.profile.display_name}` }));
1294
+ if (user.profile.title) profile.append(el("div", { class: "profile-title", text: user.profile.title }));
1295
+ if (user.profile.status_text || user.profile.status_emoji) {
1296
+ const status = el("div", { class: "profile-status" });
1297
+ renderText(status, `${user.profile.status_emoji ? `${user.profile.status_emoji} ` : ""}${user.profile.status_text}`, { resolve });
1298
+ profile.append(status);
1299
+ }
1300
+ profile.append(el("div", { class: "profile-time" }, [icon("clock"), el("span", { text: `${clockTime(nowTs(), user.tz_offset)} local time${user.tz_label ? ` · ${user.tz_label}` : ""}` })]));
1301
+ const actions = el("div", { class: "profile-actions" });
1302
+ if (user.id !== me() && !user.deleted) {
1303
+ actions.append(textButton("Message", { onClick: () => action(() => openDirectMessage([user.id])) }));
1304
+ actions.firstChild.prepend(icon("dm"));
1305
+ }
1306
+ if (user.id === me()) actions.append(textButton("This is you", { attrs: { disabled: "" } }));
1307
+ profile.append(actions);
1308
+ const contact = el("section", { class: "profile-section" }, el("h3", { text: "Contact information" }));
1309
+ const list = el("dl");
1310
+ if (user.profile.email) list.append(el("div", {}, [el("dt", { text: "Email address" }), el("dd", { text: user.profile.email })]));
1311
+ list.append(el("div", {}, [el("dt", { text: "Time zone" }), el("dd", { class: "plain", text: `${user.tz_label || user.tz} (${user.tz})` })]));
1312
+ list.append(el("div", {}, [el("dt", { text: "Member ID" }), el("dd", { class: "plain", text: user.id })]));
1313
+ if (user.is_admin || user.is_owner) list.append(el("div", {}, [el("dt", { text: "Role" }), el("dd", { class: "plain", text: user.is_owner ? "Workspace Owner" : "Workspace Admin" })]));
1314
+ contact.append(list);
1315
+ profile.append(contact);
1316
+ body.append(profile);
1317
+ }
1318
+
1319
+ async function openDirectMessage(userIds) {
1320
+ const result = await call("conversations.open", { users: userIds.join(","), return_im: true }, key());
1321
+ await loadConversations();
1322
+ navigate(result.channel.id);
1323
+ }
1324
+
1325
+ // ---------------------------------------------------------------------------------------------
1326
+ // Pins tab
1327
+ // ---------------------------------------------------------------------------------------------
1328
+
1329
+ async function renderPins() {
1330
+ const body = paneBody();
1331
+ body.append(skeleton(2));
1332
+ let items;
1333
+ try {
1334
+ items = (await call("pins.list", { channel: state.current })).items;
1335
+ } catch (error) {
1336
+ body.replaceChildren(stateCard("Pinned messages can't be shown", describe(error), { error: true }));
1337
+ return;
1338
+ }
1339
+ body.replaceChildren();
1340
+ const scroll = el("div", { class: "view-scroll" });
1341
+ body.append(scroll);
1342
+ state.pinCount = items.length;
1343
+ $("#pins-count").textContent = items.length > 0 ? String(items.length) : "";
1344
+ if (items.length === 0) {
1345
+ scroll.append(stateCard("No pinned items yet", "Pin important messages with the More actions menu so everyone in the channel can find them here."));
1346
+ return;
1347
+ }
1348
+ const list = el("div", { class: "pins-list" });
1349
+ for (const item of items) {
1350
+ const card = el("article", { class: "pin-item" });
1351
+ card.append(el("div", { class: "pin-meta" }, [icon("pin"), el("span", { text: `Pinned by ${userName(item.created_by)} · ${fullDate(String(item.created), tz())}` })]));
1352
+ card.append(messageRow(item.message, { channel: state.current, compact: true }));
1353
+ const actions = el("div", { class: "pin-actions" });
1354
+ actions.append(textButton("Jump to message", { class: "small", onClick: () => jumpTo(state.current, item.message.ts) }));
1355
+ if (state.info?.is_member && !state.info?.is_archived) actions.append(textButton("Unpin", { class: "small", onClick: () => action(() => togglePin(state.current, item.message, true)) }));
1356
+ card.append(actions);
1357
+ list.append(card);
1358
+ }
1359
+ scroll.append(list);
1360
+ }
1361
+
1362
+ /** Open a conversation and scroll to one message, paging back through history until it is loaded (bounded). */
1363
+ async function jumpTo(channel, ts) {
1364
+ state.highlight = ts;
1365
+ if (state.current !== channel || state.view !== "messages" || !state.messages.some((message) => message.ts === ts)) {
1366
+ await loadConversation(channel, { keepScroll: false });
1367
+ navigate(channel);
1368
+ for (let page = 0; page < 20 && state.hasMore && !state.messages.some((message) => message.ts === ts || message.ts === state.messages[0]?.thread_ts); page += 1) {
1369
+ if (state.messages.some((message) => message.ts === ts)) break;
1370
+ if (state.messages.length > 0 && tsSeconds(state.messages[0].ts) < tsSeconds(ts)) break;
1371
+ await loadOlder();
1372
+ }
1373
+ state.highlight = ts;
1374
+ }
1375
+ const target = state.messages.find((message) => message.ts === ts);
1376
+ if (!target) {
1377
+ // A thread reply: open its thread instead.
1378
+ const parent = state.messages.find((message) => message.thread_ts && message.reply_count > 0 && tsSeconds(message.ts) <= tsSeconds(ts));
1379
+ state.highlight = undefined;
1380
+ renderMessages({ keepScroll: true });
1381
+ if (parent) navigate(`${channel}/thread/${parent.ts}`);
1382
+ else toast("That message is inside a thread that isn't loaded here.");
1383
+ return;
1384
+ }
1385
+ renderMessages({ keepScroll: true });
1386
+ }
1387
+
1388
+ // ---------------------------------------------------------------------------------------------
1389
+ // Search
1390
+ // ---------------------------------------------------------------------------------------------
1391
+
1392
+ $("#search-form").addEventListener("submit", (event) => {
1393
+ event.preventDefault();
1394
+ const query = $("#search").value.trim();
1395
+ if (query.length === 0) return;
1396
+ navigate(`search/${query}`);
1397
+ });
1398
+ $("#search").addEventListener("input", () => {
1399
+ $("#search-form").classList.toggle("has-value", $("#search").value.length > 0);
1400
+ $("#search-clear").hidden = $("#search").value.length === 0;
1401
+ });
1402
+ $("#search-clear").addEventListener("click", () => {
1403
+ $("#search").value = "";
1404
+ $("#search-form").classList.remove("has-value");
1405
+ $("#search-clear").hidden = true;
1406
+ $("#search").focus();
1407
+ });
1408
+
1409
+ async function runSearch(query, { page = 1, sort } = {}) {
1410
+ showView("search");
1411
+ const search = state.search;
1412
+ if (search.query !== query) search.page = 1;
1413
+ search.query = query;
1414
+ search.page = page;
1415
+ if (sort) search.sort = sort;
1416
+ $("#pane-title-text").textContent = "Search";
1417
+ $("#pane-title-glyph").replaceChildren(icon("search"));
1418
+ const body = paneBody();
1419
+ body.append(searchHeader(undefined));
1420
+ body.append(skeleton(3));
1421
+ search.loading = true;
1422
+ try {
1423
+ search.result = await call("search.messages", { query, count: SEARCH_PAGE, page: search.page, sort: search.sort, sort_dir: "desc" });
1424
+ search.error = undefined;
1425
+ } catch (error) {
1426
+ search.error = error;
1427
+ search.result = undefined;
1428
+ } finally {
1429
+ search.loading = false;
1430
+ }
1431
+ if (state.view !== "search" || state.search.query !== query) return;
1432
+ renderSearch();
1433
+ }
1434
+
1435
+ function searchHeader(result) {
1436
+ const header = el("div", { class: "results-header" });
1437
+ header.append(el("h2", { class: "results-query", text: state.search.query }));
1438
+ const tabs = el("div", { class: "results-tabs", attrs: { role: "tablist" } });
1439
+ const messages = el("button", { class: "results-tab", attrs: { type: "button", role: "tab", "aria-current": "page" } }, el("span", { text: "Messages" }));
1440
+ if (result) messages.append(el("span", { class: "count", text: String(result.messages.total) }));
1441
+ tabs.append(messages);
1442
+ header.append(tabs);
1443
+ return header;
1444
+ }
1445
+
1446
+ function renderSearch() {
1447
+ const search = state.search;
1448
+ const body = paneBody();
1449
+ body.append(searchHeader(search.result));
1450
+ const filters = el("div", { class: "results-filters" });
1451
+ const inChip = el("button", { class: "filter-chip", attrs: { type: "button", "aria-expanded": "false" } }, [el("span", { text: "In" }), icon("chevron_down")]);
1452
+ inChip.addEventListener("click", (event) => {
1453
+ const items = state.conversations.filter((conversation) => conversation.is_member && !isDm(conversation)).map((conversation) => ({ label: `#${conversation.name}`, icon: conversation.is_private ? "lock" : "hash", onSelect: () => navigate(`search/${stripModifier(search.query, "in:")} in:#${conversation.name}`.trim()) }));
1454
+ openMenu(event.currentTarget, items, { header: "Search in channel" });
1455
+ });
1456
+ const fromChip = el("button", { class: "filter-chip", attrs: { type: "button", "aria-expanded": "false" } }, [el("span", { text: "From" }), icon("chevron_down")]);
1457
+ fromChip.addEventListener("click", (event) => {
1458
+ const items = [...state.users.values()].filter((user) => !user.deleted).map((user) => ({ label: user.real_name, detail: `@${user.name}`, onSelect: () => navigate(`search/${stripModifier(search.query, "from:")} from:@${user.name}`.trim()) }));
1459
+ openMenu(event.currentTarget, items, { header: "Search messages from" });
1460
+ });
1461
+ const pinnedChip = el("button", { class: "filter-chip", attrs: { type: "button", "aria-pressed": String(/\bis:pinned\b/.test(search.query)) } }, el("span", { text: "Pinned" }));
1462
+ pinnedChip.addEventListener("click", () => navigate(`search/${/\bis:pinned\b/.test(search.query) ? search.query.replace(/\s*\bis:pinned\b/, "").trim() : `${search.query} is:pinned`}`));
1463
+ const sortChip = el("button", { class: "filter-chip results-sort", attrs: { type: "button", "aria-expanded": "false" } }, [icon("sort"), el("span", { text: search.sort === "score" ? "Most relevant" : "Most recent" }), icon("chevron_down")]);
1464
+ sortChip.addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Most relevant", onSelect: () => runSearch(search.query, { page: 1, sort: "score" }) }, { label: "Most recent", onSelect: () => runSearch(search.query, { page: 1, sort: "timestamp" }) }], { align: "end" }));
1465
+ filters.append(inChip, fromChip, pinnedChip, sortChip);
1466
+ body.append(filters);
1467
+ const scroll = el("div", { class: "view-scroll" });
1468
+ body.append(scroll);
1469
+ if (search.error) {
1470
+ const error = search.error;
1471
+ if (error instanceof ToolError && error.is("INVALID_ARGUMENTS")) scroll.append(stateCard("That search isn't supported here", `${error.message}. Supported modifiers: from:@user, in:#channel, in:@user, is:pinned, is:thread, has:reaction, before:/after:/on:/during: YYYY-MM-DD, quoted phrases.`, { error: true }));
1472
+ else if (error instanceof ToolError && error.is("SERVICE_UNAVAILABLE")) scroll.append(stateCard("Search is temporarily unavailable", "The workspace answered 503 service_unavailable. Nothing was changed.", { error: true, actions: [textButton("Try again", { onClick: () => runSearch(search.query, { page: search.page }) })] }));
1473
+ else if (error instanceof ToolError && error.denied) scroll.append(stateCard("You don't have permission to search", "This actor has no grant for search.messages (missing_scope).", { error: true }));
1474
+ else scroll.append(stateCard("Search failed", describe(error), { error: true }));
1475
+ return;
1476
+ }
1477
+ const result = search.result.messages;
1478
+ if (result.total === 0) {
1479
+ scroll.append(stateCard("No results", `Nothing matched “${state.search.query}” in the conversations you're a member of. Try different keywords or a modifier like from:@name or in:#channel.`));
1480
+ return;
1481
+ }
1482
+ const terms = search.query
1483
+ .split(/\s+/)
1484
+ .filter((term) => term.length > 0 && !/^[a-z]+:/.test(term) && !/^-/.test(term))
1485
+ .map((term) => term.replace(/^"|"$/g, ""));
1486
+ for (const match of result.matches) {
1487
+ const row = el("button", { class: "result", attrs: { type: "button" } });
1488
+ const head = el("div", { class: "result-head" });
1489
+ const channelLabel = el("span", { class: "result-channel" });
1490
+ if (match.channel.is_im || match.channel.is_mpim) {
1491
+ const conversation = conversationOf(match.channel.id);
1492
+ channelLabel.append(icon("dm"), el("span", { text: conversation ? conversationTitle(conversation) : "Direct message" }));
1493
+ } else channelLabel.append(icon(match.channel.is_private ? "lock" : "hash"), el("span", { text: match.channel.name }));
1494
+ head.append(channelLabel, el("span", { text: shortDate(match.ts, nowTs(), tz()) }));
1495
+ row.append(head);
1496
+ const bodyRow = el("div", { class: "result-body" });
1497
+ bodyRow.append(avatar(userOf(match.user) ?? { id: match.user }, "lg"));
1498
+ const text = el("div", { class: "result-text" });
1499
+ text.append(el("span", { class: "result-author", text: userName(match.user) }));
1500
+ highlightTerms(text, plainText(match.text, resolve), terms);
1501
+ bodyRow.append(text);
1502
+ row.append(bodyRow);
1503
+ row.addEventListener("click", () => action(() => jumpTo(match.channel.id, match.ts)));
1504
+ scroll.append(row);
1505
+ }
1506
+ const pagination = result.pagination;
1507
+ const pager = el("div", { class: "results-pager" });
1508
+ const prev = el("button", { class: "pager-btn", attrs: { type: "button", "aria-label": "Previous page" } }, icon("chevron_left"));
1509
+ prev.disabled = pagination.page <= 1;
1510
+ prev.addEventListener("click", () => runSearch(search.query, { page: pagination.page - 1 }));
1511
+ const next = el("button", { class: "pager-btn", attrs: { type: "button", "aria-label": "Next page" } }, icon("chevron_right"));
1512
+ next.disabled = pagination.page >= pagination.page_count;
1513
+ next.addEventListener("click", () => runSearch(search.query, { page: pagination.page + 1 }));
1514
+ pager.append(prev, el("span", { text: `${pagination.first}–${pagination.last} of ${pagination.total_count}` }), next);
1515
+ scroll.append(pager);
1516
+ }
1517
+
1518
+ function stripModifier(query, modifier) {
1519
+ return query.replace(new RegExp(`\\s*\\b${modifier}\\S+`, "g"), "").trim();
1520
+ }
1521
+
1522
+ /** Append `text` to `target` with each search term wrapped in <mark>; purely text-node based. */
1523
+ function highlightTerms(target, text, terms) {
1524
+ const lowered = terms.map((term) => term.toLowerCase()).filter((term) => term.length > 0);
1525
+ if (lowered.length === 0) {
1526
+ target.append(text);
1527
+ return;
1528
+ }
1529
+ let index = 0;
1530
+ const lowerText = text.toLowerCase();
1531
+ while (index < text.length) {
1532
+ let best = -1;
1533
+ let bestTerm = "";
1534
+ for (const term of lowered) {
1535
+ const found = lowerText.indexOf(term, index);
1536
+ if (found !== -1 && (best === -1 || found < best)) {
1537
+ best = found;
1538
+ bestTerm = term;
1539
+ }
1540
+ }
1541
+ if (best === -1) {
1542
+ target.append(text.slice(index));
1543
+ break;
1544
+ }
1545
+ if (best > index) target.append(text.slice(index, best));
1546
+ target.append(el("mark", { text: text.slice(best, best + bestTerm.length) }));
1547
+ index = best + bestTerm.length;
1548
+ }
1549
+ }
1550
+
1551
+ // ---------------------------------------------------------------------------------------------
1552
+ // Browse channels and People directory
1553
+ // ---------------------------------------------------------------------------------------------
1554
+
1555
+ async function renderBrowse() {
1556
+ $("#pane-title-text").textContent = "Browse channels";
1557
+ $("#pane-title-glyph").replaceChildren(icon("channel_browse"));
1558
+ const body = paneBody();
1559
+ body.append(skeleton(3));
1560
+ await loadConversations();
1561
+ if (state.view !== "browse") return;
1562
+ const counts = new Map();
1563
+ await Promise.all(
1564
+ state.conversations
1565
+ .filter((conversation) => conversation.is_channel && !isDm(conversation))
1566
+ .slice(0, 200)
1567
+ .map((conversation) =>
1568
+ call("conversations.info", { channel: conversation.id, include_num_members: true })
1569
+ .then((result) => counts.set(conversation.id, result.channel.num_members))
1570
+ .catch(() => undefined),
1571
+ ),
1572
+ );
1573
+ if (state.view !== "browse") return;
1574
+ body.replaceChildren();
1575
+ const header = el("div", { class: "browse-header" }, [el("h2", { text: "All channels" }), textButton("Create channel", { class: "primary", onClick: () => openCreateChannel() })]);
1576
+ const tools = el("div", { class: "browse-tools" });
1577
+ const field = el("div", { class: "field" }, [el("span", { class: "field-prefix" }, icon("search"))]);
1578
+ const input = el("input", { attrs: { type: "text", placeholder: "Search for channels", "aria-label": "Search for channels" } });
1579
+ input.value = state.browse.filter;
1580
+ field.append(input);
1581
+ tools.append(field);
1582
+ const scroll = el("div", { class: "view-scroll" });
1583
+ body.append(header, tools, scroll);
1584
+ const fill = () => {
1585
+ scroll.replaceChildren();
1586
+ const filter = state.browse.filter.toLowerCase().replace(/^#/, "");
1587
+ const channels = state.conversations
1588
+ .filter((conversation) => conversation.is_channel && !isDm(conversation) && (filter.length === 0 || conversation.name.includes(filter) || (conversation.purpose?.value ?? "").toLowerCase().includes(filter)))
1589
+ .sort((left, right) => Number(left.is_archived) - Number(right.is_archived) || left.name.localeCompare(right.name));
1590
+ if (channels.length === 0) {
1591
+ scroll.append(stateCard(state.conversations.length === 0 ? "No channels yet" : "No channels match", state.conversations.length === 0 ? "This workspace has no channels you can see. Create one to get started." : `Nothing named “${state.browse.filter}”. Try another name or create the channel.`, { actions: [textButton("Create channel", { class: "primary", onClick: () => openCreateChannel(state.browse.filter) })] }));
1592
+ return;
1593
+ }
1594
+ scroll.append(el("div", { class: "results-note", text: `${channels.length} channel${channels.length === 1 ? "" : "s"}` }));
1595
+ for (const conversation of channels) {
1596
+ const row = el("div", { class: "browse-row" });
1597
+ const main = el("div", { class: "browse-main" });
1598
+ const name = el("button", { class: "browse-name", attrs: { type: "button" } }, [icon(conversation.is_private ? "lock" : "hash"), el("span", { text: conversation.name })]);
1599
+ if (conversation.is_archived) name.append(el("span", { class: "archived-badge", text: "Archived" }));
1600
+ name.addEventListener("click", () => navigate(conversation.id));
1601
+ const meta = el("div", { class: "browse-meta" });
1602
+ if (conversation.is_member) meta.append(el("span", { class: "browse-joined", text: "Joined" }));
1603
+ const members = counts.get(conversation.id);
1604
+ if (members !== undefined) meta.append(el("span", { class: conversation.is_member ? "dot" : "", text: `${members} member${members === 1 ? "" : "s"}` }));
1605
+ if (conversation.purpose?.value) meta.append(el("span", { class: "dot", text: conversation.purpose.value }));
1606
+ main.append(name, meta);
1607
+ row.append(main);
1608
+ if (conversation.is_member) row.append(el("span", { class: "btn small joined browse-join joined" }, [icon("check"), el("span", { text: "Joined" })]));
1609
+ else if (!conversation.is_archived && !conversation.is_private) row.append(textButton("Join", { class: "small primary browse-join", onClick: () => action(() => joinChannel(conversation.id)) }));
1610
+ else row.append(textButton("View", { class: "small browse-join", onClick: () => navigate(conversation.id) }));
1611
+ scroll.append(row);
1612
+ }
1613
+ };
1614
+ input.addEventListener("input", () => {
1615
+ state.browse.filter = input.value;
1616
+ fill();
1617
+ });
1618
+ fill();
1619
+ }
1620
+
1621
+ async function renderPeople() {
1622
+ $("#pane-title-text").textContent = "People";
1623
+ $("#pane-title-glyph").replaceChildren(icon("users"));
1624
+ const body = paneBody();
1625
+ body.append(skeleton(3));
1626
+ try {
1627
+ await loadIdentity();
1628
+ } catch (error) {
1629
+ body.replaceChildren(stateCard("People can't be listed", describe(error), { error: true }));
1630
+ return;
1631
+ }
1632
+ if (state.view !== "people") return;
1633
+ body.replaceChildren();
1634
+ const header = el("div", { class: "browse-header" }, el("h2", { text: `Members of ${state.identity.team}` }));
1635
+ const tools = el("div", { class: "browse-tools" });
1636
+ const field = el("div", { class: "field" }, [el("span", { class: "field-prefix" }, icon("search"))]);
1637
+ const input = el("input", { attrs: { type: "text", placeholder: "Search by name or title", "aria-label": "Search people" } });
1638
+ input.value = state.people.filter;
1639
+ field.append(input);
1640
+ tools.append(field);
1641
+ const scroll = el("div", { class: "view-scroll" });
1642
+ body.append(header, tools, scroll);
1643
+ const fill = () => {
1644
+ scroll.replaceChildren();
1645
+ const filter = state.people.filter.toLowerCase();
1646
+ const users = [...state.users.values()]
1647
+ .filter((user) => filter.length === 0 || user.real_name.toLowerCase().includes(filter) || user.name.includes(filter) || (user.profile.title ?? "").toLowerCase().includes(filter))
1648
+ .sort((left, right) => Number(left.deleted) - Number(right.deleted) || left.real_name.localeCompare(right.real_name));
1649
+ if (users.length === 0) {
1650
+ scroll.append(stateCard("No one matches", `Nobody named “${state.people.filter}” in this workspace.`));
1651
+ return;
1652
+ }
1653
+ scroll.append(el("div", { class: "results-note", text: `${users.length} member${users.length === 1 ? "" : "s"}` }));
1654
+ for (const user of users) {
1655
+ const row = el("div", { class: "browse-row" });
1656
+ row.append(avatar(user, "lg"));
1657
+ const main = el("div", { class: "browse-main" });
1658
+ const name = el("button", { class: "browse-name", attrs: { type: "button" } }, el("span", { text: user.real_name }));
1659
+ if (user.is_bot) name.append(el("span", { class: "app-badge", text: "APP" }));
1660
+ if (user.deleted) name.append(el("span", { class: "deactivated-badge", text: "Deactivated" }));
1661
+ if (user.id === me()) name.append(el("span", { class: "archived-badge", text: "you" }));
1662
+ name.addEventListener("click", () => openProfile(user.id));
1663
+ const meta = el("div", { class: "browse-meta" }, el("span", { text: `@${user.name}` }));
1664
+ if (user.profile.title) meta.append(el("span", { class: "dot", text: user.profile.title }));
1665
+ main.append(name, meta);
1666
+ row.append(main);
1667
+ if (user.id !== me() && !user.deleted) row.append(textButton("Message", { class: "small browse-join", onClick: () => action(() => openDirectMessage([user.id])) }));
1668
+ scroll.append(row);
1669
+ }
1670
+ };
1671
+ input.addEventListener("input", () => {
1672
+ state.people.filter = input.value;
1673
+ fill();
1674
+ });
1675
+ fill();
1676
+ }
1677
+
1678
+ // ---------------------------------------------------------------------------------------------
1679
+ // Dialogs: create channel, topic, people (invite / new message), details, about
1680
+ // ---------------------------------------------------------------------------------------------
1681
+
1682
+ for (const button of $$("[data-close]")) button.addEventListener("click", () => button.closest("dialog")?.close("cancel"));
1683
+
1684
+ const createDialog = $("#create-channel-dialog");
1685
+ const channelName = $("#channel-name");
1686
+ channelName.addEventListener("input", () => {
1687
+ const normalized = channelName.value.toLowerCase().replace(/\s+/g, "-");
1688
+ if (normalized !== channelName.value) channelName.value = normalized;
1689
+ $("#channel-name-counter").textContent = String(80 - channelName.value.length);
1690
+ $("#create-channel-submit").disabled = channelName.value.trim().replace(/^#/, "").length === 0;
1691
+ $("#create-channel-error").hidden = true;
1692
+ });
1693
+ for (const radio of $$("#create-channel-form input[name=visibility]")) {
1694
+ radio.addEventListener("change", () => $("#channel-name-prefix").replaceChildren(icon(radio.value === "private" && radio.checked ? "lock" : "hash")));
1695
+ }
1696
+ function openCreateChannel(prefill = "") {
1697
+ channelName.value = prefill.replace(/^#/, "");
1698
+ $("#channel-name-counter").textContent = String(80 - channelName.value.length);
1699
+ $("#create-channel-submit").disabled = channelName.value.trim().length === 0;
1700
+ $("#create-channel-error").hidden = true;
1701
+ $("#create-channel-form").visibility.value = "public";
1702
+ $("#channel-name-prefix").replaceChildren(icon("hash"));
1703
+ createDialog.showModal();
1704
+ channelName.focus();
1705
+ }
1706
+ $("#create-channel-form").addEventListener("submit", (event) => {
1707
+ event.preventDefault();
1708
+ void action(
1709
+ async () => {
1710
+ const isPrivate = $("#create-channel-form").visibility.value === "private";
1711
+ const result = await call("conversations.create", { name: channelName.value.trim(), is_private: isPrivate }, key());
1712
+ createDialog.close("ok");
1713
+ toast(`Created #${result.channel.name}`);
1714
+ await loadConversations();
1715
+ navigate(result.channel.id);
1716
+ },
1717
+ {
1718
+ onError: (error) => {
1719
+ const box = $("#create-channel-error");
1720
+ box.hidden = false;
1721
+ box.textContent = describe(error);
1722
+ },
1723
+ },
1724
+ );
1725
+ });
1726
+
1727
+ const topicDialog = $("#topic-dialog");
1728
+ function openTopicDialog() {
1729
+ if (!state.info) return;
1730
+ $("#topic-input").value = state.info.topic?.value ?? "";
1731
+ $("#topic-error").hidden = true;
1732
+ $("#topic-title").textContent = state.info.topic?.value ? "Edit topic" : "Add a topic";
1733
+ topicDialog.showModal();
1734
+ $("#topic-input").focus();
1735
+ }
1736
+ $("#topic-form").addEventListener("submit", (event) => {
1737
+ event.preventDefault();
1738
+ void action(
1739
+ async () => {
1740
+ await call("conversations.set-topic", { channel: state.current, topic: $("#topic-input").value.trim() }, key());
1741
+ topicDialog.close("ok");
1742
+ await loadConversations();
1743
+ await loadConversation(state.current, { keepScroll: true });
1744
+ if ($("#details-dialog").open) renderDetails("about");
1745
+ },
1746
+ {
1747
+ onError: (error) => {
1748
+ const box = $("#topic-error");
1749
+ box.hidden = false;
1750
+ box.textContent = describe(error);
1751
+ },
1752
+ },
1753
+ );
1754
+ });
1755
+ $("#pane-topic").addEventListener("click", () => {
1756
+ if (state.info?.is_member && !state.info?.is_archived) openTopicDialog();
1757
+ else openDetails("about");
1758
+ });
1759
+
1760
+ const peopleDialog = $("#people-dialog");
1761
+ const people = { mode: "invite", selected: new Set() };
1762
+ function openPeopleDialog(mode) {
1763
+ people.mode = mode;
1764
+ people.selected = new Set();
1765
+ $("#people-filter").value = "";
1766
+ $("#people-error").hidden = true;
1767
+ if (mode === "invite") {
1768
+ $("#people-title").textContent = "Add people";
1769
+ $("#people-subtitle").textContent = `#${state.info?.name ?? ""}`;
1770
+ $("#people-submit").textContent = "Add";
1771
+ } else {
1772
+ $("#people-title").textContent = "New message";
1773
+ $("#people-subtitle").textContent = "Choose up to 8 people to message";
1774
+ $("#people-submit").textContent = "Go";
1775
+ }
1776
+ renderPeopleList();
1777
+ peopleDialog.showModal();
1778
+ $("#people-filter").focus();
1779
+ }
1780
+ $("#people-filter").addEventListener("input", renderPeopleList);
1781
+ async function renderPeopleList() {
1782
+ const list = $("#people-list");
1783
+ const filter = $("#people-filter").value.toLowerCase();
1784
+ let members = new Set();
1785
+ if (people.mode === "invite" && state.current) {
1786
+ try {
1787
+ let cursor;
1788
+ for (let page = 0; page < 20; page += 1) {
1789
+ const result = await call("conversations.members", { channel: state.current, limit: 200, ...(cursor ? { cursor } : {}) });
1790
+ for (const id of result.members) members.add(id);
1791
+ cursor = result.response_metadata.next_cursor;
1792
+ if (!cursor) break;
1793
+ }
1794
+ } catch {
1795
+ members = new Set();
1796
+ }
1797
+ }
1798
+ list.replaceChildren();
1799
+ const candidates = [...state.users.values()]
1800
+ .filter((user) => user.id !== me() && !user.deleted)
1801
+ .filter((user) => filter.length === 0 || user.real_name.toLowerCase().includes(filter) || user.name.includes(filter))
1802
+ .sort((left, right) => left.real_name.localeCompare(right.real_name));
1803
+ if (candidates.length === 0) list.append(el("li", { class: "people-empty", text: "No one matches." }));
1804
+ for (const user of candidates) {
1805
+ const already = members.has(user.id);
1806
+ const row = el("button", { class: "person-row", attrs: { type: "button", role: "option", "aria-selected": String(people.selected.has(user.id)) } });
1807
+ row.disabled = already;
1808
+ row.append(avatar(user, "lg"));
1809
+ const main = el("div", { class: "member-main" });
1810
+ main.append(el("div", { class: "person-name" }, [el("span", { text: user.real_name }), el("span", { class: "person-handle", text: `@${user.name}` }), already ? el("span", { class: "person-handle", text: "· already in channel" }) : null]));
1811
+ if (user.profile.title) main.append(el("div", { class: "person-title", text: user.profile.title }));
1812
+ row.append(main, el("span", { class: "person-check" }, icon("check")));
1813
+ row.addEventListener("click", () => {
1814
+ if (people.selected.has(user.id)) people.selected.delete(user.id);
1815
+ else if (people.mode === "dm" && people.selected.size >= 8) toast("You can message up to 8 people at once.", { error: true });
1816
+ else people.selected.add(user.id);
1817
+ renderPeopleList();
1818
+ });
1819
+ list.append(row);
1820
+ }
1821
+ const chips = $("#people-selected");
1822
+ chips.replaceChildren();
1823
+ for (const id of people.selected) {
1824
+ const user = userOf(id);
1825
+ const chip = el("span", { class: "chip" }, [avatar(user ?? { id }, "sm"), el("span", { text: user?.real_name ?? id })]);
1826
+ const remove = el("button", { class: "chip-remove", attrs: { type: "button", "aria-label": `Remove ${user?.real_name ?? id}` } }, icon("close"));
1827
+ remove.addEventListener("click", () => {
1828
+ people.selected.delete(id);
1829
+ renderPeopleList();
1830
+ });
1831
+ chip.append(remove);
1832
+ chips.append(chip);
1833
+ }
1834
+ $("#people-submit").disabled = people.selected.size === 0;
1835
+ }
1836
+ $("#people-form").addEventListener("submit", (event) => {
1837
+ event.preventDefault();
1838
+ void action(
1839
+ async () => {
1840
+ const ids = [...people.selected];
1841
+ if (people.mode === "invite") {
1842
+ await call("conversations.invite", { channel: state.current, users: ids.join(",") }, key());
1843
+ peopleDialog.close("ok");
1844
+ toast(`Added ${ids.length} ${ids.length === 1 ? "person" : "people"} to #${state.info?.name}`);
1845
+ await loadConversation(state.current, { keepScroll: true });
1846
+ if ($("#details-dialog").open) renderDetails("members");
1847
+ } else {
1848
+ peopleDialog.close("ok");
1849
+ await openDirectMessage(ids);
1850
+ }
1851
+ },
1852
+ {
1853
+ onError: (error) => {
1854
+ const box = $("#people-error");
1855
+ box.hidden = false;
1856
+ box.textContent = describe(error);
1857
+ },
1858
+ },
1859
+ );
1860
+ });
1861
+
1862
+ const detailsDialog = $("#details-dialog");
1863
+ function openDetails(tab = "about") {
1864
+ if (!state.info) return;
1865
+ const info = state.info;
1866
+ $("#details-glyph").replaceChildren(isDm(info) ? "" : icon(info.is_private ? "lock" : "hash"));
1867
+ $("#details-name").textContent = isDm(info) ? conversationTitle(info) : info.name;
1868
+ $$("#details-tabs .modal-tab").forEach((button) => (button.hidden = button.dataset.tab === "settings" && (isDm(info) || !info.is_member)));
1869
+ detailsDialog.showModal();
1870
+ renderDetails(tab);
1871
+ }
1872
+ for (const tab of $$("#details-tabs .modal-tab")) tab.addEventListener("click", () => renderDetails(tab.dataset.tab));
1873
+ async function renderDetails(tab) {
1874
+ const info = state.info;
1875
+ $$("#details-tabs .modal-tab").forEach((button) => {
1876
+ if (button.dataset.tab === tab) button.setAttribute("aria-current", "page");
1877
+ else button.removeAttribute("aria-current");
1878
+ });
1879
+ const body = $("#details-body");
1880
+ body.replaceChildren();
1881
+ const editable = info.is_member && !info.is_archived && !isDm(info);
1882
+ if (tab === "about") {
1883
+ const block = el("div", { class: "detail-block" });
1884
+ if (!isDm(info)) {
1885
+ const topicRow = el("div", { class: "detail-row" }, el("div", {}, [el("div", { class: "detail-label", text: "Topic" }), el("div", { class: `detail-value ${info.topic?.value ? "" : "muted"}`.trim(), text: info.topic?.value || "Add a topic" })]));
1886
+ if (editable) topicRow.append(textButton("Edit", { class: "detail-edit", onClick: openTopicDialog }));
1887
+ topicRow.lastChild.classList.remove("btn");
1888
+ block.append(topicRow);
1889
+ block.append(el("div", { class: "detail-row" }, el("div", {}, [el("div", { class: "detail-label", text: "Description" }), el("div", { class: `detail-value ${info.purpose?.value ? "" : "muted"}`.trim(), text: info.purpose?.value || "No description" })])));
1890
+ }
1891
+ const created = el("div", { class: "detail-row" }, el("div", {}, [el("div", { class: "detail-label", text: "Created" }), el("div", { class: "detail-value", text: `${info.creator ? `Created by ${userName(info.creator)} on ` : "Created on "}${longDate(String(info.created), tz())}` })]));
1892
+ block.append(created);
1893
+ if (info.is_archived) block.append(el("div", { class: "detail-row" }, el("div", {}, [el("div", { class: "detail-label", text: "Archived" }), el("div", { class: "detail-value", text: "This channel has been archived. It is read-only." })])));
1894
+ body.append(block);
1895
+ if (!isDm(info) && !info.is_member) body.append(el("div", { class: "detail-block" }, el("div", { class: "detail-row" }, [el("div", {}, [el("div", { class: "detail-label", text: "You're not a member" }), el("div", { class: "detail-value muted", text: info.is_archived ? "Archived channels can't be joined." : info.is_private ? "Private channels are joined by invitation." : "Join to read and send messages." })]), !info.is_archived && !info.is_private ? textButton("Join", { class: "small primary", onClick: () => action(async () => (detailsDialog.close(), await joinChannel(info.id))) }) : null])));
1896
+ body.append(el("p", { class: "detail-footer", text: `Channel ID: ${info.id}` }));
1897
+ return;
1898
+ }
1899
+ if (tab === "members") {
1900
+ body.append(skeleton(2));
1901
+ let ids = [];
1902
+ try {
1903
+ let cursor;
1904
+ for (let page = 0; page < 20; page += 1) {
1905
+ const result = await call("conversations.members", { channel: info.id, limit: 200, ...(cursor ? { cursor } : {}) });
1906
+ ids.push(...result.members);
1907
+ cursor = result.response_metadata.next_cursor;
1908
+ if (!cursor) break;
1909
+ }
1910
+ } catch (error) {
1911
+ body.replaceChildren(stateCard("Members can't be listed", describe(error), { error: true }));
1912
+ return;
1913
+ }
1914
+ body.replaceChildren();
1915
+ $("#details-members-count").textContent = String(ids.length);
1916
+ const toolbar = el("div", { class: "members-toolbar" });
1917
+ const field = el("div", { class: "field" }, [el("span", { class: "field-prefix" }, icon("search"))]);
1918
+ const input = el("input", { attrs: { type: "text", placeholder: "Find members", "aria-label": "Find members" } });
1919
+ field.append(input);
1920
+ toolbar.append(field);
1921
+ if (editable) {
1922
+ const add = textButton("Add people", { onClick: () => openPeopleDialog("invite") });
1923
+ add.prepend(icon("user_add"));
1924
+ toolbar.append(add);
1925
+ }
1926
+ body.append(toolbar);
1927
+ const list = el("div", { class: "members-list" });
1928
+ body.append(list);
1929
+ const fill = () => {
1930
+ list.replaceChildren();
1931
+ const filter = input.value.toLowerCase();
1932
+ const members = ids
1933
+ .map((id) => userOf(id) ?? { id, real_name: id, name: id, profile: {} })
1934
+ .filter((user) => filter.length === 0 || user.real_name.toLowerCase().includes(filter) || user.name.includes(filter))
1935
+ .sort((left, right) => (left.id === me() ? -1 : right.id === me() ? 1 : left.real_name.localeCompare(right.real_name)));
1936
+ if (members.length === 0) list.append(el("p", { class: "people-empty", text: "No members match." }));
1937
+ for (const user of members) {
1938
+ const row = el("button", { class: "member-row", attrs: { type: "button" } });
1939
+ row.append(avatar(user, "lg"));
1940
+ const main = el("div", { class: "member-main" });
1941
+ const name = el("div", { class: "member-name" }, el("span", { text: user.real_name }));
1942
+ if (user.id === me()) name.append(el("span", { class: "archived-badge", text: "you" }));
1943
+ if (user.is_bot) name.append(el("span", { class: "app-badge", text: "APP" }));
1944
+ if (user.deleted) name.append(el("span", { class: "deactivated-badge", text: "Deactivated" }));
1945
+ if (user.is_admin) name.append(el("span", { class: "archived-badge", text: user.is_owner ? "Owner" : "Admin" }));
1946
+ main.append(name, el("div", { class: "member-sub", text: [`@${user.name}`, user.profile?.title].filter(Boolean).join(" · ") }));
1947
+ row.append(main);
1948
+ row.addEventListener("click", () => {
1949
+ detailsDialog.close();
1950
+ openProfile(user.id);
1951
+ });
1952
+ list.append(row);
1953
+ }
1954
+ };
1955
+ input.addEventListener("input", fill);
1956
+ fill();
1957
+ return;
1958
+ }
1959
+ if (tab === "settings") {
1960
+ const block = el("div", { class: "detail-block" });
1961
+ block.append(el("div", { class: "settings-row" }, [el("div", {}, [el("div", { class: "detail-label", text: "Channel name" }), el("div", { class: "detail-value", text: `#${info.name}` })]), el("span", { class: "detail-value muted", text: "Renaming isn't available" })]));
1962
+ block.append(el("div", { class: "settings-row" }, [el("div", {}, [el("div", { class: "detail-label", text: "Visibility" }), el("div", { class: "detail-value", text: info.is_private ? "Private — only invited members can see this channel" : "Public — anyone in the workspace can join" })])]));
1963
+ body.append(block);
1964
+ if (!info.is_archived) {
1965
+ const danger = el("div", { class: "detail-block" });
1966
+ const row = el("div", { class: "settings-row" }, el("div", {}, [el("div", { class: "detail-label", text: "Archive channel for everyone" }), el("div", { class: "detail-value muted", text: "Members won't be able to send messages. The channel stays readable in Browse channels." })]));
1967
+ const archive = textButton("Archive channel", { class: "danger-outline", onClick: () => action(archiveCurrent) });
1968
+ archive.disabled = info.is_general;
1969
+ if (info.is_general) archive.title = "The general channel can't be archived";
1970
+ row.append(archive);
1971
+ danger.append(row);
1972
+ body.append(danger);
1973
+ }
1974
+ }
1975
+ }
1976
+
1977
+ async function archiveCurrent() {
1978
+ const info = state.info;
1979
+ const ok = await confirmDialog(`Archive #${info.name}?`, "When you archive a channel, it's archived for everyone. That means no one will be able to send messages in it, but its history stays readable and searchable. Archived channels can't be unarchived in this workspace.", "Archive channel", { danger: true });
1980
+ if (!ok) return;
1981
+ await call("conversations.archive", { channel: info.id }, key());
1982
+ detailsDialog.close();
1983
+ toast(`#${info.name} was archived`);
1984
+ await loadConversations();
1985
+ await loadConversation(info.id);
1986
+ }
1987
+
1988
+ $("#pane-title").addEventListener("click", () => {
1989
+ if (state.view === "messages" || state.view === "pins") openDetails("about");
1990
+ });
1991
+ $("#pane-details").addEventListener("click", () => openDetails("about"));
1992
+ $("#members-pill").addEventListener("click", () => openDetails("members"));
1993
+ for (const tab of $$(".pane-tab[data-tab]")) {
1994
+ tab.addEventListener("click", () => {
1995
+ if (tab.dataset.tab === "pins") navigate(`pins`);
1996
+ else navigate(state.current ?? "");
1997
+ });
1998
+ }
1999
+
2000
+ function openAbout() {
2001
+ const list = $("#about-list");
2002
+ list.replaceChildren();
2003
+ const rows = [
2004
+ ["Workspace", state.identity?.team ?? "—"],
2005
+ ["URL", state.identity?.url ?? "—"],
2006
+ ["Team ID", state.identity?.team_id ?? "—"],
2007
+ ["Signed in as", state.me ? `${state.me.real_name} (@${state.me.name})` : "—"],
2008
+ ["Members", String(state.users.size)],
2009
+ ["Conversations visible to you", String(state.conversations.length)],
2010
+ ];
2011
+ for (const [label, value] of rows) list.append(el("dt", { text: label }), el("dd", { text: value }));
2012
+ $("#about-dialog").showModal();
2013
+ }
2014
+
2015
+ // ---------------------------------------------------------------------------------------------
2016
+ // Rail, sidebar controls, menus
2017
+ // ---------------------------------------------------------------------------------------------
2018
+
2019
+ for (const button of $$(".rail-item:not([data-ns])")) {
2020
+ button.addEventListener("click", (event) => {
2021
+ const view = button.dataset.view;
2022
+ if (view === "more") {
2023
+ openMenu(event.currentTarget, [
2024
+ { label: "Browse channels", icon: "channel_browse", onSelect: () => navigate("browse") },
2025
+ { label: "People", icon: "users", onSelect: () => navigate("people") },
2026
+ "divider",
2027
+ { label: "About this workspace", icon: "info", onSelect: openAbout },
2028
+ ], { align: "start" });
2029
+ return;
2030
+ }
2031
+ state.railView = view;
2032
+ // Leaving search, browse or people for a sidebar tab returns the main pane to a conversation, like Slack does.
2033
+ if (state.view !== "messages" && state.view !== "pins") {
2034
+ const target = state.current ?? defaultConversation()?.id;
2035
+ if (target) navigate(target);
2036
+ else renderSidebar();
2037
+ } else renderSidebar();
2038
+ if (window.matchMedia("(max-width: 768px)").matches) $("#shell").classList.add("sidebar-open");
2039
+ });
2040
+ }
2041
+ $("#rail-create").addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Message", icon: "compose", detail: "Start a direct message", onSelect: () => openPeopleDialog("dm") }, { label: "Channel", icon: "hash", detail: "Create a new channel", onSelect: () => openCreateChannel() }], { align: "start", above: true, header: "Create new" }));
2042
+ $("#me-button").addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Profile", icon: "users", onSelect: () => openProfile(me()) }, { label: "About this workspace", icon: "info", onSelect: openAbout }], { align: "start", above: true, header: state.me?.real_name ?? "You" }));
2043
+ $("#workspace-tile").addEventListener("click", (event) => workspaceMenu(event.currentTarget));
2044
+ $("#workspace-menu").addEventListener("click", (event) => workspaceMenu(event.currentTarget));
2045
+ function workspaceMenu(anchor) {
2046
+ openMenu(anchor, [
2047
+ { label: "About this workspace", icon: "info", onSelect: openAbout },
2048
+ "divider",
2049
+ { label: "Create a channel", icon: "hash", onSelect: () => openCreateChannel() },
2050
+ { label: "Browse channels", icon: "channel_browse", onSelect: () => navigate("browse") },
2051
+ { label: "People", icon: "users", onSelect: () => navigate("people") },
2052
+ "divider",
2053
+ { label: "Refresh from the world", icon: "refresh", onSelect: () => action(() => refresh(false)) },
2054
+ ], { header: state.identity?.url ?? "" });
2055
+ }
2056
+ $("#sidebar-filter").addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: state.unreadOnly ? "Show all conversations" : "Show unread only", icon: "filter", onSelect: () => ((state.unreadOnly = !state.unreadOnly), renderSidebar()) }, { label: state.channelsOpen && state.dmsOpen ? "Collapse all sections" : "Expand all sections", icon: "chevron_down", onSelect: () => ((state.channelsOpen = state.dmsOpen = !(state.channelsOpen && state.dmsOpen)), writePreference("channelsOpen", String(state.channelsOpen)), writePreference("dmsOpen", String(state.dmsOpen)), renderSidebar()) }], { align: "end" }));
2057
+ $("#compose").addEventListener("click", () => openPeopleDialog("dm"));
2058
+ $("#add-dm").addEventListener("click", () => openPeopleDialog("dm"));
2059
+ $("#add-channels").addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "Create a new channel", icon: "hash", onSelect: () => openCreateChannel() }, { label: "Browse channels", icon: "channel_browse", onSelect: () => navigate("browse") }]));
2060
+ $("#channels-toggle").addEventListener("click", () => {
2061
+ state.channelsOpen = !state.channelsOpen;
2062
+ writePreference("channelsOpen", String(state.channelsOpen));
2063
+ renderSidebar();
2064
+ });
2065
+ $("#apps-toggle").addEventListener("click", () => {
2066
+ state.appsOpen = !state.appsOpen;
2067
+ writePreference("appsOpen", String(state.appsOpen));
2068
+ renderSidebar();
2069
+ });
2070
+ $("#nav-directories").addEventListener("click", (event) => openMenu(event.currentTarget, [{ label: "People", icon: "users", detail: "Workspace members", onSelect: () => navigate("people") }, { label: "Channels", icon: "channel_browse", detail: "Browse all channels", onSelect: () => navigate("browse") }], { header: "Directories" }));
2071
+ for (const button of $$("[data-section-add]")) button.addEventListener("click", () => $(`#${button.dataset.sectionAdd}`).click());
2072
+ for (const button of $$("[data-section-options]")) {
2073
+ button.addEventListener("click", (event) => {
2074
+ const channels = button.dataset.sectionOptions === "Channels";
2075
+ const open = channels ? state.channelsOpen : state.dmsOpen;
2076
+ openMenu(event.currentTarget, [
2077
+ { label: open ? "Collapse section" : "Expand section", icon: "chevron_down", onSelect: () => (channels ? $("#channels-toggle") : $("#dms-toggle")).click() },
2078
+ { label: state.unreadOnly ? "Show all conversations" : "Show unread only", icon: "filter", onSelect: () => ((state.unreadOnly = !state.unreadOnly), renderSidebar()) },
2079
+ "divider",
2080
+ channels ? { label: "Create a channel", icon: "hash", onSelect: () => openCreateChannel() } : { label: "New message", icon: "compose", onSelect: () => openPeopleDialog("dm") },
2081
+ channels ? { label: "Browse channels", icon: "channel_browse", onSelect: () => navigate("browse") } : { label: "People", icon: "users", onSelect: () => navigate("people") },
2082
+ ], { header: button.dataset.sectionOptions });
2083
+ });
2084
+ }
2085
+ $("#pane-more").addEventListener("click", (event) => {
2086
+ const info = state.info;
2087
+ if (!info) return;
2088
+ const channel = !isDm(info);
2089
+ openMenu(event.currentTarget, [
2090
+ { label: channel ? "Open channel details" : "Open conversation details", icon: "info", onSelect: () => openDetails("about") },
2091
+ { label: "View members", icon: "users", disabled: info.is_im === true, onSelect: () => openDetails("members") },
2092
+ ...(channel ? [{ label: "Edit topic", icon: "pencil", disabled: !info.is_member || info.is_archived, onSelect: () => openTopicDialog() }] : []),
2093
+ { label: "Pinned messages", icon: "pin", onSelect: () => navigate("pins") },
2094
+ "divider",
2095
+ { label: "Search in conversation", icon: "search", disabled: !channel, onSelect: () => navigate(`search/in:#${info.name}`) },
2096
+ ...(channel ? [{ label: "Channel settings", onSelect: () => openDetails("settings") }] : []),
2097
+ ], { align: "end" });
2098
+ });
2099
+ $("#dms-toggle").addEventListener("click", () => {
2100
+ state.dmsOpen = !state.dmsOpen;
2101
+ writePreference("dmsOpen", String(state.dmsOpen));
2102
+ renderSidebar();
2103
+ });
2104
+ $("#help").addEventListener("click", (event) => openMenu(event.currentTarget, [
2105
+ { label: "Keyboard shortcuts", disabled: true },
2106
+ { label: "Alt ↑ / Alt ↓", detail: "Previous / next conversation", disabled: true },
2107
+ { label: "Ctrl/⌘ K", detail: "Search", disabled: true },
2108
+ { label: "↑ in an empty composer", detail: "Edit your last message", disabled: true },
2109
+ { label: "Esc", detail: "Close panel or dialog", disabled: true },
2110
+ "divider",
2111
+ { label: "Synthetic workspace served by a Firedrill Tool — messages never leave this world.", disabled: true },
2112
+ ], { align: "end", className: "wide-menu" }));
2113
+ $("#nav-menu").addEventListener("click", () => $("#shell").classList.toggle("sidebar-open"));
2114
+ $("#pane").addEventListener("pointerdown", () => {
2115
+ if (window.matchMedia("(max-width: 768px)").matches) $("#shell").classList.remove("sidebar-open");
2116
+ });
2117
+
2118
+ document.addEventListener("keydown", (event) => {
2119
+ const inField = /^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName ?? "") || document.activeElement?.isContentEditable;
2120
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
2121
+ event.preventDefault();
2122
+ $("#search").focus();
2123
+ $("#search").select();
2124
+ } else if (event.key === "Escape" && !menuOpen() && !document.querySelector("dialog[open]")) {
2125
+ if (state.panel) closePanel();
2126
+ else if (document.activeElement === $("#search")) $("#search").blur();
2127
+ } else if (event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown") && !inField) {
2128
+ const links = $$(".sb-link");
2129
+ const index = links.findIndex((link) => link.dataset.id === state.current);
2130
+ const next = links[(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length];
2131
+ if (next) {
2132
+ event.preventDefault();
2133
+ navigate(next.dataset.id);
2134
+ }
2135
+ }
2136
+ });
2137
+
2138
+ // ---------------------------------------------------------------------------------------------
2139
+ // Boot
2140
+ // ---------------------------------------------------------------------------------------------
2141
+
2142
+ function renderEmptyWorkspace() {
2143
+ state.current = undefined;
2144
+ showView("messages");
2145
+ $("#pane-title-text").textContent = state.identity?.team ?? "Workspace";
2146
+ $("#pane-title-glyph").replaceChildren();
2147
+ $("#pane-tabs").hidden = true;
2148
+ $("#pane-footer").hidden = true;
2149
+ const body = paneBody();
2150
+ body.append(stateCard("No conversations yet", "This workspace has no channels or direct messages you can see. Create a channel to get started.", { actions: [textButton("Create channel", { class: "primary", onClick: () => openCreateChannel() })] }));
2151
+ }
2152
+
2153
+ function renderFatal(error) {
2154
+ state.fatal = error;
2155
+ renderSidebarSkeleton();
2156
+ $("#channel-list").replaceChildren();
2157
+ $("#dm-list").replaceChildren();
2158
+ const body = paneBody();
2159
+ $("#pane-tabs").hidden = true;
2160
+ $("#pane-footer").hidden = true;
2161
+ $("#pane-title-text").textContent = "Slack (synthetic)";
2162
+ $(".pane-title-chevron").toggleAttribute("hidden", true);
2163
+ $("#workspace-name").textContent = "Workspace";
2164
+ $("#workspace-initial").textContent = "W";
2165
+ if (error instanceof ToolError && error.is("INVALID_AUTH")) {
2166
+ body.append(stateCard("This actor isn't a member of the workspace", "Every call answered invalid_auth: the acting actor's attributes.userId names no users row in this world (an actor without that attribute acts as the workspace's first active member). Fix or remove the attribute in firedrill/world.json (see the package README) and reopen the app.", { error: true, actions: [textButton("Retry", { onClick: () => action(() => refresh(true)) })] }));
2167
+ } else if (error instanceof ToolError && error.denied) {
2168
+ body.append(stateCard("You don't have permission to use this workspace", "This actor has no grant for auth.test or users.list (missing_scope). Grant the Tool's operations to the actor in the world definition.", { error: true, actions: [textButton("Retry", { onClick: () => action(() => refresh(true)) })] }));
2169
+ } else body.append(stateCard("The workspace can't be reached", describe(error), { error: true, actions: [textButton("Retry", { onClick: () => action(() => refresh(true)) })] }));
2170
+ }
2171
+
2172
+ async function refresh(first) {
2173
+ try {
2174
+ await loadIdentity();
2175
+ await loadConversations();
2176
+ state.fatal = undefined;
2177
+ } catch (error) {
2178
+ renderFatal(error);
2179
+ return;
2180
+ }
2181
+ if (first) {
2182
+ const hash = currentHash();
2183
+ nav.stack = [hash];
2184
+ nav.index = 0;
2185
+ renderNavButtons();
2186
+ await openRoute(hash);
2187
+ return;
2188
+ }
2189
+ // Something changed in the world: re-read what is on screen without touching composer text.
2190
+ if (state.view === "messages" && state.current && !state.paneError) await loadConversation(state.current, { keepScroll: true });
2191
+ else if (state.view === "messages" && state.current) await loadConversation(state.current, { keepScroll: false });
2192
+ else if (state.view === "pins") await renderPins();
2193
+ else if (state.view === "search" && state.search.query) await runSearch(state.search.query, { page: state.search.page });
2194
+ else if (state.view === "browse") await renderBrowse();
2195
+ else if (state.view === "people") await renderPeople();
2196
+ if (state.thread) await loadThread({ keepScroll: true });
2197
+ if (state.panel === "profile" && state.profile) await openProfile(state.profile);
2198
+ }
2199
+
2200
+ hydrateIcons();
2201
+ renderSidebarSkeleton();
2202
+ watchWorld(refresh, () => !document.querySelector("dialog[open]") && !isPending());