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,256 @@
|
|
|
1
|
+
import type { Connection, Buffer, Message } from "@/types"
|
|
2
|
+
import type { AppConfig } from "@/types/config"
|
|
3
|
+
import type { Client } from "kofany-irc-framework"
|
|
4
|
+
|
|
5
|
+
// ─── Event System ────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export enum EventPriority {
|
|
8
|
+
HIGHEST = 100,
|
|
9
|
+
HIGH = 75,
|
|
10
|
+
NORMAL = 50,
|
|
11
|
+
LOW = 25,
|
|
12
|
+
LOWEST = 0,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EventContext {
|
|
16
|
+
/** Prevent lower-priority handlers and built-in store update from running.
|
|
17
|
+
* Must be called synchronously (before any await). */
|
|
18
|
+
stop(): void
|
|
19
|
+
stopped: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type EventHandler = (data: any, ctx: EventContext) => void | Promise<void>
|
|
23
|
+
|
|
24
|
+
export interface EventRegistration {
|
|
25
|
+
event: string
|
|
26
|
+
handler: EventHandler
|
|
27
|
+
priority: number
|
|
28
|
+
once: boolean
|
|
29
|
+
owner: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Script Module ───────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface ScriptMeta {
|
|
35
|
+
name: string
|
|
36
|
+
version?: string
|
|
37
|
+
description?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScriptModule {
|
|
41
|
+
meta?: ScriptMeta
|
|
42
|
+
config?: Record<string, any>
|
|
43
|
+
default: (api: KokoAPI) => void | (() => void)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Commands ────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface ScriptCommandDef {
|
|
49
|
+
handler: (args: string[], connectionId: string) => void
|
|
50
|
+
description: string
|
|
51
|
+
usage?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Timers ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface TimerHandle {
|
|
57
|
+
clear(): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Store Access (read-only) ────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface StoreAccess {
|
|
63
|
+
getConnections(): Map<string, Connection>
|
|
64
|
+
getBuffers(): Map<string, Buffer>
|
|
65
|
+
getActiveBufferId(): string | null
|
|
66
|
+
getConfig(): AppConfig | null
|
|
67
|
+
getConnection(id: string): Connection | undefined
|
|
68
|
+
getBuffer(id: string): Buffer | undefined
|
|
69
|
+
subscribe(listener: () => void): () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── IRC Access ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface IrcAccess {
|
|
75
|
+
say(target: string, message: string, connectionId?: string): void
|
|
76
|
+
action(target: string, message: string, connectionId?: string): void
|
|
77
|
+
notice(target: string, message: string, connectionId?: string): void
|
|
78
|
+
join(channel: string, key?: string, connectionId?: string): void
|
|
79
|
+
part(channel: string, message?: string, connectionId?: string): void
|
|
80
|
+
raw(line: string, connectionId?: string): void
|
|
81
|
+
changeNick(nick: string, connectionId?: string): void
|
|
82
|
+
whois(nick: string, connectionId?: string): void
|
|
83
|
+
getClient(connectionId?: string): Client | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── UI Access ───────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface UiAccess {
|
|
89
|
+
addLocalEvent(text: string): void
|
|
90
|
+
addMessage(bufferId: string, message: Omit<Message, "id" | "timestamp">): void
|
|
91
|
+
switchBuffer(bufferId: string): void
|
|
92
|
+
makeBufferId(connectionId: string, name: string): string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Config Access ───────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface ScriptConfigAccess {
|
|
98
|
+
get<T = any>(key: string, defaultValue: T): T
|
|
99
|
+
set(key: string, value: any): void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── KokoAPI ─────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export interface KokoAPI {
|
|
105
|
+
meta: ScriptMeta
|
|
106
|
+
|
|
107
|
+
// Events
|
|
108
|
+
on(event: string, handler: EventHandler, priority?: number): () => void
|
|
109
|
+
once(event: string, handler: EventHandler, priority?: number): () => void
|
|
110
|
+
emit(event: string, data?: any): boolean
|
|
111
|
+
|
|
112
|
+
// Commands
|
|
113
|
+
command(name: string, def: ScriptCommandDef): void
|
|
114
|
+
removeCommand(name: string): void
|
|
115
|
+
|
|
116
|
+
// Timers
|
|
117
|
+
timer(ms: number, handler: () => void): TimerHandle
|
|
118
|
+
timeout(ms: number, handler: () => void): TimerHandle
|
|
119
|
+
|
|
120
|
+
// Access
|
|
121
|
+
store: StoreAccess
|
|
122
|
+
irc: IrcAccess
|
|
123
|
+
ui: UiAccess
|
|
124
|
+
config: ScriptConfigAccess
|
|
125
|
+
|
|
126
|
+
/** Event priority constants — use instead of importing EventPriority */
|
|
127
|
+
EventPriority: typeof EventPriority
|
|
128
|
+
|
|
129
|
+
log(...args: any[]): void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Event Payloads ──────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export interface IrcMessageEvent {
|
|
135
|
+
connectionId: string
|
|
136
|
+
nick: string
|
|
137
|
+
ident?: string
|
|
138
|
+
hostname?: string
|
|
139
|
+
target: string
|
|
140
|
+
message: string
|
|
141
|
+
tags?: Record<string, string>
|
|
142
|
+
time?: string
|
|
143
|
+
isChannel: boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface IrcJoinEvent {
|
|
147
|
+
connectionId: string
|
|
148
|
+
nick: string
|
|
149
|
+
ident?: string
|
|
150
|
+
hostname?: string
|
|
151
|
+
channel: string
|
|
152
|
+
account?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface IrcPartEvent {
|
|
156
|
+
connectionId: string
|
|
157
|
+
nick: string
|
|
158
|
+
ident?: string
|
|
159
|
+
hostname?: string
|
|
160
|
+
channel: string
|
|
161
|
+
message?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface IrcQuitEvent {
|
|
165
|
+
connectionId: string
|
|
166
|
+
nick: string
|
|
167
|
+
ident?: string
|
|
168
|
+
hostname?: string
|
|
169
|
+
message?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface IrcKickEvent {
|
|
173
|
+
connectionId: string
|
|
174
|
+
nick: string
|
|
175
|
+
ident?: string
|
|
176
|
+
hostname?: string
|
|
177
|
+
channel: string
|
|
178
|
+
kicked: string
|
|
179
|
+
message?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface IrcNickEvent {
|
|
183
|
+
connectionId: string
|
|
184
|
+
nick: string
|
|
185
|
+
new_nick: string
|
|
186
|
+
ident?: string
|
|
187
|
+
hostname?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface IrcTopicEvent {
|
|
191
|
+
connectionId: string
|
|
192
|
+
nick?: string
|
|
193
|
+
channel: string
|
|
194
|
+
topic: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface IrcModeEvent {
|
|
198
|
+
connectionId: string
|
|
199
|
+
nick?: string
|
|
200
|
+
target: string
|
|
201
|
+
modes: Array<{ mode: string; param?: string }>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface IrcInviteEvent {
|
|
205
|
+
connectionId: string
|
|
206
|
+
nick: string
|
|
207
|
+
channel: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface IrcNoticeEvent {
|
|
211
|
+
connectionId: string
|
|
212
|
+
nick?: string
|
|
213
|
+
target?: string
|
|
214
|
+
message: string
|
|
215
|
+
from_server?: boolean
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface IrcCtcpEvent {
|
|
219
|
+
connectionId: string
|
|
220
|
+
nick: string
|
|
221
|
+
type: string
|
|
222
|
+
message?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface IrcWallopsEvent {
|
|
226
|
+
connectionId: string
|
|
227
|
+
nick?: string
|
|
228
|
+
message: string
|
|
229
|
+
from_server?: boolean
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// App events
|
|
233
|
+
export interface MessageAddEvent {
|
|
234
|
+
bufferId: string
|
|
235
|
+
message: Message
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface BufferSwitchEvent {
|
|
239
|
+
from: string | null
|
|
240
|
+
to: string
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface CommandInputEvent {
|
|
244
|
+
command: string
|
|
245
|
+
args: string[]
|
|
246
|
+
connectionId: string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ConnectedEvent {
|
|
250
|
+
connectionId: string
|
|
251
|
+
nick: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface DisconnectedEvent {
|
|
255
|
+
connectionId: string
|
|
256
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { useStore } from "./store"
|
|
3
|
+
import { sortBuffers, sortNicks } from "./sorting"
|
|
4
|
+
import type { Buffer, NickEntry } from "@/types"
|
|
5
|
+
|
|
6
|
+
export function useActiveBuffer(): Buffer | null {
|
|
7
|
+
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
8
|
+
const buffersMap = useStore((s) => s.buffers)
|
|
9
|
+
return activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useSortedBuffers(): Array<Buffer & { connectionLabel: string }> {
|
|
13
|
+
const buffersMap = useStore((s) => s.buffers)
|
|
14
|
+
const connectionsMap = useStore((s) => s.connections)
|
|
15
|
+
return useMemo(() => {
|
|
16
|
+
const list = Array.from(buffersMap.values())
|
|
17
|
+
.filter((buf) => buf.connectionId !== "_default")
|
|
18
|
+
.map((buf) => ({
|
|
19
|
+
...buf,
|
|
20
|
+
connectionLabel: connectionsMap.get(buf.connectionId)?.label ?? buf.connectionId,
|
|
21
|
+
}))
|
|
22
|
+
return sortBuffers(list)
|
|
23
|
+
}, [buffersMap, connectionsMap])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMPTY_NICKS: NickEntry[] = []
|
|
27
|
+
|
|
28
|
+
export function useSortedNicks(bufferId: string, prefixOrder: string): NickEntry[] {
|
|
29
|
+
const buffersMap = useStore((s) => s.buffers)
|
|
30
|
+
const buffer = buffersMap.get(bufferId)
|
|
31
|
+
return useMemo(() => {
|
|
32
|
+
if (!buffer) return EMPTY_NICKS
|
|
33
|
+
return sortNicks(Array.from(buffer.users.values()), prefixOrder)
|
|
34
|
+
}, [buffer, prefixOrder])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useConnection(id: string) {
|
|
38
|
+
return useStore((s) => s.connections.get(id))
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Buffer, NickEntry } from "@/types"
|
|
2
|
+
import { getSortGroup } from "@/types"
|
|
3
|
+
|
|
4
|
+
interface SortableBuffer {
|
|
5
|
+
connectionLabel: string
|
|
6
|
+
type: Buffer["type"]
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function sortBuffers<T extends SortableBuffer>(buffers: T[]): T[] {
|
|
11
|
+
return [...buffers].sort((a, b) => {
|
|
12
|
+
const labelCmp = a.connectionLabel.localeCompare(b.connectionLabel, undefined, { sensitivity: "base" })
|
|
13
|
+
if (labelCmp !== 0) return labelCmp
|
|
14
|
+
const groupA = getSortGroup(a.type)
|
|
15
|
+
const groupB = getSortGroup(b.type)
|
|
16
|
+
if (groupA !== groupB) return groupA - groupB
|
|
17
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sortNicks(nicks: NickEntry[], prefixOrder: string): NickEntry[] {
|
|
22
|
+
return [...nicks].sort((a, b) => {
|
|
23
|
+
const prefixA = a.prefix ? prefixOrder.indexOf(a.prefix) : prefixOrder.length
|
|
24
|
+
const prefixB = b.prefix ? prefixOrder.indexOf(b.prefix) : prefixOrder.length
|
|
25
|
+
const pA = prefixA === -1 ? prefixOrder.length : prefixA
|
|
26
|
+
const pB = prefixB === -1 ? prefixOrder.length : prefixB
|
|
27
|
+
if (pA !== pB) return pA - pB
|
|
28
|
+
return a.nick.localeCompare(b.nick, undefined, { sensitivity: "base" })
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { create } from "zustand"
|
|
2
|
+
import type { Connection, Buffer, Message, NickEntry, ActivityLevel, ListEntry, ListModeKey } from "@/types"
|
|
3
|
+
import type { AppConfig } from "@/types/config"
|
|
4
|
+
import type { ThemeFile } from "@/types/theme"
|
|
5
|
+
import { logMessage, updateReadMarker } from "@/core/storage"
|
|
6
|
+
|
|
7
|
+
interface AppState {
|
|
8
|
+
// Data
|
|
9
|
+
connections: Map<string, Connection>
|
|
10
|
+
buffers: Map<string, Buffer>
|
|
11
|
+
activeBufferId: string | null
|
|
12
|
+
previousActiveBufferId: string | null
|
|
13
|
+
config: AppConfig | null
|
|
14
|
+
theme: ThemeFile | null
|
|
15
|
+
|
|
16
|
+
// Connection actions
|
|
17
|
+
addConnection: (conn: Connection) => void
|
|
18
|
+
updateConnection: (id: string, updates: Partial<Connection>) => void
|
|
19
|
+
removeConnection: (id: string) => void
|
|
20
|
+
|
|
21
|
+
// Buffer actions
|
|
22
|
+
addBuffer: (buffer: Buffer) => void
|
|
23
|
+
removeBuffer: (id: string) => void
|
|
24
|
+
setActiveBuffer: (id: string) => void
|
|
25
|
+
updateBufferActivity: (id: string, level: ActivityLevel) => void
|
|
26
|
+
|
|
27
|
+
// Message actions
|
|
28
|
+
addMessage: (bufferId: string, message: Message) => void
|
|
29
|
+
|
|
30
|
+
// Nicklist actions
|
|
31
|
+
addNick: (bufferId: string, entry: NickEntry) => void
|
|
32
|
+
removeNick: (bufferId: string, nick: string) => void
|
|
33
|
+
updateNick: (bufferId: string, oldNick: string, newNick: string, prefix?: string) => void
|
|
34
|
+
|
|
35
|
+
// Buffer topic & modes
|
|
36
|
+
updateBufferTopic: (bufferId: string, topic: string, setBy?: string) => void
|
|
37
|
+
updateBufferModes: (bufferId: string, modes: string, modeParams?: Record<string, string>) => void
|
|
38
|
+
|
|
39
|
+
// List modes (bans, exceptions, invex, reop)
|
|
40
|
+
setListEntries: (bufferId: string, modeChar: ListModeKey, entries: ListEntry[]) => void
|
|
41
|
+
addListEntry: (bufferId: string, modeChar: ListModeKey, entry: ListEntry) => void
|
|
42
|
+
removeListEntry: (bufferId: string, modeChar: ListModeKey, mask: string) => void
|
|
43
|
+
|
|
44
|
+
// Config/Theme
|
|
45
|
+
setConfig: (config: AppConfig) => void
|
|
46
|
+
setTheme: (theme: ThemeFile) => void
|
|
47
|
+
|
|
48
|
+
// App lifecycle
|
|
49
|
+
shutdownHandler: (() => void) | null
|
|
50
|
+
setShutdownHandler: (handler: () => void) => void
|
|
51
|
+
requestShutdown: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const useStore = create<AppState>((set, get) => ({
|
|
55
|
+
connections: new Map(),
|
|
56
|
+
buffers: new Map(),
|
|
57
|
+
activeBufferId: null,
|
|
58
|
+
previousActiveBufferId: null,
|
|
59
|
+
config: null,
|
|
60
|
+
theme: null,
|
|
61
|
+
|
|
62
|
+
addConnection: (conn) => set((s) => {
|
|
63
|
+
const connections = new Map(s.connections)
|
|
64
|
+
connections.set(conn.id, conn)
|
|
65
|
+
return { connections }
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
updateConnection: (id, updates) => set((s) => {
|
|
69
|
+
const connections = new Map(s.connections)
|
|
70
|
+
const existing = connections.get(id)
|
|
71
|
+
if (existing) connections.set(id, { ...existing, ...updates })
|
|
72
|
+
return { connections }
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
removeConnection: (id) => set((s) => {
|
|
76
|
+
const connections = new Map(s.connections)
|
|
77
|
+
connections.delete(id)
|
|
78
|
+
return { connections }
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
addBuffer: (buffer) => set((s) => {
|
|
82
|
+
const buffers = new Map(s.buffers)
|
|
83
|
+
buffers.set(buffer.id, buffer)
|
|
84
|
+
return { buffers }
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
removeBuffer: (id) => set((s) => {
|
|
88
|
+
const buffers = new Map(s.buffers)
|
|
89
|
+
buffers.delete(id)
|
|
90
|
+
if (s.activeBufferId !== id) return { buffers }
|
|
91
|
+
// Fall back to previous buffer if it still exists, otherwise null
|
|
92
|
+
const fallback = s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
|
|
93
|
+
? s.previousActiveBufferId : null
|
|
94
|
+
return { buffers, activeBufferId: fallback }
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
setActiveBuffer: (id) => {
|
|
98
|
+
// Persist read marker for TUI client
|
|
99
|
+
const slashIdx = id.indexOf("/")
|
|
100
|
+
if (slashIdx > 0) {
|
|
101
|
+
const network = id.slice(0, slashIdx)
|
|
102
|
+
const buffer = id.slice(slashIdx + 1)
|
|
103
|
+
updateReadMarker(network, buffer, "tui", Date.now())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return set((s) => {
|
|
107
|
+
// Reset activity when switching to buffer
|
|
108
|
+
const buffers = new Map(s.buffers)
|
|
109
|
+
const buf = buffers.get(id)
|
|
110
|
+
if (buf) {
|
|
111
|
+
buffers.set(id, { ...buf, activity: 0, unreadCount: 0, lastRead: new Date() })
|
|
112
|
+
}
|
|
113
|
+
const previousActiveBufferId = s.activeBufferId !== id ? s.activeBufferId : s.previousActiveBufferId
|
|
114
|
+
return { activeBufferId: id, previousActiveBufferId, buffers }
|
|
115
|
+
})
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
updateBufferActivity: (id, level) => set((s) => {
|
|
119
|
+
const buffers = new Map(s.buffers)
|
|
120
|
+
const buf = buffers.get(id)
|
|
121
|
+
if (buf && level > buf.activity) {
|
|
122
|
+
buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
|
|
123
|
+
}
|
|
124
|
+
return { buffers }
|
|
125
|
+
}),
|
|
126
|
+
|
|
127
|
+
addMessage: (bufferId, message) => {
|
|
128
|
+
// Log to persistent storage (fire-and-forget, outside Zustand set)
|
|
129
|
+
const slashIdx = bufferId.indexOf("/")
|
|
130
|
+
if (slashIdx > 0) {
|
|
131
|
+
const network = bufferId.slice(0, slashIdx)
|
|
132
|
+
const buffer = bufferId.slice(slashIdx + 1)
|
|
133
|
+
logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return set((s) => {
|
|
137
|
+
const buffers = new Map(s.buffers)
|
|
138
|
+
const buf = buffers.get(bufferId)
|
|
139
|
+
if (!buf) return s
|
|
140
|
+
const maxLines = s.config?.display.scrollback_lines ?? 2000
|
|
141
|
+
const messages = [...buf.messages, message]
|
|
142
|
+
if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
|
|
143
|
+
buffers.set(bufferId, { ...buf, messages })
|
|
144
|
+
return { buffers }
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
addNick: (bufferId, entry) => set((s) => {
|
|
149
|
+
const buffers = new Map(s.buffers)
|
|
150
|
+
const buf = buffers.get(bufferId)
|
|
151
|
+
if (!buf) return s
|
|
152
|
+
const users = new Map(buf.users)
|
|
153
|
+
users.set(entry.nick, entry)
|
|
154
|
+
buffers.set(bufferId, { ...buf, users })
|
|
155
|
+
return { buffers }
|
|
156
|
+
}),
|
|
157
|
+
|
|
158
|
+
removeNick: (bufferId, nick) => set((s) => {
|
|
159
|
+
const buffers = new Map(s.buffers)
|
|
160
|
+
const buf = buffers.get(bufferId)
|
|
161
|
+
if (!buf) return s
|
|
162
|
+
const users = new Map(buf.users)
|
|
163
|
+
users.delete(nick)
|
|
164
|
+
buffers.set(bufferId, { ...buf, users })
|
|
165
|
+
return { buffers }
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
updateNick: (bufferId, oldNick, newNick, prefix) => set((s) => {
|
|
169
|
+
const buffers = new Map(s.buffers)
|
|
170
|
+
const buf = buffers.get(bufferId)
|
|
171
|
+
if (!buf) return s
|
|
172
|
+
const users = new Map(buf.users)
|
|
173
|
+
const existing = users.get(oldNick)
|
|
174
|
+
if (existing) {
|
|
175
|
+
users.delete(oldNick)
|
|
176
|
+
users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
177
|
+
}
|
|
178
|
+
buffers.set(bufferId, { ...buf, users })
|
|
179
|
+
return { buffers }
|
|
180
|
+
}),
|
|
181
|
+
|
|
182
|
+
updateBufferTopic: (bufferId, topic, setBy) => set((s) => {
|
|
183
|
+
const buffers = new Map(s.buffers)
|
|
184
|
+
const buf = buffers.get(bufferId)
|
|
185
|
+
if (!buf) return s
|
|
186
|
+
buffers.set(bufferId, { ...buf, topic, topicSetBy: setBy })
|
|
187
|
+
return { buffers }
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
updateBufferModes: (bufferId, modes, modeParams) => set((s) => {
|
|
191
|
+
const buffers = new Map(s.buffers)
|
|
192
|
+
const buf = buffers.get(bufferId)
|
|
193
|
+
if (!buf) return s
|
|
194
|
+
buffers.set(bufferId, { ...buf, modes, modeParams: modeParams ?? buf.modeParams })
|
|
195
|
+
return { buffers }
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
setListEntries: (bufferId, modeChar, entries) => set((s) => {
|
|
199
|
+
const buffers = new Map(s.buffers)
|
|
200
|
+
const buf = buffers.get(bufferId)
|
|
201
|
+
if (!buf) return s
|
|
202
|
+
const listModes = new Map(buf.listModes)
|
|
203
|
+
listModes.set(modeChar, entries)
|
|
204
|
+
buffers.set(bufferId, { ...buf, listModes })
|
|
205
|
+
return { buffers }
|
|
206
|
+
}),
|
|
207
|
+
|
|
208
|
+
addListEntry: (bufferId, modeChar, entry) => set((s) => {
|
|
209
|
+
const buffers = new Map(s.buffers)
|
|
210
|
+
const buf = buffers.get(bufferId)
|
|
211
|
+
if (!buf) return s
|
|
212
|
+
const listModes = new Map(buf.listModes)
|
|
213
|
+
const existing = listModes.get(modeChar) ?? []
|
|
214
|
+
// Deduplicate by mask
|
|
215
|
+
if (existing.some((e) => e.mask === entry.mask)) return s
|
|
216
|
+
listModes.set(modeChar, [...existing, entry])
|
|
217
|
+
buffers.set(bufferId, { ...buf, listModes })
|
|
218
|
+
return { buffers }
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
removeListEntry: (bufferId, modeChar, mask) => set((s) => {
|
|
222
|
+
const buffers = new Map(s.buffers)
|
|
223
|
+
const buf = buffers.get(bufferId)
|
|
224
|
+
if (!buf) return s
|
|
225
|
+
const listModes = new Map(buf.listModes)
|
|
226
|
+
const existing = listModes.get(modeChar)
|
|
227
|
+
if (!existing) return s
|
|
228
|
+
listModes.set(modeChar, existing.filter((e) => e.mask !== mask))
|
|
229
|
+
buffers.set(bufferId, { ...buf, listModes })
|
|
230
|
+
return { buffers }
|
|
231
|
+
}),
|
|
232
|
+
|
|
233
|
+
setConfig: (config) => set({ config }),
|
|
234
|
+
setTheme: (theme) => set({ theme }),
|
|
235
|
+
|
|
236
|
+
shutdownHandler: null,
|
|
237
|
+
setShutdownHandler: (handler) => set({ shutdownHandler: handler }),
|
|
238
|
+
requestShutdown: () => {
|
|
239
|
+
const handler = get().shutdownHandler
|
|
240
|
+
if (handler) handler()
|
|
241
|
+
},
|
|
242
|
+
}))
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { chmod } from "node:fs/promises"
|
|
2
|
+
import { ENV_PATH } from "@/core/constants"
|
|
3
|
+
|
|
4
|
+
const ALGORITHM = "AES-GCM"
|
|
5
|
+
const KEY_LENGTH = 256
|
|
6
|
+
const IV_LENGTH = 12
|
|
7
|
+
const ENV_KEY_NAME = "KOKO_LOG_KEY"
|
|
8
|
+
|
|
9
|
+
let cachedKey: CryptoKey | null = null
|
|
10
|
+
|
|
11
|
+
/** Generate a random 256-bit hex key string. */
|
|
12
|
+
export function generateKeyHex(): string {
|
|
13
|
+
const bytes = new Uint8Array(KEY_LENGTH / 8)
|
|
14
|
+
crypto.getRandomValues(bytes)
|
|
15
|
+
return Buffer.from(bytes).toString("hex")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Import a hex key string into a CryptoKey. */
|
|
19
|
+
async function importKey(hexKey: string): Promise<CryptoKey> {
|
|
20
|
+
const raw = Buffer.from(hexKey, "hex")
|
|
21
|
+
return crypto.subtle.importKey("raw", raw, { name: ALGORITHM }, false, ["encrypt", "decrypt"])
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Encrypt plaintext. Returns { ciphertext: base64, iv: 12-byte Uint8Array }. */
|
|
25
|
+
export async function encrypt(
|
|
26
|
+
text: string,
|
|
27
|
+
key: CryptoKey,
|
|
28
|
+
): Promise<{ ciphertext: string; iv: Uint8Array }> {
|
|
29
|
+
const iv = new Uint8Array(IV_LENGTH)
|
|
30
|
+
crypto.getRandomValues(iv)
|
|
31
|
+
const encoded = new TextEncoder().encode(text)
|
|
32
|
+
const encrypted = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded)
|
|
33
|
+
return {
|
|
34
|
+
ciphertext: Buffer.from(encrypted).toString("base64"),
|
|
35
|
+
iv,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Decrypt base64 ciphertext with the given IV. */
|
|
40
|
+
export async function decrypt(
|
|
41
|
+
ciphertext: string,
|
|
42
|
+
iv: Uint8Array,
|
|
43
|
+
key: CryptoKey,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const data = Buffer.from(ciphertext, "base64")
|
|
46
|
+
const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv: new Uint8Array(iv) }, key, data)
|
|
47
|
+
return new TextDecoder().decode(decrypted)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Load key from .env, or generate and save one if missing. Returns CryptoKey. */
|
|
51
|
+
export async function loadOrCreateKey(): Promise<CryptoKey> {
|
|
52
|
+
if (cachedKey) return cachedKey
|
|
53
|
+
|
|
54
|
+
const file = Bun.file(ENV_PATH)
|
|
55
|
+
let content = (await file.exists()) ? await file.text() : ""
|
|
56
|
+
|
|
57
|
+
// Try to find existing key
|
|
58
|
+
const match = content.match(new RegExp(`^${ENV_KEY_NAME}=(.+)$`, "m"))
|
|
59
|
+
let hexKey: string
|
|
60
|
+
|
|
61
|
+
if (match) {
|
|
62
|
+
hexKey = match[1].trim()
|
|
63
|
+
} else {
|
|
64
|
+
// Generate and append to .env
|
|
65
|
+
hexKey = generateKeyHex()
|
|
66
|
+
content = content.trimEnd() + (content.length > 0 ? "\n" : "") + `${ENV_KEY_NAME}=${hexKey}\n`
|
|
67
|
+
await Bun.write(ENV_PATH, content)
|
|
68
|
+
await chmod(ENV_PATH, 0o600)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
cachedKey = await importKey(hexKey)
|
|
72
|
+
return cachedKey
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clear cached key (for testing). */
|
|
76
|
+
export function clearKeyCache(): void {
|
|
77
|
+
cachedKey = null
|
|
78
|
+
}
|