groupchat 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1717 @@
1
+ import {
2
+ getConfig,
3
+ getCurrentToken,
4
+ isAuthenticated,
5
+ login,
6
+ logout
7
+ } from "./chunk-ZYY5PLDM.js";
8
+
9
+ // src/components/App.tsx
10
+ import { useState as useState5, useEffect as useEffect7, useCallback as useCallback4, useRef as useRef3 } from "react";
11
+ import { Box as Box10, useApp, useInput as useInput4, useStdout as useStdout4 } from "ink";
12
+
13
+ // src/components/LoginScreen.tsx
14
+ import { useEffect } from "react";
15
+ import { Box, Text, useInput, useStdout } from "ink";
16
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
17
+ function LoginScreen({ onLogin, status, isLoading }) {
18
+ const { stdout } = useStdout();
19
+ useEffect(() => {
20
+ if (!stdout) return;
21
+ stdout.write("\x1B]0;Welcome to Groupchatty\x07");
22
+ }, [stdout]);
23
+ useInput((input, key) => {
24
+ if (key.return && !isLoading) {
25
+ onLogin();
26
+ }
27
+ });
28
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", padding: 2, children: [
29
+ /* @__PURE__ */ jsx(Box, { marginBottom: 2, children: /* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: `
30
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557
31
+ \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D
32
+ \u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D
33
+ \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
34
+ \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551
35
+ \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D
36
+ G R O U P C H A T
37
+ ` }) }),
38
+ /* @__PURE__ */ jsx(
39
+ Box,
40
+ {
41
+ borderStyle: "single",
42
+ borderColor: "redBright",
43
+ paddingX: 4,
44
+ paddingY: 1,
45
+ flexDirection: "column",
46
+ alignItems: "center",
47
+ children: isLoading ? /* @__PURE__ */ jsxs(Fragment, { children: [
48
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: status || "Authenticating..." }),
49
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Please complete login in your browser..." }) })
50
+ ] }) : status ? /* @__PURE__ */ jsxs(Fragment, { children: [
51
+ /* @__PURE__ */ jsx(Text, { color: "red", children: status }),
52
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
53
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press " }),
54
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "Enter" }),
55
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " to try again" })
56
+ ] })
57
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
58
+ /* @__PURE__ */ jsx(Text, { color: "redBright", children: "Welcome to Groupchat!" }),
59
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
60
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press " }),
61
+ /* @__PURE__ */ jsx(Text, { color: "green", bold: true, children: "Enter" }),
62
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " to login with your browser" })
63
+ ] })
64
+ ] })
65
+ }
66
+ ),
67
+ /* @__PURE__ */ jsx(Box, { marginTop: 2, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "Ctrl+C to exit" }) })
68
+ ] });
69
+ }
70
+
71
+ // src/components/Menu.tsx
72
+ import { useState, useEffect as useEffect2, useMemo } from "react";
73
+ import { Box as Box3, Text as Text3, useInput as useInput2, useStdout as useStdout2 } from "ink";
74
+
75
+ // src/components/Header.tsx
76
+ import { Box as Box2, Text as Text2 } from "ink";
77
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
78
+ function Header({
79
+ username,
80
+ roomName,
81
+ connectionStatus,
82
+ title,
83
+ showStatus = true
84
+ }) {
85
+ const statusColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
86
+ const statusText = connectionStatus === "connected" ? "ONLINE" : connectionStatus === "connecting" ? "CONNECTING" : "OFFLINE";
87
+ return /* @__PURE__ */ jsxs2(
88
+ Box2,
89
+ {
90
+ borderStyle: "single",
91
+ borderColor: "gray",
92
+ paddingX: 1,
93
+ justifyContent: "space-between",
94
+ width: "100%",
95
+ flexShrink: 0,
96
+ children: [
97
+ /* @__PURE__ */ jsx2(Box2, { children: title || /* @__PURE__ */ jsxs2(Fragment2, { children: [
98
+ /* @__PURE__ */ jsxs2(Text2, { color: "cyan", bold: true, children: [
99
+ "$",
100
+ " "
101
+ ] }),
102
+ /* @__PURE__ */ jsx2(Text2, { color: "blue", bold: true, children: "groupchat" }),
103
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " --session " }),
104
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: username || "..." })
105
+ ] }) }),
106
+ /* @__PURE__ */ jsxs2(Box2, { children: [
107
+ showStatus && /* @__PURE__ */ jsxs2(Fragment2, { children: [
108
+ /* @__PURE__ */ jsxs2(Text2, { color: statusColor, children: [
109
+ "[",
110
+ statusText,
111
+ "]"
112
+ ] }),
113
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " " })
114
+ ] }),
115
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "[Ctrl+O: LOGOUT]" })
116
+ ] })
117
+ ]
118
+ }
119
+ );
120
+ }
121
+
122
+ // src/components/Menu.tsx
123
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
124
+ function Menu({
125
+ width,
126
+ height,
127
+ currentChannel,
128
+ onChannelSelect,
129
+ onBack,
130
+ username,
131
+ connectionStatus,
132
+ onLogout,
133
+ topPadding = 0,
134
+ publicChannels,
135
+ privateChannels,
136
+ unreadCounts
137
+ }) {
138
+ const { stdout } = useStdout2();
139
+ const sortedPublicChannels = useMemo(() => {
140
+ return [...publicChannels].sort((a, b) => a.id.localeCompare(b.id));
141
+ }, [publicChannels]);
142
+ const allChannels = useMemo(() => {
143
+ return [...sortedPublicChannels, ...privateChannels];
144
+ }, [sortedPublicChannels, privateChannels]);
145
+ const [selectedIndex, setSelectedIndex] = useState(0);
146
+ useEffect2(() => {
147
+ if (allChannels.length > 0) {
148
+ const currentIndex = allChannels.findIndex((c) => c.slug === currentChannel);
149
+ setSelectedIndex(currentIndex >= 0 ? currentIndex : 0);
150
+ }
151
+ }, [allChannels, currentChannel]);
152
+ useEffect2(() => {
153
+ if (!stdout) return;
154
+ stdout.write(`\x1B]0;Menu\x07`);
155
+ }, [stdout]);
156
+ useInput2((input, key) => {
157
+ if (key.escape) {
158
+ onBack();
159
+ return;
160
+ }
161
+ if (key.upArrow) {
162
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
163
+ return;
164
+ }
165
+ if (key.downArrow) {
166
+ setSelectedIndex((prev) => Math.min(allChannels.length - 1, prev + 1));
167
+ return;
168
+ }
169
+ if (key.return && allChannels.length > 0) {
170
+ const selected = allChannels[selectedIndex];
171
+ if (selected) {
172
+ onChannelSelect(selected.slug);
173
+ onBack();
174
+ }
175
+ }
176
+ });
177
+ const headerHeight = 3;
178
+ const contentHeight = height - topPadding - headerHeight;
179
+ const privateStartIndex = sortedPublicChannels.length;
180
+ return /* @__PURE__ */ jsxs3(
181
+ Box3,
182
+ {
183
+ flexDirection: "column",
184
+ width,
185
+ height,
186
+ overflow: "hidden",
187
+ paddingTop: topPadding,
188
+ children: [
189
+ /* @__PURE__ */ jsx3(
190
+ Header,
191
+ {
192
+ username,
193
+ roomName: "Menu",
194
+ connectionStatus,
195
+ onLogout,
196
+ title: /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: "Menu" }),
197
+ showStatus: false
198
+ }
199
+ ),
200
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", height: contentHeight, padding: 2, children: [
201
+ sortedPublicChannels.length > 0 && /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
202
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { bold: true, color: "white", children: "Global Channels" }) }),
203
+ sortedPublicChannels.map((channel, idx) => {
204
+ const isSelected = selectedIndex === idx;
205
+ const unreadCount = unreadCounts[channel.slug] || 0;
206
+ return /* @__PURE__ */ jsx3(
207
+ ChannelItem,
208
+ {
209
+ channel,
210
+ isSelected,
211
+ unreadCount
212
+ },
213
+ channel.id
214
+ );
215
+ })
216
+ ] }),
217
+ privateChannels.length > 0 && /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
218
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { bold: true, color: "white", children: "Private Channels" }) }),
219
+ privateChannels.map((channel, idx) => {
220
+ const absoluteIndex = privateStartIndex + idx;
221
+ const isSelected = selectedIndex === absoluteIndex;
222
+ const unreadCount = unreadCounts[channel.slug] || 0;
223
+ return /* @__PURE__ */ jsx3(
224
+ ChannelItem,
225
+ {
226
+ channel,
227
+ isSelected,
228
+ isPrivate: true,
229
+ unreadCount
230
+ },
231
+ channel.id
232
+ );
233
+ })
234
+ ] }),
235
+ allChannels.length === 0 && /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "No channels available" }) }),
236
+ /* @__PURE__ */ jsx3(Box3, { flexGrow: 1 }),
237
+ /* @__PURE__ */ jsxs3(
238
+ Box3,
239
+ {
240
+ flexDirection: "column",
241
+ borderStyle: "single",
242
+ borderColor: "gray",
243
+ paddingX: 1,
244
+ children: [
245
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
246
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "Up/Down" }),
247
+ " Navigate channels"
248
+ ] }),
249
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
250
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "Enter" }),
251
+ " Join selected channel"
252
+ ] }),
253
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
254
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "ESC" }),
255
+ " Back to chat"
256
+ ] }),
257
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
258
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "Ctrl+C" }),
259
+ " Exit the app"
260
+ ] })
261
+ ]
262
+ }
263
+ )
264
+ ] })
265
+ ]
266
+ }
267
+ );
268
+ }
269
+ function ChannelItem({ channel, isSelected, isPrivate = false, unreadCount = 0 }) {
270
+ return /* @__PURE__ */ jsxs3(Box3, { marginLeft: 2, children: [
271
+ /* @__PURE__ */ jsxs3(Text3, { color: isSelected ? "green" : "white", bold: isSelected, children: [
272
+ isSelected ? "> " : " ",
273
+ isPrivate && /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u{1F512} " }),
274
+ "#",
275
+ channel.name || channel.slug,
276
+ unreadCount > 0 && /* @__PURE__ */ jsxs3(Text3, { color: "green", bold: true, children: [
277
+ " ",
278
+ "(",
279
+ unreadCount,
280
+ ")"
281
+ ] })
282
+ ] }),
283
+ isSelected && channel.description && /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
284
+ " ",
285
+ "- ",
286
+ channel.description
287
+ ] })
288
+ ] });
289
+ }
290
+
291
+ // src/components/ChatView.tsx
292
+ import { useEffect as useEffect4 } from "react";
293
+ import { Box as Box9, Text as Text9, useStdout as useStdout3 } from "ink";
294
+
295
+ // src/components/MessageList.tsx
296
+ import { useMemo as useMemo2 } from "react";
297
+ import { Box as Box5, Text as Text5 } from "ink";
298
+
299
+ // src/components/MessageItem.tsx
300
+ import { Box as Box4, Text as Text4 } from "ink";
301
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
302
+ function getUsernameColor(username) {
303
+ const colors = [
304
+ "cyan",
305
+ "magenta",
306
+ "yellow",
307
+ "blue",
308
+ "green",
309
+ "red"
310
+ ];
311
+ let hash = 0;
312
+ for (let i = 0; i < username.length; i++) {
313
+ hash = username.charCodeAt(i) + ((hash << 5) - hash);
314
+ }
315
+ return colors[Math.abs(hash) % colors.length];
316
+ }
317
+ function formatTime(timestamp) {
318
+ const date = new Date(timestamp);
319
+ return date.toLocaleTimeString("en-US", {
320
+ hour: "2-digit",
321
+ minute: "2-digit",
322
+ hour12: true
323
+ });
324
+ }
325
+ function MessageItem({ message, isOwnMessage }) {
326
+ const time = formatTime(message.timestamp);
327
+ const usernameColor = getUsernameColor(message.username);
328
+ if (isOwnMessage) {
329
+ return /* @__PURE__ */ jsx4(Box4, { justifyContent: "flex-end", paddingY: 0, children: /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", alignItems: "flex-end", children: [
330
+ /* @__PURE__ */ jsxs4(Box4, { children: [
331
+ /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
332
+ "[",
333
+ time,
334
+ "] "
335
+ ] }),
336
+ /* @__PURE__ */ jsx4(Text4, { color: usernameColor, bold: true, children: message.username }),
337
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: " \u2192" })
338
+ ] }),
339
+ /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { children: message.content }) })
340
+ ] }) });
341
+ }
342
+ return /* @__PURE__ */ jsx4(Box4, { justifyContent: "flex-start", paddingY: 0, children: /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
343
+ /* @__PURE__ */ jsxs4(Box4, { children: [
344
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "\u2190 " }),
345
+ /* @__PURE__ */ jsx4(Text4, { color: usernameColor, bold: true, children: message.username }),
346
+ /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
347
+ " [",
348
+ time,
349
+ "]"
350
+ ] })
351
+ ] }),
352
+ /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { children: message.content }) })
353
+ ] }) });
354
+ }
355
+
356
+ // src/components/MessageList.tsx
357
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
358
+ function MessageList({
359
+ messages,
360
+ currentUsername,
361
+ typingUsers,
362
+ height,
363
+ scrollOffset,
364
+ isDetached
365
+ }) {
366
+ const othersTyping = typingUsers.filter((u) => u !== currentUsername);
367
+ const visibleMessages = useMemo2(() => {
368
+ const linesPerMessage = 2;
369
+ const maxMessages = Math.floor(height / linesPerMessage);
370
+ const endIndex = messages.length - scrollOffset;
371
+ const startIndex = Math.max(0, endIndex - maxMessages);
372
+ return messages.slice(startIndex, endIndex);
373
+ }, [messages, height, scrollOffset]);
374
+ return /* @__PURE__ */ jsxs5(
375
+ Box5,
376
+ {
377
+ flexDirection: "column",
378
+ height,
379
+ paddingX: 1,
380
+ overflow: "hidden",
381
+ children: [
382
+ /* @__PURE__ */ jsx5(Box5, { flexGrow: 1 }),
383
+ messages.length === 0 ? /* @__PURE__ */ jsx5(Box5, { justifyContent: "center", paddingY: 2, children: /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "No messages yet. Say hello!" }) }) : visibleMessages.map((message) => /* @__PURE__ */ jsx5(
384
+ MessageItem,
385
+ {
386
+ message,
387
+ isOwnMessage: message.username === currentUsername
388
+ },
389
+ message.id
390
+ )),
391
+ isDetached && /* @__PURE__ */ jsx5(Box5, { justifyContent: "center", children: /* @__PURE__ */ jsxs5(Text5, { color: "yellow", bold: true, children: [
392
+ "-- ",
393
+ scrollOffset,
394
+ " more below (\u2193 to scroll down) --"
395
+ ] }) }),
396
+ othersTyping.length > 0 && !isDetached && /* @__PURE__ */ jsx5(Box5, { paddingTop: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "gray", italic: true, children: othersTyping.length === 1 ? `${othersTyping[0]} is typing...` : `${othersTyping.join(", ")} are typing...` }) })
397
+ ]
398
+ }
399
+ );
400
+ }
401
+
402
+ // src/components/UserList.tsx
403
+ import { Box as Box6, Text as Text6 } from "ink";
404
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
405
+ function UserList({
406
+ users,
407
+ currentUsername,
408
+ height,
409
+ isPrivateChannel = false
410
+ }) {
411
+ const onlineCount = users.filter((u) => u.isOnline).length;
412
+ const offlineCount = users.filter((u) => !u.isOnline).length;
413
+ const sortedUsers = [...users].sort((a, b) => {
414
+ if (a.username === currentUsername) return -1;
415
+ if (b.username === currentUsername) return 1;
416
+ if (a.isOnline && !b.isOnline) return -1;
417
+ if (!a.isOnline && b.isOnline) return 1;
418
+ return 0;
419
+ });
420
+ return /* @__PURE__ */ jsxs6(
421
+ Box6,
422
+ {
423
+ flexDirection: "column",
424
+ flexShrink: 0,
425
+ borderStyle: "single",
426
+ borderColor: "gray",
427
+ width: 24,
428
+ height,
429
+ paddingX: 1,
430
+ children: [
431
+ /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsx6(Text6, { color: "white", bold: true, children: "MEMBERS" }) : /* @__PURE__ */ jsxs6(Fragment3, { children: [
432
+ /* @__PURE__ */ jsx6(Text6, { color: "green", bold: true, children: "\u25CF " }),
433
+ /* @__PURE__ */ jsx6(Text6, { color: "white", bold: true, children: "ONLINE USERS" })
434
+ ] }) }),
435
+ /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsxs6(Text6, { color: "cyan", children: [
436
+ "[",
437
+ onlineCount,
438
+ " online]"
439
+ ] }) : /* @__PURE__ */ jsxs6(Text6, { color: "cyan", children: [
440
+ "[",
441
+ onlineCount,
442
+ " connected]"
443
+ ] }) }),
444
+ /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: sortedUsers.map((user) => {
445
+ const isTruncated = user.username.length > 8;
446
+ const displayName = isTruncated ? user.username.substring(0, 8) : user.username;
447
+ return /* @__PURE__ */ jsxs6(Box6, { children: [
448
+ /* @__PURE__ */ jsx6(Text6, { color: user.isOnline ? "green" : "gray", children: "\u25CF" }),
449
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
450
+ /* @__PURE__ */ jsxs6(Text6, { color: user.username === currentUsername ? "yellow" : "white", children: [
451
+ displayName,
452
+ isTruncated && "\u2026"
453
+ ] }),
454
+ user.username === currentUsername && /* @__PURE__ */ jsx6(Text6, { color: "gray", children: " (you)" })
455
+ ] }, user.username);
456
+ }) })
457
+ ]
458
+ }
459
+ );
460
+ }
461
+
462
+ // src/components/InputBox.tsx
463
+ import { useState as useState2, useCallback, useRef, useEffect as useEffect3 } from "react";
464
+ import { Box as Box7, Text as Text7 } from "ink";
465
+ import TextInput from "ink-text-input";
466
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
467
+ function InputBox({
468
+ onSend,
469
+ onTypingStart,
470
+ onTypingStop,
471
+ disabled
472
+ }) {
473
+ const [value, setValue] = useState2("");
474
+ const [isSending, setIsSending] = useState2(false);
475
+ const typingTimeoutRef = useRef(null);
476
+ const isTypingRef = useRef(false);
477
+ const handleChange = useCallback(
478
+ (newValue) => {
479
+ setValue(newValue);
480
+ if (!isTypingRef.current && newValue.length > 0) {
481
+ isTypingRef.current = true;
482
+ onTypingStart();
483
+ }
484
+ if (typingTimeoutRef.current) {
485
+ clearTimeout(typingTimeoutRef.current);
486
+ }
487
+ if (newValue.length > 0) {
488
+ typingTimeoutRef.current = setTimeout(() => {
489
+ isTypingRef.current = false;
490
+ onTypingStop();
491
+ }, 2e3);
492
+ } else {
493
+ isTypingRef.current = false;
494
+ onTypingStop();
495
+ }
496
+ },
497
+ [onTypingStart, onTypingStop]
498
+ );
499
+ const handleSubmit = useCallback(async () => {
500
+ const trimmed = value.trim();
501
+ if (!trimmed || disabled || isSending) return;
502
+ setIsSending(true);
503
+ if (typingTimeoutRef.current) {
504
+ clearTimeout(typingTimeoutRef.current);
505
+ }
506
+ isTypingRef.current = false;
507
+ onTypingStop();
508
+ try {
509
+ await onSend(trimmed);
510
+ setValue("");
511
+ } catch {
512
+ } finally {
513
+ setIsSending(false);
514
+ }
515
+ }, [value, disabled, isSending, onSend, onTypingStop]);
516
+ useEffect3(() => {
517
+ return () => {
518
+ if (typingTimeoutRef.current) {
519
+ clearTimeout(typingTimeoutRef.current);
520
+ }
521
+ };
522
+ }, []);
523
+ return /* @__PURE__ */ jsxs7(
524
+ Box7,
525
+ {
526
+ borderStyle: "single",
527
+ borderColor: "gray",
528
+ paddingX: 1,
529
+ flexDirection: "column",
530
+ width: "100%",
531
+ flexShrink: 0,
532
+ children: [
533
+ /* @__PURE__ */ jsxs7(Box7, { children: [
534
+ /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: "$ " }),
535
+ /* @__PURE__ */ jsx7(Box7, { flexGrow: 1, children: /* @__PURE__ */ jsx7(
536
+ TextInput,
537
+ {
538
+ value,
539
+ onChange: handleChange,
540
+ onSubmit: handleSubmit,
541
+ placeholder: disabled ? "Connecting..." : "Type a message..."
542
+ }
543
+ ) }),
544
+ /* @__PURE__ */ jsxs7(Text7, { color: disabled || !value.trim() ? "gray" : "green", children: [
545
+ " ",
546
+ "[SEND]"
547
+ ] })
548
+ ] }),
549
+ /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: "gray", dimColor: true, children: "Enter to send" }) })
550
+ ]
551
+ }
552
+ );
553
+ }
554
+
555
+ // src/components/StatusBar.tsx
556
+ import { Box as Box8, Text as Text8 } from "ink";
557
+ import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
558
+ function StatusBar({
559
+ connectionStatus,
560
+ error,
561
+ userCount
562
+ }) {
563
+ const presenceText = connectionStatus === "connected" ? "Active" : connectionStatus === "connecting" ? "Connecting" : "Disconnected";
564
+ const presenceColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
565
+ return /* @__PURE__ */ jsxs8(
566
+ Box8,
567
+ {
568
+ borderStyle: "single",
569
+ borderColor: "gray",
570
+ paddingX: 1,
571
+ justifyContent: "space-between",
572
+ width: "100%",
573
+ flexShrink: 0,
574
+ children: [
575
+ /* @__PURE__ */ jsx8(Box8, { children: error ? /* @__PURE__ */ jsxs8(Text8, { color: "red", children: [
576
+ "[Error: ",
577
+ error,
578
+ "]"
579
+ ] }) : /* @__PURE__ */ jsxs8(Fragment4, { children: [
580
+ /* @__PURE__ */ jsx8(Text8, { color: "gray", children: "\u2192 Presence: " }),
581
+ /* @__PURE__ */ jsx8(Text8, { color: presenceColor, children: presenceText })
582
+ ] }) }),
583
+ /* @__PURE__ */ jsxs8(Box8, { children: [
584
+ /* @__PURE__ */ jsx8(Text8, { color: "gray", children: "Users: " }),
585
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", children: userCount }),
586
+ /* @__PURE__ */ jsx8(Text8, { color: "gray", children: " | \u2191/\u2193 scroll | Ctrl+E users | Ctrl+C exit" })
587
+ ] })
588
+ ]
589
+ }
590
+ );
591
+ }
592
+
593
+ // src/components/ChatView.tsx
594
+ import { Fragment as Fragment5, jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
595
+ function ChatView({
596
+ terminalSize,
597
+ currentChannel,
598
+ channelName,
599
+ channelDescription,
600
+ connectionStatus,
601
+ username,
602
+ onLogout,
603
+ messages,
604
+ typingUsers,
605
+ middleSectionHeight,
606
+ scrollOffset,
607
+ isDetached,
608
+ showUserList,
609
+ users,
610
+ isPrivateChannel = false,
611
+ topPadding = 0,
612
+ onSend,
613
+ onTypingStart,
614
+ onTypingStop,
615
+ error
616
+ }) {
617
+ const { stdout } = useStdout3();
618
+ const displayName = channelName || currentChannel;
619
+ const displayText = channelDescription ? `${displayName} - ${channelDescription}` : displayName;
620
+ useEffect4(() => {
621
+ if (!stdout) return;
622
+ const prefix = connectionStatus === "connected" ? "\u2022 " : "";
623
+ stdout.write(`\x1B]0;${prefix}#${displayName}\x07`);
624
+ }, [stdout, connectionStatus, displayName]);
625
+ return /* @__PURE__ */ jsxs9(
626
+ Box9,
627
+ {
628
+ flexDirection: "column",
629
+ width: terminalSize.columns,
630
+ height: terminalSize.rows,
631
+ overflow: "hidden",
632
+ paddingTop: topPadding,
633
+ children: [
634
+ /* @__PURE__ */ jsx9(
635
+ Header,
636
+ {
637
+ username,
638
+ roomName: currentChannel,
639
+ connectionStatus,
640
+ onLogout,
641
+ title: /* @__PURE__ */ jsxs9(Fragment5, { children: [
642
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", children: "\u2190 Menu " }),
643
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: "[CTRL+Q]" }),
644
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", children: " | " }),
645
+ /* @__PURE__ */ jsxs9(Text9, { color: "cyan", bold: true, children: [
646
+ "#",
647
+ displayText
648
+ ] })
649
+ ] })
650
+ }
651
+ ),
652
+ /* @__PURE__ */ jsxs9(Box9, { flexDirection: "row", height: middleSectionHeight, overflow: "hidden", children: [
653
+ /* @__PURE__ */ jsx9(Box9, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx9(
654
+ MessageList,
655
+ {
656
+ messages,
657
+ currentUsername: username,
658
+ typingUsers,
659
+ height: middleSectionHeight,
660
+ scrollOffset,
661
+ isDetached
662
+ }
663
+ ) }),
664
+ showUserList && /* @__PURE__ */ jsx9(
665
+ UserList,
666
+ {
667
+ users,
668
+ currentUsername: username,
669
+ height: middleSectionHeight,
670
+ isPrivateChannel
671
+ }
672
+ )
673
+ ] }),
674
+ /* @__PURE__ */ jsx9(
675
+ InputBox,
676
+ {
677
+ onSend,
678
+ onTypingStart,
679
+ onTypingStop,
680
+ disabled: connectionStatus !== "connected"
681
+ }
682
+ ),
683
+ /* @__PURE__ */ jsx9(
684
+ StatusBar,
685
+ {
686
+ connectionStatus,
687
+ error,
688
+ userCount: users.length
689
+ }
690
+ )
691
+ ]
692
+ }
693
+ );
694
+ }
695
+
696
+ // src/hooks/use-multi-channel-chat.ts
697
+ import { useState as useState3, useCallback as useCallback2, useRef as useRef2, useEffect as useEffect5 } from "react";
698
+
699
+ // src/lib/channel-manager.ts
700
+ import { Socket } from "phoenix";
701
+ if (typeof globalThis.WebSocket === "undefined") {
702
+ throw new Error(
703
+ "WebSocket is not available. Load the ws polyfill before ChannelManager."
704
+ );
705
+ }
706
+ function extractTimestampFromUUIDv7(uuid) {
707
+ const hex = uuid.replace(/-/g, "").slice(0, 12);
708
+ const ms = parseInt(hex, 16);
709
+ return new Date(ms).toISOString();
710
+ }
711
+ var MAX_REALTIME_MESSAGES_PER_CHANNEL = 100;
712
+ var ChannelManager = class {
713
+ socket = null;
714
+ channelStates = /* @__PURE__ */ new Map();
715
+ callbacks;
716
+ wsUrl;
717
+ token;
718
+ connectionStatus = "disconnected";
719
+ currentActiveChannel = null;
720
+ username = null;
721
+ constructor(wsUrl, token, callbacks = {}) {
722
+ this.wsUrl = wsUrl;
723
+ this.token = token;
724
+ this.callbacks = callbacks;
725
+ }
726
+ /**
727
+ * Connect to the WebSocket and initialize the socket.
728
+ * Does not subscribe to any channels yet - use subscribeToChannels() for that.
729
+ */
730
+ async connect() {
731
+ this.setConnectionStatus("connecting");
732
+ this.socket = new Socket(this.wsUrl, {
733
+ params: { token: this.token },
734
+ reconnectAfterMs: (tries) => {
735
+ return [1e3, 2e3, 5e3, 1e4][tries - 1] || 1e4;
736
+ }
737
+ });
738
+ return new Promise((resolve, reject) => {
739
+ if (!this.socket) {
740
+ reject(new Error("Socket not initialized"));
741
+ return;
742
+ }
743
+ this.socket.onOpen(() => {
744
+ this.setConnectionStatus("connected");
745
+ resolve();
746
+ });
747
+ this.socket.onError((error) => {
748
+ this.setConnectionStatus("error");
749
+ this.callbacks.onError?.("Connection error");
750
+ reject(error);
751
+ });
752
+ this.socket.onClose(() => {
753
+ this.setConnectionStatus("disconnected");
754
+ });
755
+ this.socket.connect();
756
+ });
757
+ }
758
+ /**
759
+ * Subscribe to multiple channels simultaneously.
760
+ * Each channel will have its own ChannelState for tracking messages, presence, etc.
761
+ */
762
+ async subscribeToChannels(channels) {
763
+ if (!this.socket) {
764
+ throw new Error("Socket not connected. Call connect() first.");
765
+ }
766
+ const subscriptionPromises = channels.map(
767
+ (channel) => this.subscribeToChannel(channel.slug)
768
+ );
769
+ const results = await Promise.allSettled(subscriptionPromises);
770
+ results.forEach((result, index) => {
771
+ if (result.status === "rejected") {
772
+ const channelSlug = channels[index].slug;
773
+ console.error(`Failed to subscribe to ${channelSlug}:`, result.reason);
774
+ this.callbacks.onError?.(`Failed to join channel: ${channelSlug}`);
775
+ }
776
+ });
777
+ }
778
+ /**
779
+ * Subscribe to a single channel and setup event handlers.
780
+ */
781
+ async subscribeToChannel(channelSlug) {
782
+ if (!this.socket) {
783
+ throw new Error("Socket not connected");
784
+ }
785
+ const channel = this.socket.channel(channelSlug, {});
786
+ const channelState = {
787
+ slug: channelSlug,
788
+ channel,
789
+ presence: {},
790
+ typingUsers: /* @__PURE__ */ new Set(),
791
+ realtimeMessages: [],
792
+ subscribers: []
793
+ };
794
+ this.setupChannelHandlers(channel, channelSlug);
795
+ return new Promise((resolve, reject) => {
796
+ channel.join().receive("ok", (resp) => {
797
+ const response = resp;
798
+ if (response.username && !this.username) {
799
+ this.username = response.username;
800
+ }
801
+ this.channelStates.set(channelSlug, channelState);
802
+ this.callbacks.onChannelJoined?.(channelSlug, response.username || "");
803
+ resolve();
804
+ }).receive("error", (error) => {
805
+ const errorMsg = `Failed to join channel: ${channelSlug}`;
806
+ this.callbacks.onError?.(errorMsg);
807
+ reject(error);
808
+ }).receive("timeout", () => {
809
+ const errorMsg = `Timeout joining channel: ${channelSlug}`;
810
+ this.callbacks.onError?.(errorMsg);
811
+ reject(new Error("timeout"));
812
+ });
813
+ });
814
+ }
815
+ /**
816
+ * Setup event handlers for a specific channel.
817
+ * Handlers route events to the correct channel state and callbacks.
818
+ */
819
+ setupChannelHandlers(channel, channelSlug) {
820
+ channel.on("new_message", (payload) => {
821
+ const msg = payload;
822
+ const message = {
823
+ ...msg,
824
+ timestamp: extractTimestampFromUUIDv7(msg.id)
825
+ };
826
+ if (channelSlug === this.currentActiveChannel) {
827
+ this.callbacks.onMessage?.(channelSlug, message);
828
+ } else {
829
+ const state = this.channelStates.get(channelSlug);
830
+ if (state) {
831
+ state.realtimeMessages.push(message);
832
+ if (state.realtimeMessages.length > MAX_REALTIME_MESSAGES_PER_CHANNEL) {
833
+ state.realtimeMessages.shift();
834
+ }
835
+ }
836
+ }
837
+ });
838
+ channel.on("presence_state", (payload) => {
839
+ const state = payload;
840
+ const channelState = this.channelStates.get(channelSlug);
841
+ if (channelState) {
842
+ channelState.presence = state;
843
+ }
844
+ if (channelSlug === this.currentActiveChannel) {
845
+ this.callbacks.onPresenceState?.(channelSlug, state);
846
+ }
847
+ });
848
+ channel.on("presence_diff", (payload) => {
849
+ const diff = payload;
850
+ const channelState = this.channelStates.get(channelSlug);
851
+ if (channelState) {
852
+ const next = { ...channelState.presence };
853
+ Object.entries(diff.joins).forEach(([username, data]) => {
854
+ next[username] = data;
855
+ });
856
+ Object.keys(diff.leaves).forEach((username) => {
857
+ delete next[username];
858
+ });
859
+ channelState.presence = next;
860
+ }
861
+ if (channelSlug === this.currentActiveChannel) {
862
+ this.callbacks.onPresenceDiff?.(channelSlug, diff);
863
+ }
864
+ });
865
+ channel.on("user_typing_start", (payload) => {
866
+ const { username } = payload;
867
+ const channelState = this.channelStates.get(channelSlug);
868
+ if (channelState) {
869
+ channelState.typingUsers.add(username);
870
+ }
871
+ if (channelSlug === this.currentActiveChannel) {
872
+ this.callbacks.onUserTyping?.(channelSlug, username, true);
873
+ }
874
+ });
875
+ channel.on("user_typing_stop", (payload) => {
876
+ const { username } = payload;
877
+ const channelState = this.channelStates.get(channelSlug);
878
+ if (channelState) {
879
+ channelState.typingUsers.delete(username);
880
+ }
881
+ if (channelSlug === this.currentActiveChannel) {
882
+ this.callbacks.onUserTyping?.(channelSlug, username, false);
883
+ }
884
+ });
885
+ }
886
+ /**
887
+ * Set the currently active channel.
888
+ * This determines whether incoming messages are delivered immediately or buffered.
889
+ */
890
+ setActiveChannel(channelSlug) {
891
+ this.currentActiveChannel = channelSlug;
892
+ }
893
+ /**
894
+ * Fetch message history for a specific channel from the HTTP API.
895
+ */
896
+ async fetchHistory(channelSlug, limit = 50) {
897
+ const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
898
+ const encodedSlug = encodeURIComponent(channelSlug);
899
+ const url = `${backendUrl}/api/messages/${encodedSlug}?limit=${limit}`;
900
+ const response = await fetch(url, {
901
+ headers: {
902
+ Authorization: `Bearer ${this.token}`
903
+ }
904
+ });
905
+ if (!response.ok) {
906
+ throw new Error(`Failed to fetch message history: ${response.status}`);
907
+ }
908
+ const data = await response.json();
909
+ return data.messages || [];
910
+ }
911
+ /**
912
+ * Fetch and store subscriber list for a private channel.
913
+ * Only applicable to private channels.
914
+ */
915
+ async fetchSubscribers(channelSlug) {
916
+ if (!channelSlug.startsWith("private_room:")) {
917
+ return [];
918
+ }
919
+ const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
920
+ const encodedSlug = encodeURIComponent(channelSlug);
921
+ const url = `${backendUrl}/api/channels/${encodedSlug}/subscribers`;
922
+ const response = await fetch(url, {
923
+ headers: {
924
+ Authorization: `Bearer ${this.token}`
925
+ }
926
+ });
927
+ if (!response.ok) {
928
+ throw new Error(`Failed to fetch subscribers: ${response.status}`);
929
+ }
930
+ const data = await response.json();
931
+ const subscribers = data.subscribers || [];
932
+ const channelState = this.channelStates.get(channelSlug);
933
+ if (channelState) {
934
+ channelState.subscribers = subscribers;
935
+ }
936
+ return subscribers;
937
+ }
938
+ /**
939
+ * Get subscriber list for a specific channel.
940
+ */
941
+ getSubscribers(channelSlug) {
942
+ const channelState = this.channelStates.get(channelSlug);
943
+ return channelState?.subscribers || [];
944
+ }
945
+ /**
946
+ * Send a message to a specific channel.
947
+ */
948
+ async sendMessage(channelSlug, content) {
949
+ const channelState = this.channelStates.get(channelSlug);
950
+ if (!channelState) {
951
+ throw new Error(`Not subscribed to channel: ${channelSlug}`);
952
+ }
953
+ if (!this.socket || this.connectionStatus !== "connected") {
954
+ throw new Error("Connection lost");
955
+ }
956
+ const channel = channelState.channel;
957
+ return new Promise((resolve, reject) => {
958
+ channel.push("new_message", { content }).receive("ok", (resp) => {
959
+ const response = resp;
960
+ resolve(response);
961
+ }).receive("error", (err) => {
962
+ const error = err;
963
+ const errorMsg = error.reason || "Failed to send message";
964
+ this.callbacks.onError?.(errorMsg);
965
+ reject(new Error(errorMsg));
966
+ }).receive("timeout", () => {
967
+ const errorMsg = "Message send timeout";
968
+ this.callbacks.onError?.(errorMsg);
969
+ reject(new Error("timeout"));
970
+ });
971
+ });
972
+ }
973
+ /**
974
+ * Send typing:start event to a specific channel.
975
+ */
976
+ startTyping(channelSlug) {
977
+ if (this.connectionStatus !== "connected") return;
978
+ const channelState = this.channelStates.get(channelSlug);
979
+ if (!channelState) return;
980
+ try {
981
+ channelState.channel.push("typing:start", {});
982
+ } catch {
983
+ }
984
+ }
985
+ /**
986
+ * Send typing:stop event to a specific channel.
987
+ */
988
+ stopTyping(channelSlug) {
989
+ if (this.connectionStatus !== "connected") return;
990
+ const channelState = this.channelStates.get(channelSlug);
991
+ if (!channelState) return;
992
+ try {
993
+ channelState.channel.push("typing:stop", {});
994
+ } catch {
995
+ }
996
+ }
997
+ /**
998
+ * Get presence state for a specific channel.
999
+ */
1000
+ getPresence(channelSlug) {
1001
+ const channelState = this.channelStates.get(channelSlug);
1002
+ return channelState?.presence || {};
1003
+ }
1004
+ /**
1005
+ * Get buffered real-time messages for a specific channel.
1006
+ * These are messages that arrived while viewing other channels.
1007
+ */
1008
+ getRealtimeMessages(channelSlug) {
1009
+ const channelState = this.channelStates.get(channelSlug);
1010
+ return channelState?.realtimeMessages || [];
1011
+ }
1012
+ /**
1013
+ * Clear buffered real-time messages for a specific channel.
1014
+ * Called after merging with fetched history.
1015
+ */
1016
+ clearRealtimeMessages(channelSlug) {
1017
+ const channelState = this.channelStates.get(channelSlug);
1018
+ if (channelState) {
1019
+ channelState.realtimeMessages = [];
1020
+ }
1021
+ }
1022
+ /**
1023
+ * Get typing users for a specific channel.
1024
+ */
1025
+ getTypingUsers(channelSlug) {
1026
+ const channelState = this.channelStates.get(channelSlug);
1027
+ return channelState ? Array.from(channelState.typingUsers) : [];
1028
+ }
1029
+ /**
1030
+ * Get the current connection status.
1031
+ */
1032
+ getConnectionStatus() {
1033
+ return this.connectionStatus;
1034
+ }
1035
+ /**
1036
+ * Get the username (same across all channels).
1037
+ */
1038
+ getUsername() {
1039
+ return this.username;
1040
+ }
1041
+ /**
1042
+ * Check if connected.
1043
+ */
1044
+ isConnected() {
1045
+ return this.connectionStatus === "connected" && !!this.socket;
1046
+ }
1047
+ /**
1048
+ * Mark current channel as read via WebSocket.
1049
+ * Sends "mark_as_read" event to update last_seen to current seq_no.
1050
+ * Gracefully handles disconnected channels (returns silently during shutdown).
1051
+ */
1052
+ async markChannelAsRead(channelSlug) {
1053
+ const channelState = this.channelStates.get(channelSlug);
1054
+ if (!channelState || !channelState.channel) {
1055
+ return;
1056
+ }
1057
+ return new Promise((resolve, reject) => {
1058
+ channelState.channel.push("mark_as_read", {}).receive("ok", (response) => {
1059
+ console.log(`Marked ${channelSlug} as read`, response);
1060
+ resolve();
1061
+ }).receive("error", (err) => {
1062
+ console.error(`Failed to mark ${channelSlug} as read:`, err);
1063
+ reject(err);
1064
+ }).receive("timeout", () => {
1065
+ console.error(`Timeout marking ${channelSlug} as read`);
1066
+ reject(new Error("timeout"));
1067
+ });
1068
+ });
1069
+ }
1070
+ /**
1071
+ * Mark all messages in channel as read (used when first joining).
1072
+ * Gracefully handles disconnected channels (returns silently during shutdown).
1073
+ */
1074
+ async markAllMessagesAsRead(channelSlug) {
1075
+ const channelState = this.channelStates.get(channelSlug);
1076
+ if (!channelState || !channelState.channel) {
1077
+ return;
1078
+ }
1079
+ return new Promise((resolve, reject) => {
1080
+ channelState.channel.push("mark_all_read", {}).receive("ok", (response) => {
1081
+ console.log(`Marked all in ${channelSlug} as read`, response);
1082
+ resolve();
1083
+ }).receive("error", (err) => {
1084
+ console.error(`Failed to mark all as read in ${channelSlug}:`, err);
1085
+ reject(err);
1086
+ }).receive("timeout", () => {
1087
+ console.error(`Timeout marking all as read in ${channelSlug}`);
1088
+ reject(new Error("timeout"));
1089
+ });
1090
+ });
1091
+ }
1092
+ /**
1093
+ * Disconnect from all channels and close the socket.
1094
+ */
1095
+ disconnect() {
1096
+ this.channelStates.forEach((state) => {
1097
+ try {
1098
+ state.channel.leave();
1099
+ } catch {
1100
+ }
1101
+ });
1102
+ if (this.socket) {
1103
+ this.socket.disconnect();
1104
+ this.socket = null;
1105
+ }
1106
+ this.channelStates.clear();
1107
+ this.currentActiveChannel = null;
1108
+ this.username = null;
1109
+ this.setConnectionStatus("disconnected");
1110
+ }
1111
+ /**
1112
+ * Set connection status and notify callback.
1113
+ */
1114
+ setConnectionStatus(status) {
1115
+ this.connectionStatus = status;
1116
+ this.callbacks.onConnectionChange?.(status);
1117
+ }
1118
+ };
1119
+
1120
+ // src/lib/chat-client.ts
1121
+ import { Socket as Socket2 } from "phoenix";
1122
+ if (typeof globalThis.WebSocket === "undefined") {
1123
+ throw new Error(
1124
+ "WebSocket is not available. Load the ws polyfill before ChatClient."
1125
+ );
1126
+ }
1127
+ async function fetchChannels(wsUrl, token) {
1128
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1129
+ const url = `${backendUrl}/api/channels`;
1130
+ const response = await fetch(url, {
1131
+ headers: {
1132
+ Authorization: `Bearer ${token}`
1133
+ }
1134
+ });
1135
+ if (!response.ok) {
1136
+ throw new Error(`Failed to fetch channels: ${response.status}`);
1137
+ }
1138
+ return response.json();
1139
+ }
1140
+ async function fetchUnreadCounts(wsUrl, token) {
1141
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1142
+ const url = `${backendUrl}/api/unread-counts`;
1143
+ const response = await fetch(url, {
1144
+ headers: {
1145
+ Authorization: `Bearer ${token}`
1146
+ }
1147
+ });
1148
+ if (!response.ok) {
1149
+ throw new Error(`Failed to fetch unread counts: ${response.status}`);
1150
+ }
1151
+ const data = await response.json();
1152
+ return data.unread_counts || {};
1153
+ }
1154
+
1155
+ // src/hooks/use-multi-channel-chat.ts
1156
+ function useMultiChannelChat(token, currentChannel) {
1157
+ const [messages, setMessages] = useState3([]);
1158
+ const [connectionStatus, setConnectionStatus] = useState3("disconnected");
1159
+ const [username, setUsername] = useState3(null);
1160
+ const [error, setError] = useState3(null);
1161
+ const [typingUsers, setTypingUsers] = useState3([]);
1162
+ const [presenceState, setPresenceState] = useState3({});
1163
+ const [subscribers, setSubscribers] = useState3([]);
1164
+ const managerRef = useRef2(null);
1165
+ const prevChannelRef = useRef2(null);
1166
+ const isLoadingHistory = useRef2(false);
1167
+ useEffect5(() => {
1168
+ if (!token) {
1169
+ if (managerRef.current) {
1170
+ managerRef.current.disconnect();
1171
+ managerRef.current = null;
1172
+ }
1173
+ return;
1174
+ }
1175
+ if (managerRef.current) {
1176
+ return;
1177
+ }
1178
+ const config = getConfig();
1179
+ const manager = new ChannelManager(
1180
+ config.wsUrl,
1181
+ token,
1182
+ {
1183
+ onMessage: (channelSlug, message) => {
1184
+ setMessages((prev) => [...prev, message]);
1185
+ },
1186
+ onPresenceState: (channelSlug, state) => {
1187
+ setPresenceState(state);
1188
+ },
1189
+ onPresenceDiff: (channelSlug, diff) => {
1190
+ setPresenceState((prev) => {
1191
+ const next = { ...prev };
1192
+ Object.entries(diff.joins).forEach(([username2, data]) => {
1193
+ next[username2] = data;
1194
+ });
1195
+ Object.keys(diff.leaves).forEach((username2) => {
1196
+ delete next[username2];
1197
+ });
1198
+ return next;
1199
+ });
1200
+ },
1201
+ onUserTyping: (channelSlug, username2, typing) => {
1202
+ setTypingUsers((prev) => {
1203
+ if (typing) {
1204
+ return prev.includes(username2) ? prev : [...prev, username2];
1205
+ } else {
1206
+ return prev.filter((u) => u !== username2);
1207
+ }
1208
+ });
1209
+ },
1210
+ onConnectionChange: (status) => {
1211
+ setConnectionStatus(status);
1212
+ if (status === "disconnected" || status === "error") {
1213
+ setError(null);
1214
+ }
1215
+ },
1216
+ onError: (err) => {
1217
+ setError(err);
1218
+ },
1219
+ onChannelJoined: (channelSlug, joinedUsername) => {
1220
+ if (!username) {
1221
+ setUsername(joinedUsername);
1222
+ }
1223
+ }
1224
+ }
1225
+ );
1226
+ managerRef.current = manager;
1227
+ const authToken = token;
1228
+ async function init() {
1229
+ if (!authToken) {
1230
+ return;
1231
+ }
1232
+ try {
1233
+ await manager.connect();
1234
+ const channelsResponse = await fetchChannels(config.wsUrl, authToken);
1235
+ const allChannels = [
1236
+ ...channelsResponse.channels.public,
1237
+ ...channelsResponse.channels.private
1238
+ ];
1239
+ await manager.subscribeToChannels(allChannels);
1240
+ setError(null);
1241
+ } catch (err) {
1242
+ setError(err instanceof Error ? err.message : "Connection failed");
1243
+ console.error("Failed to initialize multi-channel chat:", err);
1244
+ }
1245
+ }
1246
+ init();
1247
+ return () => {
1248
+ if (managerRef.current) {
1249
+ managerRef.current.disconnect();
1250
+ managerRef.current = null;
1251
+ }
1252
+ };
1253
+ }, [token]);
1254
+ useEffect5(() => {
1255
+ const manager = managerRef.current;
1256
+ if (!manager || !manager.isConnected() || !currentChannel) {
1257
+ return;
1258
+ }
1259
+ if (prevChannelRef.current && prevChannelRef.current !== currentChannel) {
1260
+ manager.stopTyping(prevChannelRef.current);
1261
+ }
1262
+ prevChannelRef.current = currentChannel;
1263
+ manager.setActiveChannel(currentChannel);
1264
+ async function loadHistory() {
1265
+ if (isLoadingHistory.current || !manager) return;
1266
+ isLoadingHistory.current = true;
1267
+ try {
1268
+ const history = await manager.fetchHistory(currentChannel);
1269
+ if (currentChannel.startsWith("private_room:")) {
1270
+ const subs = await manager.fetchSubscribers(currentChannel);
1271
+ setSubscribers(subs);
1272
+ } else {
1273
+ setSubscribers([]);
1274
+ }
1275
+ const realtimeMessages = manager.getRealtimeMessages(currentChannel);
1276
+ const merged = [...history, ...realtimeMessages];
1277
+ const seen = /* @__PURE__ */ new Set();
1278
+ const deduplicated = merged.filter((msg) => {
1279
+ if (seen.has(msg.id)) return false;
1280
+ seen.add(msg.id);
1281
+ return true;
1282
+ });
1283
+ deduplicated.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1284
+ setMessages(deduplicated);
1285
+ manager.clearRealtimeMessages(currentChannel);
1286
+ const presence = manager.getPresence(currentChannel);
1287
+ setPresenceState(presence);
1288
+ const typing = manager.getTypingUsers(currentChannel);
1289
+ setTypingUsers(typing);
1290
+ setError(null);
1291
+ } catch (err) {
1292
+ setError(err instanceof Error ? err.message : "Failed to load history");
1293
+ console.error("Failed to load message history:", err);
1294
+ } finally {
1295
+ isLoadingHistory.current = false;
1296
+ }
1297
+ }
1298
+ loadHistory();
1299
+ }, [currentChannel, connectionStatus]);
1300
+ const sendMessage = useCallback2(
1301
+ async (content) => {
1302
+ if (!managerRef.current) {
1303
+ throw new Error("Not connected");
1304
+ }
1305
+ await managerRef.current.sendMessage(currentChannel, content);
1306
+ },
1307
+ [currentChannel]
1308
+ );
1309
+ const startTyping = useCallback2(() => {
1310
+ managerRef.current?.startTyping(currentChannel);
1311
+ }, [currentChannel]);
1312
+ const stopTyping = useCallback2(() => {
1313
+ managerRef.current?.stopTyping(currentChannel);
1314
+ }, [currentChannel]);
1315
+ const connect = useCallback2(() => {
1316
+ }, []);
1317
+ const disconnect = useCallback2(() => {
1318
+ }, []);
1319
+ return {
1320
+ messages,
1321
+ connectionStatus,
1322
+ username,
1323
+ error,
1324
+ sendMessage,
1325
+ startTyping,
1326
+ stopTyping,
1327
+ typingUsers,
1328
+ presenceState,
1329
+ subscribers,
1330
+ connect,
1331
+ // No-op for backward compatibility
1332
+ disconnect,
1333
+ // No-op for backward compatibility
1334
+ channelManager: managerRef.current
1335
+ };
1336
+ }
1337
+
1338
+ // src/hooks/use-presence.ts
1339
+ import { useMemo as useMemo3 } from "react";
1340
+ function presenceToUsers(presence) {
1341
+ return Object.entries(presence).map(([username, data]) => ({
1342
+ username,
1343
+ user_id: data.metas[0]?.user_id ?? 0,
1344
+ online_at: data.metas[0]?.online_at || ""
1345
+ }));
1346
+ }
1347
+ function mergeSubscribersWithPresence(subscribers, presence, isPrivateChannel) {
1348
+ if (!isPrivateChannel) {
1349
+ const onlineUsers = presenceToUsers(presence);
1350
+ return onlineUsers.map((user) => ({
1351
+ ...user,
1352
+ isOnline: true
1353
+ }));
1354
+ }
1355
+ const onlineUsernames = new Set(Object.keys(presence));
1356
+ return subscribers.map((subscriber) => {
1357
+ const isOnline = onlineUsernames.has(subscriber.username);
1358
+ return {
1359
+ username: subscriber.username,
1360
+ user_id: subscriber.user_id,
1361
+ online_at: isOnline ? presence[subscriber.username].metas[0]?.online_at || "" : "",
1362
+ isOnline
1363
+ };
1364
+ });
1365
+ }
1366
+ function usePresence(presenceState, subscribers = [], currentChannel = "") {
1367
+ const isPrivateChannel = currentChannel.startsWith("private_room:");
1368
+ const users = useMemo3(
1369
+ () => mergeSubscribersWithPresence(subscribers, presenceState, isPrivateChannel),
1370
+ [presenceState, subscribers, isPrivateChannel]
1371
+ );
1372
+ return { users };
1373
+ }
1374
+
1375
+ // src/hooks/use-channels.ts
1376
+ import { useState as useState4, useCallback as useCallback3, useEffect as useEffect6 } from "react";
1377
+ function useChannels(token) {
1378
+ const [publicChannels, setPublicChannels] = useState4([]);
1379
+ const [privateChannels, setPrivateChannels] = useState4([]);
1380
+ const [unreadCounts, setUnreadCounts] = useState4({});
1381
+ const [loading, setLoading] = useState4(false);
1382
+ const [error, setError] = useState4(null);
1383
+ const fetchData = useCallback3(async () => {
1384
+ if (!token) return;
1385
+ setLoading(true);
1386
+ setError(null);
1387
+ try {
1388
+ const config = getConfig();
1389
+ const [channelsData, unreadData] = await Promise.all([
1390
+ fetchChannels(config.wsUrl, token),
1391
+ fetchUnreadCounts(config.wsUrl, token)
1392
+ ]);
1393
+ setPublicChannels(channelsData.channels.public);
1394
+ setPrivateChannels(channelsData.channels.private);
1395
+ setUnreadCounts(unreadData);
1396
+ } catch (err) {
1397
+ setError(err instanceof Error ? err.message : "Failed to fetch data");
1398
+ } finally {
1399
+ setLoading(false);
1400
+ }
1401
+ }, [token]);
1402
+ const refetchUnreadCounts = useCallback3(async () => {
1403
+ if (!token) return;
1404
+ try {
1405
+ const config = getConfig();
1406
+ const unreadData = await fetchUnreadCounts(config.wsUrl, token);
1407
+ setUnreadCounts(unreadData);
1408
+ } catch (err) {
1409
+ console.error("Failed to refetch unread counts:", err);
1410
+ }
1411
+ }, [token]);
1412
+ useEffect6(() => {
1413
+ if (token) {
1414
+ fetchData();
1415
+ }
1416
+ }, [token, fetchData]);
1417
+ return {
1418
+ publicChannels,
1419
+ privateChannels,
1420
+ unreadCounts,
1421
+ loading,
1422
+ error,
1423
+ refetch: fetchData,
1424
+ refetchUnreadCounts
1425
+ };
1426
+ }
1427
+
1428
+ // src/components/App.tsx
1429
+ import { jsx as jsx10 } from "react/jsx-runtime";
1430
+ function App() {
1431
+ const { exit } = useApp();
1432
+ const { stdout } = useStdout4();
1433
+ const isWarp = process.env.TERM_PROGRAM === "WarpTerminal";
1434
+ const topPadding = isWarp ? 1 : 0;
1435
+ const [authState, setAuthState] = useState5("unauthenticated");
1436
+ const [authStatus, setAuthStatus] = useState5("");
1437
+ const [token, setToken] = useState5(null);
1438
+ const [terminalSize, setTerminalSize] = useState5({
1439
+ rows: stdout?.rows || 24,
1440
+ columns: stdout?.columns || 80
1441
+ });
1442
+ const [scrollOffset, setScrollOffset] = useState5(0);
1443
+ const [isScrollDetached, setIsScrollDetached] = useState5(false);
1444
+ const [showUserList, setShowUserList] = useState5(true);
1445
+ const [currentView, setCurrentView] = useState5("menu");
1446
+ const [currentChannel, setCurrentChannel] = useState5("chat_room:global");
1447
+ const prevAuthStateRef = useRef3(null);
1448
+ useEffect7(() => {
1449
+ if (!stdout) return;
1450
+ const handleResize = () => {
1451
+ setTerminalSize({
1452
+ rows: stdout.rows || 24,
1453
+ columns: stdout.columns || 80
1454
+ });
1455
+ };
1456
+ stdout.on("resize", handleResize);
1457
+ return () => {
1458
+ stdout.off("resize", handleResize);
1459
+ };
1460
+ }, [stdout]);
1461
+ useEffect7(() => {
1462
+ if (!stdout) return;
1463
+ if (prevAuthStateRef.current === "authenticated" && authState !== "authenticated") {
1464
+ stdout.write("\x1B[2J\x1B[0f");
1465
+ }
1466
+ prevAuthStateRef.current = authState;
1467
+ }, [authState, stdout]);
1468
+ useEffect7(() => {
1469
+ async function checkAuth() {
1470
+ const authenticated = await isAuthenticated();
1471
+ if (authenticated) {
1472
+ const stored = await getCurrentToken();
1473
+ if (stored) {
1474
+ setToken(stored.token);
1475
+ setAuthState("authenticated");
1476
+ }
1477
+ }
1478
+ }
1479
+ checkAuth();
1480
+ }, []);
1481
+ const { publicChannels, privateChannels, unreadCounts, refetchUnreadCounts } = useChannels(token);
1482
+ const {
1483
+ messages,
1484
+ connectionStatus,
1485
+ username,
1486
+ error,
1487
+ sendMessage,
1488
+ startTyping,
1489
+ stopTyping,
1490
+ typingUsers,
1491
+ presenceState,
1492
+ subscribers,
1493
+ connect,
1494
+ disconnect,
1495
+ channelManager
1496
+ } = useMultiChannelChat(token, currentChannel);
1497
+ const { users } = usePresence(presenceState, subscribers, currentChannel);
1498
+ const allChannels = [...publicChannels, ...privateChannels];
1499
+ const currentChannelDetails = allChannels.find((ch) => ch.slug === currentChannel);
1500
+ const isPrivateChannel = currentChannel.startsWith("private_room:");
1501
+ const prevChannelForMarkAsReadRef = useRef3(null);
1502
+ const markedAsReadOnEntryRef = useRef3(/* @__PURE__ */ new Set());
1503
+ useEffect7(() => {
1504
+ const markChannelAsRead = async (channelSlug, isEntry) => {
1505
+ if (!channelManager) {
1506
+ return;
1507
+ }
1508
+ try {
1509
+ if (isEntry) {
1510
+ if (!markedAsReadOnEntryRef.current.has(channelSlug)) {
1511
+ await channelManager.markAllMessagesAsRead(channelSlug);
1512
+ markedAsReadOnEntryRef.current.add(channelSlug);
1513
+ } else {
1514
+ await channelManager.markChannelAsRead(channelSlug);
1515
+ }
1516
+ } else {
1517
+ await channelManager.markChannelAsRead(channelSlug);
1518
+ }
1519
+ await refetchUnreadCounts();
1520
+ } catch (err) {
1521
+ console.error(`Failed to mark ${channelSlug} as read:`, err);
1522
+ }
1523
+ };
1524
+ if (currentChannel !== prevChannelForMarkAsReadRef.current) {
1525
+ if (prevChannelForMarkAsReadRef.current) {
1526
+ markChannelAsRead(prevChannelForMarkAsReadRef.current, false);
1527
+ }
1528
+ if (currentChannel) {
1529
+ markChannelAsRead(currentChannel, true);
1530
+ }
1531
+ prevChannelForMarkAsReadRef.current = currentChannel;
1532
+ }
1533
+ }, [currentChannel, channelManager, refetchUnreadCounts]);
1534
+ useEffect7(() => {
1535
+ return () => {
1536
+ if (currentChannel && channelManager) {
1537
+ const markOnUnmount = async () => {
1538
+ try {
1539
+ await channelManager.markChannelAsRead(currentChannel);
1540
+ } catch (err) {
1541
+ console.error("Failed to mark as read on unmount:", err);
1542
+ }
1543
+ };
1544
+ markOnUnmount();
1545
+ }
1546
+ };
1547
+ }, [currentChannel, channelManager]);
1548
+ useEffect7(() => {
1549
+ if (currentView === "menu") {
1550
+ refetchUnreadCounts();
1551
+ }
1552
+ }, [currentView, refetchUnreadCounts]);
1553
+ const handleLogin = useCallback4(async () => {
1554
+ setAuthState("authenticating");
1555
+ setAuthStatus("Starting login...");
1556
+ const result = await login((status) => setAuthStatus(status));
1557
+ if (result.success) {
1558
+ const stored = await getCurrentToken();
1559
+ if (stored) {
1560
+ setToken(stored.token);
1561
+ setAuthState("authenticated");
1562
+ setAuthStatus("");
1563
+ }
1564
+ } else {
1565
+ setAuthState("unauthenticated");
1566
+ setAuthStatus(result.error || "Login failed");
1567
+ }
1568
+ }, []);
1569
+ const handleLogout = useCallback4(async () => {
1570
+ if (currentChannel && channelManager) {
1571
+ try {
1572
+ await channelManager.markChannelAsRead(currentChannel);
1573
+ } catch (err) {
1574
+ console.error("Failed to mark as read on logout:", err);
1575
+ }
1576
+ }
1577
+ disconnect();
1578
+ setToken(null);
1579
+ setAuthState("unauthenticated");
1580
+ setAuthStatus("");
1581
+ try {
1582
+ await logout();
1583
+ } catch {
1584
+ setAuthStatus("Logged out locally; failed to clear credentials.");
1585
+ }
1586
+ }, [disconnect, currentChannel, channelManager]);
1587
+ const headerHeight = 3;
1588
+ const inputBoxHeight = 4;
1589
+ const statusBarHeight = 1;
1590
+ const middleSectionHeight = Math.max(
1591
+ 5,
1592
+ terminalSize.rows - topPadding - headerHeight - inputBoxHeight - statusBarHeight
1593
+ );
1594
+ const linesPerMessage = 2;
1595
+ const maxVisibleMessages = Math.floor(middleSectionHeight / linesPerMessage);
1596
+ useInput4((input, key) => {
1597
+ if (input === "c" && key.ctrl) {
1598
+ const handleExit = async () => {
1599
+ if (currentChannel && channelManager) {
1600
+ try {
1601
+ await channelManager.markChannelAsRead(currentChannel);
1602
+ } catch (err) {
1603
+ console.error("Failed to mark as read on exit:", err);
1604
+ }
1605
+ }
1606
+ disconnect();
1607
+ exit();
1608
+ };
1609
+ handleExit();
1610
+ }
1611
+ if (input === "o" && key.ctrl && authState === "authenticated") {
1612
+ handleLogout();
1613
+ }
1614
+ if (input === "e" && key.ctrl && authState === "authenticated") {
1615
+ setShowUserList((prev) => !prev);
1616
+ }
1617
+ if (input === "q" && key.ctrl && authState === "authenticated" && currentView === "chat") {
1618
+ setCurrentView("menu");
1619
+ }
1620
+ if (authState === "authenticated") {
1621
+ const maxOffset = Math.max(0, messages.length - maxVisibleMessages);
1622
+ if (key.upArrow) {
1623
+ setScrollOffset((prev) => {
1624
+ const newOffset = Math.min(prev + 1, maxOffset);
1625
+ if (newOffset > 0) {
1626
+ setIsScrollDetached(true);
1627
+ }
1628
+ return newOffset;
1629
+ });
1630
+ }
1631
+ if (key.downArrow) {
1632
+ setScrollOffset((prev) => {
1633
+ const newOffset = Math.max(prev - 1, 0);
1634
+ if (newOffset === 0) {
1635
+ setIsScrollDetached(false);
1636
+ }
1637
+ return newOffset;
1638
+ });
1639
+ }
1640
+ }
1641
+ });
1642
+ if (authState !== "authenticated") {
1643
+ return /* @__PURE__ */ jsx10(
1644
+ Box10,
1645
+ {
1646
+ flexDirection: "column",
1647
+ width: terminalSize.columns,
1648
+ height: terminalSize.rows,
1649
+ overflow: "hidden",
1650
+ children: /* @__PURE__ */ jsx10(
1651
+ LoginScreen,
1652
+ {
1653
+ onLogin: handleLogin,
1654
+ status: authStatus,
1655
+ isLoading: authState === "authenticating"
1656
+ }
1657
+ )
1658
+ }
1659
+ );
1660
+ }
1661
+ if (currentView === "menu") {
1662
+ return /* @__PURE__ */ jsx10(
1663
+ Box10,
1664
+ {
1665
+ flexDirection: "column",
1666
+ width: terminalSize.columns,
1667
+ height: terminalSize.rows,
1668
+ overflow: "hidden",
1669
+ children: /* @__PURE__ */ jsx10(
1670
+ Menu,
1671
+ {
1672
+ width: terminalSize.columns,
1673
+ height: terminalSize.rows,
1674
+ currentChannel,
1675
+ onChannelSelect: setCurrentChannel,
1676
+ onBack: () => setCurrentView("chat"),
1677
+ username,
1678
+ connectionStatus,
1679
+ onLogout: handleLogout,
1680
+ topPadding,
1681
+ publicChannels,
1682
+ privateChannels,
1683
+ unreadCounts
1684
+ }
1685
+ )
1686
+ }
1687
+ );
1688
+ }
1689
+ return /* @__PURE__ */ jsx10(
1690
+ ChatView,
1691
+ {
1692
+ terminalSize,
1693
+ currentChannel,
1694
+ channelName: currentChannelDetails?.name,
1695
+ channelDescription: currentChannelDetails?.description || void 0,
1696
+ connectionStatus,
1697
+ username,
1698
+ onLogout: handleLogout,
1699
+ messages,
1700
+ typingUsers,
1701
+ middleSectionHeight,
1702
+ scrollOffset,
1703
+ isDetached: isScrollDetached,
1704
+ showUserList,
1705
+ users,
1706
+ isPrivateChannel,
1707
+ topPadding,
1708
+ onSend: sendMessage,
1709
+ onTypingStart: startTyping,
1710
+ onTypingStop: stopTyping,
1711
+ error
1712
+ }
1713
+ );
1714
+ }
1715
+ export {
1716
+ App
1717
+ };