kokoirc 0.2.4 → 0.2.5
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 +1 -1
- package/package.json +2 -2
- package/src/app/App.tsx +16 -2
- package/src/core/commands/registry.ts +3 -0
- package/src/core/config/loader.ts +0 -2
- package/src/core/irc/antiflood.ts +3 -3
- package/src/core/irc/client.ts +10 -0
- package/src/core/irc/events.ts +80 -70
- package/src/core/scripts/api.ts +10 -1
- package/src/core/state/selectors.ts +1 -2
- package/src/core/state/store.ts +149 -41
- package/src/core/theme/index.ts +1 -1
- package/src/core/theme/parser.ts +3 -1
- package/src/core/theme/renderer.tsx +21 -19
- package/src/types/theme.ts +1 -0
- package/src/ui/chat/ChatView.tsx +19 -14
- package/src/ui/chat/MessageLine.tsx +26 -5
- package/src/ui/input/CommandInput.tsx +54 -0
- package/src/ui/layout/AppLayout.tsx +3 -3
- package/src/ui/sidebar/BufferList.tsx +16 -7
- package/src/ui/sidebar/NickList.tsx +15 -7
- package/src/ui/splash/SplashScreen.tsx +6 -2
- package/src/ui/statusbar/StatusLine.tsx +4 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kokoirc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Modern terminal IRC client with inline image preview, SASL, scripting, encrypted logging, and theming — built with OpenTUI, React, and Bun",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"start": "bun run src/index.tsx",
|
|
57
57
|
"dev": "bun --watch run src/index.tsx",
|
|
58
58
|
"test": "bun test",
|
|
59
|
-
"build": "bun build --compile src/index.tsx --outfile
|
|
59
|
+
"build": "bun build --compile src/index.tsx --outfile kokoirc",
|
|
60
60
|
"docs:build": "bun run docs/build.ts"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
package/src/app/App.tsx
CHANGED
|
@@ -35,7 +35,10 @@ export function App() {
|
|
|
35
35
|
|
|
36
36
|
useKeyboard((key) => {
|
|
37
37
|
if (key.name === "q" && key.ctrl) {
|
|
38
|
-
shutdownStorage().finally(() =>
|
|
38
|
+
shutdownStorage().finally(() => {
|
|
39
|
+
renderer.destroy()
|
|
40
|
+
process.exit(0)
|
|
41
|
+
})
|
|
39
42
|
return
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -121,7 +124,10 @@ export function App() {
|
|
|
121
124
|
// Register shutdown handler so commands can close the app
|
|
122
125
|
useEffect(() => {
|
|
123
126
|
useStore.getState().setShutdownHandler(() => {
|
|
124
|
-
shutdownStorage().finally(() =>
|
|
127
|
+
shutdownStorage().finally(() => {
|
|
128
|
+
renderer.destroy()
|
|
129
|
+
process.exit(0)
|
|
130
|
+
})
|
|
125
131
|
})
|
|
126
132
|
}, [renderer])
|
|
127
133
|
|
|
@@ -140,6 +146,14 @@ export function App() {
|
|
|
140
146
|
setTheme(theme)
|
|
141
147
|
|
|
142
148
|
await loadAllDocs()
|
|
149
|
+
|
|
150
|
+
// Run image cache cleanup (fire-and-forget)
|
|
151
|
+
const imgConfig = config.image_preview
|
|
152
|
+
if (imgConfig?.enabled) {
|
|
153
|
+
import("@/core/image-preview/cache").then(({ cleanupCache }) => {
|
|
154
|
+
cleanupCache(imgConfig.cache_max_mb ?? 100, imgConfig.cache_max_days ?? 7).catch(() => {})
|
|
155
|
+
})
|
|
156
|
+
}
|
|
143
157
|
}
|
|
144
158
|
init().catch((err) => console.error("[init]", err))
|
|
145
159
|
}, [])
|
|
@@ -379,6 +379,9 @@ export const commands: Record<string, CommandDef> = {
|
|
|
379
379
|
const client = getClient(connId)
|
|
380
380
|
if (client) {
|
|
381
381
|
client.part(buf.name, args[0] ?? "Window closed")
|
|
382
|
+
} else {
|
|
383
|
+
// Already disconnected — just remove the buffer
|
|
384
|
+
s.removeBuffer(buf.id)
|
|
382
385
|
}
|
|
383
386
|
} else if (buf.type === BufferType.Query) {
|
|
384
387
|
s.removeBuffer(buf.id)
|
|
@@ -48,8 +48,6 @@ export function mergeWithDefaults(partial: Record<string, any>): AppConfig {
|
|
|
48
48
|
aliases: partial.aliases ?? {},
|
|
49
49
|
ignores: (partial.ignores as IgnoreEntry[] | undefined) ?? [],
|
|
50
50
|
scripts: {
|
|
51
|
-
autoload: [],
|
|
52
|
-
debug: false,
|
|
53
51
|
...DEFAULT_CONFIG.scripts,
|
|
54
52
|
...partial.scripts,
|
|
55
53
|
},
|
|
@@ -169,9 +169,9 @@ export function createAntiFloodMiddleware(connId: string) {
|
|
|
169
169
|
state.msgWindow.push({ text: message, time: now })
|
|
170
170
|
// Prune old entries
|
|
171
171
|
const cutoff = now - DUP_WINDOW
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
let pruneIdx = 0
|
|
173
|
+
while (pruneIdx < state.msgWindow.length && state.msgWindow[pruneIdx].time < cutoff) pruneIdx++
|
|
174
|
+
if (pruneIdx > 0) state.msgWindow.splice(0, pruneIdx)
|
|
175
175
|
|
|
176
176
|
// Only analyze when enough messages in window
|
|
177
177
|
if (state.msgWindow.length >= DUP_MIN_IN_WINDOW) {
|
package/src/core/irc/client.ts
CHANGED
|
@@ -123,8 +123,18 @@ export function disconnectServer(id: string, message?: string) {
|
|
|
123
123
|
const client = clients.get(id)
|
|
124
124
|
if (client) {
|
|
125
125
|
client.quit(message ?? "kokoIRC — https://github.com/kofany/kokoIRC")
|
|
126
|
+
;(client as any).removeAllListeners()
|
|
126
127
|
clients.delete(id)
|
|
128
|
+
// Force-close the TCP socket after a brief delay so the QUIT message
|
|
129
|
+
// has time to flush. Without this, the half-open socket keeps the
|
|
130
|
+
// event loop alive and prevents clean process exit.
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
try { (client as any).connection?.disposeSocket?.() } catch {}
|
|
133
|
+
}, 200)
|
|
127
134
|
}
|
|
135
|
+
// Update store even if client was already gone — removeAllListeners() prevents
|
|
136
|
+
// the "close" event handler from updating status, so we must do it here.
|
|
137
|
+
useStore.getState().updateConnection(id, { status: "disconnected" })
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
export function getClient(id: string): Client | undefined {
|
package/src/core/irc/events.ts
CHANGED
|
@@ -178,6 +178,8 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
178
178
|
s.addMessage(bufferId, makeEventMessage(
|
|
179
179
|
`%Zf7768eYou were kicked from ${event.channel} by %Za9b1d6${event.nick}%Zf7768e (${event.message || ""})%N`
|
|
180
180
|
))
|
|
181
|
+
// Auto-close the channel buffer after being kicked
|
|
182
|
+
getStore().removeBuffer(bufferId)
|
|
181
183
|
} else {
|
|
182
184
|
s.removeNick(bufferId, event.kicked)
|
|
183
185
|
if (shouldIgnore(event.nick, event.ident, event.hostname, "KICKS", event.channel)) return
|
|
@@ -231,7 +233,13 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
231
233
|
? event.message.toLowerCase().includes(conn.nick.toLowerCase())
|
|
232
234
|
: false
|
|
233
235
|
|
|
234
|
-
s.
|
|
236
|
+
const activityLevel = (s.activeBufferId !== bufferId && !isOwnMsg)
|
|
237
|
+
? (!isChannel ? ActivityLevel.Mention
|
|
238
|
+
: isMention ? ActivityLevel.Mention
|
|
239
|
+
: ActivityLevel.Activity)
|
|
240
|
+
: undefined
|
|
241
|
+
|
|
242
|
+
s.addMessageWithActivity(bufferId, {
|
|
235
243
|
id: nextMsgId(),
|
|
236
244
|
timestamp: new Date(event.time || Date.now()),
|
|
237
245
|
type: "message",
|
|
@@ -239,14 +247,7 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
239
247
|
nickMode: getNickMode(s.buffers, bufferId, event.nick),
|
|
240
248
|
text: event.message,
|
|
241
249
|
highlight: isMention,
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
if (s.activeBufferId !== bufferId && !isOwnMsg) {
|
|
245
|
-
const level = !isChannel ? ActivityLevel.Mention
|
|
246
|
-
: isMention ? ActivityLevel.Mention
|
|
247
|
-
: ActivityLevel.Activity
|
|
248
|
-
s.updateBufferActivity(bufferId, level)
|
|
249
|
-
}
|
|
250
|
+
}, activityLevel)
|
|
250
251
|
})
|
|
251
252
|
|
|
252
253
|
client.on("action", (event) => {
|
|
@@ -426,53 +427,55 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
426
427
|
const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
|
|
427
428
|
const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
|
|
428
429
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
430
|
+
// Batch nick prefix updates (1 set() instead of N)
|
|
431
|
+
const nickUpdates: Array<{ bufferId: string; entry: NickEntry }> = []
|
|
432
|
+
const buf = getStore().buffers.get(bufferId)
|
|
433
|
+
if (buf) {
|
|
434
|
+
for (const mc of event.modes) {
|
|
435
|
+
if (!mc.param) continue
|
|
436
|
+
const isAdding = mc.mode.startsWith("+")
|
|
437
|
+
const modeChar = mc.mode.replace(/[+-]/, "")
|
|
438
|
+
if (!modeOrder.includes(modeChar)) continue
|
|
434
439
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (!entry) continue
|
|
438
|
-
|
|
439
|
-
// Add or remove this specific mode char from the user's modes string
|
|
440
|
-
let modes = entry.modes ?? ""
|
|
441
|
-
if (isAdding && !modes.includes(modeChar)) {
|
|
442
|
-
modes += modeChar
|
|
443
|
-
} else if (!isAdding) {
|
|
444
|
-
modes = modes.replace(modeChar, "")
|
|
445
|
-
}
|
|
440
|
+
const entry = buf.users.get(mc.param)
|
|
441
|
+
if (!entry) continue
|
|
446
442
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
443
|
+
let modes = entry.modes ?? ""
|
|
444
|
+
if (isAdding && !modes.includes(modeChar)) {
|
|
445
|
+
modes += modeChar
|
|
446
|
+
} else if (!isAdding) {
|
|
447
|
+
modes = modes.replace(modeChar, "")
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
nickUpdates.push({
|
|
451
|
+
bufferId,
|
|
452
|
+
entry: { ...entry, modes, prefix: getHighestPrefix(modes, modeOrder, prefixMap) },
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (nickUpdates.length > 0) {
|
|
457
|
+
getStore().batchAddNick(nickUpdates)
|
|
452
458
|
}
|
|
453
459
|
|
|
454
|
-
//
|
|
455
|
-
const
|
|
456
|
-
const
|
|
457
|
-
if (
|
|
458
|
-
let chanModes =
|
|
459
|
-
const params: Record<string, string> = { ...
|
|
460
|
+
// Batch list entry ops + update channel modes (non-nick-prefix, non-list modes)
|
|
461
|
+
const listModeSet = getListModes(connectionId)
|
|
462
|
+
const buf2 = getStore().buffers.get(bufferId)
|
|
463
|
+
if (buf2) {
|
|
464
|
+
let chanModes = buf2.modes ?? ""
|
|
465
|
+
const params: Record<string, string> = { ...buf2.modeParams }
|
|
466
|
+
const listOps: Array<{ action: "add" | "remove"; modeChar: string; entry?: { mask: string; setBy: string; setAt: number }; mask?: string }> = []
|
|
467
|
+
|
|
460
468
|
for (const mc of event.modes) {
|
|
461
469
|
const isAdding = mc.mode.startsWith("+")
|
|
462
470
|
const modeChar = mc.mode.replace(/[+-]/, "")
|
|
463
|
-
if (modeOrder.includes(modeChar)) continue
|
|
464
|
-
if (
|
|
465
|
-
// Track list mode changes in store
|
|
471
|
+
if (modeOrder.includes(modeChar)) continue
|
|
472
|
+
if (listModeSet.has(modeChar)) {
|
|
466
473
|
if (isAdding && mc.param) {
|
|
467
|
-
|
|
468
|
-
mask: mc.param,
|
|
469
|
-
setBy: event.nick || "server",
|
|
470
|
-
setAt: Date.now() / 1000,
|
|
471
|
-
})
|
|
474
|
+
listOps.push({ action: "add", modeChar, entry: { mask: mc.param, setBy: event.nick || "server", setAt: Date.now() / 1000 } })
|
|
472
475
|
} else if (!isAdding && mc.param) {
|
|
473
|
-
|
|
476
|
+
listOps.push({ action: "remove", modeChar, mask: mc.param })
|
|
474
477
|
}
|
|
475
|
-
continue
|
|
478
|
+
continue
|
|
476
479
|
}
|
|
477
480
|
if (isAdding && !chanModes.includes(modeChar)) {
|
|
478
481
|
chanModes += modeChar
|
|
@@ -484,6 +487,9 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
484
487
|
params[modeChar] = mc.param
|
|
485
488
|
}
|
|
486
489
|
}
|
|
490
|
+
if (listOps.length > 0) {
|
|
491
|
+
getStore().batchListEntryOps(bufferId, listOps)
|
|
492
|
+
}
|
|
487
493
|
getStore().updateBufferModes(bufferId, chanModes, params)
|
|
488
494
|
}
|
|
489
495
|
})
|
|
@@ -510,6 +516,7 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
510
516
|
clearInterval(lagPingInterval)
|
|
511
517
|
destroyNetsplitState(connectionId)
|
|
512
518
|
destroyAntifloodState(connectionId)
|
|
519
|
+
reopCollector.clear()
|
|
513
520
|
getStore().updateConnection(connectionId, { status: "disconnected" })
|
|
514
521
|
statusMsg("%Zf7768eDisconnected from server%N")
|
|
515
522
|
})
|
|
@@ -592,8 +599,12 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
592
599
|
return
|
|
593
600
|
}
|
|
594
601
|
if (!event.motd) return
|
|
595
|
-
|
|
596
|
-
|
|
602
|
+
const lines = event.motd.split("\n").filter((l: string) => l.trim())
|
|
603
|
+
if (lines.length > 0) {
|
|
604
|
+
getStore().batchAddMessage(lines.map((line: string) => ({
|
|
605
|
+
bufferId: statusId,
|
|
606
|
+
message: makeEventMessage(`%Z565f89${line}%N`),
|
|
607
|
+
})))
|
|
597
608
|
}
|
|
598
609
|
})
|
|
599
610
|
|
|
@@ -968,9 +979,10 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
968
979
|
|
|
969
980
|
lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
|
|
970
981
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
982
|
+
getStore().batchAddMessage(lines.map(line => ({
|
|
983
|
+
bufferId: targetBuffer,
|
|
984
|
+
message: makeEventMessage(line),
|
|
985
|
+
})))
|
|
974
986
|
})
|
|
975
987
|
|
|
976
988
|
// ─── Whowas response ──────────────────────────────────────
|
|
@@ -998,9 +1010,10 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
998
1010
|
|
|
999
1011
|
lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
|
|
1000
1012
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1013
|
+
getStore().batchAddMessage(lines.map(line => ({
|
|
1014
|
+
bufferId: targetBuffer,
|
|
1015
|
+
message: makeEventMessage(line),
|
|
1016
|
+
})))
|
|
1004
1017
|
})
|
|
1005
1018
|
}
|
|
1006
1019
|
|
|
@@ -1011,24 +1024,21 @@ function displayNumberedList(
|
|
|
1011
1024
|
channel: string,
|
|
1012
1025
|
entries: { mask: string; setBy: string; setAt: number }[],
|
|
1013
1026
|
) {
|
|
1014
|
-
const
|
|
1027
|
+
const msgs: Array<{ bufferId: string; message: Message }> = []
|
|
1015
1028
|
if (entries.length === 0) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
))
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
|
|
1028
|
-
s.addMessage(target, makeEventMessage(
|
|
1029
|
-
`%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
|
|
1030
|
-
))
|
|
1029
|
+
msgs.push({ bufferId: target, message: makeEventMessage(`%Z565f89${channel}: ${label} is empty%N`) })
|
|
1030
|
+
} else {
|
|
1031
|
+
msgs.push({ bufferId: target, message: makeEventMessage(`%Z7aa2f7───── ${label} for ${channel} ─────%N`) })
|
|
1032
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1033
|
+
const e = entries[i]
|
|
1034
|
+
const by = e.setBy ? ` set by ${e.setBy}` : ""
|
|
1035
|
+
const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
|
|
1036
|
+
msgs.push({ bufferId: target, message: makeEventMessage(
|
|
1037
|
+
`%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
|
|
1038
|
+
) })
|
|
1039
|
+
}
|
|
1031
1040
|
}
|
|
1041
|
+
useStore.getState().batchAddMessage(msgs)
|
|
1032
1042
|
}
|
|
1033
1043
|
|
|
1034
1044
|
/** System/inline event — text may contain %Z color codes. */
|
package/src/core/scripts/api.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
|
|
|
25
25
|
} {
|
|
26
26
|
const scriptName = meta.name
|
|
27
27
|
const unsubs: Array<() => void> = []
|
|
28
|
+
const storeUnsubs: Array<() => void> = []
|
|
28
29
|
const timers: Array<TimerHandle> = []
|
|
29
30
|
const registeredCommands: string[] = []
|
|
30
31
|
|
|
@@ -37,7 +38,11 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
|
|
|
37
38
|
getConfig: () => useStore.getState().config,
|
|
38
39
|
getConnection: (id) => useStore.getState().connections.get(id),
|
|
39
40
|
getBuffer: (id) => useStore.getState().buffers.get(id),
|
|
40
|
-
subscribe: (listener) =>
|
|
41
|
+
subscribe: (listener) => {
|
|
42
|
+
const unsub = useStore.subscribe(listener)
|
|
43
|
+
storeUnsubs.push(unsub)
|
|
44
|
+
return unsub
|
|
45
|
+
},
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
// ─── IRC Access ──────────────────────────────────────────
|
|
@@ -223,6 +228,10 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
|
|
|
223
228
|
for (const unsub of unsubs) unsub()
|
|
224
229
|
unsubs.length = 0
|
|
225
230
|
|
|
231
|
+
// Remove store subscriptions
|
|
232
|
+
for (const unsub of storeUnsubs) unsub()
|
|
233
|
+
storeUnsubs.length = 0
|
|
234
|
+
|
|
226
235
|
// Clear all timers
|
|
227
236
|
for (const t of timers) t.clear()
|
|
228
237
|
timers.length = 0
|
|
@@ -26,8 +26,7 @@ export function useSortedBuffers(): Array<Buffer & { connectionLabel: string }>
|
|
|
26
26
|
const EMPTY_NICKS: NickEntry[] = []
|
|
27
27
|
|
|
28
28
|
export function useSortedNicks(bufferId: string, prefixOrder: string): NickEntry[] {
|
|
29
|
-
const
|
|
30
|
-
const buffer = buffersMap.get(bufferId)
|
|
29
|
+
const buffer = useStore((s) => s.buffers.get(bufferId))
|
|
31
30
|
return useMemo(() => {
|
|
32
31
|
if (!buffer) return EMPTY_NICKS
|
|
33
32
|
return sortNicks(Array.from(buffer.users.values()), prefixOrder)
|
package/src/core/state/store.ts
CHANGED
|
@@ -45,6 +45,7 @@ interface AppState {
|
|
|
45
45
|
|
|
46
46
|
// Message actions
|
|
47
47
|
addMessage: (bufferId: string, message: Message) => void
|
|
48
|
+
addMessageWithActivity: (bufferId: string, message: Message, activity?: ActivityLevel) => void
|
|
48
49
|
clearMessages: (bufferId: string) => void
|
|
49
50
|
|
|
50
51
|
// Nicklist actions
|
|
@@ -66,6 +67,7 @@ interface AppState {
|
|
|
66
67
|
batchAddNick: (entries: Array<{ bufferId: string; entry: NickEntry }>) => void
|
|
67
68
|
batchUpdateNick: (entries: Array<{ bufferId: string; oldNick: string; newNick: string; prefix?: string }>) => void
|
|
68
69
|
batchAddMessage: (entries: Array<{ bufferId: string; message: Message }>) => void
|
|
70
|
+
batchListEntryOps: (bufferId: string, ops: Array<{ action: "add" | "remove"; modeChar: ListModeKey; entry?: ListEntry; mask?: string }>) => void
|
|
69
71
|
|
|
70
72
|
// Config/Theme
|
|
71
73
|
setConfig: (config: AppConfig) => void
|
|
@@ -301,11 +303,11 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
301
303
|
},
|
|
302
304
|
|
|
303
305
|
updateBufferActivity: (id, level) => set((s) => {
|
|
306
|
+
const buf = s.buffers.get(id)
|
|
307
|
+
if (!buf || level <= buf.activity) return s
|
|
308
|
+
// Only trigger a store update when activity actually increases
|
|
304
309
|
const buffers = new Map(s.buffers)
|
|
305
|
-
|
|
306
|
-
if (buf && level > buf.activity) {
|
|
307
|
-
buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
|
|
308
|
-
}
|
|
310
|
+
buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
|
|
309
311
|
return { buffers }
|
|
310
312
|
}),
|
|
311
313
|
|
|
@@ -319,12 +321,60 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
319
321
|
}
|
|
320
322
|
|
|
321
323
|
return set((s) => {
|
|
324
|
+
const buf = s.buffers.get(bufferId)
|
|
325
|
+
if (!buf) return s
|
|
326
|
+
const maxLines = s.config?.display.scrollback_lines ?? 2000
|
|
327
|
+
|
|
328
|
+
// Inactive buffer — mutate in-place to avoid garbage (nobody is rendering these)
|
|
329
|
+
if (bufferId !== s.activeBufferId) {
|
|
330
|
+
buf.messages.push(message)
|
|
331
|
+
if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
|
|
332
|
+
return s
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Active buffer — immutable update so ChatView re-renders
|
|
336
|
+
const messages = [...buf.messages, message]
|
|
337
|
+
if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
|
|
322
338
|
const buffers = new Map(s.buffers)
|
|
323
|
-
|
|
339
|
+
buffers.set(bufferId, { ...buf, messages })
|
|
340
|
+
return { buffers }
|
|
341
|
+
})
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
addMessageWithActivity: (bufferId, message, activity) => {
|
|
345
|
+
const slashIdx = bufferId.indexOf("/")
|
|
346
|
+
if (slashIdx > 0) {
|
|
347
|
+
const network = bufferId.slice(0, slashIdx)
|
|
348
|
+
const buffer = bufferId.slice(slashIdx + 1)
|
|
349
|
+
logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return set((s) => {
|
|
353
|
+
const buf = s.buffers.get(bufferId)
|
|
324
354
|
if (!buf) return s
|
|
325
355
|
const maxLines = s.config?.display.scrollback_lines ?? 2000
|
|
356
|
+
|
|
357
|
+
// Inactive buffer — mutate messages in-place to avoid garbage
|
|
358
|
+
if (bufferId !== s.activeBufferId) {
|
|
359
|
+
buf.messages.push(message)
|
|
360
|
+
if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
|
|
361
|
+
buf.unreadCount++
|
|
362
|
+
|
|
363
|
+
// Only trigger a store update if activity level actually increases
|
|
364
|
+
// (first unread message sets the marker; subsequent messages are free)
|
|
365
|
+
if (activity != null && activity > buf.activity) {
|
|
366
|
+
buf.activity = activity
|
|
367
|
+
const buffers = new Map(s.buffers)
|
|
368
|
+
buffers.set(bufferId, { ...buf })
|
|
369
|
+
return { buffers }
|
|
370
|
+
}
|
|
371
|
+
return s // no state change — React won't re-render
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Active buffer — immutable update so ChatView re-renders
|
|
326
375
|
const messages = [...buf.messages, message]
|
|
327
376
|
if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
|
|
377
|
+
const buffers = new Map(s.buffers)
|
|
328
378
|
buffers.set(bufferId, { ...buf, messages })
|
|
329
379
|
return { buffers }
|
|
330
380
|
})
|
|
@@ -339,9 +389,14 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
339
389
|
}),
|
|
340
390
|
|
|
341
391
|
addNick: (bufferId, entry) => set((s) => {
|
|
342
|
-
const
|
|
343
|
-
const buf = buffers.get(bufferId)
|
|
392
|
+
const buf = s.buffers.get(bufferId)
|
|
344
393
|
if (!buf) return s
|
|
394
|
+
// Inactive buffer — mutate in-place (NickList only renders for active buffer)
|
|
395
|
+
if (bufferId !== s.activeBufferId) {
|
|
396
|
+
buf.users.set(entry.nick, entry)
|
|
397
|
+
return s
|
|
398
|
+
}
|
|
399
|
+
const buffers = new Map(s.buffers)
|
|
345
400
|
const users = new Map(buf.users)
|
|
346
401
|
users.set(entry.nick, entry)
|
|
347
402
|
buffers.set(bufferId, { ...buf, users })
|
|
@@ -349,9 +404,13 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
349
404
|
}),
|
|
350
405
|
|
|
351
406
|
removeNick: (bufferId, nick) => set((s) => {
|
|
352
|
-
const
|
|
353
|
-
const buf = buffers.get(bufferId)
|
|
407
|
+
const buf = s.buffers.get(bufferId)
|
|
354
408
|
if (!buf) return s
|
|
409
|
+
if (bufferId !== s.activeBufferId) {
|
|
410
|
+
buf.users.delete(nick)
|
|
411
|
+
return s
|
|
412
|
+
}
|
|
413
|
+
const buffers = new Map(s.buffers)
|
|
355
414
|
const users = new Map(buf.users)
|
|
356
415
|
users.delete(nick)
|
|
357
416
|
buffers.set(bufferId, { ...buf, users })
|
|
@@ -359,15 +418,19 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
359
418
|
}),
|
|
360
419
|
|
|
361
420
|
updateNick: (bufferId, oldNick, newNick, prefix) => set((s) => {
|
|
362
|
-
const
|
|
363
|
-
const buf = buffers.get(bufferId)
|
|
421
|
+
const buf = s.buffers.get(bufferId)
|
|
364
422
|
if (!buf) return s
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
users.delete(oldNick)
|
|
369
|
-
users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
423
|
+
const existing = buf.users.get(oldNick)
|
|
424
|
+
if (!existing) return s
|
|
425
|
+
if (bufferId !== s.activeBufferId) {
|
|
426
|
+
buf.users.delete(oldNick)
|
|
427
|
+
buf.users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
428
|
+
return s
|
|
370
429
|
}
|
|
430
|
+
const buffers = new Map(s.buffers)
|
|
431
|
+
const users = new Map(buf.users)
|
|
432
|
+
users.delete(oldNick)
|
|
433
|
+
users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
371
434
|
buffers.set(bufferId, { ...buf, users })
|
|
372
435
|
return { buffers }
|
|
373
436
|
}),
|
|
@@ -424,43 +487,58 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
424
487
|
}),
|
|
425
488
|
|
|
426
489
|
batchRemoveNick: (entries) => set((s) => {
|
|
427
|
-
|
|
490
|
+
let buffers: Map<string, Buffer> | null = null
|
|
428
491
|
for (const { bufferId, nick } of entries) {
|
|
429
|
-
const buf = buffers.get(bufferId)
|
|
492
|
+
const buf = (buffers ?? s.buffers).get(bufferId)
|
|
430
493
|
if (!buf) continue
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
494
|
+
if (bufferId !== s.activeBufferId) {
|
|
495
|
+
buf.users.delete(nick)
|
|
496
|
+
} else {
|
|
497
|
+
if (!buffers) buffers = new Map(s.buffers)
|
|
498
|
+
const users = new Map(buf.users)
|
|
499
|
+
users.delete(nick)
|
|
500
|
+
buffers.set(bufferId, { ...buf, users })
|
|
501
|
+
}
|
|
434
502
|
}
|
|
435
|
-
return { buffers }
|
|
503
|
+
return buffers ? { buffers } : s
|
|
436
504
|
}),
|
|
437
505
|
|
|
438
506
|
batchAddNick: (entries) => set((s) => {
|
|
439
|
-
|
|
507
|
+
let buffers: Map<string, Buffer> | null = null
|
|
440
508
|
for (const { bufferId, entry } of entries) {
|
|
441
|
-
const buf = buffers.get(bufferId)
|
|
509
|
+
const buf = (buffers ?? s.buffers).get(bufferId)
|
|
442
510
|
if (!buf) continue
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
511
|
+
if (bufferId !== s.activeBufferId) {
|
|
512
|
+
buf.users.set(entry.nick, entry)
|
|
513
|
+
} else {
|
|
514
|
+
if (!buffers) buffers = new Map(s.buffers)
|
|
515
|
+
const users = new Map(buf.users)
|
|
516
|
+
users.set(entry.nick, entry)
|
|
517
|
+
buffers.set(bufferId, { ...buf, users })
|
|
518
|
+
}
|
|
446
519
|
}
|
|
447
|
-
return { buffers }
|
|
520
|
+
return buffers ? { buffers } : s
|
|
448
521
|
}),
|
|
449
522
|
|
|
450
523
|
batchUpdateNick: (entries) => set((s) => {
|
|
451
|
-
|
|
524
|
+
let buffers: Map<string, Buffer> | null = null
|
|
452
525
|
for (const { bufferId, oldNick, newNick, prefix } of entries) {
|
|
453
|
-
const buf = buffers.get(bufferId)
|
|
526
|
+
const buf = (buffers ?? s.buffers).get(bufferId)
|
|
454
527
|
if (!buf) continue
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
if (
|
|
528
|
+
const existing = buf.users.get(oldNick)
|
|
529
|
+
if (!existing) continue
|
|
530
|
+
if (bufferId !== s.activeBufferId) {
|
|
531
|
+
buf.users.delete(oldNick)
|
|
532
|
+
buf.users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
533
|
+
} else {
|
|
534
|
+
if (!buffers) buffers = new Map(s.buffers)
|
|
535
|
+
const users = new Map(buf.users)
|
|
458
536
|
users.delete(oldNick)
|
|
459
537
|
users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
|
|
538
|
+
buffers.set(bufferId, { ...buf, users })
|
|
460
539
|
}
|
|
461
|
-
buffers.set(bufferId, { ...buf, users })
|
|
462
540
|
}
|
|
463
|
-
return { buffers }
|
|
541
|
+
return buffers ? { buffers } : s
|
|
464
542
|
}),
|
|
465
543
|
|
|
466
544
|
batchAddMessage: (entries) => {
|
|
@@ -475,19 +553,49 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
475
553
|
}
|
|
476
554
|
|
|
477
555
|
return set((s) => {
|
|
478
|
-
const buffers = new Map(s.buffers)
|
|
479
556
|
const maxLines = s.config?.display.scrollback_lines ?? 2000
|
|
557
|
+
let buffers: Map<string, Buffer> | null = null
|
|
558
|
+
|
|
480
559
|
for (const { bufferId, message } of entries) {
|
|
481
|
-
const buf = buffers.get(bufferId)
|
|
560
|
+
const buf = (buffers ?? s.buffers).get(bufferId)
|
|
482
561
|
if (!buf) continue
|
|
483
|
-
|
|
484
|
-
if (
|
|
485
|
-
|
|
562
|
+
|
|
563
|
+
if (bufferId !== s.activeBufferId) {
|
|
564
|
+
// Inactive buffer — mutate in-place
|
|
565
|
+
buf.messages.push(message)
|
|
566
|
+
if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
|
|
567
|
+
} else {
|
|
568
|
+
// Active buffer — immutable update
|
|
569
|
+
if (!buffers) buffers = new Map(s.buffers)
|
|
570
|
+
const messages = [...buf.messages, message]
|
|
571
|
+
if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
|
|
572
|
+
buffers.set(bufferId, { ...buf, messages })
|
|
573
|
+
}
|
|
486
574
|
}
|
|
487
|
-
|
|
575
|
+
|
|
576
|
+
return buffers ? { buffers } : s
|
|
488
577
|
})
|
|
489
578
|
},
|
|
490
579
|
|
|
580
|
+
batchListEntryOps: (bufferId, ops) => set((s) => {
|
|
581
|
+
const buffers = new Map(s.buffers)
|
|
582
|
+
const buf = buffers.get(bufferId)
|
|
583
|
+
if (!buf) return s
|
|
584
|
+
const listModes = new Map(buf.listModes)
|
|
585
|
+
for (const op of ops) {
|
|
586
|
+
const existing = listModes.get(op.modeChar) ?? []
|
|
587
|
+
if (op.action === "add" && op.entry) {
|
|
588
|
+
if (!existing.some((e) => e.mask === op.entry!.mask)) {
|
|
589
|
+
listModes.set(op.modeChar, [...existing, op.entry])
|
|
590
|
+
}
|
|
591
|
+
} else if (op.action === "remove" && op.mask) {
|
|
592
|
+
listModes.set(op.modeChar, existing.filter((e) => e.mask !== op.mask))
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
buffers.set(bufferId, { ...buf, listModes })
|
|
596
|
+
return { buffers }
|
|
597
|
+
}),
|
|
598
|
+
|
|
491
599
|
setConfig: (config) => set({ config }),
|
|
492
600
|
setTheme: (theme) => set({ theme }),
|
|
493
601
|
|
package/src/core/theme/index.ts
CHANGED
package/src/core/theme/parser.ts
CHANGED
|
@@ -335,8 +335,10 @@ export function parseFormatString(input: string, params: string[] = []): StyledS
|
|
|
335
335
|
continue
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
// %| — indent marker
|
|
338
|
+
// %| — indent marker: emit a zero-width span so renderers can split here
|
|
339
339
|
if (code === "|") {
|
|
340
|
+
flush()
|
|
341
|
+
spans.push({ text: "", bold: false, italic: false, underline: false, dim: false, indentMarker: true })
|
|
340
342
|
i++
|
|
341
343
|
continue
|
|
342
344
|
}
|
|
@@ -30,24 +30,26 @@ function linkify(text: string): React.ReactNode[] {
|
|
|
30
30
|
return parts.length > 0 ? parts : [text]
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Render StyledSpan[] as React elements (without wrapping <text>). */
|
|
34
|
+
export function renderStyledSpans(spans: StyledSpan[], keyOffset = 0): React.ReactNode[] {
|
|
35
|
+
return spans.map((span, i) => {
|
|
36
|
+
if (span.indentMarker) return null
|
|
37
|
+
let content: any = linkify(span.text)
|
|
38
|
+
if (content.length === 1 && typeof content[0] === "string") {
|
|
39
|
+
content = content[0]
|
|
40
|
+
}
|
|
41
|
+
if (span.bold) content = <strong>{content}</strong>
|
|
42
|
+
if (span.italic) content = <em>{content}</em>
|
|
43
|
+
if (span.underline) content = <u>{content}</u>
|
|
44
|
+
if (span.dim) content = <span attributes={TextAttributes.DIM}>{content}</span>
|
|
45
|
+
return (
|
|
46
|
+
<span key={keyOffset + i} fg={span.fg} bg={span.bg}>
|
|
47
|
+
{content}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export function StyledText({ spans }: Props) {
|
|
34
|
-
return (
|
|
35
|
-
<text>
|
|
36
|
-
{spans.map((span, i) => {
|
|
37
|
-
let content: any = linkify(span.text)
|
|
38
|
-
if (content.length === 1 && typeof content[0] === "string") {
|
|
39
|
-
content = content[0]
|
|
40
|
-
}
|
|
41
|
-
if (span.bold) content = <strong>{content}</strong>
|
|
42
|
-
if (span.italic) content = <em>{content}</em>
|
|
43
|
-
if (span.underline) content = <u>{content}</u>
|
|
44
|
-
if (span.dim) content = <span attributes={TextAttributes.DIM}>{content}</span>
|
|
45
|
-
return (
|
|
46
|
-
<span key={i} fg={span.fg} bg={span.bg}>
|
|
47
|
-
{content}
|
|
48
|
-
</span>
|
|
49
|
-
)
|
|
50
|
-
})}
|
|
51
|
-
</text>
|
|
52
|
-
)
|
|
54
|
+
return <text>{renderStyledSpans(spans)}</text>
|
|
53
55
|
}
|
package/src/types/theme.ts
CHANGED
package/src/ui/chat/ChatView.tsx
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { useRef, useEffect } from "react"
|
|
2
2
|
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { useShallow } from "zustand/react/shallow"
|
|
3
4
|
import { MessageLine } from "./MessageLine"
|
|
5
|
+
import type { Message } from "@/types"
|
|
4
6
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
5
7
|
|
|
8
|
+
const NO_BUFFER = { messages: [] as Message[], activeBufferId: null as string | null, currentNick: "", hasBuffer: false }
|
|
9
|
+
|
|
6
10
|
export function ChatView() {
|
|
7
|
-
const
|
|
8
|
-
const id = s.activeBufferId
|
|
9
|
-
return id ? s.buffers.get(id) ?? null : null
|
|
10
|
-
})
|
|
11
|
-
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
12
|
-
const currentNick = useStore((s) => {
|
|
11
|
+
const data = useStore(useShallow((s) => {
|
|
13
12
|
const id = s.activeBufferId
|
|
14
|
-
if (!id) return
|
|
13
|
+
if (!id) return NO_BUFFER
|
|
15
14
|
const buf = s.buffers.get(id)
|
|
16
|
-
if (!buf) return
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
if (!buf) return NO_BUFFER
|
|
16
|
+
const conn = s.connections.get(buf.connectionId)
|
|
17
|
+
return {
|
|
18
|
+
messages: buf.messages,
|
|
19
|
+
activeBufferId: id,
|
|
20
|
+
currentNick: conn?.nick ?? "",
|
|
21
|
+
hasBuffer: true,
|
|
22
|
+
}
|
|
23
|
+
}))
|
|
19
24
|
const colors = useStore((s) => s.theme?.colors)
|
|
20
25
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
21
26
|
|
|
@@ -25,9 +30,9 @@ export function ChatView() {
|
|
|
25
30
|
scrollRef.current.stickyScroll = true
|
|
26
31
|
scrollRef.current.scrollTo(scrollRef.current.scrollHeight)
|
|
27
32
|
}
|
|
28
|
-
}, [activeBufferId])
|
|
33
|
+
}, [data.activeBufferId])
|
|
29
34
|
|
|
30
|
-
if (!
|
|
35
|
+
if (!data.hasBuffer) {
|
|
31
36
|
return (
|
|
32
37
|
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
33
38
|
<text><span fg={colors?.fg_dim ?? "#292e42"}>No active buffer</span></text>
|
|
@@ -37,8 +42,8 @@ export function ChatView() {
|
|
|
37
42
|
|
|
38
43
|
return (
|
|
39
44
|
<scrollbox ref={scrollRef} height="100%" stickyScroll stickyStart="bottom">
|
|
40
|
-
{
|
|
41
|
-
<MessageLine key={msg.id} message={msg} isOwnNick={msg.nick === currentNick} />
|
|
45
|
+
{data.messages.map((msg) => (
|
|
46
|
+
<MessageLine key={msg.id} message={msg} isOwnNick={msg.nick === data.currentNick} />
|
|
42
47
|
))}
|
|
43
48
|
</scrollbox>
|
|
44
49
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import React from "react"
|
|
1
2
|
import { useStore } from "@/core/state/store"
|
|
2
|
-
import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
|
|
3
|
+
import { resolveAbstractions, parseFormatString, StyledText, renderStyledSpans } from "@/core/theme"
|
|
3
4
|
import { formatTimestamp } from "@/core/irc/formatting"
|
|
4
5
|
import { classifyUrl } from "@/core/image-preview/fetch"
|
|
5
6
|
import type { Message } from "@/types"
|
|
@@ -12,7 +13,7 @@ interface Props {
|
|
|
12
13
|
isOwnNick: boolean
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function MessageLine({ message, isOwnNick }: Props) {
|
|
16
|
+
export const MessageLine = React.memo(function MessageLine({ message, isOwnNick }: Props) {
|
|
16
17
|
const theme = useStore((s) => s.theme)
|
|
17
18
|
const config = useStore((s) => s.config)
|
|
18
19
|
const abstracts = theme?.abstracts ?? {}
|
|
@@ -45,10 +46,10 @@ export function MessageLine({ message, isOwnNick }: Props) {
|
|
|
45
46
|
const maxLen = config?.display.nick_max_length ?? nickWidth
|
|
46
47
|
const truncate = config?.display.nick_truncation ?? true
|
|
47
48
|
|
|
48
|
-
// Truncate nick if needed
|
|
49
|
+
// Truncate nick if needed — show "+" to indicate truncation
|
|
49
50
|
let displayNick = nick
|
|
50
51
|
if (truncate && displayNick.length > maxLen) {
|
|
51
|
-
displayNick = displayNick.slice(0, maxLen)
|
|
52
|
+
displayNick = displayNick.slice(0, maxLen - 1) + "+"
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// Pad the combined mode+nick so alignment covers the whole column
|
|
@@ -101,9 +102,29 @@ export function MessageLine({ message, isOwnNick }: Props) {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// Split at %| indent marker for wrap-indented two-column layout
|
|
106
|
+
const markerIdx = allSpans.findIndex((s) => s.indentMarker)
|
|
107
|
+
if (markerIdx !== -1) {
|
|
108
|
+
// Absorb whitespace-only spans after marker into prefix (for correct alignment)
|
|
109
|
+
let bodyStart = markerIdx + 1
|
|
110
|
+
while (bodyStart < allSpans.length && allSpans[bodyStart].text.trim() === "") {
|
|
111
|
+
bodyStart++
|
|
112
|
+
}
|
|
113
|
+
const prefixSpans = [...allSpans.slice(0, markerIdx), ...allSpans.slice(markerIdx + 1, bodyStart)]
|
|
114
|
+
const bodySpans = allSpans.slice(bodyStart)
|
|
115
|
+
const prefixWidth = prefixSpans.reduce((w, s) => w + s.text.length, 0)
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<box width="100%" flexDirection="row" onMouseDown={handleClick}>
|
|
119
|
+
<text width={prefixWidth}>{renderStyledSpans(prefixSpans)}</text>
|
|
120
|
+
<text flexGrow={1}>{renderStyledSpans(bodySpans, prefixSpans.length)}</text>
|
|
121
|
+
</box>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
return (
|
|
105
126
|
<box width="100%" onMouseDown={handleClick}>
|
|
106
127
|
<StyledText spans={allSpans} />
|
|
107
128
|
</box>
|
|
108
129
|
)
|
|
109
|
-
}
|
|
130
|
+
})
|
|
@@ -50,6 +50,10 @@ export function CommandInput() {
|
|
|
50
50
|
const addMessage = useStore((s) => s.addMessage)
|
|
51
51
|
const sb = useStatusbarColors()
|
|
52
52
|
|
|
53
|
+
// ── Multiline paste handling ──────────────────────────────────
|
|
54
|
+
const pasteQueueRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
|
55
|
+
const handleSubmitRef = useRef<(v: string) => void>(() => {})
|
|
56
|
+
|
|
53
57
|
const handleSubmit = useCallback((submittedValue?: string | unknown) => {
|
|
54
58
|
const text = typeof submittedValue === "string" ? submittedValue : value
|
|
55
59
|
const trimmed = text.trim()
|
|
@@ -88,6 +92,56 @@ export function CommandInput() {
|
|
|
88
92
|
}
|
|
89
93
|
}, [value, buffer, addMessage])
|
|
90
94
|
|
|
95
|
+
// Keep ref in sync for paste queue callbacks
|
|
96
|
+
handleSubmitRef.current = handleSubmit
|
|
97
|
+
|
|
98
|
+
// Intercept multiline paste — split into lines and send with delay
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const PASTE_DELAY = 500 // ms between lines
|
|
101
|
+
|
|
102
|
+
const onPaste = (event: { text: string; preventDefault(): void }) => {
|
|
103
|
+
const text = event.text
|
|
104
|
+
if (!text) return
|
|
105
|
+
|
|
106
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim())
|
|
107
|
+
if (lines.length <= 1) return // single-line paste: let input handle normally
|
|
108
|
+
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
|
|
111
|
+
// Prepend any existing input text to first pasted line
|
|
112
|
+
const currentInput = inputRef.current?.value ?? ""
|
|
113
|
+
if (currentInput.trim()) {
|
|
114
|
+
lines[0] = currentInput + lines[0]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clear input
|
|
118
|
+
setValue("")
|
|
119
|
+
if (inputRef.current) inputRef.current.value = ""
|
|
120
|
+
|
|
121
|
+
// Cancel any pending paste queue
|
|
122
|
+
for (const t of pasteQueueRef.current) clearTimeout(t)
|
|
123
|
+
pasteQueueRef.current = []
|
|
124
|
+
|
|
125
|
+
// Capture current submit for all queued lines
|
|
126
|
+
const submit = handleSubmitRef.current
|
|
127
|
+
|
|
128
|
+
// Send first line immediately, rest with delay to avoid excess flood
|
|
129
|
+
submit(lines[0])
|
|
130
|
+
for (let i = 1; i < lines.length; i++) {
|
|
131
|
+
const line = lines[i]
|
|
132
|
+
const timer = setTimeout(() => submit(line), PASTE_DELAY * i)
|
|
133
|
+
pasteQueueRef.current.push(timer)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
renderer.keyInput.on("paste", onPaste)
|
|
138
|
+
return () => {
|
|
139
|
+
renderer.keyInput.off("paste", onPaste)
|
|
140
|
+
for (const t of pasteQueueRef.current) clearTimeout(t)
|
|
141
|
+
pasteQueueRef.current = []
|
|
142
|
+
}
|
|
143
|
+
}, [renderer])
|
|
144
|
+
|
|
91
145
|
const tryNickCompletion = (currentValue: string) => {
|
|
92
146
|
if (!buffer) return null
|
|
93
147
|
const nicks = Array.from(buffer.users.keys())
|
|
@@ -35,7 +35,6 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
|
|
|
35
35
|
const [liveLeftWidth, setLiveLeftWidth] = useState(leftWidth)
|
|
36
36
|
const [liveRightWidth, setLiveRightWidth] = useState(rightWidth)
|
|
37
37
|
const dragRef = useRef<{ side: "left" | "right"; startX: number; startWidth: number; currentWidth: number } | null>(null)
|
|
38
|
-
const store = useStore()
|
|
39
38
|
|
|
40
39
|
useEffect(() => { setLiveLeftWidth(leftWidth) }, [leftWidth])
|
|
41
40
|
useEffect(() => { setLiveRightWidth(rightWidth) }, [rightWidth])
|
|
@@ -59,10 +58,11 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
|
|
|
59
58
|
const d = dragRef.current
|
|
60
59
|
if (!d) return
|
|
61
60
|
dragRef.current = null
|
|
62
|
-
const
|
|
61
|
+
const s = useStore.getState()
|
|
62
|
+
const newConfig = cloneConfig(s.config!)
|
|
63
63
|
if (d.side === "left") newConfig.sidepanel.left.width = d.currentWidth
|
|
64
64
|
else newConfig.sidepanel.right.width = d.currentWidth
|
|
65
|
-
|
|
65
|
+
s.setConfig(newConfig)
|
|
66
66
|
saveConfig(CONFIG_PATH, newConfig)
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -21,26 +21,35 @@ export function BufferList() {
|
|
|
21
21
|
// Connection header
|
|
22
22
|
if (buf.connectionId !== lastConnectionId) {
|
|
23
23
|
lastConnectionId = buf.connectionId
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
24
|
+
const hdrFormat = theme?.formats.sidepanel.header ?? "%B$0%N"
|
|
25
|
+
const hdrResolved = resolveAbstractions(hdrFormat, theme?.abstracts ?? {})
|
|
26
|
+
// Measure visible overhead of header format (everything except $0)
|
|
27
|
+
const hdrOverhead = parseFormatString(hdrResolved, [""]).reduce((w, s) => w + s.text.length, 0)
|
|
28
|
+
const maxLabelLen = leftWidth - 3 - hdrOverhead
|
|
29
|
+
const displayLabel = maxLabelLen > 0 && buf.connectionLabel.length > maxLabelLen
|
|
30
|
+
? buf.connectionLabel.slice(0, maxLabelLen - 1) + "+"
|
|
31
|
+
: buf.connectionLabel
|
|
32
|
+
const hdrSpans = parseFormatString(hdrResolved, [displayLabel])
|
|
27
33
|
items.push(
|
|
28
34
|
<box key={`h-${buf.connectionId}`} width="100%">
|
|
29
|
-
<StyledText spans={
|
|
35
|
+
<StyledText spans={hdrSpans} />
|
|
30
36
|
</box>
|
|
31
37
|
)
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
refNum++
|
|
41
|
+
const refStr = String(refNum)
|
|
35
42
|
const isActive = buf.id === activeBufferId
|
|
36
43
|
const formatKey = isActive
|
|
37
44
|
? "item_selected"
|
|
38
45
|
: `item_activity_${buf.activity}`
|
|
39
46
|
const format = theme?.formats.sidepanel[formatKey] ?? "$0. $1"
|
|
40
47
|
const resolved = resolveAbstractions(format, theme?.abstracts ?? {})
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
48
|
+
// Measure visible overhead of format (refnum + decoration, excluding channel name)
|
|
49
|
+
const formatOverhead = parseFormatString(resolved, [refStr, ""]).reduce((w, s) => w + s.text.length, 0)
|
|
50
|
+
const maxLen = leftWidth - 3 - formatOverhead
|
|
51
|
+
const displayName = maxLen > 0 && buf.name.length > maxLen ? buf.name.slice(0, maxLen - 1) + "+" : buf.name
|
|
52
|
+
const spans = parseFormatString(resolved, [refStr, displayName])
|
|
44
53
|
|
|
45
54
|
items.push(
|
|
46
55
|
<box key={buf.id} width="100%" onMouseDown={() => setActiveBuffer(buf.id)}>
|
|
@@ -8,15 +8,15 @@ const DEFAULT_PREFIX_ORDER = "~&@%+"
|
|
|
8
8
|
const EMPTY_NICKS: import("@/types").NickEntry[] = []
|
|
9
9
|
|
|
10
10
|
export function NickList() {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
11
|
+
const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) ?? null : null)
|
|
12
|
+
const conn = useStore((s) => {
|
|
13
|
+
const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
|
|
14
|
+
return buf ? s.connections.get(buf.connectionId) : undefined
|
|
15
|
+
})
|
|
14
16
|
const theme = useStore((s) => s.theme)
|
|
17
|
+
const rightWidth = useStore((s) => s.config?.sidepanel.right.width ?? 18)
|
|
15
18
|
const colors = theme?.colors
|
|
16
19
|
|
|
17
|
-
const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
|
|
18
|
-
const conn = buffer ? connectionsMap.get(buffer.connectionId) : undefined
|
|
19
|
-
|
|
20
20
|
const prefixOrder = conn?.isupport?.PREFIX
|
|
21
21
|
? extractPrefixChars(conn.isupport.PREFIX)
|
|
22
22
|
: DEFAULT_PREFIX_ORDER
|
|
@@ -46,7 +46,14 @@ export function NickList() {
|
|
|
46
46
|
const formatKey = getFormatKey(entry.prefix)
|
|
47
47
|
const format = formats[formatKey] ?? " $0"
|
|
48
48
|
const resolved = resolveAbstractions(format, abstracts)
|
|
49
|
-
|
|
49
|
+
// Measure visible overhead of format (prefix char etc., excluding nick)
|
|
50
|
+
const formatOverhead = parseFormatString(resolved, [""]).reduce((w, s) => w + s.text.length, 0)
|
|
51
|
+
const maxNickLen = rightWidth - 3 - formatOverhead
|
|
52
|
+
let displayNick = entry.nick
|
|
53
|
+
if (maxNickLen > 0 && displayNick.length > maxNickLen) {
|
|
54
|
+
displayNick = displayNick.slice(0, maxNickLen - 1) + "+"
|
|
55
|
+
}
|
|
56
|
+
const spans = parseFormatString(resolved, [displayNick])
|
|
50
57
|
|
|
51
58
|
return (
|
|
52
59
|
<box key={entry.nick} width="100%"
|
|
@@ -65,6 +72,7 @@ export function NickList() {
|
|
|
65
72
|
unreadCount: 0,
|
|
66
73
|
lastRead: new Date(),
|
|
67
74
|
users: new Map(),
|
|
75
|
+
listModes: new Map(),
|
|
68
76
|
})
|
|
69
77
|
}
|
|
70
78
|
store.setActiveBuffer(queryId)
|
|
@@ -46,6 +46,7 @@ export function SplashScreen({ onDone }: { onDone: () => void }) {
|
|
|
46
46
|
const [visibleLines, setVisibleLines] = useState(0)
|
|
47
47
|
const [showLogo, setShowLogo] = useState(false)
|
|
48
48
|
const doneRef = useRef(false)
|
|
49
|
+
const finishTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
49
50
|
|
|
50
51
|
const finish = () => {
|
|
51
52
|
if (doneRef.current) return
|
|
@@ -63,10 +64,13 @@ export function SplashScreen({ onDone }: { onDone: () => void }) {
|
|
|
63
64
|
if (count >= BIRD.length) {
|
|
64
65
|
clearInterval(timer)
|
|
65
66
|
setShowLogo(true)
|
|
66
|
-
setTimeout(finish, 2500)
|
|
67
|
+
finishTimer.current = setTimeout(finish, 2500)
|
|
67
68
|
}
|
|
68
69
|
}, 50)
|
|
69
|
-
return () =>
|
|
70
|
+
return () => {
|
|
71
|
+
clearInterval(timer)
|
|
72
|
+
if (finishTimer.current) clearTimeout(finishTimer.current)
|
|
73
|
+
}
|
|
70
74
|
}, [])
|
|
71
75
|
|
|
72
76
|
return (
|
|
@@ -8,7 +8,10 @@ import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
|
|
|
8
8
|
export function StatusLine() {
|
|
9
9
|
const config = useStore((s) => s.config)
|
|
10
10
|
const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
|
|
11
|
-
const
|
|
11
|
+
const conn = useStore((s) => {
|
|
12
|
+
const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
|
|
13
|
+
return buf ? s.connections.get(buf.connectionId) ?? null : null
|
|
14
|
+
})
|
|
12
15
|
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
13
16
|
const setActiveBuffer = useStore((s) => s.setActiveBuffer)
|
|
14
17
|
|
|
@@ -24,7 +27,6 @@ export function StatusLine() {
|
|
|
24
27
|
|
|
25
28
|
if (!config?.statusbar.enabled) return null
|
|
26
29
|
|
|
27
|
-
const conn = buffer ? connections.get(buffer.connectionId) : null
|
|
28
30
|
const items = config.statusbar.items
|
|
29
31
|
|
|
30
32
|
function getItemFormat(item: StatusbarItem): string {
|