refactor: extract MODE and fold AWAY/ACCOUNT member-notifies into membership
This commit is contained in:
parent
ff33c4acf7
commit
ae8232afa6
5 changed files with 221 additions and 88 deletions
|
|
@ -6,10 +6,11 @@ import { makeBatch } from './batch';
|
|||
import { makeTagmsg } from './tagmsg';
|
||||
import { makeMsgState } from './msgstate';
|
||||
import { makeMessaging } from './messaging';
|
||||
import { makeMode } from './mode';
|
||||
import type { IrcMessage, Member, MessageKind } from '../irc/types';
|
||||
import { NUMERICS, ERROR_NUMERICS } from '../irc/numerics';
|
||||
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
|
||||
import { hostmask, maskMatches } from './text';
|
||||
import { hostmask } from './text';
|
||||
import { SERVER, isupport, canon, isChannelName, openBatches, historyCollect, inHistoryBatch } from './context';
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { ChatState } from '../store';
|
||||
|
|
@ -32,7 +33,7 @@ const HANDLED_NUMERICS = new Set(['005', '332', '333', '353', '366', '396', '366
|
|||
// `handle` function; the store wires it to client.on('message', handle).
|
||||
export function makeHandler(ctx: HandlerCtx) {
|
||||
const { set, get, helpers, closedChannels, knownServices, lastCantSend, lastAwayNotice, filehost } = ctx;
|
||||
const { ensureBuffer, patchBuffer, dropBuffer, patchMemberEverywhere, tsOf, sysLine, serverLine, patchWhois } = helpers;
|
||||
const { ensureBuffer, patchBuffer, dropBuffer, tsOf, sysLine, serverLine, patchWhois } = helpers;
|
||||
|
||||
// WHOIS/WHOWAS → the profile panel (and yomirc text WHOIS). See ./whois.
|
||||
const { handleWhois, clearWhois } = makeWhois({ get, set, patchWhois, sysLine });
|
||||
|
|
@ -46,6 +47,8 @@ export function makeHandler(ctx: HandlerCtx) {
|
|||
const { handleMsgState } = makeMsgState({ ensureBuffer, patchBuffer });
|
||||
// PRIVMSG/NOTICE hot path (routing, services, notify, batch collection). See ./messaging.
|
||||
const { handleMessaging } = makeMessaging({ get, set, knownServices, filehost, helpers });
|
||||
// MODE changes (user + channel modes, prefixes, ban lists). See ./mode.
|
||||
const { handleMode } = makeMode({ get, set, helpers });
|
||||
|
||||
// ---- IRC event -> state ------------------------------------------------
|
||||
function handle(msg: IrcMessage): void {
|
||||
|
|
@ -318,6 +321,7 @@ export function makeHandler(ctx: HandlerCtx) {
|
|||
if (handleTagmsg(msg, me)) return;
|
||||
if (handleMsgState(msg)) return;
|
||||
if (handleMessaging(msg, me)) return;
|
||||
if (handleMode(msg, me)) return;
|
||||
|
||||
switch (msg.command) {
|
||||
case 'CAP': {
|
||||
|
|
@ -362,21 +366,6 @@ export function makeHandler(ctx: HandlerCtx) {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case 'AWAY': {
|
||||
// away-notify: ":nick AWAY :<reason>" = away, ":nick AWAY" = back. Keeps
|
||||
// away state live in every common channel — no WHO poll needed.
|
||||
patchMemberEverywhere(msg.nick, { away: msg.params.length > 0 });
|
||||
break;
|
||||
}
|
||||
case 'ACCOUNT': {
|
||||
// account-notify: ":nick ACCOUNT <account>" ('*' = logged out). Live account
|
||||
// = live avatar; no WHOX re-poll.
|
||||
const acct = msg.params[0];
|
||||
const account = acct && acct !== '*' ? acct : undefined;
|
||||
patchMemberEverywhere(msg.nick, { account });
|
||||
if (msg.nick === me) set({ account: account ?? '' });
|
||||
break;
|
||||
}
|
||||
case 'TOPIC': { // :<nick> TOPIC <channel> :<new topic>
|
||||
const ch = msg.params[0];
|
||||
const topic = msg.params[1] ?? '';
|
||||
|
|
@ -386,72 +375,6 @@ export function makeHandler(ctx: HandlerCtx) {
|
|||
sysLine(ch, topic, 'topic', msg.nick);
|
||||
break;
|
||||
}
|
||||
case 'MODE': {
|
||||
const chan = msg.params[0];
|
||||
if (!isChannelName(chan)) {
|
||||
// User mode change. User modes are global per-user and take no params;
|
||||
// we only track our own (target === our nick).
|
||||
if (chan === me) {
|
||||
const change = msg.params[1] ?? '';
|
||||
const next = applyUserModes(get().umodes, change);
|
||||
set({ umodes: next });
|
||||
const named = change.replace(/[+-]/g, '').split('').map((c) => i18n.t(`umodes.${c}`, '')).filter(Boolean);
|
||||
serverLine(named.length
|
||||
? i18n.t('system.yourModesNamed', { modes: next, change, names: named.join(', ') })
|
||||
: i18n.t('system.yourModes', { modes: next, change }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
const client = get().client;
|
||||
const order = client?.server.prefixModes ?? '~&@%+';
|
||||
const ctx = buildModeContext(client?.server.isupport ?? {}, client?.server.prefixModeToChar ?? {});
|
||||
const changes = parseModeChanges(msg.params[1] ?? '', msg.params.slice(2), ctx);
|
||||
|
||||
const banLines: string[] = []; // +b/-b shown as their own clear lines (like mIRC)
|
||||
let showCombined = false; // any prefix/flag/param change → show the mode line
|
||||
|
||||
for (const c of changes) {
|
||||
if (c.kind === 'prefix' && c.param && c.prefix) {
|
||||
// membership grant (+o/+v/…) → update that member's held prefixes
|
||||
showCombined = true;
|
||||
const sym = c.prefix;
|
||||
patchBuffer(chan, (b) => {
|
||||
const m = b.members[c.param!];
|
||||
if (!m) return b;
|
||||
const held = (m.prefixes ?? m.prefix ?? '').split('').filter((x) => x !== sym);
|
||||
if (c.add) held.push(sym);
|
||||
held.sort((a, z) => order.indexOf(a) - order.indexOf(z));
|
||||
const prefixes = held.join('');
|
||||
return { ...b, members: { ...b.members, [c.param!]: { ...m, prefixes, prefix: prefixes[0] ?? '' } } };
|
||||
});
|
||||
} else if (c.kind === 'list') {
|
||||
// type A list mode. A ban (+b/-b) gets its own clear lines + who it hits;
|
||||
// other list modes (+e/+I) ride along in the combined line.
|
||||
if (c.mode === 'b' && c.param) {
|
||||
const mask = c.param;
|
||||
banLines.push(c.add ? `🔨 ${i18n.t('system.banned', { nick: msg.nick, mask })}` : `♻️ ${i18n.t('system.unbanned', { nick: msg.nick, mask })}`);
|
||||
const members = get().buffers[canon(chan)]?.members ?? {};
|
||||
const hit = Object.values(members)
|
||||
.filter((m) => maskMatches(mask, `${m.nick}!${m.user || '*'}@${m.host || '*'}`))
|
||||
.map((m) => m.nick);
|
||||
if (hit.length) banLines.push(i18n.t(c.add ? 'system.bansAdded' : 'system.bansRemoved', { list: hit.join(', ') }));
|
||||
} else showCombined = true;
|
||||
} else {
|
||||
// type B/C param mode or type D flag → maintain the channel mode string
|
||||
showCombined = true;
|
||||
patchBuffer(chan, (b) => ({ ...b, ...applyChannelFlag(b.modes || '', b.modeParams || {}, c) }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of banLines) sysLine(chan, line, 'ban');
|
||||
// The combined mode line is shown for everything except a pure ban change
|
||||
// (those are already covered by the dedicated ban lines above).
|
||||
if (showCombined) {
|
||||
const argStr = msg.params.length > 2 ? ' ' + msg.params.slice(2).join(' ') : '';
|
||||
sysLine(chan, `${msg.params[1] ?? ''}${argStr}`, 'mode', msg.nick);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '332': // RPL_TOPIC
|
||||
ensureBuffer(msg.params[1]);
|
||||
patchBuffer(msg.params[1], (b) => ({ ...b, topic: msg.params[2] ?? '' }));
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import type { StoreHelpers } from './helpers';
|
|||
// membership handler mutates through the helper stubs.
|
||||
function setup() {
|
||||
const state = {
|
||||
nick: 'me', active: '', order: [] as string[],
|
||||
buffers: {} as Record<string, { name: string; isChannel: boolean; members: Record<string, { nick: string; user?: string; host?: string; realname?: string; account?: string; prefix?: string }>; joined: boolean }>,
|
||||
nick: 'me', account: '', active: '', order: [] as string[],
|
||||
buffers: {} as Record<string, { name: string; isChannel: boolean; members: Record<string, { nick: string; user?: string; host?: string; realname?: string; account?: string; prefix?: string; away?: boolean }>; joined: boolean }>,
|
||||
whois: {} as Record<string, { nick: string; loading: boolean; user?: string; host?: string; realname?: string }>,
|
||||
prefs: { sound: false }, client: null as unknown,
|
||||
kicked: null as unknown, profileUser: 'x',
|
||||
|
|
@ -28,6 +28,12 @@ function setup() {
|
|||
if (state.buffers[k(name)]) state.buffers[k(name)] = fn(state.buffers[k(name)]);
|
||||
},
|
||||
dropBuffer: (name: string) => { delete state.buffers[k(name)]; state.order = state.order.filter((x) => x !== k(name)); },
|
||||
patchMemberEverywhere: (nick: string, patch: Record<string, unknown>) => {
|
||||
for (const key of Object.keys(state.buffers)) {
|
||||
const b = state.buffers[key];
|
||||
if (b.members[nick]) b.members = { ...b.members, [nick]: { ...b.members[nick], ...patch } };
|
||||
}
|
||||
},
|
||||
patchWhois: (nick: string, fn: (w: typeof state.whois[string]) => typeof state.whois[string]) => {
|
||||
state.whois = { ...state.whois, [nick]: fn(state.whois[nick] ?? { nick, loading: true }) };
|
||||
},
|
||||
|
|
@ -89,6 +95,27 @@ describe('membership handler', () => {
|
|||
expect(state.kicked).toMatchObject({ channel: '#x', by: 'op', kind: 'kick' });
|
||||
});
|
||||
|
||||
it('AWAY marks/clears a member as away across shared channels', () => {
|
||||
const { on, state, seed } = setup();
|
||||
seed('#a', ['bob']); seed('#b', ['bob']);
|
||||
on(':bob!u@h AWAY :lunch');
|
||||
expect(state.buffers['#a'].members['bob']).toMatchObject({ away: true });
|
||||
expect(state.buffers['#b'].members['bob']).toMatchObject({ away: true });
|
||||
on(':bob!u@h AWAY');
|
||||
expect(state.buffers['#a'].members['bob']).toMatchObject({ away: false });
|
||||
});
|
||||
|
||||
it('ACCOUNT updates a member account; our own login updates state.account', () => {
|
||||
const { on, state, seed } = setup();
|
||||
seed('#a', ['bob']);
|
||||
on(':bob!u@h ACCOUNT bobacct');
|
||||
expect(state.buffers['#a'].members['bob']).toMatchObject({ account: 'bobacct' });
|
||||
on(':me!u@h ACCOUNT myacct');
|
||||
expect(state.account).toBe('myacct');
|
||||
on(':me!u@h ACCOUNT *'); // logged out
|
||||
expect(state.account).toBe('');
|
||||
});
|
||||
|
||||
it('returns false for a non-membership command', () => {
|
||||
const { on } = setup();
|
||||
expect(on(':bob!u@h PRIVMSG #x :hi')).toBe(false);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,11 @@ interface MembershipDeps {
|
|||
}
|
||||
|
||||
export function makeMembership({ get, set, closedChannels, helpers }: MembershipDeps) {
|
||||
const { ensureBuffer, patchBuffer, dropBuffer, patchWhois, sysLine } = helpers;
|
||||
const { ensureBuffer, patchBuffer, dropBuffer, patchMemberEverywhere, patchWhois, sysLine } = helpers;
|
||||
|
||||
// Handle a membership event (JOIN/PART/KICK/QUIT/NICK/CHGHOST/SETNAME). Returns
|
||||
// true when handled; `me` is the client's current nick.
|
||||
// Handle a membership event (JOIN/PART/KICK/QUIT/NICK/CHGHOST/SETNAME) or a live
|
||||
// member-state notify (AWAY/ACCOUNT). Returns true when handled; `me` is the
|
||||
// client's current nick.
|
||||
function handleMembership(msg: IrcMessage, me: string): boolean {
|
||||
switch (msg.command) {
|
||||
case 'JOIN': {
|
||||
|
|
@ -154,6 +155,21 @@ export function makeMembership({ get, set, closedChannels, helpers }: Membership
|
|||
if (get().whois[msg.nick]) patchWhois(msg.nick, (w) => ({ ...w, realname: newReal }));
|
||||
return true;
|
||||
}
|
||||
case 'AWAY': {
|
||||
// away-notify: ":nick AWAY :<reason>" = away, ":nick AWAY" = back. Keeps
|
||||
// away state live in every common channel — no WHO poll needed.
|
||||
patchMemberEverywhere(msg.nick, { away: msg.params.length > 0 });
|
||||
return true;
|
||||
}
|
||||
case 'ACCOUNT': {
|
||||
// account-notify: ":nick ACCOUNT <account>" ('*' = logged out). Live account
|
||||
// = live avatar; no WHOX re-poll.
|
||||
const acct = msg.params[0];
|
||||
const account = acct && acct !== '*' ? acct : undefined;
|
||||
patchMemberEverywhere(msg.nick, { account });
|
||||
if (msg.nick === me) set({ account: account ?? '' });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
71
src/core/store/mode.test.ts
Normal file
71
src/core/store/mode.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { makeMode } from './mode';
|
||||
import { parseLine } from '../irc/parser';
|
||||
import type { ChatState } from '../store';
|
||||
import type { StoreHelpers } from './helpers';
|
||||
|
||||
type Member = { nick: string; user?: string; host?: string; prefix?: string; prefixes?: string };
|
||||
type Buf = { name: string; members: Record<string, Member>; modes?: string; modeParams?: Record<string, string> };
|
||||
|
||||
function setup() {
|
||||
const state = {
|
||||
umodes: '', account: '',
|
||||
client: { server: { prefixModes: '~&@%+', isupport: { CHANMODES: 'beI,k,l,imnpst' }, prefixModeToChar: { q: '~', a: '&', o: '@', h: '%', v: '+' } } },
|
||||
buffers: {} as Record<string, Buf>,
|
||||
};
|
||||
const k = (n: string) => n.toLowerCase();
|
||||
const lines: { name: string; text: string; kind: string }[] = [];
|
||||
const serverLines: string[] = [];
|
||||
const get = () => state as unknown as ChatState;
|
||||
const set = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
const helpers = {
|
||||
patchBuffer: (name: string, fn: (b: Buf) => Buf) => { if (state.buffers[k(name)]) state.buffers[k(name)] = fn(state.buffers[k(name)]); },
|
||||
sysLine: (name: string, text: string, kind: string) => { lines.push({ name, text, kind }); },
|
||||
serverLine: (text: string) => { serverLines.push(text); },
|
||||
} as unknown as StoreHelpers;
|
||||
const seedChan = (chan: string, members: string[]) => {
|
||||
state.buffers[k(chan)] = { name: chan, modes: '', modeParams: {}, members: Object.fromEntries(members.map((n) => [n, { nick: n, user: 'u', host: 'h', prefix: '' }])) };
|
||||
};
|
||||
const { handleMode } = makeMode({ get, set, helpers } as Parameters<typeof makeMode>[0]);
|
||||
const on = (line: string, me = 'me') => handleMode(parseLine(line), me);
|
||||
return { on, state, lines, serverLines, seedChan };
|
||||
}
|
||||
|
||||
describe('MODE handler', () => {
|
||||
it('tracks our own user modes', () => {
|
||||
const { on, state, serverLines } = setup();
|
||||
on(':srv MODE me +iw', 'me');
|
||||
expect(state.umodes).toContain('i');
|
||||
expect(state.umodes).toContain('w');
|
||||
expect(serverLines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies a +o membership grant to the member prefix', () => {
|
||||
const { on, state, seedChan } = setup();
|
||||
seedChan('#x', ['bob']);
|
||||
on(':op!u@h MODE #x +o bob', 'me');
|
||||
expect(state.buffers['#x'].members['bob'].prefix).toBe('@');
|
||||
expect(state.buffers['#x'].members['bob'].prefixes).toBe('@');
|
||||
});
|
||||
|
||||
it('renders a +b ban as its own line, listing the present members it hits', () => {
|
||||
const { on, lines, seedChan } = setup();
|
||||
seedChan('#x', ['bob']); // bob!u@h
|
||||
on(':op!u@h MODE #x +b *!*@h', 'me');
|
||||
const bans = lines.filter((l) => l.kind === 'ban');
|
||||
expect(bans.length).toBeGreaterThanOrEqual(1);
|
||||
expect(bans.some((l) => l.text.includes('bob'))).toBe(true); // hit list
|
||||
});
|
||||
|
||||
it('shows a combined mode line for a channel flag change', () => {
|
||||
const { on, lines, seedChan } = setup();
|
||||
seedChan('#x', []);
|
||||
on(':op!u@h MODE #x +m', 'me');
|
||||
expect(lines.some((l) => l.kind === 'mode')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-MODE command', () => {
|
||||
const { on } = setup();
|
||||
expect(on(':bob!u@h JOIN #x')).toBe(false);
|
||||
});
|
||||
});
|
||||
96
src/core/store/mode.ts
Normal file
96
src/core/store/mode.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// MODE sub-handler — user- and channel-mode changes.
|
||||
//
|
||||
// User modes: track our own umode string. Channel modes: apply membership grants
|
||||
// (+o/+v/… → a member's held prefixes), keep the channel mode string current for
|
||||
// flag/param modes, and render +b/-b as their own mIRC-style ban lines (with the
|
||||
// present members each ban hits). Split out of handler.ts; the dispatcher calls
|
||||
// handleMode(msg, me) before its command switch.
|
||||
import i18n from '../i18n';
|
||||
import { canon, isChannelName } from './context';
|
||||
import { maskMatches } from './text';
|
||||
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
|
||||
import type { IrcMessage } from '../irc/types';
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { ChatState } from '../store';
|
||||
import type { StoreHelpers } from './helpers';
|
||||
|
||||
interface ModeDeps {
|
||||
get: StoreApi<ChatState>['getState'];
|
||||
set: StoreApi<ChatState>['setState'];
|
||||
helpers: StoreHelpers;
|
||||
}
|
||||
|
||||
export function makeMode({ get, set, helpers }: ModeDeps) {
|
||||
const { patchBuffer, sysLine, serverLine } = helpers;
|
||||
|
||||
// Handle a MODE change. Returns true when it was one; `me` is our current nick.
|
||||
function handleMode(msg: IrcMessage, me: string): boolean {
|
||||
if (msg.command !== 'MODE') return false;
|
||||
const chan = msg.params[0];
|
||||
if (!isChannelName(chan)) {
|
||||
// User mode change. User modes are global per-user and take no params;
|
||||
// we only track our own (target === our nick).
|
||||
if (chan === me) {
|
||||
const change = msg.params[1] ?? '';
|
||||
const next = applyUserModes(get().umodes, change);
|
||||
set({ umodes: next });
|
||||
const named = change.replace(/[+-]/g, '').split('').map((c) => i18n.t(`umodes.${c}`, '')).filter(Boolean);
|
||||
serverLine(named.length
|
||||
? i18n.t('system.yourModesNamed', { modes: next, change, names: named.join(', ') })
|
||||
: i18n.t('system.yourModes', { modes: next, change }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const client = get().client;
|
||||
const order = client?.server.prefixModes ?? '~&@%+';
|
||||
const ctx = buildModeContext(client?.server.isupport ?? {}, client?.server.prefixModeToChar ?? {});
|
||||
const changes = parseModeChanges(msg.params[1] ?? '', msg.params.slice(2), ctx);
|
||||
|
||||
const banLines: string[] = []; // +b/-b shown as their own clear lines (like mIRC)
|
||||
let showCombined = false; // any prefix/flag/param change → show the mode line
|
||||
|
||||
for (const c of changes) {
|
||||
if (c.kind === 'prefix' && c.param && c.prefix) {
|
||||
// membership grant (+o/+v/…) → update that member's held prefixes
|
||||
showCombined = true;
|
||||
const sym = c.prefix;
|
||||
patchBuffer(chan, (b) => {
|
||||
const m = b.members[c.param!];
|
||||
if (!m) return b;
|
||||
const held = (m.prefixes ?? m.prefix ?? '').split('').filter((x) => x !== sym);
|
||||
if (c.add) held.push(sym);
|
||||
held.sort((a, z) => order.indexOf(a) - order.indexOf(z));
|
||||
const prefixes = held.join('');
|
||||
return { ...b, members: { ...b.members, [c.param!]: { ...m, prefixes, prefix: prefixes[0] ?? '' } } };
|
||||
});
|
||||
} else if (c.kind === 'list') {
|
||||
// type A list mode. A ban (+b/-b) gets its own clear lines + who it hits;
|
||||
// other list modes (+e/+I) ride along in the combined line.
|
||||
if (c.mode === 'b' && c.param) {
|
||||
const mask = c.param;
|
||||
banLines.push(c.add ? `🔨 ${i18n.t('system.banned', { nick: msg.nick, mask })}` : `♻️ ${i18n.t('system.unbanned', { nick: msg.nick, mask })}`);
|
||||
const members = get().buffers[canon(chan)]?.members ?? {};
|
||||
const hit = Object.values(members)
|
||||
.filter((m) => maskMatches(mask, `${m.nick}!${m.user || '*'}@${m.host || '*'}`))
|
||||
.map((m) => m.nick);
|
||||
if (hit.length) banLines.push(i18n.t(c.add ? 'system.bansAdded' : 'system.bansRemoved', { list: hit.join(', ') }));
|
||||
} else showCombined = true;
|
||||
} else {
|
||||
// type B/C param mode or type D flag → maintain the channel mode string
|
||||
showCombined = true;
|
||||
patchBuffer(chan, (b) => ({ ...b, ...applyChannelFlag(b.modes || '', b.modeParams || {}, c) }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of banLines) sysLine(chan, line, 'ban');
|
||||
// The combined mode line is shown for everything except a pure ban change
|
||||
// (those are already covered by the dedicated ban lines above).
|
||||
if (showCombined) {
|
||||
const argStr = msg.params.length > 2 ? ' ' + msg.params.slice(2).join(' ') : '';
|
||||
sysLine(chan, `${msg.params[1] ?? ''}${argStr}`, 'mode', msg.nick);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { handleMode };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue