Compare commits
3
Commits
main
...
c223c274f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c223c274f9 | ||
|
|
e1173eba9e | ||
|
|
76ef8ffaaf |
+20
-48
@@ -96,20 +96,7 @@ func (a *App) handleInput(text string) {
|
|||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Send error: " + err.Error(),
|
"[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) {
|
func (a *App) handleCommand(text string) {
|
||||||
@@ -301,9 +288,6 @@ func (a *App) cmdJoin(channel string) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(channel)
|
a.ui.SwitchToBuffer(channel)
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Joined "+channel,
|
|
||||||
)
|
|
||||||
a.ui.SetStatus(nick, channel, "connected")
|
a.ui.SetStatus(nick, channel, "connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,10 +322,6 @@ func (a *App) cmdPart(channel string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Left "+channel,
|
|
||||||
)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if a.target == channel {
|
if a.target == channel {
|
||||||
a.target = ""
|
a.target = ""
|
||||||
@@ -368,7 +348,6 @@ func (a *App) cmdMsg(args string) {
|
|||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
@@ -386,16 +365,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[red]Send failed: %v", err,
|
"[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) {
|
func (a *App) cmdQuery(nick string) {
|
||||||
@@ -785,12 +755,10 @@ func (a *App) handlePrivmsgEvent(
|
|||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
text := strings.Join(lines, " ")
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
if msg.From == myNick {
|
// A direct message shows in the buffer of the other user:
|
||||||
return
|
// the recipient of one the user sent, else the sender.
|
||||||
}
|
|
||||||
|
|
||||||
target := msg.To
|
target := msg.To
|
||||||
if !strings.HasPrefix(target, "#") {
|
if !strings.HasPrefix(target, "#") && msg.From != myNick {
|
||||||
target = msg.From
|
target = msg.From
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,31 +781,35 @@ func (a *App) handleJoinEvent(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePartEvent shows another user's part. The server
|
// handlePartEvent shows a part in the channel's buffer. The
|
||||||
// also sends the user's own part back to it, which cmdPart
|
// user's own part does not mark the channel unread: the user
|
||||||
// has already shown; showing it again would add a second
|
// has just left it.
|
||||||
// line and mark the channel just left as unread.
|
|
||||||
func (a *App) handlePartEvent(
|
func (a *App) handlePartEvent(
|
||||||
msg *api.Message, timestamp, myNick string,
|
msg *api.Message, timestamp, myNick string,
|
||||||
) {
|
) {
|
||||||
if msg.To == "" || msg.From == myNick {
|
if msg.To == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
reason := strings.Join(lines, " ")
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
if reason != "" {
|
line := fmt.Sprintf(
|
||||||
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(
|
|
||||||
"[gray]%s [yellow]*** %s has left %s",
|
"[gray]%s [yellow]*** %s has left %s",
|
||||||
timestamp, msg.From, msg.To,
|
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(
|
func (a *App) handleQuitEvent(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
@@ -8,6 +10,15 @@ import (
|
|||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"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) {
|
func TestMemberPrefix(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -53,46 +64,188 @@ func TestMemberPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestPartEvent checks that a PART event adds a line to the
|
// TestPartEvent checks that a PART event adds a line to the
|
||||||
// channel's buffer and marks it unread, except for the
|
// channel's buffer and marks it unread, except that the
|
||||||
// user's own part, which cmdPart has already shown.
|
// user's own part does not mark it unread.
|
||||||
func TestPartEvent(t *testing.T) {
|
func TestPartEvent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
from string
|
from string
|
||||||
wantLines int
|
|
||||||
wantUnread int
|
wantUnread int
|
||||||
}{
|
}{
|
||||||
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
|
{name: "own part", from: testNick, wantUnread: 0},
|
||||||
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
|
{name: "other member", from: testOtherNick, wantUnread: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := &App{ui: NewUI(), nick: "erin"}
|
app := &App{ui: NewUI(), nick: testNick}
|
||||||
runUI(t, app.ui)
|
runUI(t, app.ui)
|
||||||
|
|
||||||
app.handleServerMessage(&api.Message{
|
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
|
var lines, unread int
|
||||||
|
|
||||||
app.ui.app.QueueUpdate(func() {
|
ui.app.QueueUpdate(func() {
|
||||||
buf := app.ui.getOrCreateBuffer("#chan")
|
buf := ui.getOrCreateBuffer(name)
|
||||||
lines = len(buf.Lines)
|
lines = len(buf.Lines)
|
||||||
unread = buf.Unread
|
unread = buf.Unread
|
||||||
})
|
})
|
||||||
|
|
||||||
if lines != test.wantLines || unread != test.wantUnread {
|
return lines, unread
|
||||||
t.Fatalf("got %d lines and %d unread, want %d and %d",
|
|
||||||
lines, unread, test.wantLines, test.wantUnread)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runUI runs ui on a simulated screen until the test ends.
|
// runUI runs ui on a simulated screen until the test ends.
|
||||||
|
|||||||
@@ -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.
|
// AddStatus adds a line to the status buffer.
|
||||||
func (ui *UI) AddStatus(line string) {
|
func (ui *UI) AddStatus(line string) {
|
||||||
ts := time.Now().Format("15:04")
|
ts := time.Now().Format("15:04")
|
||||||
|
|||||||
+43
-8
@@ -355,6 +355,14 @@ function App() {
|
|||||||
.catch(() => {});
|
.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(
|
const processMessage = useCallback(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
|
const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
|
||||||
@@ -424,6 +432,13 @@ function App() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "PART": {
|
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 reason = body ? " (" + body + ")" : "";
|
||||||
const text = `${msg.from} has parted ${msg.to}${reason}`;
|
const text = `${msg.from} has parted ${msg.to}${reason}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
@@ -431,6 +446,20 @@ function App() {
|
|||||||
|
|
||||||
break;
|
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": {
|
case "QUIT": {
|
||||||
const reason = body ? " (" + body + ")" : "";
|
const reason = body ? " (" + body + ")" : "";
|
||||||
const text = `${msg.from} has quit${reason}`;
|
const text = `${msg.from} has quit${reason}`;
|
||||||
@@ -656,7 +685,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addMessage, refreshMembers],
|
[addMessage, refreshMembers, removeChannelTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Long-poll loop.
|
// Long-poll loop.
|
||||||
@@ -829,14 +858,23 @@ function App() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ command: "JOIN", to: name }),
|
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) => {
|
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;
|
return prev;
|
||||||
|
}
|
||||||
|
setActiveTab(prev.length);
|
||||||
|
|
||||||
return [...prev, { type: "channel", name }];
|
return [...prev, { type: "channel", name }];
|
||||||
});
|
});
|
||||||
const newIdx = tabsRef.current.length;
|
|
||||||
setActiveTab(newIdx);
|
|
||||||
try {
|
try {
|
||||||
const hist = await api(
|
const hist = await api(
|
||||||
`/history?target=${encodeURIComponent(name)}&limit=50`,
|
`/history?target=${encodeURIComponent(name)}&limit=50`,
|
||||||
@@ -865,10 +903,7 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore.
|
// Ignore.
|
||||||
}
|
}
|
||||||
setTabs((prev) =>
|
removeChannelTab(name);
|
||||||
prev.filter((t) => !(t.type === "channel" && t.name === name)),
|
|
||||||
);
|
|
||||||
setActiveTab(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeTab = (idx) => {
|
const closeTab = (idx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user