kokoirc 0.2.3 → 0.2.4

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -38
  3. package/docs/commands/clear.md +26 -0
  4. package/docs/commands/image.md +47 -0
  5. package/docs/commands/invite.md +23 -0
  6. package/docs/commands/names.md +25 -0
  7. package/docs/commands/preview.md +31 -0
  8. package/docs/commands/topic.md +12 -6
  9. package/docs/commands/version.md +23 -0
  10. package/package.json +46 -3
  11. package/src/app/App.tsx +11 -1
  12. package/src/core/commands/help-formatter.ts +1 -1
  13. package/src/core/commands/helpers.ts +3 -1
  14. package/src/core/commands/registry.ts +179 -5
  15. package/src/core/config/defaults.ts +11 -0
  16. package/src/core/config/loader.ts +4 -0
  17. package/src/core/constants.ts +3 -0
  18. package/src/core/image-preview/cache.ts +108 -0
  19. package/src/core/image-preview/detect.ts +105 -0
  20. package/src/core/image-preview/encode.ts +116 -0
  21. package/src/core/image-preview/fetch.ts +174 -0
  22. package/src/core/image-preview/index.ts +6 -0
  23. package/src/core/image-preview/render.ts +222 -0
  24. package/src/core/image-preview/stdin-guard.ts +33 -0
  25. package/src/core/init.ts +2 -1
  26. package/src/core/irc/antiflood.ts +2 -1
  27. package/src/core/irc/client.ts +3 -2
  28. package/src/core/irc/events.ts +60 -39
  29. package/src/core/irc/netsplit.ts +2 -1
  30. package/src/core/scripts/api.ts +3 -2
  31. package/src/core/state/store.ts +261 -3
  32. package/src/core/storage/index.ts +2 -2
  33. package/src/core/storage/writer.ts +12 -10
  34. package/src/core/theme/renderer.tsx +29 -1
  35. package/src/core/utils/id.ts +2 -0
  36. package/src/types/config.ts +13 -0
  37. package/src/types/index.ts +1 -2
  38. package/src/ui/chat/ChatView.tsx +11 -5
  39. package/src/ui/chat/MessageLine.tsx +18 -1
  40. package/src/ui/input/CommandInput.tsx +2 -1
  41. package/src/ui/layout/AppLayout.tsx +3 -1
  42. package/src/ui/overlay/ImagePreview.tsx +77 -0
@@ -1,7 +1,8 @@
1
1
  import type { Client } from "kofany-irc-framework"
2
2
  import { useStore } from "@/core/state/store"
3
3
  import { makeBufferId, BufferType, ActivityLevel } from "@/types"
4
- import type { Message } from "@/types"
4
+ import type { Message, NickEntry } from "@/types"
5
+ import { nextMsgId } from "@/core/utils/id"
5
6
  import { formatDuration, formatDate, buildModeString, buildPrefixMap, buildModeOrder, getHighestPrefix, getNickMode } from "./formatting"
6
7
  import { handleNetsplitQuit, handleNetsplitJoin, destroyNetsplitState } from "./netsplit"
7
8
  import { shouldSuppressNickFlood, destroyAntifloodState } from "./antiflood"
