refactor: extract the IRCv3 layer into its own module behind client.ircv3

This commit is contained in:
Jean Chevronnet 2026-07-06 02:09:57 +00:00
parent 85251c537d
commit 2ba5d884ee
No known key found for this signature in database
7 changed files with 321 additions and 143 deletions

View file

@ -511,7 +511,7 @@ function CapabilitiesSection() {
const client = useActiveChat((s) => s.client);
const status = useActiveChat((s) => s.status);
const connected = status === 'registered' && !!client;
const caps = client ? client.listCaps() : CAP_KEYS.map((name) => ({ name, available: false, enabled: false }));
const caps = client ? client.ircv3.listCaps() : CAP_KEYS.map((name) => ({ name, available: false, enabled: false }));
const active = caps.filter((c) => c.enabled).length;
return (
@ -614,7 +614,7 @@ function LoginTab() {
// to enable it AND the server to advertise draft/account-registration; otherwise
// REGISTER would just come back as an unknown command (e.g. a leaner ircd).
const canRegister = getConfig().features.register
&& status === 'registered' && !!client?.hasCap('draft/account-registration');
&& status === 'registered' && !!client?.ircv3.hasCap('draft/account-registration');
return (
<>
{canRegister && (

View file

@ -1,7 +1,7 @@
// Tchatou IRC client — IRCv3-aware connection over WebSocket to InspIRCd.
import { parseLine } from './parser';
import { casefold } from './casemap';
import { WANTED_CAPS } from './caps';
import { Ircv3 } from './ircv3';
import { CTCP_REPLIES } from './ctcp';
import type { ConnectOptions, IrcMessage } from './types';
@ -23,8 +23,15 @@ export class IrcClient {
private listeners: Record<string, Listener[]> = {};
private opts!: ConnectOptions;
private availableCaps = new Set<string>();
private ackedCaps = new Set<string>();
// The IRCv3 capability layer: negotiation, the negotiated cap set, and every
// cap-gated extended-feature command. The rest of the app reaches these via
// `client.ircv3`. It calls back through this tiny transport so the send queues
// below stay private.
readonly ircv3 = new Ircv3({
send: (l) => this.send(l),
lowSend: (l) => this.lowSend(l),
isupport: () => this.isupport,
});
private capEnded = false;
nick = '';
@ -34,7 +41,6 @@ export class IrcClient {
prefixModeToChar: Record<string, string> = {}; // mode letter -> prefix symbol (o->@, v->+, …)
chantypes = '#&'; // valid channel-name prefixes (ISUPPORT CHANTYPES)
casemapping = 'rfc1459'; // ISUPPORT CASEMAPPING — how names are compared/folded
multilineMaxLines = 20; // draft/multiline cap limit (max-lines)
vapid = ''; // ISUPPORT VAPID — server's Web Push public key (base64url P-256)
network = ''; // ISUPPORT NETWORK (network name, for the UI)
nicklen = 30; // ISUPPORT NICKLEN
@ -183,7 +189,7 @@ export class IrcClient {
this.ws = undefined;
this.clearConnectTimer();
// Reset per-connection state so a reconnect starts clean.
this.availableCaps.clear(); this.ackedCaps.clear(); this.capEnded = false;
this.ircv3.reset(); this.capEnded = false;
this.registered = false; this.rxBuf = ''; this.sendQueue = []; this.tokens = this.burst;
this.lowQueue = []; if (this.lowTimer) { clearTimeout(this.lowTimer); this.lowTimer = null; }
this.emit('status', 'connecting');
@ -432,60 +438,18 @@ export class IrcClient {
}
private handleCap(msg: IrcMessage): void {
// The subcommand is case-insensitive and InspIRCd echoes back the exact case
// the client sent (`cap ls` → `ls`), so normalise before matching.
const sub = (msg.params[1] || '').toUpperCase();
// A CAP LS / CAP LIST arriving AFTER registration is a manual query (the user
// typed `cap ls` in the status window), not negotiation — forward it so the
// handler can print it, and DON'T re-run REQ/END, which would needlessly churn
// the negotiated cap set. cap-notify NEW/DEL and ACK/NAK still flow below.
if (this.registered && (sub === 'LS' || sub === 'LIST')) {
this.emit('message', msg);
return;
}
if (sub === 'LS' || sub === 'NEW') {
// CAP * LS * :cap1 cap2 (the '*' before ':' means more lines follow)
const more = msg.params[2] === '*';
const capStr = msg.params[more ? 3 : 2] ?? '';
for (const tok of capStr.split(' ').filter(Boolean)) {
const name = tok.split('=')[0];
this.availableCaps.add(name);
if (name === 'draft/multiline') {
const m = tok.match(/max-lines=(\d+)/);
if (m) this.multilineMaxLines = parseInt(m[1], 10) || this.multilineMaxLines;
}
}
if (!more) {
const req = WANTED_CAPS.filter((c) => this.availableCaps.has(c));
// Work around servers that drop a capability from CAP LS at a line-split
// boundary (InspIRCd m_cap loses the overflowing cap). message-tags is the
// usual victim, yet it's mandatory for msgid/labels/reactions and is implied
// by server-time/echo-message/batch/etc. — so request it explicitly when any
// tag-dependent cap IS advertised. The server still ACKs it.
if (!req.includes('message-tags') &&
['server-time', 'echo-message', 'batch', 'account-tag', 'labeled-response', 'message-redaction'].some((c) => this.availableCaps.has(c))) {
req.push('message-tags');
}
if (req.length) this.send(`CAP REQ :${req.join(' ')}`);
else this.endCap();
}
} else if (sub === 'ACK') {
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
this.ackedCaps.add(c);
}
if (this.ackedCaps.has('sasl') && this.opts.password) {
this.send('AUTHENTICATE PLAIN');
} else {
this.endCap();
}
} else if (sub === 'DEL') {
// cap-notify: server withdrew capabilities — forget them.
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
this.availableCaps.delete(c);
this.ackedCaps.delete(c);
}
} else if (sub === 'NAK') {
this.endCap();
// Negotiation logic + the cap set live in the IRCv3 layer; it returns the
// action to take so the registration/SASL ordering stays here.
const action = this.ircv3.handleCap(msg, {
registered: this.registered,
hasPassword: !!this.opts.password,
});
switch (action.do) {
case 'req': this.send(`CAP REQ :${action.caps.join(' ')}`); break;
case 'sasl': this.send('AUTHENTICATE PLAIN'); break;
case 'end': this.endCap(); break;
case 'forward': this.emit('message', msg); break; // post-registration manual `cap ls`
// 'none': nothing to do
}
}
@ -522,7 +486,7 @@ export class IrcClient {
this.capEnded = true;
// draft/pre-away: if we're (re)connecting while marked away, set it DURING
// registration so we're away from the instant we're connected — before CAP END.
if (this.awayMessage && this.ackedCaps.has('draft/pre-away'))
if (this.awayMessage && this.ircv3.hasCap('draft/pre-away'))
this.send(`AWAY :${this.awayMessage}`);
this.send('CAP END');
}
@ -551,19 +515,6 @@ export class IrcClient {
}
}
hasCap(name: string): boolean { return this.ackedCaps.has(name); }
// Snapshot of every capability Orbit wants, with its negotiated state — for the
// Settings "IRCv3 capabilities" panel. `enabled` = the server ACK'd it and we're
// using it; `available` = the server advertised it in CAP LS.
listCaps(): { name: string; available: boolean; enabled: boolean }[] {
return WANTED_CAPS.map((name) => ({
name,
available: this.availableCaps.has(name),
enabled: this.ackedCaps.has(name),
}));
}
// ---- line-length handling (the 512-byte IRC line limit) -----------------
private enc = new TextEncoder();
private byteLen(s: string): number { return this.enc.encode(s).length; }
@ -606,9 +557,9 @@ export class IrcClient {
// marking this DM as relating to that channel (IRCv3 client-tags/channel-context).
privmsg(target: string, text: string, context?: string): void {
const ctx = context ? `+draft/channel-context=${context}` : '';
if (this.useMultiline && /\r?\n/.test(text) && this.hasCap('draft/multiline') && this.hasCap('batch')) {
if (this.useMultiline && /\r?\n/.test(text) && this.ircv3.hasCap('draft/multiline') && this.ircv3.hasCap('batch')) {
const lines = text.split(/\r?\n/);
if (lines.length <= this.multilineMaxLines) { this.multilineMsg(target, lines, ctx); return; }
if (lines.length <= this.ircv3.multilineMaxLines) { this.multilineMsg(target, lines, ctx); return; }
}
const pre = ctx ? `@${ctx} ` : '';
for (const part of this.splitForLine('PRIVMSG', target, text)) this.send(`${pre}PRIVMSG ${target} :${part}`);
@ -656,61 +607,18 @@ export class IrcClient {
setChannelMode(channel: string, mode: string, add: boolean): void {
this.send(`MODE ${channel} ${add ? '+' : '-'}${mode}`);
}
// draft/account-registration: create + confirm a Swaygo/Tchatou account.
register(account: string, email: string, password: string): void {
this.send(`REGISTER ${account} ${email} :${password}`);
}
verify(account: string, code: string): void { this.send(`VERIFY ${account} ${code}`); }
// draft/webpush: register/remove a Web Push subscription. <keys> is the
// message-tag-format "p256dh=<b64url>;auth=<b64url>".
webpushRegister(endpoint: string, keys: string): void { this.send(`WEBPUSH REGISTER ${endpoint} ${keys}`); }
webpushUnregister(endpoint: string): void { this.send(`WEBPUSH UNREGISTER ${endpoint}`); }
resend(account: string): void { this.send(`RESEND ${account}`); }
list(): void { this.send('LIST'); }
whois(nick: string): void { this.send(`WHOIS ${nick} ${nick}`); }
whowas(nick: string): void { this.send(`WHOWAS ${nick}`); }
invite(nick: string, channel: string): void { this.send(`INVITE ${nick} ${channel}`); }
names(channel: string): void { this.send(`NAMES ${channel}`); }
// draft/chathistory — load up to `limit` messages older than the given ISO time.
chathistoryBefore(target: string, beforeIso: string, limit: number): void {
this.send(`CHATHISTORY BEFORE ${target} timestamp=${beforeIso} ${limit}`);
}
// draft/chathistory — most recent `limit` items (messages + events w/ event-playback).
// Low priority: prefetched on join for every channel, must not block typing.
chathistoryLatest(target: string, limit: number): void {
this.lowSend(`CHATHISTORY LATEST ${target} * ${limit}`);
}
// Remember the away message so draft/pre-away can re-apply it during the next
// (re)connect's registration. '' = back (clears it).
awayMessage = '';
setAway(reason: string): void { this.awayMessage = reason; this.send(reason ? `AWAY :${reason}` : 'AWAY'); }
// MONITOR (online-notify): + add, - remove, L list, C clear.
monitor(op: '+' | '-' | 'L' | 'C', targets = ''): void {
if (this.isupport['MONITOR'] === undefined) return; // server doesn't advertise MONITOR → don't send it
this.send(`MONITOR ${op}${targets ? ` ${targets}` : ''}`);
}
// Query a channel's ban/except/invex list (replies via 367/348/346).
modeList(channel: string, mode: 'b' | 'e' | 'I'): void { this.send(`MODE ${channel} ${mode}`); }
// WHOX: request token(t)/channel(c)/nick(n)/flags(f)/account(a) so we can map
// members → their services account (for real avatars). Token 152 echoes back.
who(target: string): void { this.send(`WHO ${target} %tcnfa,152`); }
react(target: string, msgid: string, emoji: string): void {
// Reaction rides on TAGMSG (message-tags). Without the cap the server rejects
// TAGMSG as an unknown command, so don't send it.
if (!this.hasCap('message-tags')) return;
this.send(`@+draft/reply=${msgid};+draft/react=${emoji} TAGMSG ${target}`);
}
redact(target: string, msgid: string, reason = ''): void {
if (!this.hasCap('draft/message-redaction')) return;
this.send(`REDACT ${target} ${msgid}${reason ? ` :${reason}` : ''}`);
}
markRead(target: string, ts: string): void {
if (!this.hasCap('draft/read-marker')) return; // MARKREAD is unknown without the cap
this.send(`MARKREAD ${target} timestamp=${ts}`);
}
sendTyping(target: string, state: 'active' | 'done'): void {
// +typing rides on TAGMSG (message-tags); skip entirely if the server lacks it.
if (!this.hasCap('message-tags')) return;
this.send(`@+typing=${state} TAGMSG ${target}`);
}
}

103
src/core/irc/ircv3.test.ts Normal file
View file

@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { Ircv3 } from './ircv3';
import { parseLine } from './parser';
// A fake transport that records what the IRCv3 layer would put on the wire.
function make(isup: Record<string, string> = {}) {
const sent: string[] = [];
const low: string[] = [];
const ircv3 = new Ircv3({
send: (l) => sent.push(l),
lowSend: (l) => low.push(l),
isupport: () => isup,
});
const cap = (line: string, ctx = { registered: false, hasPassword: false }) =>
ircv3.handleCap(parseLine(line), ctx);
return { ircv3, sent, low, cap };
}
describe('Ircv3 capability negotiation', () => {
it('requests the intersection of wanted and advertised caps', () => {
const { cap } = make();
const a = cap('CAP * LS :message-tags server-time sasl bogus-cap');
expect(a.do).toBe('req');
if (a.do === 'req') {
expect(a.caps).toEqual(expect.arrayContaining(['message-tags', 'server-time', 'sasl']));
expect(a.caps).not.toContain('bogus-cap');
}
});
it('ends negotiation when nothing wanted is advertised', () => {
expect(make().cap('CAP * LS :bogus-cap another-bogus').do).toBe('end');
});
it('buffers a multi-line CAP LS and only requests on the final line', () => {
const { cap } = make();
expect(cap('CAP * LS * :message-tags').do).toBe('none'); // trailing '*' → more to come
expect(cap('CAP * LS :server-time').do).toBe('req');
});
it('starts SASL only when acked and a password is present', () => {
expect(make().cap('CAP * ACK :sasl', { registered: false, hasPassword: true }).do).toBe('sasl');
expect(make().cap('CAP * ACK :sasl', { registered: false, hasPassword: false }).do).toBe('end');
});
it('forwards a post-registration manual CAP LS instead of renegotiating', () => {
expect(make().cap('CAP * LS :message-tags', { registered: true, hasPassword: false }).do).toBe('forward');
});
it('NAK ends negotiation', () => {
expect(make().cap('CAP * NAK :sasl').do).toBe('end');
});
it('tracks acked caps; DEL and reset() forget them', () => {
const { ircv3, cap } = make();
cap('CAP * ACK :message-tags draft/read-marker');
expect(ircv3.hasCap('message-tags')).toBe(true);
expect(ircv3.hasCap('draft/read-marker')).toBe(true);
cap('CAP * DEL :draft/read-marker');
expect(ircv3.hasCap('draft/read-marker')).toBe(false);
ircv3.reset();
expect(ircv3.hasCap('message-tags')).toBe(false);
});
it('learns draft/multiline max-lines from CAP LS', () => {
const { ircv3, cap } = make();
cap('CAP * LS :draft/multiline=max-lines=42');
expect(ircv3.multilineMaxLines).toBe(42);
});
});
describe('Ircv3 cap-gated commands', () => {
it('skips MARKREAD without the cap, sends it with', () => {
const { ircv3, sent, cap } = make();
ircv3.markRead('#x', '2020-01-01T00:00:00Z');
expect(sent).toHaveLength(0);
cap('CAP * ACK :draft/read-marker');
ircv3.markRead('#x', '2020-01-01T00:00:00Z');
expect(sent).toEqual(['MARKREAD #x timestamp=2020-01-01T00:00:00Z']);
});
it('skips typing and reactions without message-tags', () => {
const { ircv3, sent } = make();
ircv3.sendTyping('#x', 'active');
ircv3.react('#x', 'abc', '\u{1F600}');
expect(sent).toHaveLength(0);
});
it('only sends MONITOR when ISUPPORT advertises it', () => {
const off = make();
off.ircv3.monitor('+', 'bob');
expect(off.sent).toHaveLength(0);
const on = make({ MONITOR: '100' });
on.ircv3.monitor('+', 'bob');
expect(on.sent).toEqual(['MONITOR + bob']); // per spec: "MONITOR + <nicklist>"
});
it('routes chathistory prefetch through the low-priority queue', () => {
const { ircv3, sent, low } = make();
ircv3.chathistoryLatest('#x', 50);
expect(sent).toHaveLength(0);
expect(low).toEqual(['CHATHISTORY LATEST #x * 50']);
});
});

167
src/core/irc/ircv3.ts Normal file
View file

@ -0,0 +1,167 @@
// IRCv3 capability layer for the IRC client.
//
// Split out of client.ts so the transport (WebSocket, reconnect, send queues) and
// the core IRC verbs stay separate from the IRCv3 feature surface. This owns:
// * capability negotiation (CAP LS/REQ/ACK/NAK/NEW/DEL) + the negotiated cap set,
// * the cap-gated extended-feature commands (reactions, read markers, typing,
// redaction, chathistory, account registration, web push, MONITOR).
//
// The client holds one instance and exposes it as `client.ircv3`; the rest of the
// app reaches every IRCv3 feature through that API rather than the raw socket.
import { WANTED_CAPS } from './caps';
import type { IrcMessage } from './types';
// The minimal slice of the client the IRCv3 layer needs. Kept as an interface
// (not a client reference) so the client's send queues stay private and this
// module can be unit-tested against a fake transport.
export interface Ircv3Transport {
/** Throttled send (token bucket). */
send(line: string): void;
/** Low-priority background queue — history prefetch, must yield to live traffic. */
lowSend(line: string): void;
/** Live view of the parsed ISUPPORT (005) tokens. */
isupport(): Record<string, string>;
}
// What the client should do in response to a CAP line. The negotiation *logic*
// lives here (pure + testable); the client performs the actual I/O so the
// registration/SASL ordering stays in one place.
export type CapAction =
| { do: 'req'; caps: string[] } // send CAP REQ for these caps
| { do: 'sasl' } // begin SASL (AUTHENTICATE PLAIN)
| { do: 'end' } // finish negotiation (CAP END)
| { do: 'forward' } // a post-registration manual `cap ls` → let the UI print it
| { do: 'none' }; // nothing to do
export class Ircv3 {
private available = new Set<string>();
private acked = new Set<string>();
/** draft/multiline max-lines, learned from CAP LS (`draft/multiline=max-lines=N`). */
multilineMaxLines = 20;
private readonly tx: Ircv3Transport;
constructor(tx: Ircv3Transport) { this.tx = tx; }
/** Forget every negotiated cap — called when a socket (re)opens. */
reset(): void {
this.available.clear();
this.acked.clear();
this.multilineMaxLines = 20;
}
/** True once the server ACK'd `name` and we're using it. */
hasCap(name: string): boolean { return this.acked.has(name); }
/** True if the server advertised `name` in CAP LS (whether or not we requested it). */
isAvailable(name: string): boolean { return this.available.has(name); }
// Snapshot for the Settings "IRCv3 capabilities" panel. `enabled` = ACK'd and in
// use; `available` = advertised in CAP LS.
listCaps(): { name: string; available: boolean; enabled: boolean }[] {
return WANTED_CAPS.map((name) => ({
name,
available: this.available.has(name),
enabled: this.acked.has(name),
}));
}
// Capability negotiation. Mutates the cap sets and returns the action the client
// should perform next.
handleCap(msg: IrcMessage, ctx: { registered: boolean; hasPassword: boolean }): CapAction {
// The subcommand is case-insensitive and InspIRCd echoes back the exact case
// the client sent (`cap ls` → `ls`), so normalise before matching.
const sub = (msg.params[1] || '').toUpperCase();
// A CAP LS / LIST arriving AFTER registration is a manual query the user typed
// in the status window — forward it to the UI; don't re-run REQ/END.
if (ctx.registered && (sub === 'LS' || sub === 'LIST')) return { do: 'forward' };
if (sub === 'LS' || sub === 'NEW') {
// CAP * LS * :cap1 cap2 (the '*' before the trailing arg means more follow)
const more = msg.params[2] === '*';
const capStr = msg.params[more ? 3 : 2] ?? '';
for (const tok of capStr.split(' ').filter(Boolean)) {
const name = tok.split('=')[0];
this.available.add(name);
if (name === 'draft/multiline') {
const m = tok.match(/max-lines=(\d+)/);
if (m) this.multilineMaxLines = parseInt(m[1], 10) || this.multilineMaxLines;
}
}
if (more) return { do: 'none' };
const req = WANTED_CAPS.filter((c) => this.available.has(c));
// Work around servers that drop a capability from CAP LS at a line-split
// boundary (InspIRCd m_cap loses the overflowing cap). message-tags is the
// usual victim, yet it's mandatory for msgid/labels/reactions and is implied
// by server-time/echo-message/batch/etc. — so request it explicitly when any
// tag-dependent cap IS advertised. The server still ACKs it.
if (!req.includes('message-tags') &&
['server-time', 'echo-message', 'batch', 'account-tag', 'labeled-response', 'message-redaction'].some((c) => this.available.has(c))) {
req.push('message-tags');
}
return req.length ? { do: 'req', caps: req } : { do: 'end' };
}
if (sub === 'ACK') {
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) this.acked.add(c);
// SASL if the server ACK'd it and we have a password, else we're done.
return this.acked.has('sasl') && ctx.hasPassword ? { do: 'sasl' } : { do: 'end' };
}
if (sub === 'DEL') { // cap-notify: server withdrew capabilities — forget them.
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
this.available.delete(c);
this.acked.delete(c);
}
return { do: 'none' };
}
if (sub === 'NAK') return { do: 'end' };
return { do: 'none' };
}
// ---- cap-gated extended-feature commands --------------------------------
// Each guards on the negotiated cap/ISUPPORT: a leaner ircd rejects an
// unsupported command as 421 "Unknown command", so we skip it entirely.
// draft/chathistory — load up to `limit` messages older than the given ISO time.
chathistoryBefore(target: string, beforeIso: string, limit: number): void {
this.tx.send(`CHATHISTORY BEFORE ${target} timestamp=${beforeIso} ${limit}`);
}
// draft/chathistory — most recent `limit` items (messages + events w/ event-playback).
// Low priority: prefetched on join for every channel, must not block typing.
chathistoryLatest(target: string, limit: number): void {
this.tx.lowSend(`CHATHISTORY LATEST ${target} * ${limit}`);
}
// MONITOR (online-notify): + add, - remove, L list, C clear.
monitor(op: '+' | '-' | 'L' | 'C', targets = ''): void {
if (this.tx.isupport()['MONITOR'] === undefined) return; // server doesn't advertise MONITOR
this.tx.send(`MONITOR ${op}${targets ? ` ${targets}` : ''}`);
}
// Reaction rides on TAGMSG (message-tags); without the cap the server rejects it.
react(target: string, msgid: string, emoji: string): void {
if (!this.acked.has('message-tags')) return;
this.tx.send(`@+draft/reply=${msgid};+draft/react=${emoji} TAGMSG ${target}`);
}
redact(target: string, msgid: string, reason = ''): void {
if (!this.acked.has('draft/message-redaction')) return;
this.tx.send(`REDACT ${target} ${msgid}${reason ? ` :${reason}` : ''}`);
}
markRead(target: string, ts: string): void {
if (!this.acked.has('draft/read-marker')) return; // MARKREAD is unknown without the cap
this.tx.send(`MARKREAD ${target} timestamp=${ts}`);
}
sendTyping(target: string, state: 'active' | 'done'): void {
if (!this.acked.has('message-tags')) return; // +typing rides on TAGMSG
this.tx.send(`@+typing=${state} TAGMSG ${target}`);
}
// draft/account-registration: create + confirm a Swaygo/Tchatou account. Gated at
// the UI (the register form only shows when the cap is present), so no guard here.
register(account: string, email: string, password: string): void {
this.tx.send(`REGISTER ${account} ${email} :${password}`);
}
verify(account: string, code: string): void { this.tx.send(`VERIFY ${account} ${code}`); }
resend(account: string): void { this.tx.send(`RESEND ${account}`); }
// draft/webpush: register/remove a Web Push subscription (gated at the call site on
// the VAPID ISUPPORT key). <keys> is "p256dh=<b64url>;auth=<b64url>".
webpushRegister(endpoint: string, keys: string): void { this.tx.send(`WEBPUSH REGISTER ${endpoint} ${keys}`); }
webpushUnregister(endpoint: string): void { this.tx.send(`WEBPUSH UNREGISTER ${endpoint}`); }
}

View file

@ -209,7 +209,7 @@ export function createChatStore(ns = '') {
client.queryUserModes();
// Watch our friends via MONITOR (server pushes 730/731 on presence change).
const fr = get().friends;
if (fr.length) client.monitor('+', fr.join(','));
if (fr.length) client.ircv3.monitor('+', fr.join(','));
// On a RECONNECT, rejoin every channel we still have open (the client
// only auto-joins the initial set; this restores ones joined later).
if (wasReconnect) {
@ -294,7 +294,7 @@ export function createChatStore(ns = '') {
if (s.historyLoading[key] || s.historyDone[key]) return;
const client = s.client;
const buf = s.buffers[key];
if (!client || !buf || !client.hasCap('draft/chathistory')) return;
if (!client || !buf || !client.ircv3.hasCap('draft/chathistory')) return;
// A channel we've parted is no longer a valid CHATHISTORY target — the server
// would answer FAIL INVALID_TARGET, so don't ask.
if (isChannelName(buf.name) && !buf.joined) return;
@ -302,7 +302,7 @@ export function createChatStore(ns = '') {
const oldest = buf.messages.find((m) => m.kind === 'privmsg' || m.kind === 'action' || m.kind === 'notice');
if (!oldest) return;
set({ historyLoading: { ...s.historyLoading, [key]: true } });
client.chathistoryBefore(buf.name, new Date(oldest.ts).toISOString(), 50);
client.ircv3.chathistoryBefore(buf.name, new Date(oldest.ts).toISOString(), 50);
// safety: clear the spinner if the server never answers.
setTimeout(() => set({ historyLoading: { ...get().historyLoading, [key]: false } }), 8000);
},
@ -320,7 +320,7 @@ export function createChatStore(ns = '') {
const next = [...cur, n];
saveFriends(next); syncGlobal();
set({ friends: next });
get().client?.monitor('+', n); // start watching
get().client?.ircv3.monitor('+', n); // start watching
},
removeFriend(nick) {
@ -328,7 +328,7 @@ export function createChatStore(ns = '') {
saveFriends(next); syncGlobal();
const online = { ...get().friendsOnline }; delete online[nick.toLowerCase()];
set({ friends: next, friendsOnline: online });
get().client?.monitor('-', nick);
get().client?.ircv3.monitor('-', nick);
},
loadBanList(channel) {
@ -397,7 +397,7 @@ export function createChatStore(ns = '') {
if (pb && pb.messages.length) {
const latest = pb.messages[pb.messages.length - 1].ts;
if (latest > pb.readTs) {
get().client?.markRead(pb.name, new Date(latest).toISOString());
get().client?.ircv3.markRead(pb.name, new Date(latest).toISOString());
patchBuffer(prev, (b) => ({ ...b, readTs: latest }));
}
}
@ -412,7 +412,7 @@ export function createChatStore(ns = '') {
const b = s.buffers[name];
if (b?.messages.length) {
const latest = b.messages[b.messages.length - 1].ts;
if (latest > b.readTs) s.client?.markRead(b.name, new Date(latest).toISOString());
if (latest > b.readTs) s.client?.ircv3.markRead(b.name, new Date(latest).toISOString());
}
}
// …then clear unread/highlight everywhere in one update.
@ -430,7 +430,7 @@ export function createChatStore(ns = '') {
const { client, active } = get();
if (!client || !active || !isChannelName(active)) return;
const now = Date.now();
if (now - lastTypingSent > 3000) { lastTypingSent = now; client.sendTyping(active, 'active'); }
if (now - lastTypingSent > 3000) { lastTypingSent = now; client.ircv3.sendTyping(active, 'active'); }
},
sendInput(text) {
@ -485,7 +485,7 @@ export function createChatStore(ns = '') {
if (!t || !body) break;
client.privmsg(t, body);
// Optimistic echo (masked for services) so the user sees feedback.
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
const dest = isChannelName(t) ? t : t;
addMessage(dest, {
id: newId(), bufferName: dest, from: get().nick,
@ -501,7 +501,7 @@ export function createChatStore(ns = '') {
if (!t || !body) break;
client.send(`NOTICE ${t} :${body}`);
// No optimistic echo if the server will echo it back to us.
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
const dest = isChannelName(t) ? t : (active || SERVER);
addMessage(dest, {
id: newId(), bufferName: dest, from: get().nick,
@ -539,7 +539,7 @@ export function createChatStore(ns = '') {
i18n.t('security.leakGuard', { channel: active, service: leak.service }),
'warning');
ensureBuffer(leak.service);
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
addMessage(leak.service, {
id: newId(), bufferName: leak.service, from: get().nick,
text: maskSecret(leak.command), ts: Date.now(), kind: 'privmsg', self: true,
@ -557,9 +557,9 @@ export function createChatStore(ns = '') {
const ctx = !isChannelName(active) ? get().pmContext[canon(active)] : undefined;
if (reply) { client.privmsgReply(active, body, reply.id, ctx); set({ replyTarget: null }); }
else client.privmsg(active, body, ctx);
if (isChannelName(active)) { client.sendTyping(active, 'done'); lastTypingSent = 0; }
if (isChannelName(active)) { client.ircv3.sendTyping(active, 'done'); lastTypingSent = 0; }
// Optimistic echo only if the server won't echo it back to us.
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
addMessage(active, {
id: newId(), bufferName: active, from: get().nick,
text: isService(active) ? maskSecret(text) : body,
@ -624,12 +624,12 @@ export function createChatStore(ns = '') {
toggleReaction(msgid, emoji) {
const { client, active } = get();
client?.react(active, msgid, emoji);
client?.ircv3.react(active, msgid, emoji);
},
redact(msgid) {
const { client, active } = get();
client?.redact(active, msgid);
client?.ircv3.redact(active, msgid);
},
async uploadImage(file) {
@ -660,7 +660,7 @@ export function createChatStore(ns = '') {
// "* Mik 📷 partage une image : <url>" (mIRC/irssi etc.); web renders the card.
const caption = `📷 ${i18n.t('system.shareImage', { url: data.url })}`;
client.action(active, caption);
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
}
} catch (e) {
@ -711,7 +711,7 @@ export function createChatStore(ns = '') {
const data = await res.json() as { url: string };
const caption = `🎤 ${i18n.t('system.shareVoice', { url: data.url })}`;
client.action(active, caption);
if (!client.hasCap('echo-message')) {
if (!client.ircv3.hasCap('echo-message')) {
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
}
} catch (e) {
@ -739,19 +739,19 @@ export function createChatStore(ns = '') {
const { client } = get();
if (!client) return;
set({ reg: { step: 'idle', account, busy: true, error: '', info: '', challengeUrl: '' } });
client.register(account, email, password);
client.ircv3.register(account, email, password);
},
accountVerify(code) {
const { client, reg } = get();
if (!client || !reg.account) return;
set({ reg: { ...reg, busy: true, error: '' } });
client.verify(reg.account, code);
client.ircv3.verify(reg.account, code);
},
accountResend() {
const { client, reg } = get();
if (!client || !reg.account) return;
set({ reg: { ...reg, error: '', info: i18n.t('reg.codeResent') } });
client.resend(reg.account);
client.ircv3.resend(reg.account);
},
// Change the account password via Django (same-origin proxy → swaygo). The
// server updates BOTH Anope (IRC login) AND Django (website) so they never

View file

@ -565,7 +565,7 @@ export function makeHandler(ctx: HandlerCtx) {
// Pull full history (messages + JOIN/PART/KICK/MODE/TOPIC events via event-playback)
// from m_ircv3_chathistory — the +H auto-replay only carries messages. Deduped by id.
const cl = get().client;
if (cl?.hasCap('draft/chathistory')) cl.chathistoryLatest(ch, 50);
if (cl?.ircv3.hasCap('draft/chathistory')) cl.ircv3.chathistoryLatest(ch, 50);
}
// extended-join: ":nick JOIN #chan <account> :<realname>" — '*'/'0' = none.
// Gives us account + realname up front, so no WHO needed for joiners.

View file

@ -60,7 +60,7 @@ function registerWithServer(client: IrcClient, sub: PushSubscription): void {
const p256dh = bytesToUrlB64(sub.getKey('p256dh'));
const auth = bytesToUrlB64(sub.getKey('auth'));
if (!p256dh || !auth) return;
client.webpushRegister(sub.endpoint, `p256dh=${p256dh};auth=${auth}`);
client.ircv3.webpushRegister(sub.endpoint, `p256dh=${p256dh};auth=${auth}`);
}
// Turn push on: ask permission, subscribe, hand the endpoint to the ircd.
@ -87,7 +87,7 @@ export async function disablePush(client: IrcClient): Promise<void> {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
client.webpushUnregister(sub.endpoint);
client.ircv3.webpushUnregister(sub.endpoint);
await sub.unsubscribe();
}
} catch { /* ignore */ }