fix: IRC SPA cleanup — /motd, /query, Firefox / key, default MOTD (#58)
All checks were successful
check / check (push) Successful in 1m0s
All checks were successful
check / check (push) Successful in 1m0s
## Summary Fixes IRC client SPA issues reported in [issue #57](#57). ## Changes ### Server-side - **Default MOTD**: Added figlet-style ASCII art MOTD for "neoirc" as the default when no MOTD is configured via environment/config - **MOTD command handler**: Added `MOTD` case to `dispatchCommand` so clients can re-request the MOTD at any time (proper IRC behavior) ### SPA (web client) - **`/motd` command**: Sends MOTD request to server, displays 375/372/376 numerics in server window - **`/query nick [message]`**: Opens a DM tab with the specified user, optionally sends a message - **`/clear`**: Clears messages in the current tab - **Firefox `/` key fix**: Added global `keydown` listener that captures `/` when input is not focused, preventing Firefox quick search and redirecting focus to the input element. Also auto-focuses input on SPA init. - **MOTD on resumed sessions**: When restoring from a saved token, the MOTD is re-requested so it always appears in the server window - **Updated `/help`**: Shows all new commands with descriptions - **Login screen MOTD styling**: Improved for ASCII art display (monospace, proper line height) ## Testing - `docker build .` passes (includes `make check` with tests, lint, fmt-check) - All existing tests pass with no modifications closes #57 <!-- session: agent:sdlc-manager:subagent:7c880fec-f818-49ff-a548-2d3c26758bb6 --> Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #58 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #58.
This commit is contained in:
@@ -138,16 +138,29 @@ func (a *App) dispatchCommand(cmd, args string) {
|
|||||||
a.cmdQuery(args)
|
a.cmdQuery(args)
|
||||||
case "/topic":
|
case "/topic":
|
||||||
a.cmdTopic(args)
|
a.cmdTopic(args)
|
||||||
case "/names":
|
|
||||||
a.cmdNames()
|
|
||||||
case "/list":
|
|
||||||
a.cmdList()
|
|
||||||
case "/window", "/w":
|
case "/window", "/w":
|
||||||
a.cmdWindow(args)
|
a.cmdWindow(args)
|
||||||
case "/quit":
|
case "/quit":
|
||||||
a.cmdQuit()
|
a.cmdQuit()
|
||||||
case "/help":
|
case "/help":
|
||||||
a.cmdHelp()
|
a.cmdHelp()
|
||||||
|
default:
|
||||||
|
a.dispatchInfoCommand(cmd, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||||
|
switch cmd {
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
|
case "/motd":
|
||||||
|
a.cmdMotd()
|
||||||
|
case "/who":
|
||||||
|
a.cmdWho(args)
|
||||||
|
case "/whois":
|
||||||
|
a.cmdWhois(args)
|
||||||
default:
|
default:
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Unknown command: " + cmd,
|
"[red]Unknown command: " + cmd,
|
||||||
@@ -510,6 +523,96 @@ func (a *App) cmdList() {
|
|||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMotd() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]MOTD failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWho(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := args
|
||||||
|
if channel == "" {
|
||||||
|
channel = target
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel == "" ||
|
||||||
|
!strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /who #channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: "WHO", To: channel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHO failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWhois(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /whois <nick>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: "WHOIS", To: args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHOIS failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
func (a *App) cmdWindow(args string) {
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
@@ -574,6 +677,9 @@ func (a *App) cmdHelp() {
|
|||||||
" /topic [text] — View/set topic",
|
" /topic [text] — View/set topic",
|
||||||
" /names — List channel members",
|
" /names — List channel members",
|
||||||
" /list — List channels",
|
" /list — List channels",
|
||||||
|
" /who [#channel] — List users in channel",
|
||||||
|
" /whois <nick> — Show user info",
|
||||||
|
" /motd — Show message of the day",
|
||||||
" /window <n> — Switch buffer",
|
" /window <n> — Switch buffer",
|
||||||
" /quit — Disconnect and exit",
|
" /quit — Disconnect and exit",
|
||||||
" /help — This help",
|
" /help — This help",
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import (
|
|||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
||||||
|
| '_ \ / _ \/ _ \ | | '__/ __|
|
||||||
|
| | | | __/ (_) || | | | (__
|
||||||
|
|_| |_|\___|\___/ |_|_| \___|
|
||||||
|
|
||||||
|
Welcome to NeoIRC — IRC semantics over HTTP.
|
||||||
|
Type /help for available commands.`
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -62,7 +70,7 @@ func New(
|
|||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MAX_HISTORY", "10000")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("MOTD", "")
|
viper.SetDefault("MOTD", defaultMOTD)
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
|
|||||||
@@ -670,13 +670,12 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case "PING":
|
case "MOTD", "LIST", "WHO", "WHOIS", "PING":
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.dispatchInfoCommand(
|
||||||
map[string]string{
|
writer, request,
|
||||||
"command": "PONG",
|
sessionID, clientID, nick,
|
||||||
"from": hdlr.serverName(),
|
command, target, bodyLines,
|
||||||
},
|
)
|
||||||
http.StatusOK)
|
|
||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
@@ -1391,6 +1390,269 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchInfoCommand handles informational IRC commands
|
||||||
|
// that produce server-side numerics (MOTD, LIST, WHO,
|
||||||
|
// WHOIS, PING).
|
||||||
|
func (hdlr *Handlers) dispatchInfoCommand(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, command, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
okResp := map[string]string{"status": "ok"}
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "MOTD":
|
||||||
|
hdlr.deliverMOTD(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
case "LIST":
|
||||||
|
hdlr.handleListCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
case "WHO":
|
||||||
|
hdlr.handleWhoCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
case "WHOIS":
|
||||||
|
hdlr.handleWhoisCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target, bodyLines,
|
||||||
|
)
|
||||||
|
case "PING":
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{
|
||||||
|
"command": "PONG",
|
||||||
|
"from": hdlr.serverName(),
|
||||||
|
},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request, okResp, http.StatusOK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListCmd sends RPL_LIST (322) for each channel,
|
||||||
|
// then sends 323 to signal the end of the list.
|
||||||
|
func (hdlr *Handlers) handleListCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
channels, err := hdlr.params.Database.ListAllChannels(
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "323", nick, nil,
|
||||||
|
"End of /LIST",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channel := range channels {
|
||||||
|
memberIDs, _ :=
|
||||||
|
hdlr.params.Database.GetChannelMemberIDs(
|
||||||
|
ctx, channel.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
count := strconv.Itoa(len(memberIDs))
|
||||||
|
topic := channel.Topic
|
||||||
|
|
||||||
|
if topic == "" {
|
||||||
|
topic = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "322", nick,
|
||||||
|
[]string{channel.Name, count}, topic,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "323", nick, nil,
|
||||||
|
"End of /LIST",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
|
||||||
|
// of the target channel, followed by RPL_ENDOFWHO (315).
|
||||||
|
func (hdlr *Handlers) handleWhoCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, target string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "461", nick,
|
||||||
|
[]string{"WHO"}, "Not enough parameters",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := target
|
||||||
|
if !strings.HasPrefix(channel, "#") {
|
||||||
|
channel = "#" + channel
|
||||||
|
}
|
||||||
|
|
||||||
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
|
ctx, channel,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "315", nick,
|
||||||
|
[]string{channel}, "End of /WHO list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := hdlr.params.Database.ChannelMembers(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
for _, mem := range members {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "352", nick,
|
||||||
|
[]string{
|
||||||
|
channel, mem.Nick, "neoirc",
|
||||||
|
srvName, mem.Nick, "H",
|
||||||
|
},
|
||||||
|
"0 "+mem.Nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "315", nick,
|
||||||
|
[]string{channel}, "End of /WHO list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
|
||||||
|
// 319, 318) for the target nick.
|
||||||
|
func (hdlr *Handlers) handleWhoisCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
whoisNick := target
|
||||||
|
if whoisNick == "" {
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) > 0 {
|
||||||
|
whoisNick = strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if whoisNick == "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "461", nick,
|
||||||
|
[]string{"WHOIS"}, "Not enough parameters",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, err :=
|
||||||
|
hdlr.params.Database.GetSessionByNick(
|
||||||
|
ctx, whoisNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "401", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
)
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "318", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"End of /WHOIS list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.sendWhoisNumerics(
|
||||||
|
ctx, clientID, sessionID, nick,
|
||||||
|
whoisNick, targetSID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWhoisNumerics emits 311/312/319/318 for a
|
||||||
|
// resolved WHOIS target.
|
||||||
|
func (hdlr *Handlers) sendWhoisNumerics(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, whoisNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 311 RPL_WHOISUSER
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "311", nick,
|
||||||
|
[]string{whoisNick, whoisNick, "neoirc", "*"},
|
||||||
|
whoisNick,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 312 RPL_WHOISSERVER
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "312", nick,
|
||||||
|
[]string{whoisNick, srvName},
|
||||||
|
srvName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 319 RPL_WHOISCHANNELS
|
||||||
|
channels, _ := hdlr.params.Database.GetSessionChannels(
|
||||||
|
ctx, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(channels) > 0 {
|
||||||
|
names := make([]string, 0, len(channels))
|
||||||
|
|
||||||
|
for _, chanInfo := range channels {
|
||||||
|
names = append(names, chanInfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "319", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
strings.Join(names, " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 318 RPL_ENDOFWHOIS
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "318", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"End of /WHOIS list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleQuit(
|
func (hdlr *Handlers) handleQuit(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
|
|||||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
10
web/dist/style.css
vendored
10
web/dist/style.css
vendored
@@ -70,14 +70,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box .motd {
|
.login-box .motd {
|
||||||
color: var(--text-dim);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
border-left: 2px solid var(--border);
|
line-height: 1.2;
|
||||||
padding-left: 12px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box form {
|
.login-box form {
|
||||||
|
|||||||
264
web/src/app.jsx
264
web/src/app.jsx
@@ -71,7 +71,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state")
|
api("/state")
|
||||||
.then((u) => onLogin(u.nick))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -419,6 +419,100 @@ function App() {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "322": {
|
||||||
|
// RPL_LIST — channel, member count, topic.
|
||||||
|
if (Array.isArray(msg.params) && msg.params.length >= 2) {
|
||||||
|
const chName = msg.params[0];
|
||||||
|
const count = msg.params[1];
|
||||||
|
const chTopic = body || "";
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: `${chName} (${count} users): ${chTopic.trim()}`,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "323":
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: body || "End of channel list",
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "352": {
|
||||||
|
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
|
||||||
|
if (Array.isArray(msg.params) && msg.params.length >= 5) {
|
||||||
|
const whoCh = msg.params[0];
|
||||||
|
const whoNick = msg.params[4];
|
||||||
|
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: `${whoCh} ${whoNick} ${whoFlags}`,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "315":
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: body || "End of /WHO list",
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "311": {
|
||||||
|
// RPL_WHOISUSER — nick, user, host, *, realname.
|
||||||
|
if (Array.isArray(msg.params) && msg.params.length >= 1) {
|
||||||
|
const wiNick = msg.params[0];
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: `${wiNick} (${body})`,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "312": {
|
||||||
|
// RPL_WHOISSERVER — nick, server, server info.
|
||||||
|
if (Array.isArray(msg.params) && msg.params.length >= 2) {
|
||||||
|
const wiNick = msg.params[0];
|
||||||
|
const wiServer = msg.params[1];
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: `${wiNick} on ${wiServer}`,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "319": {
|
||||||
|
// RPL_WHOISCHANNELS — nick, channels.
|
||||||
|
if (Array.isArray(msg.params) && msg.params.length >= 1) {
|
||||||
|
const wiNick = msg.params[0];
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: `${wiNick} is on: ${body}`,
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "318":
|
||||||
|
addMessage("Server", {
|
||||||
|
...base,
|
||||||
|
text: body || "End of /WHOIS list",
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
case "375":
|
case "375":
|
||||||
case "372":
|
case "372":
|
||||||
case "376":
|
case "376":
|
||||||
@@ -497,6 +591,31 @@ function App() {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// Global keyboard handler — capture '/' to prevent
|
||||||
|
// Firefox quick search and redirect focus to the input.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e) => {
|
||||||
|
if (
|
||||||
|
e.key === "/" &&
|
||||||
|
document.activeElement !== inputRef.current &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
|
||||||
|
// Also focus input on initial mount.
|
||||||
|
inputRef.current?.focus();
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Fetch topic for active channel.
|
// Fetch topic for active channel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return;
|
||||||
@@ -512,10 +631,24 @@ function App() {
|
|||||||
}, [loggedIn, activeTab, tabs]);
|
}, [loggedIn, activeTab, tabs]);
|
||||||
|
|
||||||
const onLogin = useCallback(
|
const onLogin = useCallback(
|
||||||
async (userNick) => {
|
async (userNick, isResumed) => {
|
||||||
setNick(userNick);
|
setNick(userNick);
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
|
// Request MOTD on resumed sessions (new sessions get
|
||||||
|
// it automatically from the server during creation).
|
||||||
|
if (isResumed) {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ command: "MOTD" }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// MOTD is non-critical.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
@@ -791,18 +924,127 @@ function App() {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "/motd": {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ command: "MOTD" }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(
|
||||||
|
"Server",
|
||||||
|
`Failed to request MOTD: ${err.data?.error || "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/query": {
|
||||||
|
if (parts[1]) {
|
||||||
|
const target = parts[1];
|
||||||
|
openDM(target);
|
||||||
|
const msgText = parts.slice(2).join(" ");
|
||||||
|
if (msgText) {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
command: "PRIVMSG",
|
||||||
|
to: target,
|
||||||
|
body: [msgText],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(
|
||||||
|
"Server",
|
||||||
|
`Message failed: ${err.data?.error || "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addSystemMessage("Server", "Usage: /query <nick> [message]");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/list": {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ command: "LIST" }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(
|
||||||
|
"Server",
|
||||||
|
`Failed to list channels: ${err.data?.error || "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/who": {
|
||||||
|
const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
|
||||||
|
if (whoTarget) {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ command: "WHO", to: whoTarget }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(
|
||||||
|
"Server",
|
||||||
|
`WHO failed: ${err.data?.error || "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addSystemMessage("Server", "Usage: /who #channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/whois": {
|
||||||
|
if (parts[1]) {
|
||||||
|
try {
|
||||||
|
await api("/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(
|
||||||
|
"Server",
|
||||||
|
`WHOIS failed: ${err.data?.error || "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addSystemMessage("Server", "Usage: /whois <nick>");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/clear": {
|
||||||
|
const clearTarget = tab.name;
|
||||||
|
setMessages((prev) => ({ ...prev, [clearTarget]: [] }));
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "/help": {
|
case "/help": {
|
||||||
const helpLines = [
|
const helpLines = [
|
||||||
"Available commands:",
|
"Available commands:",
|
||||||
" /join #channel — Join a channel",
|
" /join #channel — Join a channel",
|
||||||
" /part [reason] — Part the current channel",
|
" /part [reason] — Part the current channel",
|
||||||
" /msg nick message — Send a private message",
|
" /msg nick message — Send a private message",
|
||||||
" /me action — Send an action",
|
" /query nick [message] — Open a DM tab (optionally send a message)",
|
||||||
" /nick newnick — Change your nickname",
|
" /me action — Send an action",
|
||||||
" /topic [text] — View or set channel topic",
|
" /nick newnick — Change your nickname",
|
||||||
" /mode +/-flags — Set channel modes",
|
" /topic [text] — View or set channel topic",
|
||||||
" /quit [reason] — Disconnect from server",
|
" /mode +/-flags — Set channel modes",
|
||||||
" /help — Show this help",
|
" /motd — Display the message of the day",
|
||||||
|
" /list — List all channels",
|
||||||
|
" /who [#channel] — List users in a channel",
|
||||||
|
" /whois nick — Show info about a user",
|
||||||
|
" /clear — Clear messages in the current tab",
|
||||||
|
" /quit [reason] — Disconnect from server",
|
||||||
|
" /help — Show this help",
|
||||||
];
|
];
|
||||||
for (const line of helpLines) {
|
for (const line of helpLines) {
|
||||||
addSystemMessage("Server", line);
|
addSystemMessage("Server", line);
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box .motd {
|
.login-box .motd {
|
||||||
color: var(--text-dim);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
border-left: 2px solid var(--border);
|
line-height: 1.2;
|
||||||
padding-left: 12px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box form {
|
.login-box form {
|
||||||
|
|||||||
Reference in New Issue
Block a user