kokoirc 0.2.2 → 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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -40
  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/kill.md +24 -0
  7. package/docs/commands/names.md +25 -0
  8. package/docs/commands/oper.md +24 -0
  9. package/docs/commands/preview.md +31 -0
  10. package/docs/commands/quote.md +29 -0
  11. package/docs/commands/server.md +6 -0
  12. package/docs/commands/stats.md +31 -0
  13. package/docs/commands/topic.md +12 -6
  14. package/docs/commands/version.md +23 -0
  15. package/docs/commands/wallops.md +24 -0
  16. package/package.json +46 -3
  17. package/src/app/App.tsx +11 -1
  18. package/src/core/commands/help-formatter.ts +1 -1
  19. package/src/core/commands/helpers.ts +3 -1
  20. package/src/core/commands/registry.ts +251 -6
  21. package/src/core/config/defaults.ts +11 -0
  22. package/src/core/config/loader.ts +5 -0
  23. package/src/core/constants.ts +3 -0
  24. package/src/core/image-preview/cache.ts +108 -0
  25. package/src/core/image-preview/detect.ts +105 -0
  26. package/src/core/image-preview/encode.ts +116 -0
  27. package/src/core/image-preview/fetch.ts +174 -0
  28. package/src/core/image-preview/index.ts +6 -0
  29. package/src/core/image-preview/render.ts +222 -0
  30. package/src/core/image-preview/stdin-guard.ts +33 -0
  31. package/src/core/init.ts +2 -1
  32. package/src/core/irc/antiflood.ts +2 -1
  33. package/src/core/irc/client.ts +3 -2
  34. package/src/core/irc/events.ts +121 -47
  35. package/src/core/irc/netsplit.ts +2 -1
  36. package/src/core/scripts/api.ts +3 -2
  37. package/src/core/state/store.ts +261 -3
  38. package/src/core/storage/index.ts +2 -2
  39. package/src/core/storage/writer.ts +12 -10
  40. package/src/core/theme/renderer.tsx +29 -1
  41. package/src/core/utils/id.ts +2 -0
  42. package/src/types/config.ts +14 -0
  43. package/src/types/index.ts +1 -2
  44. package/src/ui/chat/ChatView.tsx +11 -5
  45. package/src/ui/chat/MessageLine.tsx +18 -1
  46. package/src/ui/input/CommandInput.tsx +2 -1
  47. package/src/ui/layout/AppLayout.tsx +3 -1
  48. package/src/ui/overlay/ImagePreview.tsx +77 -0
@@ -1,12 +1,15 @@
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"
8
9
  import { shouldIgnore } from "./ignore"
9
10
  import { eventBus } from "@/core/scripts/event-bus"
11
+ import { parseCommand } from "@/core/commands/parser"
12
+ import { executeCommand } from "@/core/commands/execution"
10
13
 
