kokoirc 0.2.0

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 (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,132 @@
1
+ import type { ModeEvent } from "kofany-irc-framework"
2
+ import type { Buffer } from "@/types"
3
+
4
+ /**
5
+ * Strip mIRC/IRC formatting codes from a string.
6
+ * Removes: bold (\x02), color (\x03NN,NN), hex color (\x04RRGGBB,RRGGBB),
7
+ * reset (\x0F), monospace (\x11), reverse (\x16), italic (\x1D),
8
+ * strikethrough (\x1E), underline (\x1F).
9
+ */
10
+ export function stripIrcFormatting(text: string): string {
11
+ // eslint-disable-next-line no-control-regex
12
+ return text.replace(/\x04([0-9a-fA-F]{6}(,[0-9a-fA-F]{6})?)?|\x03(\d{1,2}(,\d{1,2})?)?|[\x02\x0F\x11\x16\x1D\x1E\x1F]/g, "")
13
+ }
14
+
15
+ /** Format seconds into human-readable duration (e.g. "2d 5h 30m"). */
16
+ export function formatDuration(seconds: number): string {
17
+ if (seconds < 60) return `${seconds}s`
18
+ const days = Math.floor(seconds / 86400)
19
+ const hours = Math.floor((seconds % 86400) / 3600)
20
+ const mins = Math.floor((seconds % 3600) / 60)
21
+ const secs = seconds % 60
22
+ const parts: string[] = []
23
+ if (days > 0) parts.push(`${days}d`)
24
+ if (hours > 0) parts.push(`${hours}h`)
25
+ if (mins > 0) parts.push(`${mins}m`)
26
+ if (secs > 0 && days === 0) parts.push(`${secs}s`)
27
+ return parts.join(" ")
28
+ }
29
+
30
+ /** Format a Date to "YYYY-MM-DD HH:MM:SS". */
31
+ export function formatDate(date: Date): string {
32
+ const y = date.getFullYear()
33
+ const mo = String(date.getMonth() + 1).padStart(2, "0")
34
+ const d = String(date.getDate()).padStart(2, "0")
35
+ const h = String(date.getHours()).padStart(2, "0")
36
+ const mi = String(date.getMinutes()).padStart(2, "0")
37
+ const s = String(date.getSeconds()).padStart(2, "0")
38
+ return `${y}-${mo}-${d} ${h}:${mi}:${s}`
39
+ }
40
+
41
+ /** Format a timestamp with a simple %H:%M:%S template. */
42
+ export function formatTimestamp(date: Date, format: string): string {
43
+ const h = String(date.getHours()).padStart(2, "0")
44
+ const m = String(date.getMinutes()).padStart(2, "0")
45
+ const s = String(date.getSeconds()).padStart(2, "0")
46
+ return format.replace("%H", h).replace("%M", m).replace("%S", s)
47
+ }
48
+
49
+ /** Reconstruct a displayable mode string from irc-framework mode event. */
50
+ export function buildModeString(event: ModeEvent): string {
51
+ if (event.raw_modes) {
52
+ const params = event.raw_params ?? []
53
+ return event.raw_modes + (params.length > 0 ? " " + params.join(" ") : "")
54
+ }
55
+ if (!Array.isArray(event.modes)) return String(event.modes ?? "")
56
+
57
+ let modeChars = ""
58
+ const params: string[] = []
59
+ let lastSign = ""
60
+
61
+ for (const m of event.modes) {
62
+ const sign = m.mode[0]
63
+ const char = m.mode.slice(1)
64
+ if (sign !== lastSign) {
65
+ modeChars += sign
66
+ lastSign = sign
67
+ }
68
+ modeChars += char
69
+ if (m.param) params.push(m.param)
70
+ }
71
+
72
+ return modeChars + (params.length > 0 ? " " + params.join(" ") : "")
73
+ }
74
+
75
+ /**
76
+ * Build a map from mode char → prefix symbol using ISUPPORT PREFIX.
77
+ * e.g., "(ov)@+" → { o: "@", v: "+" }
78
+ * Also maps prefix symbols to themselves so both formats work.
79
+ */
80
+ export function buildPrefixMap(isupportPrefix: unknown): Record<string, string> {
81
+ const map: Record<string, string> = {}
82
+ if (typeof isupportPrefix !== "string") {
83
+ // Default fallback mapping
84
+ map["o"] = "@"; map["v"] = "+"; map["h"] = "%"
85
+ map["a"] = "&"; map["q"] = "~"
86
+ map["@"] = "@"; map["+"] = "+"; map["%"] = "%"
87
+ map["&"] = "&"; map["~"] = "~"
88
+ return map
89
+ }
90
+ const match = isupportPrefix.match(/^\(([^)]+)\)(.+)$/)
91
+ if (!match) return map
92
+ const modes = match[1]
93
+ const prefixes = match[2]
94
+ for (let i = 0; i < modes.length && i < prefixes.length; i++) {
95
+ map[modes[i]] = prefixes[i]
96
+ map[prefixes[i]] = prefixes[i] // identity mapping for prefix symbols
97
+ }
98
+ return map
99
+ }
100
+
101
+ /** Get the prefix mode character for a nick in a buffer. */
102
+ export function getNickMode(buffers: Map<string, Buffer>, bufferId: string, nick: string): string {
103
+ const buf = buffers.get(bufferId)
104
+ return buf?.users.get(nick)?.prefix ?? ""
105
+ }
106
+
107
+ /**
108
+ * Extract mode character ordering from ISUPPORT PREFIX (highest rank first).
109
+ * e.g. "(qaohv)~&@%+" → "qaohv"
110
+ */
111
+ export function buildModeOrder(isupportPrefix: unknown): string {
112
+ if (typeof isupportPrefix === "string") {
113
+ const match = isupportPrefix.match(/^\(([^)]+)\)/)
114
+ if (match) return match[1]
115
+ }
116
+ return "qaohv" // default rank order
117
+ }
118
+
119
+ /**
120
+ * Compute the displayed prefix from a set of raw mode chars.
121
+ * Returns the prefix symbol of the highest-ranked mode the user holds.
122
+ */
123
+ export function getHighestPrefix(
124
+ modes: string,
125
+ modeOrder: string,
126
+ prefixMap: Record<string, string>,
127
+ ): string {
128
+ for (const ch of modeOrder) {
129
+ if (modes.includes(ch)) return prefixMap[ch] ?? ""
130
+ }
131
+ return ""
132
+ }
@@ -0,0 +1,84 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import type { IgnoreLevel, IgnoreEntry } from "@/types/config"
3
+ import type { Client } from "kofany-irc-framework"
4
+
5
+ // ─── Wildcard matching ───────────────────────────────────────
6
+
7
+ /** Convert a simple wildcard pattern (*, ?) to a RegExp. */
8
+ function wildcardToRegex(pattern: string): RegExp {
9
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
10
+ const withWildcards = escaped.replace(/\*/g, ".*").replace(/\?/g, ".")
11
+ return new RegExp(`^${withWildcards}$`, "i")
12
+ }
13
+
14
+ // ─── Public helpers ──────────────────────────────────────────
15
+
16
+ /** Build a nick!user@host mask from event data. */
17
+ export function buildMask(nick: string, ident?: string, hostname?: string): string {
18
+ return `${nick}!${ident || "*"}@${hostname || "*"}`
19
+ }
20
+
21
+ /** Check if an event from this user/level/channel should be ignored. */
22
+ export function shouldIgnore(
23
+ nick: string,
24
+ ident: string | undefined,
25
+ hostname: string | undefined,
26
+ level: IgnoreLevel,
27
+ channel?: string,
28
+ ): boolean {
29
+ const ignores = useStore.getState().config?.ignores
30
+ if (!ignores?.length) return false
31
+
32
+ const fullMask = buildMask(nick, ident, hostname)
33
+
34
+ for (const entry of ignores) {
35
+ // Level check
36
+ if (!entry.levels.includes("ALL") && !entry.levels.includes(level)) continue
37
+
38
+ // Pattern check: bare nick vs full mask
39
+ if (entry.mask.includes("!")) {
40
+ if (!wildcardToRegex(entry.mask).test(fullMask)) continue
41
+ } else {
42
+ if (!wildcardToRegex(entry.mask).test(nick)) continue
43
+ }
44
+
45
+ // Channel restriction
46
+ if (entry.channels?.length) {
47
+ if (!channel || !entry.channels.some((ch) => ch.toLowerCase() === channel.toLowerCase())) continue
48
+ }
49
+
50
+ return true
51
+ }
52
+
53
+ return false
54
+ }
55
+
56
+ // ─── Parsed middleware (privmsg, action, notice, ctcp) ───────
57
+
58
+ function isChannel(target: string): boolean {
59
+ return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
60
+ }
61
+
62
+ export function createIgnoreMiddleware() {
63
+ return function middlewareInstaller(_client: Client, _rawEvents: any, parsedEvents: any) {
64
+ parsedEvents.use(function ignoreHandler(command: string, event: any, _c: Client, next: () => void) {
65
+ const nick: string = event.nick || ""
66
+ const ident: string = event.ident || ""
67
+ const hostname: string = event.hostname || ""
68
+ const target: string = event.target || ""
69
+
70
+ if (command === "privmsg") {
71
+ const level: IgnoreLevel = isChannel(target) ? "PUBLIC" : "MSGS"
72
+ if (shouldIgnore(nick, ident, hostname, level, isChannel(target) ? target : undefined)) return
73
+ } else if (command === "action") {
74
+ if (shouldIgnore(nick, ident, hostname, "ACTIONS", isChannel(target) ? target : undefined)) return
75
+ } else if (command === "notice") {
76
+ if (shouldIgnore(nick, ident, hostname, "NOTICES", isChannel(target) ? target : undefined)) return
77
+ } else if (command === "ctcp request" || command === "ctcp response") {
78
+ if (shouldIgnore(nick, ident, hostname, "CTCPS")) return
79
+ }
80
+
81
+ next()
82
+ })
83
+ }
84
+ }
@@ -0,0 +1,2 @@
1
+ export { connectServer, disconnectServer, getClient, getAllClientIds, connectAllAutoconnect } from "./client"
2
+ export { formatDuration, formatDate, formatTimestamp, buildModeString, buildPrefixMap, getNickMode, stripIrcFormatting } from "./formatting"
@@ -0,0 +1,292 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import { makeBufferId } from "@/types"
3
+ import type { Message } from "@/types"
4
+
5
+ // ─── Types ───────────────────────────────────────────────────
6
+
7
+ interface SplitRecord {
8
+ nick: string
9
+ channels: string[] // buffer IDs the user was in
10
+ }
11
+
12
+ interface SplitGroup {
13
+ server1: string
14
+ server2: string
15
+ nicks: SplitRecord[]
16
+ lastQuit: number
17
+ printed: boolean
18
+ }
19
+
20
+ interface NetjoinGroup {
21
+ server1: string
22
+ server2: string
23
+ nicks: string[]
24
+ channels: Set<string> // buffer IDs
25
+ lastJoin: number
26
+ printed: boolean
27
+ }
28
+
29
+ // ─── Constants ───────────────────────────────────────────────
30
+
31
+ const SPLIT_BATCH_WAIT = 5_000 // 5s — collect quits before printing
32
+ const NETJOIN_BATCH_WAIT = 5_000 // 5s — collect joins before printing
33
+ const SPLIT_EXPIRE = 3600_000 // 1 hour — forget split records
34
+ const MAX_NICKS_DISPLAY = 15 // truncate nick list after this
35
+
36
+ // ─── Per-connection state ────────────────────────────────────
37
+
38
+ const splitState = new Map<string, {
39
+ groups: SplitGroup[]
40
+ /** nick → SplitGroup mapping for fast netjoin lookup */
41
+ nickIndex: Map<string, SplitGroup>
42
+ netjoins: NetjoinGroup[]
43
+ timer: ReturnType<typeof setInterval> | null
44
+ }>()
45
+
46
+ function getState(connId: string) {
47
+ let s = splitState.get(connId)
48
+ if (!s) {
49
+ s = { groups: [], nickIndex: new Map(), netjoins: [], timer: null }
50
+ splitState.set(connId, s)
51
+ }
52
+ if (!s.timer) {
53
+ s.timer = setInterval(() => tick(connId), 1_000)
54
+ }
55
+ return s
56
+ }
57
+
58
+ /** Clean up when disconnecting. */
59
+ export function destroyNetsplitState(connId: string) {
60
+ const s = splitState.get(connId)
61
+ if (s?.timer) clearInterval(s.timer)
62
+ splitState.delete(connId)
63
+ }
64
+
65
+ // ─── Detection ───────────────────────────────────────────────
66
+
67
+ /**
68
+ * Check if a QUIT message looks like a netsplit.
69
+ * Format: "host1.domain host2.domain" — two valid hostnames separated by a single space.
70
+ */
71
+ export function isNetsplitQuit(message: string): boolean {
72
+ if (!message) return false
73
+ // Must not contain : or / (avoids URLs and other messages)
74
+ if (message.includes(":") || message.includes("/")) return false
75
+
76
+ const space = message.indexOf(" ")
77
+ if (space <= 0 || space === message.length - 1) return false
78
+ // Only one space
79
+ if (message.indexOf(" ", space + 1) !== -1) return false
80
+
81
+ const host1 = message.slice(0, space)
82
+ const host2 = message.slice(space + 1)
83
+
84
+ return isValidSplitHost(host1) && isValidSplitHost(host2) && host1 !== host2
85
+ }
86
+
87
+ function isValidSplitHost(host: string): boolean {
88
+ if (host.length < 3) return false
89
+ if (host.startsWith(".") || host.endsWith(".")) return false
90
+ if (host.includes("..")) return false
91
+
92
+ const dot = host.lastIndexOf(".")
93
+ if (dot <= 0) return false
94
+
95
+ const tld = host.slice(dot + 1)
96
+ if (tld.length < 2) return false
97
+ if (!/^[a-zA-Z]+$/.test(tld)) return false
98
+
99
+ return true
100
+ }
101
+
102
+ // ─── Quit handling ───────────────────────────────────────────
103
+
104
+ /**
105
+ * Process a QUIT that looks like a netsplit.
106
+ * Returns true if it was handled (suppress normal quit display).
107
+ */
108
+ export function handleNetsplitQuit(
109
+ connId: string,
110
+ nick: string,
111
+ message: string,
112
+ affectedBufferIds: string[],
113
+ ): boolean {
114
+ if (!isNetsplitQuit(message)) return false
115
+
116
+ const space = message.indexOf(" ")
117
+ const server1 = message.slice(0, space)
118
+ const server2 = message.slice(space + 1)
119
+ const state = getState(connId)
120
+ const now = Date.now()
121
+
122
+ // Find existing group for this server pair
123
+ let group = state.groups.find(
124
+ (g) => g.server1 === server1 && g.server2 === server2 && !g.printed,
125
+ )
126
+
127
+ if (!group) {
128
+ group = { server1, server2, nicks: [], lastQuit: now, printed: false }
129
+ state.groups.push(group)
130
+ }
131
+
132
+ group.nicks.push({ nick, channels: affectedBufferIds })
133
+ group.lastQuit = now
134
+ state.nickIndex.set(nick, group)
135
+
136
+ return true
137
+ }
138
+
139
+ // ─── Join handling (netjoin) ─────────────────────────────────
140
+
141
+ /**
142
+ * Check if a JOIN is from a user who was in a netsplit.
143
+ * Returns true if it was handled (suppress normal join display).
144
+ */
145
+ export function handleNetsplitJoin(
146
+ connId: string,
147
+ nick: string,
148
+ bufferId: string,
149
+ ): boolean {
150
+ const state = splitState.get(connId)
151
+ if (!state) return false
152
+
153
+ const splitGroup = state.nickIndex.get(nick)
154
+ if (!splitGroup) return false
155
+
156
+ // This nick was in a netsplit — batch the rejoin
157
+ const now = Date.now()
158
+ const key = `${splitGroup.server1} ${splitGroup.server2}`
159
+
160
+ let njGroup = state.netjoins.find(
161
+ (g) => g.server1 === splitGroup.server1 && g.server2 === splitGroup.server2 && !g.printed,
162
+ )
163
+
164
+ if (!njGroup) {
165
+ njGroup = {
166
+ server1: splitGroup.server1,
167
+ server2: splitGroup.server2,
168
+ nicks: [],
169
+ channels: new Set(),
170
+ lastJoin: now,
171
+ printed: false,
172
+ }
173
+ state.netjoins.push(njGroup)
174
+ }
175
+
176
+ if (!njGroup.nicks.includes(nick)) {
177
+ njGroup.nicks.push(nick)
178
+ }
179
+ njGroup.channels.add(bufferId)
180
+ njGroup.lastJoin = now
181
+
182
+ // Remove from split index
183
+ state.nickIndex.delete(nick)
184
+
185
+ return true
186
+ }
187
+
188
+ // ─── Tick — check for batches to print ───────────────────────
189
+
190
+ function tick(connId: string) {
191
+ const state = splitState.get(connId)
192
+ if (!state) return
193
+ const now = Date.now()
194
+
195
+ // Print split groups that have been quiet for SPLIT_BATCH_WAIT
196
+ for (const group of state.groups) {
197
+ if (!group.printed && now - group.lastQuit >= SPLIT_BATCH_WAIT) {
198
+ printSplitGroup(connId, group)
199
+ group.printed = true
200
+ }
201
+ }
202
+
203
+ // Print netjoin groups that have been quiet for NETJOIN_BATCH_WAIT
204
+ for (const nj of state.netjoins) {
205
+ if (!nj.printed && now - nj.lastJoin >= NETJOIN_BATCH_WAIT) {
206
+ printNetjoinGroup(connId, nj)
207
+ nj.printed = true
208
+ }
209
+ }
210
+
211
+ // Expire old split records
212
+ state.groups = state.groups.filter((g) => now - g.lastQuit < SPLIT_EXPIRE)
213
+ state.netjoins = state.netjoins.filter((nj) => now - nj.lastJoin < SPLIT_EXPIRE)
214
+
215
+ // Clean up nick index for expired groups
216
+ for (const [nick, group] of state.nickIndex) {
217
+ if (now - group.lastQuit >= SPLIT_EXPIRE) {
218
+ state.nickIndex.delete(nick)
219
+ }
220
+ }
221
+
222
+ // If nothing left, stop the timer
223
+ if (state.groups.length === 0 && state.netjoins.length === 0 && state.nickIndex.size === 0) {
224
+ if (state.timer) {
225
+ clearInterval(state.timer)
226
+ state.timer = null
227
+ }
228
+ }
229
+ }
230
+
231
+ // ─── Printing ────────────────────────────────────────────────
232
+
233
+ function makeEventMessage(text: string): Message {
234
+ return {
235
+ id: crypto.randomUUID(),
236
+ timestamp: new Date(),
237
+ type: "event",
238
+ text,
239
+ highlight: false,
240
+ }
241
+ }
242
+
243
+ function printSplitGroup(connId: string, group: SplitGroup) {
244
+ const store = useStore.getState()
245
+
246
+ // Collect all affected buffer IDs
247
+ const allBufferIds = new Set<string>()
248
+ for (const rec of group.nicks) {
249
+ for (const id of rec.channels) allBufferIds.add(id)
250
+ }
251
+
252
+ // Build nick list with truncation
253
+ const nickNames = group.nicks.map((r) => r.nick)
254
+ let nickStr: string
255
+ if (nickNames.length > MAX_NICKS_DISPLAY) {
256
+ const shown = nickNames.slice(0, MAX_NICKS_DISPLAY).join(", ")
257
+ const more = nickNames.length - MAX_NICKS_DISPLAY
258
+ nickStr = `${shown} %Z565f89(+${more} more)%N`
259
+ } else {
260
+ nickStr = nickNames.join(", ")
261
+ }
262
+
263
+ const msg = `%Zf7768eNetsplit%N %Za9b1d6${group.server1}%N %Z565f89\u21C4%N %Za9b1d6${group.server2}%N %Z565f89quits:%N %Ze0af68${nickStr}%N`
264
+
265
+ // Show in all affected channels
266
+ for (const bufferId of allBufferIds) {
267
+ if (store.buffers.has(bufferId)) {
268
+ store.addMessage(bufferId, makeEventMessage(msg))
269
+ }
270
+ }
271
+ }
272
+
273
+ function printNetjoinGroup(connId: string, group: NetjoinGroup) {
274
+ const store = useStore.getState()
275
+
276
+ let nickStr: string
277
+ if (group.nicks.length > MAX_NICKS_DISPLAY) {
278
+ const shown = group.nicks.slice(0, MAX_NICKS_DISPLAY).join(", ")
279
+ const more = group.nicks.length - MAX_NICKS_DISPLAY
280
+ nickStr = `${shown} %Z565f89(+${more} more)%N`
281
+ } else {
282
+ nickStr = group.nicks.join(", ")
283
+ }
284
+
285
+ const msg = `%Z9ece6aNetsplit over%N %Za9b1d6${group.server1}%N %Z565f89\u21C4%N %Za9b1d6${group.server2}%N %Z565f89joins:%N %Ze0af68${nickStr}%N`
286
+
287
+ for (const bufferId of group.channels) {
288
+ if (store.buffers.has(bufferId)) {
289
+ store.addMessage(bufferId, makeEventMessage(msg))
290
+ }
291
+ }
292
+ }