3 Commits
Author SHA1 Message Date
sneak c223c274f9 Show a joined channel's web tab whichever reply comes first (closes #193)
check / check (push) Waiting to run
After a join request returned, joinChannel made tab number
tabsRef.current.length active. When the user's own JOIN event had
already added the tab, or the tab was already open, that index was one
past the last tab. The view then fell back to the Server tab and typed
lines were dropped.

joinChannel now finds the channel's tab by name inside its own tab-list
update and makes that tab active there. Preact runs that update at once
on the newest tab list, so the position is right even when the JOIN
event, or the close of another tab, landed first.

Model: opus-5-5
2026-10-27 02:56:14 +00:00
clawbot e1173eba9e Close a channel's web tab when the user parts or is kicked (closes #185)
check / check (push) Waiting to run
A PART event from the user's own nick now closes the channel's tab,
whether this web client or another client of the session sent it. It
adds no message or unread count.

A KICK event now goes into the channel's tab as "nick was kicked from
channel by kicker (reason)", with the kicked nick taken from params.
Before, it fell through to the Server tab, which showed only the
reason. When the kicked nick is the user's own, the tab closes.

The tab removal that partChannel did moves into removeChannelTab,
which partChannel and both events share.

Model: opus-5-5
2026-10-27 03:11:33 +01:00
clawbot 76ef8ffaaf Show neoirc-cli's own messages, joins and parts from the server's events (closes #186)
check / check (push) Waiting to run
neoirc-cli wrote its own line when it sent a message, joined or parted,
and ignored the server's event for its own nick, except a JOIN, so a
join showed twice and what the session did from another client never
showed. The HTTP API queues these events to every client of the
session, so the CLI now writes no line of its own when it sends and
shows the event as it shows anyone else's. A direct message the user
sent goes in the recipient's buffer. The user's own part does not mark
the channel unread, as the user has just left it.

Model: opus-5-5
2026-10-27 02:23:34 +01:00
4 changed files with 248 additions and 75 deletions
+20 -48
View File
@@ -96,20 +96,7 @@ func (a *App) handleInput(text string) {
a.ui.AddStatus(
"[red]Send error: " + err.Error(),
)
return
}
timestamp := time.Now().Format(timeFormat)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
}
func (a *App) handleCommand(text string) {
@@ -301,9 +288,6 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock()
a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected")
}
@@ -338,10 +322,6 @@ func (a *App) cmdPart(channel string) {
return
}
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.mu.Lock()
if a.target == channel {
a.target = ""
@@ -368,7 +348,6 @@ func (a *App) cmdMsg(args string) {
a.mu.Lock()
connected := a.connected
nick := a.nick
a.mu.Unlock()
if !connected {
@@ -386,16 +365,7 @@ func (a *App) cmdMsg(args string) {
a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err,
))
return
}
timestamp := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
}
func (a *App) cmdQuery(nick string) {
@@ -785,12 +755,10 @@ func (a *App) handlePrivmsgEvent(
lines := msg.BodyLines()
text := strings.Join(lines, " ")
if msg.From == myNick {
return
}
// A direct message shows in the buffer of the other user:
// the recipient of one the user sent, else the sender.
target := msg.To
if !strings.HasPrefix(target, "#") {
if !strings.HasPrefix(target, "#") && msg.From != myNick {
target = msg.From
}
@@ -813,31 +781,35 @@ func (a *App) handleJoinEvent(
))
}
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string,
) {
if msg.To == "" || msg.From == myNick {
if msg.To == "" {
return
}
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
if reason != "" {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
timestamp, msg.From, msg.To, reason,
))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
))
)
if reason != "" {
line += " (" + reason + ")"
}
if msg.From == myNick {
a.ui.AddLineAsRead(msg.To, line)
return
}
a.ui.AddLine(msg.To, line)
}
func (a *App) handleQuitEvent(
+168 -15
View File
@@ -1,6 +1,8 @@
package cli
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gdamore/tcell/v2"
@@ -8,6 +10,15 @@ import (
"sneak.berlin/go/neoirc/pkg/irc"
)
// The user's nick, a channel it is in, another user's nick and
// a message text, for the tests below.
const (
testNick = "erin"
testChannel = "#chan"
testOtherNick = "bob"
testText = "hello"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
@@ -53,46 +64,188 @@ func TestMemberPrefix(t *testing.T) {
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except for the
// user's own part, which cmdPart has already shown.
// channel's buffer and marks it unread, except that the
// user's own part does not mark it unread.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
{name: "own part", from: testNick, wantUnread: 0},
{name: "other member", from: testOtherNick, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "erin"}
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
Command: irc.CmdPart, From: test.from, To: testChannel,
})
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want 1 and %d",
lines, unread, test.wantUnread)
}
})
}
}
// TestOwnCommandShownOnce checks that a message, join or
// part made from neoirc-cli shows as one line in the
// channel's buffer once the server's event for it arrives,
// and does not mark the channel unread.
func TestOwnCommandShownOnce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
send func(app *App)
event api.Message
}{
{
name: "message",
send: func(app *App) { app.handleInput(testText) },
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
},
{
name: "join",
send: func(app *App) { app.cmdJoin(testChannel) },
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
},
{
name: "part",
send: func(app *App) { app.cmdPart(testChannel) },
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command and answers nothing.
server := httptest.NewServer(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {},
))
t.Cleanup(server.Close)
app := &App{
ui: NewUI(),
client: api.NewClient(server.URL),
nick: testNick,
target: testChannel,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testChannel)
test.send(app)
app.handleServerMessage(&test.event)
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != 0 {
t.Fatalf("got %d lines and %d unread, want 1 and 0",
lines, unread)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to.
func TestOwnEventFromOtherClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
event api.Message
buffer string
}{
{
name: "channel message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: testChannel,
},
{
name: "direct message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick,
Body: []string{testText},
},
buffer: testOtherNick,
},
{
name: "notice",
event: api.Message{
Command: irc.CmdNotice, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: "(status)",
},
{
name: "join",
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
buffer: testChannel,
},
{
name: "part",
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
buffer: testChannel,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&test.event)
lines, _ := bufferState(app.ui, test.buffer)
if lines != 1 {
t.Fatalf("got %d lines in %s, want 1", lines, test.buffer)
}
})
}
}
// bufferState returns the number of lines in the named
// buffer and how many of them are unread.
func bufferState(ui *UI, name string) (int, int) {
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
ui.app.QueueUpdate(func() {
buf := ui.getOrCreateBuffer(name)
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
return lines, unread
}
// runUI runs ui on a simulated screen until the test ends.
+13
View File
@@ -95,6 +95,19 @@ func (ui *UI) AddLine(bufferName, line string) {
})
}
// AddLineAsRead adds a line to the specified buffer, as
// AddLine does, but does not mark the buffer unread.
func (ui *UI) AddLineAsRead(bufferName, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
if ui.buffers[ui.currentBuffer] == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer.
func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04")
+43 -8
View File
@@ -355,6 +355,14 @@ function App() {
.catch(() => {});
}, []);
// removeChannelTab closes a channel's tab and shows the Server tab.
const removeChannelTab = useCallback((name) => {
setTabs((prev) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
}, []);
const processMessage = useCallback(
(msg) => {
const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
@@ -424,6 +432,13 @@ function App() {
break;
}
case "PART": {
// The user's own PART, sent from this client or another,
// closes the tab and adds no message or unread count.
if (msg.from === nickRef.current) {
removeChannelTab(msg.to);
break;
}
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has parted ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
@@ -431,6 +446,20 @@ function App() {
break;
}
case "KICK": {
// The kicked nick is in params; the body is the reason.
const kicked = Array.isArray(msg.params) ? msg.params[0] : "";
const reason = body ? " (" + body + ")" : "";
const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (kicked === nickRef.current) {
removeChannelTab(msg.to);
} else if (msg.to && msg.to.startsWith("#")) {
refreshMembers(msg.to);
}
break;
}
case "QUIT": {
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has quit${reason}`;
@@ -656,7 +685,7 @@ function App() {
}
}
},
[addMessage, refreshMembers],
[addMessage, refreshMembers, removeChannelTab],
);
// Long-poll loop.
@@ -829,14 +858,23 @@ function App() {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }),
});
// Show the channel's tab. Preact runs this update at once on the
// newest tab list, so the tab is found there by name: the user's
// own JOIN event may already have added it, and other tabs may
// have closed.
setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === name))
const idx = prev.findIndex(
(t) => t.type === "channel" && t.name === name,
);
if (idx >= 0) {
setActiveTab(idx);
return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "channel", name }];
});
const newIdx = tabsRef.current.length;
setActiveTab(newIdx);
try {
const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`,
@@ -865,10 +903,7 @@ function App() {
} catch (e) {
// Ignore.
}
setTabs((prev) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
removeChannelTab(name);
};
const closeTab = (idx) => {