11
14
  function isChannelTarget(target: string): boolean {
12
15
  return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
@@ -53,15 +56,18 @@ export function bindEvents(client: Client, connectionId: string) {
53
56
  const s = getStore()
54
57
  s.updateConnection(connectionId, { status: "connected", nick: event.nick })
55
58
  statusMsg(`%Z9ece6aRegistered as %Zc0caf5${event.nick}%N`)
56
- // Auto-join channels from config
57
59
  const config = s.config
58
- if (config) {
59
- const serverConfig = Object.entries(config.servers).find(([id]) => id === connectionId)?.[1]
60
- if (serverConfig) {
61
- for (const channel of serverConfig.channels) {
62
- client.join(channel)
63
- }
64
- }
60
+ const serverConfig = config
61
+ ? Object.entries(config.servers).find(([id]) => id === connectionId)?.[1]
62
+ : undefined
63
+
64
+ // Execute autosendcmd before autojoin (erssi-style: ";"-separated, WAIT <ms> for delays)
65
+ if (serverConfig?.autosendcmd) {
66
+ runAutosendcmd(serverConfig.autosendcmd, connectionId, event.nick, () => {
67
+ autojoinChannels(client, serverConfig)
68
+ })
69
+ } else {
70
+ if (serverConfig) autojoinChannels(client, serverConfig)
65
71
  }
66
72
  })
67
73
 
@@ -140,10 +146,8 @@ export function bindEvents(client: Client, connectionId: string) {
140
146
  .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
141
147
  .map(([id]) => id)
142
148
 
143
- // Remove nick from all affected channels
144
- for (const id of affected) {
145
- getStore().removeNick(id, event.nick)
146
- }
149
+ // Batch-remove nick from all affected channels (1 set() call)
150
+ getStore().batchRemoveNick(affected.map(id => ({ bufferId: id, nick: event.nick })))
147
151
 
148
152
  // Check if this is a netsplit — if so, batch it instead of showing individual quits
149
153
  if (handleNetsplitQuit(connectionId, event.nick, event.message || "", affected)) {
@@ -152,11 +156,12 @@ export function bindEvents(client: Client, connectionId: string) {
152
156
 
153
157
  if (shouldIgnore(event.nick, event.ident, event.hostname, "QUITS")) return
154
158
 
155
- for (const id of affected) {
156
- getStore().addMessage(id, makeFormattedEvent("quit", [
159
+ getStore().batchAddMessage(affected.map(id => ({
160
+ bufferId: id,
161
+ message: makeFormattedEvent("quit", [
157
162
  event.nick, event.ident || "", event.hostname || "", event.message || "",
158
- ]))
159
- }
163
+ ]),
164
+ })))
160
165
  })
161
166
 
162
167
  client.on("kick", (event) => {
@@ -227,14 +232,13 @@ export function bindEvents(client: Client, connectionId: string) {
227
232
  : false
228
233
 
229
234
  s.addMessage(bufferId, {
230
- id: crypto.randomUUID(),
235
+ id: nextMsgId(),
231
236
  timestamp: new Date(event.time || Date.now()),
232
237
  type: "message",
233
238
  nick: event.nick,
234
239
  nickMode: getNickMode(s.buffers, bufferId, event.nick),
235
240
  text: event.message,
236
241
  highlight: isMention,
237
- tags: event.tags,
238
242
  })
239
243
 
240
244
  if (s.activeBufferId !== bufferId && !isOwnMsg) {
@@ -257,13 +261,12 @@ export function bindEvents(client: Client, connectionId: string) {
257
261
  const bufferId = makeBufferId(connectionId, bufferName)
258
262
 
259
263
  s.addMessage(bufferId, {
260
- id: crypto.randomUUID(),
264
+ id: nextMsgId(),
261
265
  timestamp: new Date(event.time || Date.now()),
262
266
  type: "action",
263
267
  nick: event.nick,
264
268
  text: event.message,
265
269
  highlight: false,
266
- tags: event.tags,
267
270
  })
268
271
  })
269
272
 
@@ -283,7 +286,7 @@ export function bindEvents(client: Client, connectionId: string) {
283
286
  // Fallback to server status buffer
284
287
  const statusId = makeBufferId(connectionId, "Status")
285
288
  s.addMessage(statusId, {
286
- id: crypto.randomUUID(),
289
+ id: nextMsgId(),
287
290
  timestamp: new Date(event.time || Date.now()),
288
291
  type: "notice",
289
292
  nick: event.nick,
@@ -294,7 +297,7 @@ export function bindEvents(client: Client, connectionId: string) {
294
297
  }
295
298
 
296
299
  s.addMessage(bufferId, {
297
- id: crypto.randomUUID(),
300
+ id: nextMsgId(),
298
301
  timestamp: new Date(event.time || Date.now()),
299
302
  type: "notice",
300
303
  nick: event.nick,
@@ -322,14 +325,24 @@ export function bindEvents(client: Client, connectionId: string) {
322
325
  .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
323
326
  .map(([id]) => id)
324
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
+
325
333
  const nickIgnored = shouldIgnore(event.nick, event.ident, event.hostname, "NICKS")
326
- for (const id of affected) {
327
- getStore().updateNick(id, event.nick, event.new_nick)
328
- if (nickIgnored) continue
329
- if (shouldSuppressNickFlood(connectionId, id)) continue
330
- getStore().addMessage(id, makeFormattedEvent("nick_change", [
331
- event.nick, event.new_nick,
332
- ]))
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
+ }
333
346
  }
334
347
  })
335
348
 
@@ -364,11 +377,12 @@ export function bindEvents(client: Client, connectionId: string) {
364
377
  const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
365
378
  const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
366
379
 
380
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
367
381
  for (const user of event.users) {
368
382
  // irc-framework gives modes as array of chars (["o","v"]) or prefix symbols (["@","+"])
369
383
  // Normalize to mode chars and store all of them
370
384
  const rawModes = (user.modes ?? [])
371
- .map((m) => {
385
+ .map((m: string) => {
372
386
  // If it's already a mode char in the order list, keep it
373
387
  if (modeOrder.includes(m)) return m
374
388
  // Otherwise it's a prefix symbol — reverse-lookup
@@ -380,14 +394,14 @@ export function bindEvents(client: Client, connectionId: string) {
380
394
  .filter(Boolean)
381
395
  .join("")
382
396
  const prefix = getHighestPrefix(rawModes, modeOrder, prefixMap)
383
- getStore().addNick(bufferId, {
384
- nick: user.nick,
385
- prefix,
386
- modes: rawModes,
387
- away: !!user.away,
388
- account: user.account,
397
+ nickEntries.push({
398
+ bufferId,
399
+ entry: { nick: user.nick, prefix, modes: rawModes, away: !!user.away, account: user.account },
389
400
  })
390
401
  }
402
+ if (nickEntries.length > 0) {
403
+ getStore().batchAddNick(nickEntries)
404
+ }
391
405
  })
392
406
 
393
407
  client.on("mode", (event) => {
@@ -592,18 +606,22 @@ export function bindEvents(client: Client, connectionId: string) {
592
606
  }
593
607
  if (!event.nick) return
594
608
 
595
- // 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 }> = []
596
611
  for (const [bufId, buf] of s.buffers) {
597
612
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
598
613
  const entry = buf.users.get(event.nick)!
599
- getStore().addNick(bufId, { ...entry, away: true })
614
+ nickEntries.push({ bufferId: bufId, entry: { ...entry, away: true } })
600
615
  }
601
616
  }
617
+ if (nickEntries.length > 0) {
618
+ getStore().batchAddNick(nickEntries)
619
+ }
602
620
 
603
621
  // Show in query buffer if we have one open (RPL_AWAY response to messaging)
604
622
  const queryId = makeBufferId(connectionId, event.nick)
605
623
  if (s.buffers.has(queryId)) {
606
- s.addMessage(queryId, makeEventMessage(
624
+ getStore().addMessage(queryId, makeEventMessage(
607
625
  `%Z565f89${event.nick} is away${event.message ? ": " + event.message : ""}%N`
608
626
  ))
609
627
  }
@@ -617,13 +635,17 @@ export function bindEvents(client: Client, connectionId: string) {
617
635
  }
618
636
  if (!event.nick) return
619
637
 
620
- // 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 }> = []
621
640
  for (const [bufId, buf] of s.buffers) {
622
641
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
623
642
  const entry = buf.users.get(event.nick)!
624
- getStore().addNick(bufId, { ...entry, away: false })
643
+ nickEntries.push({ bufferId: bufId, entry: { ...entry, away: false } })
625
644
  }
626
645
  }
646
+ if (nickEntries.length > 0) {
647
+ getStore().batchAddNick(nickEntries)
648
+ }
627
649
  })
628
650
 
629
651
  // ─── Channel redirect ─────────────────────────────────────
@@ -712,15 +734,19 @@ export function bindEvents(client: Client, connectionId: string) {
712
734
  // ACCOUNT-NOTIFY — a user's account changed (requires account-notify cap)
713
735
  client.on("account", (event) => {
714
736
  const s = getStore()
737
+ const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
715
738
  for (const [bufId, buf] of s.buffers) {
716
739
  if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
717
740
  const entry = buf.users.get(event.nick)!
718
- getStore().addNick(bufId, {
719
- ...entry,
720
- account: event.account === false ? undefined : event.account,
741
+ nickEntries.push({
742
+ bufferId: bufId,
743
+ entry: { ...entry, account: event.account === false ? undefined : event.account },
721
744
  })
722
745
  }
723
746
  }
747
+ if (nickEntries.length > 0) {
748
+ getStore().batchAddNick(nickEntries)
749
+ }
724
750
  })
725
751
 
726
752
  // ─── Displayed host ────────────────────────────────────────
@@ -1008,7 +1034,7 @@ function displayNumberedList(
1008
1034
  /** System/inline event — text may contain %Z color codes. */
1009
1035
  function makeEventMessage(text: string): Message {
1010
1036
  return {
1011
- id: crypto.randomUUID(),
1037
+ id: nextMsgId(),
1012
1038
  timestamp: new Date(),
1013
1039
  type: "event",
1014
1040
  text,
@@ -1019,7 +1045,7 @@ function makeEventMessage(text: string): Message {
1019
1045
  /** IRC event with theme format key — rendered via [formats.events] in MessageLine. */
1020
1046
  function makeFormattedEvent(key: string, params: string[]): Message {
1021
1047
  return {
1022
- id: crypto.randomUUID(),
1048
+ id: nextMsgId(),
1023
1049
  timestamp: new Date(),
1024
1050
  type: "event",
1025
1051
  text: params.join(" "),
@@ -1029,3 +1055,51 @@ function makeFormattedEvent(key: string, params: string[]): Message {
1029
1055
  }
1030
1056
  }
1031
1057
 
1058
+ // ─── Autosendcmd ─────────────────────────────────────────
1059
+
1060
+ /** Join channels from config, supporting "channel key" syntax. */
1061
+ function autojoinChannels(client: Client, serverConfig: import("@/types/config").ServerConfig) {
1062
+ for (const entry of serverConfig.channels) {
1063
+ const spaceIdx = entry.indexOf(" ")
1064
+ if (spaceIdx === -1) {
1065
+ client.join(entry)
1066
+ } else {
1067
+ client.join(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1))
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Execute autosendcmd string (erssi-style: ";"-separated commands, WAIT <ms> for delays).
1074
+ * Substitutes $N with current nick. Calls onDone() when all commands (including WAITs) complete.
1075
+ */
1076
+ function runAutosendcmd(cmd: string, connectionId: string, nick: string, onDone: () => void) {
1077
+ const parts = cmd.split(";").map((p) => p.trim()).filter(Boolean)
1078
+ let i = 0
1079
+
1080
+ function next() {
1081
+ while (i < parts.length) {
1082
+ const part = parts[i++]
1083
+ // Variable substitution ($N = nick)
1084
+ const expanded = part.replace(/\$\{?N\}?/g, nick)
1085
+
1086
+ // WAIT <ms> — delay before next command
1087
+ const waitMatch = expanded.match(/^WAIT\s+(\d+)$/i)
1088
+ if (waitMatch) {
1089
+ setTimeout(next, parseInt(waitMatch[1], 10))
1090
+ return
1091
+ }
1092
+
1093
+ // Treat as command: prepend / if missing, parse and execute
1094
+ const line = expanded.startsWith("/") ? expanded : "/" + expanded
1095
+ const parsed = parseCommand(line)
1096
+ if (parsed) {
1097
+ executeCommand(parsed, connectionId)
1098
+ }
1099
+ }
1100
+ onDone()
1101
+ }
1102
+
1103
+ next()
1104
+ }
1105
+
@@ -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
  })