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.
- package/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- 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,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
|
+
}
|