@@ -145,10 +146,8 @@ export function bindEvents(client: Client, connectionId: string) {
145
146
  .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
146
147
  .map(([id]) => id)
147
148
 
148
- // Remove nick from all affected channels
149
- for (const id of affected) {
150
- getStore().removeNick(id, event.nick)
151
- }
149
+ // Batch-remove nick from all affected channels (1 set() call)
150
+ getStore().batchRemoveNick(affected.map(id => ({ bufferId: id, nick: event.nick })))
152
151
 
153
152
  // Check if this is a netsplit — if so, batch it instead of showing individual quits
154
153
  if (handleNetsplitQuit(connectionId, event.nick, event.message || "", affected)) {
@@ -157,11 +156,12 @@ export function bindEvents(client: Client, connectionId: string) {
157
156
 
158
157
  if (shouldIgnore(event.nick, event.ident, event.hostname, "QUITS")) return
159
158
 
160
- for (const id of affected) {
161
- getStore().addMessage(id, makeFormattedEvent("quit", [
159
+ getStore().batchAddMessage(affected.map(id => ({
160
+ bufferId: id,
161
+ message: makeFormattedEvent("quit", [
162
162
  event.nick, event.ident || "", event.hostname || "", event.message || "",
163
- ]))
164
- }
163
+ ]),
164
+ })))
165
165
  })
166
166
 
167
167
  client.on("kick", (event) => {
@@ -232,14 +232,13 @@ export function bindEvents(client: Client, connectionId: string) {
232
232
  : false
233
233
 
234
234
  s.addMessage(bufferId, {
235
- id: crypto.randomUUID(),
235
+ id: nextMsgId(),
236
236
  timestamp: new Date(event.time || Date.now()),
237
237
  type: "message",
238
238
  nick: event.nick,
239
239
  nickMode: getNickMode(s.buffers, bufferId, event.nick),
240
240
  text: event.message,
241
241
  highlight: isMention,
242
- tags: event.tags,
243
242
  })
244
243
 
245
244
  if (s.activeBufferId !== bufferId && !isOwnMsg) {
@@ -262,13 +261,12 @@ export function bindEvents(client: Client, connectionId: string) {
262
261
  const bufferId = makeBufferId(connectionId, bufferName)
263
262
 
264
263
  s.addMessage(bufferId, {
265
- id: crypto.randomUUID(),
264
+ id: nextMsgId(),
266
265
  timestamp: new Date(event.time || Date.now()),
267
266
  type: "action",
268
267
  nick: event.nick,
269
268
  text: event.message,
270
269
  highlight: false,
271
- tags: event.tags,
272
270
  })
273
271
  })
274
272
 
@@ -288,7 +286,7 @@ export function bindEvents(client: Client, connectionId: string) {
288
286
  // Fallback to server status buffer
289
287
  const statusId = makeBufferId(connectionId, "Status")
290
288
  s.addMessage(statusId, {
291
- id: crypto.randomUUID(),
289
+ id: nextMsgId(),
292
290
  timestamp: new Date(event.time || Date.now()),
293
291
  type: "notice",
294
292
  nick: event.nick,
@@ -299,7 +297,7 @@ export function bindEvents(client: Client, connectionId: string) {
299
297
  }
300
298
 
301
299
  s.addMessage(bufferId, {
302
- id: crypto.randomUUID(),
300
+ id: nextMsgId(),
303
301
  timestamp: new Date(event.time || Date.now()),
304
302
  type: "notice",
305
303
  nick: event.nick,
@@ -327,14 +325,24 @@ export function bindEvents(client: Client, connectionId: string) {
327
325
  .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
328
326
  .map(([id]) => id)
329
327
 
328
+ // Batch-update nick across all affected buffers (1 set() call)
329
+ getStore().batchUpdateNick(affected.map(id => ({
330
+ bufferId: id, oldNick: event.nick, newNick: event.new_nick,
331
+ })))
332
+
330
333
  const nickIgnored = shouldIgnore(event.nick, event.ident, event.hostname, "NICKS")
331
- for (const id of affected) {
332
- getStore().updateNick(id, event.nick, event.new_nick)
333
- if (nickIgnored) continue
334
- if (shouldSuppressNickFlood(connectionId, id)) continue
335
- getStore().addMessage(id, makeFormattedEvent("nick_change", [
336
- event.nick, event.new_nick,
337
- ]))
334
+ if (!nickIgnored) {
335
+ const msgEntries: Array<{ bufferId: string; message: Message }> = []
336
+ for (const id of affected) {
337
+ if (shouldSuppressNickFlood(connectionId, id)) continue
338
+ msgEntries.push({
339
+ bufferId: id,
340
+ message: makeFormattedEvent("nick_change", [event.nick, event.new_nick]),
341
+ })
342
+ }
343
+ if (msgEntries.length > 0) {
344
+ getStore().batchAddMessage(msgEntries)
345
+ }
338
346
  }
339
347
  })
340
348
 
@@ -369,11 +377,12 @@ export function bindEvents(client: Client, connectionId: string) {
369
377
  const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
370
378
  const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
371
379
 
380
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
372
381
  for (const user of event.users) {
373
382
  // irc-framework gives modes as array of chars (["o","v"]) or prefix symbols (["@","+"])
374
383
  // Normalize to mode chars and store all of them
375
384
  const rawModes = (user.modes ?? [])
376
- .map((m) => {
385
+ .map((m: string) => {
377
386
  // If it's already a mode char in the order list, keep it
378
387
  if (modeOrder.includes(m)) return m
379
388
  // Otherwise it's a prefix symbol — reverse-lookup
@@ -385,14 +394,14 @@ export function bindEvents(client: Client, connectionId: string) {
385
394
  .filter(Boolean)
386
395
  .join("")
387
396
  const prefix = getHighestPrefix(rawModes, modeOrder, prefixMap)
388
- getStore().addNick(bufferId, {
389
- nick: user.nick,
390
- prefix,
391
- modes: rawModes,
392
- away: !!user.away,
393
- account: user.account,
397
+ nickEntries.push({
398
+ bufferId,
399
+ entry: { nick: user.nick, prefix, modes: rawModes, away: !!user.away, account: user.account },
394
400
  })
395
401
  }
402
+ if (nickEntries.length > 0) {
403
+ getStore().batchAddNick(nickEntries)
404
+ }
396
405
  })
397
406
 
398
407
  client.on("mode", (event) => {
@@ -597,18 +606,22 @@ export function bindEvents(client: Client, connectionId: string) {
597
606
  }
598
607
  if (!event.nick) return
599
608
 
600
- // Update nick away status in all shared channels
609
+ // Batch-update nick away status in all shared channels (1 set() call)
610
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
601
611
  for (const [bufId, buf] of s.buffers) {
602
612
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
603
613
  const entry = buf.users.get(event.nick)!
604
- getStore().addNick(bufId, { ...entry, away: true })
614
+ nickEntries.push({ bufferId: bufId, entry: { ...entry, away: true } })
605
615
  }
606
616
  }
617
+ if (nickEntries.length > 0) {
618
+ getStore().batchAddNick(nickEntries)
619
+ }
607
620
 
608
621
  // Show in query buffer if we have one open (RPL_AWAY response to messaging)
609
622
  const queryId = makeBufferId(connectionId, event.nick)
610
623
  if (s.buffers.has(queryId)) {
611
- s.addMessage(queryId, makeEventMessage(
624
+ getStore().addMessage(queryId, makeEventMessage(
612
625
  `%Z565f89${event.nick} is away${event.message ? ": " + event.message : ""}%N`
613
626
  ))
614
627
  }
@@ -622,13 +635,17 @@ export function bindEvents(client: Client, connectionId: string) {
622
635
  }
623
636
  if (!event.nick) return
624
637
 
625
- // Update nick away status in all shared channels
638
+ // Batch-update nick away status in all shared channels (1 set() call)
639
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
626
640
  for (const [bufId, buf] of s.buffers) {
627
641
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
628
642
  const entry = buf.users.get(event.nick)!
629
- getStore().addNick(bufId, { ...entry, away: false })
643
+ nickEntries.push({ bufferId: bufId, entry: { ...entry, away: false } })
630
644
  }
631
645
  }
646
+ if (nickEntries.length > 0) {
647
+ getStore().batchAddNick(nickEntries)
648
+ }
632
649
  })
633
650
 
634
651
  // ─── Channel redirect ─────────────────────────────────────
@@ -717,15 +734,19 @@ export function bindEvents(client: Client, connectionId: string) {
717
734
  // ACCOUNT-NOTIFY — a user's account changed (requires account-notify cap)
718
735
  client.on("account", (event) => {
719
736
  const s = getStore()
737
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
720
738
  for (const [bufId, buf] of s.buffers) {
721
739
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
722
740
  const entry = buf.users.get(event.nick)!
723
- getStore().addNick(bufId, {
724
- ...entry,
725
- account: event.account === false ? undefined : event.account,
741
+ nickEntries.push({
742
+ bufferId: bufId,
743
+ entry: { ...entry, account: event.account === false ? undefined : event.account },
726
744
  })
727
745
  }
728
746
  }
747
+ if (nickEntries.length > 0) {
748
+ getStore().batchAddNick(nickEntries)
749
+ }
729
750
  })
730
751
 
731
752
  // ─── Displayed host ────────────────────────────────────────
@@ -1013,7 +1034,7 @@ function displayNumberedList(
1013
1034
  /** System/inline event — text may contain %Z color codes. */
1014
1035
  function makeEventMessage(text: string): Message {
1015
1036
  return {
1016
- id: crypto.randomUUID(),
1037
+ id: nextMsgId(),
1017
1038
  timestamp: new Date(),
1018
1039
  type: "event",
1019
1040
  text,
@@ -1024,7 +1045,7 @@ function makeEventMessage(text: string): Message {
1024
1045
  /** IRC event with theme format key — rendered via [formats.events] in MessageLine. */
1025
1046
  function makeFormattedEvent(key: string, params: string[]): Message {
1026
1047
  return {
1027
- id: crypto.randomUUID(),
1048
+ id: nextMsgId(),
1028
1049
  timestamp: new Date(),
1029
1050
  type: "event",
1030
1051
  text: params.join(" "),
@@ -1,6 +1,7 @@
1
1
  import { useStore } from "@/core/state/store"
2
2
  import { makeBufferId } from "@/types"
3
3
  import type { Message } from "@/types"
4
+ import { nextMsgId } from "@/core/utils/id"
4
5
 
5
6
  // ─── Types ───────────────────────────────────────────────────
6
7
 
@@ -232,7 +233,7 @@ function tick(connId: string) {
232
233
 
233
234
  function makeEventMessage(text: string): Message {
234
235
  return {
235
- id: crypto.randomUUID(),
236
+ id: nextMsgId(),
236
237
  timestamp: new Date(),
237
238
  type: "event",
238
239
  text,
@@ -1,6 +1,7 @@
1
1
  import { useStore } from "@/core/state/store"
2
2
  import { getClient } from "@/core/irc"
3
3
  import { makeBufferId } from "@/types"
4
+ import { nextMsgId } from "@/core/utils/id"
4
5
  import { eventBus } from "./event-bus"
5
6
  import { EventPriority } from "./types"
6
7
  import type {
@@ -106,7 +107,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
106
107
  const buf = s.activeBufferId
107
108
  if (!buf) return
108
109
  s.addMessage(buf, {
109
- id: crypto.randomUUID(),
110
+ id: nextMsgId(),
110
111
  timestamp: new Date(),
111
112
  type: "event",
112
113
  text,
@@ -115,7 +116,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
115
116
  },
116
117
  addMessage(bufferId, partial) {
117
118
  useStore.getState().addMessage(bufferId, {
118
- id: crypto.randomUUID(),
119
+ id: nextMsgId(),
119
120
  timestamp: new Date(),
120
121
  ...partial,
121
122
  })
@@ -1,8 +1,20 @@
1
1
  import { create } from "zustand"
2
- import type { Connection, Buffer, Message, NickEntry, ActivityLevel, ListEntry, ListModeKey } from "@/types"
2
+ import { BufferType, ActivityLevel, makeBufferId } from "@/types"
3
+ import type { Connection, Buffer, Message, NickEntry, ListEntry, ListModeKey } from "@/types"
3
4
  import type { AppConfig } from "@/types/config"
4
5
  import type { ThemeFile } from "@/types/theme"
5
6
  import { logMessage, updateReadMarker } from "@/core/storage"
7
+ import { nextMsgId } from "@/core/utils/id"
8
+
9
+ export interface ImagePreviewState {
10
+ url: string
11
+ status: "loading" | "ready" | "error"
12
+ error?: string
13
+ width: number
14
+ height: number
15
+ title?: string
16
+ protocol?: string // needed for cleanup on dismiss
17
+ }
6
18
 
7
19
  interface AppState {
8
20
  // Data
@@ -13,6 +25,12 @@ interface AppState {
13
25
  config: AppConfig | null
14
26
  theme: ThemeFile | null
15
27
 
28
+ // Image preview
29
+ imagePreview: ImagePreviewState | null
30
+ showImagePreview: (url: string) => void
31
+ updateImagePreview: (updates: Partial<ImagePreviewState>) => void
32
+ hideImagePreview: () => void
33
+
16
34
  // Connection actions
17
35
  addConnection: (conn: Connection) => void
18
36
  updateConnection: (id: string, updates: Partial<Connection>) => void
@@ -21,11 +39,13 @@ interface AppState {
21
39
  // Buffer actions
22
40
  addBuffer: (buffer: Buffer) => void
23
41
  removeBuffer: (id: string) => void
42
+ closeConnection: (connectionId: string) => void
24
43
  setActiveBuffer: (id: string) => void
25
44
  updateBufferActivity: (id: string, level: ActivityLevel) => void
26
45
 
27
46
  // Message actions
28
47
  addMessage: (bufferId: string, message: Message) => void
48
+ clearMessages: (bufferId: string) => void
29
49
 
30
50
  // Nicklist actions
31
51
  addNick: (bufferId: string, entry: NickEntry) => void
@@ -41,6 +61,12 @@ interface AppState {
41
61
  addListEntry: (bufferId: string, modeChar: ListModeKey, entry: ListEntry) => void
42
62
  removeListEntry: (bufferId: string, modeChar: ListModeKey, mask: string) => void
43
63
 
64
+ // Batch actions (single set() for N mutations)
65
+ batchRemoveNick: (entries: Array<{ bufferId: string; nick: string }>) => void
66
+ batchAddNick: (entries: Array<{ bufferId: string; entry: NickEntry }>) => void
67
+ batchUpdateNick: (entries: Array<{ bufferId: string; oldNick: string; newNick: string; prefix?: string }>) => void
68
+ batchAddMessage: (entries: Array<{ bufferId: string; message: Message }>) => void
69
+
44
70
  // Config/Theme
45
71
  setConfig: (config: AppConfig) => void
46
72
  setTheme: (theme: ThemeFile) => void
@@ -59,6 +85,96 @@ export const useStore = create<AppState>((set, get) => ({
59
85
  config: null,
60
86
  theme: null,
61
87
 
88
+ // Image preview
89
+ imagePreview: null,
90
+ showImagePreview: (url) => {
91
+ // Concurrency guard — ignore if already loading
92
+ const current = get().imagePreview
93
+ if (current?.status === "loading") return
94
+
95
+ set({
96
+ imagePreview: { url, status: "loading", width: 0, height: 0 },
97
+ })
98
+ // Kick off the async render pipeline
99
+ import("@/core/image-preview/render").then(({ preparePreview }) => {
100
+ preparePreview(url)
101
+ }).catch((err) => {
102
+ const s = get()
103
+ const buf = s.activeBufferId
104
+ if (buf) {
105
+ s.addMessage(buf, {
106
+ id: nextMsgId(),
107
+ timestamp: new Date(),
108
+ type: "event",
109
+ text: `%Zf7768e[img] import failed: ${err.message}%N`,
110
+ highlight: false,
111
+ })
112
+ }
113
+ set({ imagePreview: null })
114
+ })
115
+ },
116
+ updateImagePreview: (updates) => set((s) => {
117
+ if (!s.imagePreview) return s
118
+ return { imagePreview: { ...s.imagePreview, ...updates } }
119
+ }),
120
+ hideImagePreview: () => {
121
+ const prev = get().imagePreview
122
+ if (!prev) { set({ imagePreview: null }); return }
123
+
124
+ try {
125
+ const { writeSync } = require("node:fs")
126
+ const { flushStdin } = require("@/core/image-preview/stdin-guard")
127
+ const inTmux = !!process.env.TMUX
128
+
129
+ // Disable mouse tracking + flush kernel input buffer.
130
+ // Do NOT call process.stdin.pause()/resume() — triggers Bun malloc crash.
131
+ writeSync(1, "\x1b[?1003l\x1b[?1006l\x1b[?1002l\x1b[?1000l")
132
+ flushStdin()
133
+
134
+ if (prev.protocol === "kitty") {
135
+ // Kitty: image is on separate graphics layer — delete command removes it
136
+ const deleteCmd = "\x1b_Ga=d,q=2\x1b\\"
137
+ if (inTmux) {
138
+ const escaped = deleteCmd.replace(/\x1b/g, "\x1b\x1b")
139
+ writeSync(1, `\x1bPtmux;${escaped}\x1b\\`)
140
+ } else {
141
+ writeSync(1, deleteCmd)
142
+ }
143
+ } else {
144
+ // iTerm2/Sixel/Symbols: image is in the cell buffer — overwrite with
145
+ // spaces using theme bg color. OpenTUI's diff renderer skips empty
146
+ // cells (they look "unchanged"), so we must fill them with the correct
147
+ // bg to avoid a default-bg hole where the image was.
148
+ const termCols = process.stdout.columns || 80
149
+ const termRows = process.stdout.rows || 24
150
+ const popupW = prev.width || 0
151
+ const popupH = prev.height || 0
152
+ if (popupW > 0 && popupH > 0) {
153
+ const left = Math.max(0, Math.floor((termCols - popupW) / 2))
154
+ const top = Math.max(0, Math.floor((termRows - popupH) / 2))
155
+ // Use theme bg color for spaces so empty cells match the UI
156
+ const theme = get().theme
157
+ const hex = theme?.colors?.bg ?? "#1a1b26"
158
+ const r = parseInt(hex.slice(1, 3), 16)
159
+ const g = parseInt(hex.slice(3, 5), 16)
160
+ const b = parseInt(hex.slice(5, 7), 16)
161
+ const bgSeq = `\x1b[48;2;${r};${g};${b}m`
162
+ const blankLine = bgSeq + " ".repeat(popupW) + "\x1b[0m"
163
+ writeSync(1, "\x1b7") // save cursor
164
+ for (let row = 0; row < popupH; row++) {
165
+ writeSync(1, `\x1b[${top + row + 1};${left + 1}H${blankLine}`)
166
+ }
167
+ writeSync(1, "\x1b8") // restore cursor
168
+ }
169
+ }
170
+
171
+ flushStdin()
172
+ writeSync(1, "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")
173
+ } catch {}
174
+
175
+ set({ imagePreview: null })
176
+ },
177
+
62
178
  addConnection: (conn) => set((s) => {
63
179
  const connections = new Map(s.connections)
64
180
  connections.set(conn.id, conn)
@@ -87,13 +203,82 @@ export const useStore = create<AppState>((set, get) => ({
87
203
  removeBuffer: (id) => set((s) => {
88
204
  const buffers = new Map(s.buffers)
89
205
  buffers.delete(id)
206
+
207
+ // If no buffers left, recreate the default welcome state
208
+ if (buffers.size === 0) {
209
+ const defaultId = makeBufferId("_default", "Status")
210
+ buffers.set(defaultId, {
211
+ id: defaultId,
212
+ connectionId: "_default",
213
+ type: BufferType.Server,
214
+ name: "Status",
215
+ messages: [{
216
+ id: nextMsgId(),
217
+ timestamp: new Date(),
218
+ type: "event" as const,
219
+ text: "Welcome to kokoIRC. Type /connect to connect to a server.",
220
+ highlight: false,
221
+ }],
222
+ activity: ActivityLevel.None,
223
+ unreadCount: 0,
224
+ lastRead: new Date(),
225
+ users: new Map(),
226
+ listModes: new Map(),
227
+ })
228
+ return { buffers, activeBufferId: defaultId, previousActiveBufferId: null }
229
+ }
230
+
90
231
  if (s.activeBufferId !== id) return { buffers }
91
- // Fall back to previous buffer if it still exists, otherwise null
232
+ // Fall back to previous buffer if it still exists, otherwise first available
92
233
  const fallback = s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
93
- ? s.previousActiveBufferId : null
234
+ ? s.previousActiveBufferId : buffers.keys().next().value ?? null
94
235
  return { buffers, activeBufferId: fallback }
95
236
  }),
96
237
 
238
+ closeConnection: (connectionId) => set((s) => {
239
+ const buffers = new Map(s.buffers)
240
+ const connections = new Map(s.connections)
241
+
242
+ // Remove all buffers for this connection
243
+ for (const [id, buf] of buffers) {
244
+ if (buf.connectionId === connectionId) buffers.delete(id)
245
+ }
246
+ // Remove the connection entry
247
+ connections.delete(connectionId)
248
+
249
+ // If no buffers left, recreate the default welcome state
250
+ if (buffers.size === 0) {
251
+ const defaultId = makeBufferId("_default", "Status")
252
+ buffers.set(defaultId, {
253
+ id: defaultId,
254
+ connectionId: "_default",
255
+ type: BufferType.Server,
256
+ name: "Status",
257
+ messages: [{
258
+ id: nextMsgId(),
259
+ timestamp: new Date(),
260
+ type: "event" as const,
261
+ text: "Welcome to kokoIRC. Type /connect to connect to a server.",
262
+ highlight: false,
263
+ }],
264
+ activity: ActivityLevel.None,
265
+ unreadCount: 0,
266
+ lastRead: new Date(),
267
+ users: new Map(),
268
+ listModes: new Map(),
269
+ })
270
+ return { buffers, connections, activeBufferId: defaultId, previousActiveBufferId: null }
271
+ }
272
+
273
+ // If active buffer was removed, fall back
274
+ const needsFallback = !buffers.has(s.activeBufferId ?? "")
275
+ const fallback = needsFallback
276
+ ? (s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
277
+ ? s.previousActiveBufferId : buffers.keys().next().value ?? null)
278
+ : s.activeBufferId
279
+ return { buffers, connections, activeBufferId: fallback }
280
+ }),
281
+
97
282
  setActiveBuffer: (id) => {
98
283
  // Persist read marker for TUI client
99
284
  const slashIdx = id.indexOf("/")
@@ -145,6 +330,14 @@ export const useStore = create<AppState>((set, get) => ({
145
330
  })
146
331
  },
147
332
 
333
+ clearMessages: (bufferId) => set((s) => {
334
+ const buffers = new Map(s.buffers)
335
+ const buf = buffers.get(bufferId)
336
+ if (!buf) return s
337
+ buffers.set(bufferId, { ...buf, messages: [] })
338
+ return { buffers }
339
+ }),
340
+
148
341
  addNick: (bufferId, entry) => set((s) => {
149
342
  const buffers = new Map(s.buffers)
150
343
  const buf = buffers.get(bufferId)
@@ -230,6 +423,71 @@ export const useStore = create<AppState>((set, get) => ({
230
423
  return { buffers }
231
424
  }),
232
425
 
426
+ batchRemoveNick: (entries) => set((s) => {
427
+ const buffers = new Map(s.buffers)
428
+ for (const { bufferId, nick } of entries) {
429
+ const buf = buffers.get(bufferId)
430
+ if (!buf) continue
431
+ const users = new Map(buf.users)
432
+ users.delete(nick)
433
+ buffers.set(bufferId, { ...buf, users })
434
+ }
435
+ return { buffers }
436
+ }),
437
+
438
+ batchAddNick: (entries) => set((s) => {
439
+ const buffers = new Map(s.buffers)
440
+ for (const { bufferId, entry } of entries) {
441
+ const buf = buffers.get(bufferId)
442
+ if (!buf) continue
443
+ const users = new Map(buf.users)
444
+ users.set(entry.nick, entry)
445
+ buffers.set(bufferId, { ...buf, users })
446
+ }
447
+ return { buffers }
448
+ }),
449
+
450
+ batchUpdateNick: (entries) => set((s) => {
451
+ const buffers = new Map(s.buffers)
452
+ for (const { bufferId, oldNick, newNick, prefix } of entries) {
453
+ const buf = buffers.get(bufferId)
454
+ if (!buf) continue
455
+ const users = new Map(buf.users)
456
+ const existing = users.get(oldNick)
457
+ if (existing) {
458
+ users.delete(oldNick)
459
+ users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
460
+ }
461
+ buffers.set(bufferId, { ...buf, users })
462
+ }
463
+ return { buffers }
464
+ }),
465
+
466
+ batchAddMessage: (entries) => {
467
+ // Log each message to persistent storage before the set() call
468
+ for (const { bufferId, message } of entries) {
469
+ const slashIdx = bufferId.indexOf("/")
470
+ if (slashIdx > 0) {
471
+ const network = bufferId.slice(0, slashIdx)
472
+ const buffer = bufferId.slice(slashIdx + 1)
473
+ logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
474
+ }
475
+ }
476
+
477
+ return set((s) => {
478
+ const buffers = new Map(s.buffers)
479
+ const maxLines = s.config?.display.scrollback_lines ?? 2000
480
+ for (const { bufferId, message } of entries) {
481
+ const buf = buffers.get(bufferId)
482
+ if (!buf) continue
483
+ const messages = [...buf.messages, message]
484
+ if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
485
+ buffers.set(bufferId, { ...buf, messages })
486
+ }
487
+ return { buffers }
488
+ })
489
+ },
490
+
233
491
  setConfig: (config) => set({ config }),
234
492
  setTheme: (theme) => set({ theme }),
235
493
 
@@ -35,7 +35,7 @@ export async function initStorage(config: LoggingConfig): Promise<void> {
35
35
  export function logMessage(
36
36
  network: string,
37
37
  buffer: string,
38
- msgId: string,
38
+ msgId: string | number,
39
39
  type: MessageType,
40
40
  text: string,
41
41
  nick: string | null,
@@ -45,7 +45,7 @@ export function logMessage(
45
45
  if (!writer) return
46
46
 
47
47
  const row: LogRow = {
48
- msg_id: msgId,
48
+ msg_id: String(msgId),
49
49
  network,
50
50
  buffer,
51
51
  timestamp: timestamp.getTime(),
@@ -14,6 +14,8 @@ export class LogWriter {
14
14
  private cryptoKey: CryptoKey | null = null
15
15
  private hasFts: boolean
16
16
  private listeners: MessageListener[] = []
17
+ private insertStmt: ReturnType<Database["prepare"]> | null = null
18
+ private insertFtsStmt: ReturnType<Database["prepare"]> | null = null
17
19
 
18
20
  constructor(db: Database, config: LoggingConfig) {
19
21
  this.db = db
@@ -25,6 +27,14 @@ export class LogWriter {
25
27
  if (this.config.encrypt) {
26
28
  this.cryptoKey = await loadOrCreateKey()
27
29
  }
30
+ this.insertStmt = this.db.prepare(
31
+ "INSERT INTO messages (msg_id, network, buffer, timestamp, type, nick, text, highlight, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
32
+ )
33
+ if (this.hasFts) {
34
+ this.insertFtsStmt = this.db.prepare(
35
+ "INSERT INTO messages_fts (rowid, nick, text) VALUES (?, ?, ?)"
36
+ )
37
+ }
28
38
  }
29
39
 
30
40
  /** Subscribe to new messages (for WebSocket real-time push). */
@@ -70,16 +80,8 @@ export class LogWriter {
70
80
  this.flushing = true
71
81
  const batch = this.queue.splice(0)
72
82
 
73
- const insert = this.db.prepare(
74
- "INSERT INTO messages (msg_id, network, buffer, timestamp, type, nick, text, highlight, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
75
- )
76
-
77
- // Prepare FTS insert if available
78
- const insertFts = this.hasFts
79
- ? this.db.prepare(
80
- "INSERT INTO messages_fts (rowid, nick, text) VALUES (?, ?, ?)"
81
- )
82
- : null
83
+ const insert = this.insertStmt!
84
+ const insertFts = this.insertFtsStmt
83
85
 
84
86
  try {
85
87
  // bun:sqlite transactions are sync, but encrypt is async — handle both modes