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