kokoirc 0.2.3 → 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/LICENSE +21 -0
- package/README.md +64 -39
- 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 +27 -3
- package/src/core/commands/help-formatter.ts +1 -1
- package/src/core/commands/helpers.ts +3 -1
- package/src/core/commands/registry.ts +182 -5
- package/src/core/config/defaults.ts +11 -0
- package/src/core/config/loader.ts +4 -2
- 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 +5 -4
- package/src/core/irc/client.ts +13 -2
- package/src/core/irc/events.ts +140 -109
- package/src/core/irc/netsplit.ts +2 -1
- package/src/core/scripts/api.ts +13 -3
- package/src/core/state/selectors.ts +1 -2
- package/src/core/state/store.ts +384 -18
- package/src/core/storage/index.ts +2 -2
- package/src/core/storage/writer.ts +12 -10
- package/src/core/theme/index.ts +1 -1
- package/src/core/theme/parser.ts +3 -1
- package/src/core/theme/renderer.tsx +46 -16
- package/src/core/utils/id.ts +2 -0
- package/src/types/config.ts +13 -0
- package/src/types/index.ts +1 -2
- package/src/types/theme.ts +1 -0
- package/src/ui/chat/ChatView.tsx +21 -10
- package/src/ui/chat/MessageLine.tsx +44 -6
- package/src/ui/input/CommandInput.tsx +56 -1
- package/src/ui/layout/AppLayout.tsx +6 -4
- package/src/ui/overlay/ImagePreview.tsx +77 -0
- 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/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/types/theme.ts
CHANGED
package/src/ui/chat/ChatView.tsx
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
11
|
+
const data = useStore(useShallow((s) => {
|
|
12
|
+
const id = s.activeBufferId
|
|
13
|
+
if (!id) return NO_BUFFER
|
|
14
|
+
const buf = s.buffers.get(id)
|
|
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
|
+
}))
|
|
10
24
|
const colors = useStore((s) => s.theme?.colors)
|
|
11
25
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
12
26
|
|
|
13
|
-
const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
|
|
14
|
-
const currentNick = buffer ? connectionsMap.get(buffer.connectionId)?.nick ?? "" : ""
|
|
15
|
-
|
|
16
27
|
// Snap to bottom when switching buffers
|
|
17
28
|
useEffect(() => {
|
|
18
29
|
if (scrollRef.current) {
|
|
19
30
|
scrollRef.current.stickyScroll = true
|
|
20
31
|
scrollRef.current.scrollTo(scrollRef.current.scrollHeight)
|
|
21
32
|
}
|
|
22
|
-
}, [activeBufferId])
|
|
33
|
+
}, [data.activeBufferId])
|
|
23
34
|
|
|
24
|
-
if (!
|
|
35
|
+
if (!data.hasBuffer) {
|
|
25
36
|
return (
|
|
26
37
|
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
27
38
|
<text><span fg={colors?.fg_dim ?? "#292e42"}>No active buffer</span></text>
|
|
@@ -31,8 +42,8 @@ export function ChatView() {
|
|
|
31
42
|
|
|
32
43
|
return (
|
|
33
44
|
<scrollbox ref={scrollRef} height="100%" stickyScroll stickyStart="bottom">
|
|
34
|
-
{
|
|
35
|
-
<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} />
|
|
36
47
|
))}
|
|
37
48
|
</scrollbox>
|
|
38
49
|
)
|
|
@@ -1,15 +1,19 @@
|
|
|
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"
|
|
5
|
+
import { classifyUrl } from "@/core/image-preview/fetch"
|
|
4
6
|
import type { Message } from "@/types"
|
|
5
7
|
import type { StyledSpan } from "@/types/theme"
|
|
6
8
|
|
|
9
|
+
const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
|
|
10
|
+
|
|
7
11
|
interface Props {
|
|
8
12
|
message: Message
|
|
9
13
|
isOwnNick: boolean
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
export function MessageLine({ message, isOwnNick }: Props) {
|
|
16
|
+
export const MessageLine = React.memo(function MessageLine({ message, isOwnNick }: Props) {
|
|
13
17
|
const theme = useStore((s) => s.theme)
|
|
14
18
|
const config = useStore((s) => s.config)
|
|
15
19
|
const abstracts = theme?.abstracts ?? {}
|
|
@@ -42,10 +46,10 @@ export function MessageLine({ message, isOwnNick }: Props) {
|
|
|
42
46
|
const maxLen = config?.display.nick_max_length ?? nickWidth
|
|
43
47
|
const truncate = config?.display.nick_truncation ?? true
|
|
44
48
|
|
|
45
|
-
// Truncate nick if needed
|
|
49
|
+
// Truncate nick if needed — show "+" to indicate truncation
|
|
46
50
|
let displayNick = nick
|
|
47
51
|
if (truncate && displayNick.length > maxLen) {
|
|
48
|
-
displayNick = displayNick.slice(0, maxLen)
|
|
52
|
+
displayNick = displayNick.slice(0, maxLen - 1) + "+"
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// Pad the combined mode+nick so alignment covers the whole column
|
|
@@ -84,9 +88,43 @@ export function MessageLine({ message, isOwnNick }: Props) {
|
|
|
84
88
|
const separator: StyledSpan = { text: " ", bold: false, italic: false, underline: false, dim: false }
|
|
85
89
|
const allSpans = [...tsSpans, separator, ...msgSpans]
|
|
86
90
|
|
|
91
|
+
// Click any URL in the message to attempt image preview (erssi-style content-type sniffing)
|
|
92
|
+
const handleClick = () => {
|
|
93
|
+
const text = message.text
|
|
94
|
+
const urls = text.match(URL_RE)
|
|
95
|
+
if (!urls) return
|
|
96
|
+
|
|
97
|
+
for (const url of urls) {
|
|
98
|
+
if (classifyUrl(url)) {
|
|
99
|
+
useStore.getState().showImagePreview(url)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
}
|
|
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
|
+
|
|
87
125
|
return (
|
|
88
|
-
<box width="100%">
|
|
126
|
+
<box width="100%" onMouseDown={handleClick}>
|
|
89
127
|
<StyledText spans={allSpans} />
|
|
90
128
|
</box>
|
|
91
129
|
)
|
|
92
|
-
}
|
|
130
|
+
})
|
|
@@ -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"
|
|
@@ -49,6 +50,10 @@ export function CommandInput() {
|
|
|
49
50
|
const addMessage = useStore((s) => s.addMessage)
|
|
50
51
|
const sb = useStatusbarColors()
|
|
51
52
|
|
|
53
|
+
// ── Multiline paste handling ──────────────────────────────────
|
|
54
|
+
const pasteQueueRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
|
55
|
+
const handleSubmitRef = useRef<(v: string) => void>(() => {})
|
|
56
|
+
|
|
52
57
|
const handleSubmit = useCallback((submittedValue?: string | unknown) => {
|
|
53
58
|
const text = typeof submittedValue === "string" ? submittedValue : value
|
|
54
59
|
const trimmed = text.trim()
|
|
@@ -75,7 +80,7 @@ export function CommandInput() {
|
|
|
75
80
|
client.say(buffer.name, trimmed)
|
|
76
81
|
const conn = useStore.getState().connections.get(buffer.connectionId)
|
|
77
82
|
addMessage(buffer.id, {
|
|
78
|
-
id:
|
|
83
|
+
id: nextMsgId(),
|
|
79
84
|
timestamp: new Date(),
|
|
80
85
|
type: "message",
|
|
81
86
|
nick: conn?.nick ?? "",
|
|
@@ -87,6 +92,56 @@ export function CommandInput() {
|
|
|
87
92
|
}
|
|
88
93
|
}, [value, buffer, addMessage])
|
|
89
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
|
+
|
|
90
145
|
const tryNickCompletion = (currentValue: string) => {
|
|
91
146
|
if (!buffer) return null
|
|
92
147
|
const nicks = Array.from(buffer.users.keys())
|
|
@@ -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)
|
|
@@ -34,7 +35,6 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
|
|
|
34
35
|
const [liveLeftWidth, setLiveLeftWidth] = useState(leftWidth)
|
|
35
36
|
const [liveRightWidth, setLiveRightWidth] = useState(rightWidth)
|
|
36
37
|
const dragRef = useRef<{ side: "left" | "right"; startX: number; startWidth: number; currentWidth: number } | null>(null)
|
|
37
|
-
const store = useStore()
|
|
38
38
|
|
|
39
39
|
useEffect(() => { setLiveLeftWidth(leftWidth) }, [leftWidth])
|
|
40
40
|
useEffect(() => { setLiveRightWidth(rightWidth) }, [rightWidth])
|
|
@@ -58,10 +58,11 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
|
|
|
58
58
|
const d = dragRef.current
|
|
59
59
|
if (!d) return
|
|
60
60
|
dragRef.current = null
|
|
61
|
-
const
|
|
61
|
+
const s = useStore.getState()
|
|
62
|
+
const newConfig = cloneConfig(s.config!)
|
|
62
63
|
if (d.side === "left") newConfig.sidepanel.left.width = d.currentWidth
|
|
63
64
|
else newConfig.sidepanel.right.width = d.currentWidth
|
|
64
|
-
|
|
65
|
+
s.setConfig(newConfig)
|
|
65
66
|
saveConfig(CONFIG_PATH, newConfig)
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -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
|
+
}
|
|
@@ -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 {
|