kokoirc 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -40
- package/docs/commands/clear.md +26 -0
- package/docs/commands/image.md +47 -0
- package/docs/commands/invite.md +23 -0
- package/docs/commands/kill.md +24 -0
- package/docs/commands/names.md +25 -0
- package/docs/commands/oper.md +24 -0
- package/docs/commands/preview.md +31 -0
- package/docs/commands/quote.md +29 -0
- package/docs/commands/server.md +6 -0
- package/docs/commands/stats.md +31 -0
- package/docs/commands/topic.md +12 -6
- package/docs/commands/version.md +23 -0
- package/docs/commands/wallops.md +24 -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 +251 -6
- package/src/core/config/defaults.ts +11 -0
- package/src/core/config/loader.ts +5 -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 +121 -47
- 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 +14 -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/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
|
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
2
|
import type { StyledSpan } from "@/types/theme"
|
|
3
3
|
|
|
4
|
+
const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
|
|
5
|
+
|
|
4
6
|
interface Props {
|
|
5
7
|
spans: StyledSpan[]
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
/** Split text into segments, wrapping URLs in <a href> for OSC 8 hyperlinks */
|
|
11
|
+
function linkify(text: string): React.ReactNode[] {
|
|
12
|
+
const parts: React.ReactNode[] = []
|
|
13
|
+
let lastIndex = 0
|
|
14
|
+
URL_RE.lastIndex = 0
|
|
15
|
+
|
|
16
|
+
let match: RegExpExecArray | null
|
|
17
|
+
while ((match = URL_RE.exec(text)) !== null) {
|
|
18
|
+
if (match.index > lastIndex) {
|
|
19
|
+
parts.push(text.slice(lastIndex, match.index))
|
|
20
|
+
}
|
|
21
|
+
const url = match[0]
|
|
22
|
+
parts.push(<a key={match.index} href={url}>{url}</a>)
|
|
23
|
+
lastIndex = match.index + url.length
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (lastIndex < text.length) {
|
|
27
|
+
parts.push(text.slice(lastIndex))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return parts.length > 0 ? parts : [text]
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
export function StyledText({ spans }: Props) {
|
|
9
34
|
return (
|
|
10
35
|
<text>
|
|
11
36
|
{spans.map((span, i) => {
|
|
12
|
-
let content: any = span.text
|
|
37
|
+
let content: any = linkify(span.text)
|
|
38
|
+
if (content.length === 1 && typeof content[0] === "string") {
|
|
39
|
+
content = content[0]
|
|
40
|
+
}
|
|
13
41
|
if (span.bold) content = <strong>{content}</strong>
|
|
14
42
|
if (span.italic) content = <em>{content}</em>
|
|
15
43
|
if (span.underline) content = <u>{content}</u>
|
package/src/types/config.ts
CHANGED
|
@@ -63,11 +63,24 @@ export interface ScriptsConfig {
|
|
|
63
63
|
[scriptName: string]: any // per-script config: [scripts.my-script]
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export interface ImagePreviewConfig {
|
|
67
|
+
enabled: boolean
|
|
68
|
+
max_width: number // max popup width in columns (0 = auto ~60% of terminal)
|
|
69
|
+
max_height: number // max popup height in rows (0 = auto ~60% of terminal)
|
|
70
|
+
cache_max_mb: number // disk cache limit (default: 100)
|
|
71
|
+
cache_max_days: number // max age before cleanup (default: 7)
|
|
72
|
+
fetch_timeout: number // seconds (default: 30)
|
|
73
|
+
max_file_size: number // max download bytes (default: 10MB)
|
|
74
|
+
protocol: string // "auto" | "kitty" | "iterm2" | "sixel" | "symbols"
|
|
75
|
+
kitty_format: string // "rgba" | "png" — kitty protocol pixel format (default: "rgba")
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export interface AppConfig {
|
|
67
79
|
general: GeneralConfig
|
|
68
80
|
display: DisplayConfig
|
|
69
81
|
sidepanel: SidepanelConfig
|
|
70
82
|
statusbar: StatusbarConfig
|
|
83
|
+
image_preview: ImagePreviewConfig
|
|
71
84
|
servers: Record<string, ServerConfig>
|
|
72
85
|
aliases: Record<string, string>
|
|
73
86
|
ignores: IgnoreEntry[]
|
|
@@ -123,4 +136,5 @@ export interface ServerConfig {
|
|
|
123
136
|
auto_reconnect?: boolean // auto reconnect on disconnect (default: true)
|
|
124
137
|
reconnect_delay?: number // seconds between reconnect attempts (default: 30)
|
|
125
138
|
reconnect_max_retries?: number // max reconnect attempts (default: 10)
|
|
139
|
+
autosendcmd?: string // commands to run on connect, before autojoin (;-separated, WAIT <ms> for delays)
|
|
126
140
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -59,14 +59,13 @@ export interface Buffer {
|
|
|
59
59
|
export type MessageType = 'message' | 'action' | 'event' | 'notice' | 'ctcp'
|
|
60
60
|
|
|
61
61
|
export interface Message {
|
|
62
|
-
id:
|
|
62
|
+
id: number
|
|
63
63
|
timestamp: Date
|
|
64
64
|
type: MessageType
|
|
65
65
|
nick?: string
|
|
66
66
|
nickMode?: string
|
|
67
67
|
text: string
|
|
68
68
|
highlight: boolean
|
|
69
|
-
tags?: Record<string, string>
|
|
70
69
|
eventKey?: string
|
|
71
70
|
eventParams?: string[]
|
|
72
71
|
}
|
package/src/ui/chat/ChatView.tsx
CHANGED
|
@@ -4,15 +4,21 @@ import { MessageLine } from "./MessageLine"
|
|
|
4
4
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
5
5
|
|
|
6
6
|
export function ChatView() {
|
|
7
|
+
const buffer = useStore((s) => {
|
|
8
|
+
const id = s.activeBufferId
|
|
9
|
+
return id ? s.buffers.get(id) ?? null : null
|
|
10
|
+
})
|
|
7
11
|
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
8
|
-
const
|
|
9
|
-
|
|
12
|
+
const currentNick = useStore((s) => {
|
|
13
|
+
const id = s.activeBufferId
|
|
14
|
+
if (!id) return ""
|
|
15
|
+
const buf = s.buffers.get(id)
|
|
16
|
+
if (!buf) return ""
|
|
17
|
+
return s.connections.get(buf.connectionId)?.nick ?? ""
|
|
18
|
+
})
|
|
10
19
|
const colors = useStore((s) => s.theme?.colors)
|
|
11
20
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
12
21
|
|
|
13
|
-
const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
|
|
14
|
-
const currentNick = buffer ? connectionsMap.get(buffer.connectionId)?.nick ?? "" : ""
|
|
15
|
-
|
|
16
22
|
// Snap to bottom when switching buffers
|
|
17
23
|
useEffect(() => {
|
|
18
24
|
if (scrollRef.current) {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useStore } from "@/core/state/store"
|
|
2
2
|
import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
|
|
3
3
|
import { formatTimestamp } from "@/core/irc/formatting"
|
|
4
|
+
import { classifyUrl } from "@/core/image-preview/fetch"
|
|
4
5
|
import type { Message } from "@/types"
|
|
5
6
|
import type { StyledSpan } from "@/types/theme"
|
|
6
7
|
|
|
8
|
+
const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
|
|
9
|
+
|
|
7
10
|
interface Props {
|
|
8
11
|
message: Message
|
|
9
12
|
isOwnNick: boolean
|
|
@@ -84,8 +87,22 @@ export function MessageLine({ message, isOwnNick }: Props) {
|
|
|
84
87
|
const separator: StyledSpan = { text: " ", bold: false, italic: false, underline: false, dim: false }
|
|
85
88
|
const allSpans = [...tsSpans, separator, ...msgSpans]
|
|
86
89
|
|
|
90
|
+
// Click any URL in the message to attempt image preview (erssi-style content-type sniffing)
|
|
91
|
+
const handleClick = () => {
|
|
92
|
+
const text = message.text
|
|
93
|
+
const urls = text.match(URL_RE)
|
|
94
|
+
if (!urls) return
|
|
95
|
+
|
|
96
|
+
for (const url of urls) {
|
|
97
|
+
if (classifyUrl(url)) {
|
|
98
|
+
useStore.getState().showImagePreview(url)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
return (
|
|
88
|
-
<box width="100%">
|
|
105
|
+
<box width="100%" onMouseDown={handleClick}>
|
|
89
106
|
<StyledText spans={allSpans} />
|
|
90
107
|
</box>
|
|
91
108
|
)
|
|
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"
|
|
|
2
2
|
import { useStore } from "@/core/state/store"
|
|
3
3
|
import { parseCommand, executeCommand, getCommandNames, getSubcommands } from "@/core/commands"
|
|
4
4
|
import { getClient } from "@/core/irc"
|
|
5
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
5
6
|
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
6
7
|
import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
|
|
7
8
|
import type { InputRenderable } from "@opentui/core"
|
|
@@ -75,7 +76,7 @@ export function CommandInput() {
|
|
|
75
76
|
client.say(buffer.name, trimmed)
|
|
76
77
|
const conn = useStore.getState().connections.get(buffer.connectionId)
|
|
77
78
|
addMessage(buffer.id, {
|
|
78
|
-
id:
|
|
79
|
+
id: nextMsgId(),
|
|
79
80
|
timestamp: new Date(),
|
|
80
81
|
type: "message",
|
|
81
82
|
nick: conn?.nick ?? "",
|
|
@@ -15,9 +15,10 @@ interface Props {
|
|
|
15
15
|
input: React.ReactNode
|
|
16
16
|
topicbar: React.ReactNode
|
|
17
17
|
statusline?: React.ReactNode
|
|
18
|
+
overlay?: React.ReactNode
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline }: Props) {
|
|
21
|
+
export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline, overlay }: Props) {
|
|
21
22
|
const config = useStore((s) => s.config)
|
|
22
23
|
const colors = useStore((s) => s.theme?.colors)
|
|
23
24
|
const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
|
|
@@ -114,6 +115,7 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
|
|
|
114
115
|
onMouseDragEnd={endDrag}
|
|
115
116
|
/>
|
|
116
117
|
)}
|
|
118
|
+
{overlay}
|
|
117
119
|
</box>
|
|
118
120
|
|
|
119
121
|
{/* Status line + Input area — shared background from config */}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { useStore } from "@/core/state/store"
|
|
3
|
+
|
|
4
|
+
export function ImagePreview() {
|
|
5
|
+
const preview = useStore((s) => s.imagePreview)
|
|
6
|
+
const hideImagePreview = useStore((s) => s.hideImagePreview)
|
|
7
|
+
const theme = useStore((s) => s.theme?.colors)
|
|
8
|
+
|
|
9
|
+
const termCols = process.stdout.columns || 80
|
|
10
|
+
const termRows = process.stdout.rows || 24
|
|
11
|
+
|
|
12
|
+
const layout = useMemo(() => {
|
|
13
|
+
if (!preview) return null
|
|
14
|
+
const popupWidth = Math.max(preview.width, 20)
|
|
15
|
+
const popupHeight = Math.max(preview.height, 5)
|
|
16
|
+
const left = Math.max(0, Math.floor((termCols - popupWidth) / 2))
|
|
17
|
+
const top = Math.max(0, Math.floor((termRows - popupHeight) / 2))
|
|
18
|
+
return { popupWidth, popupHeight, left, top }
|
|
19
|
+
}, [preview?.width, preview?.height, termCols, termRows])
|
|
20
|
+
|
|
21
|
+
if (!preview || !layout) return null
|
|
22
|
+
|
|
23
|
+
const bg = theme?.bg ?? "#1a1b26"
|
|
24
|
+
const accent = theme?.accent ?? "#7aa2f7"
|
|
25
|
+
const muted = theme?.fg_muted ?? "#565f89"
|
|
26
|
+
|
|
27
|
+
const title = preview.title
|
|
28
|
+
? ` ${preview.title.slice(0, layout.popupWidth - 4)} `
|
|
29
|
+
: " Preview "
|
|
30
|
+
|
|
31
|
+
let statusText: React.ReactNode = null
|
|
32
|
+
if (preview.status === "loading") {
|
|
33
|
+
statusText = <text><span fg={muted}>Loading image...</span></text>
|
|
34
|
+
} else if (preview.status === "error") {
|
|
35
|
+
statusText = <text><span fg="#f7768e">{preview.error ?? "Error"}</span></text>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{/* Full-screen transparent backdrop — click anywhere to dismiss */}
|
|
41
|
+
<box
|
|
42
|
+
position="absolute"
|
|
43
|
+
left={0}
|
|
44
|
+
top={0}
|
|
45
|
+
width="100%"
|
|
46
|
+
height="100%"
|
|
47
|
+
onMouseDown={() => hideImagePreview()}
|
|
48
|
+
/>
|
|
49
|
+
{/* Centered popup */}
|
|
50
|
+
<box
|
|
51
|
+
position="absolute"
|
|
52
|
+
left={layout.left}
|
|
53
|
+
top={layout.top}
|
|
54
|
+
width={layout.popupWidth}
|
|
55
|
+
height={layout.popupHeight}
|
|
56
|
+
border={["top", "bottom", "left", "right"]}
|
|
57
|
+
borderStyle="single"
|
|
58
|
+
borderColor={accent}
|
|
59
|
+
backgroundColor={bg}
|
|
60
|
+
onMouseDown={() => hideImagePreview()}
|
|
61
|
+
>
|
|
62
|
+
<box height={1} width="100%">
|
|
63
|
+
<text>
|
|
64
|
+
<span fg={accent}>{title}</span>
|
|
65
|
+
<span fg={muted}> [click/key to close]</span>
|
|
66
|
+
</text>
|
|
67
|
+
</box>
|
|
68
|
+
|
|
69
|
+
{statusText && (
|
|
70
|
+
<box width="100%" flexGrow={1} justifyContent="center" alignItems="center">
|
|
71
|
+
{statusText}
|
|
72
|
+
</box>
|
|
73
|
+
)}
|
|
74
|
+
</box>
|
|
75
|
+
</>
|
|
76
|
+
)
|
|
77
|
+
}
|