Compare commits

...

6 commits

12 changed files with 120 additions and 23 deletions

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<!-- Apply the saved theme before first paint so there's no flash of the light <!-- Apply the saved theme before first paint so there's no flash of the light
default on load/reload. Full theme logic runs later in src/ui/theme.ts. --> default on load/reload. Full theme logic runs later in src/ui/theme.ts. -->
<script>try{var t=localStorage.getItem('tchatou-theme');if(t)document.documentElement.dataset.theme=t;}catch(e){}</script> <script src="/app/theme-init.js"></script>
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<link rel="manifest" href="/app/manifest.webmanifest" /> <link rel="manifest" href="/app/manifest.webmanifest" />
<link rel="icon" href="/app/orbit-icon.svg" type="image/svg+xml" /> <link rel="icon" href="/app/orbit-icon.svg" type="image/svg+xml" />

29
package-lock.json generated
View file

@ -1976,12 +1976,12 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/html-parse-stringify": { "node_modules/html-parse-stringify": {
"version": "3.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "funding": {
"void-elements": "3.1.0" "url": "https://locize.com"
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
@ -2748,19 +2748,19 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.8", "version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1", "html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"peerDependencies": { "peerDependencies": {
"i18next": ">= 26.2.0", "i18next": ">= 26.2.0",
"react": ">= 16.8.0", "react": ">= 16.8.0",
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react-dom": { "react-dom": {
@ -3219,15 +3219,6 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -16,7 +16,7 @@ Orbit.plugin('games', (orbit, log) => {
const { useState, useEffect } = orbit.React; const { useState, useEffect } = orbit.React;
const html = orbit.html; const html = orbit.html;
const T = orbit.i18n.t; const T = orbit.i18n.t;
const GS = 'gameserv'; // GameServ nick, lower-cased for comparison const GS = 'gamesserv'; // GamesServ nick (singular "GameServ" is reserved), lower-cased for comparison
// ════════════════════════ decoding GameServ's state ════════════════════════ // ════════════════════════ decoding GameServ's state ════════════════════════
const GLYPH = { K: '♔', Q: '♕', R: '♖', B: '♗', N: '♘', P: '♙', k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }; const GLYPH = { K: '♔', Q: '♕', R: '♖', B: '♗', N: '♘', P: '♙', k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' };
@ -90,7 +90,7 @@ Orbit.plugin('games', (orbit, log) => {
orbit.on('orbit:panel', (id) => { if (id !== 'games' && store.open) { store.open = false; notify(); } }); orbit.on('orbit:panel', (id) => { if (id !== 'games' && store.open) { store.open = false; notify(); } });
// ════════════════════════ transport: commands to GameServ ════════════════════════ // ════════════════════════ transport: commands to GameServ ════════════════════════
const cmd = (line) => orbit.irc.send('PRIVMSG GameServ :' + line); const cmd = (line) => orbit.irc.send('PRIVMSG GamesServ :' + line);
const challenge = (nick, type) => cmd('CHALLENGE ' + nick + ' ' + type); const challenge = (nick, type) => cmd('CHALLENGE ' + nick + ' ' + type);
const accept = (id) => cmd('ACCEPT ' + id); const accept = (id) => cmd('ACCEPT ' + id);
const decline = (id) => cmd('DECLINE ' + id); const decline = (id) => cmd('DECLINE ' + id);

5
public/theme-init.js Normal file
View file

@ -0,0 +1,5 @@
// Apply the saved theme before first paint so there's no flash of the light
// default on load/reload. Kept as an external (non-inline) script so the page
// CSP is satisfied by script-src 'self' with no per-script hash to maintain.
// Full theme logic runs later in src/ui/theme.ts.
try { var t = localStorage.getItem('tchatou-theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}

View file

@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { useActiveChat } from '@/core/networks';
import { Modal } from './Modal';
// Shown when a JOIN is refused because the channel is CBANed network-wide
// (numeric 926, ERR_BADCHANNEL). The CBAN reason often names the correct
// channel, so we offer a one-click join to it.
export function CbanModal() {
const { t } = useTranslation();
const setModal = useActiveChat((s) => s.setModal);
const cban = useActiveChat((s) => s.cban);
const client = useActiveChat((s) => s.client);
const setActive = useActiveChat((s) => s.setActive);
if (!cban) return null;
const suggested = (cban.reason.match(/#[^\s,.:;!?]+/) || [])[0];
const close = () => setModal('');
// Join the suggested channel AND switch the active view to it (same as the
// join dialog / the /join command), so the user lands in #accueil.
const joinSuggested = () => { if (suggested) { client?.join(suggested); setActive(suggested); } close(); };
return (
<Modal title={t('modals.cban.title')} onClose={close}>
<p className="modal__sub">{t('modals.cban.sub', { channel: cban.channel })}</p>
{cban.reason && (
<div style={{
margin: '.2rem 0 .9rem', padding: '.6rem .8rem', borderRadius: '10px',
background: 'var(--bg-soft, rgba(255,255,255,.05))', borderLeft: '3px solid var(--accent)',
fontSize: '.9rem', lineHeight: 1.45, wordBreak: 'break-word',
}}>{cban.reason}</div>
)}
<div className="modal__actions">
<button className="upbtn" onClick={close}>{t('modals.cban.close')}</button>
{suggested && (
<button className="upbtn upbtn--primary" onClick={joinSuggested}>
{t('modals.cban.joinSuggested', { channel: suggested })}
</button>
)}
</div>
</Modal>
);
}

View file

@ -6,6 +6,7 @@ import { JoinDialog } from './JoinDialog';
import { ExploreModal } from './ExploreModal'; import { ExploreModal } from './ExploreModal';
import { ChanAdminModal } from './ChanAdminModal'; import { ChanAdminModal } from './ChanAdminModal';
import { ReportModal } from './ReportModal'; import { ReportModal } from './ReportModal';
import { CbanModal } from './CbanModal';
import { PluginModal } from './PluginModal'; import { PluginModal } from './PluginModal';
export function Modals() { export function Modals() {
@ -17,6 +18,7 @@ export function Modals() {
{modal === 'explore' && <ExploreModal />} {modal === 'explore' && <ExploreModal />}
{modal === 'chanadmin' && <ChanAdminModal />} {modal === 'chanadmin' && <ChanAdminModal />}
{modal === 'report' && <ReportModal />} {modal === 'report' && <ReportModal />}
{modal === 'cban' && <CbanModal />}
{modal === 'switcher' && <QuickSwitcher />} {modal === 'switcher' && <QuickSwitcher />}
{modal === 'shortcuts' && <Shortcuts />} {modal === 'shortcuts' && <Shortcuts />}
<PluginModal /> <PluginModal />

View file

@ -202,6 +202,12 @@
"reasonPlaceholder": "Describe the problem (spam, harassment, etc.)", "reasonPlaceholder": "Describe the problem (spam, harassment, etc.)",
"submit": "Send report" "submit": "Send report"
}, },
"cban": {
"title": "Channel unavailable",
"sub": "You can't join {{channel}}.",
"close": "Close",
"joinSuggested": "Join {{channel}}"
},
"chanadmin": { "chanadmin": {
"title": "Manage channel", "title": "Manage channel",
"topic": "Channel topic…", "topic": "Channel topic…",

View file

@ -202,6 +202,12 @@
"reasonPlaceholder": "Décrivez le problème (spam, harcèlement, etc.)", "reasonPlaceholder": "Décrivez le problème (spam, harcèlement, etc.)",
"submit": "Envoyer le signalement" "submit": "Envoyer le signalement"
}, },
"cban": {
"title": "Salon indisponible",
"sub": "Le salon {{channel}} nest pas accessible.",
"close": "Fermer",
"joinSuggested": "Rejoindre {{channel}}"
},
"chanadmin": { "chanadmin": {
"title": "Gérer le salon", "title": "Gérer le salon",
"topic": "Sujet du salon…", "topic": "Sujet du salon…",

View file

@ -50,6 +50,26 @@ describe('Ircv3 capability negotiation', () => {
expect(make().cap('CAP * NAK :sasl').do).toBe('end'); expect(make().cap('CAP * NAK :sasl').do).toBe('end');
}); });
it('never re-runs SASL or CAP END on a post-registration CAP NEW (rehash re-advertise)', () => {
// A server rehash re-advertises a value-cap via cap-notify. On a live session
// this must NOT re-request sasl or CAP END (that would re-auth with a spent
// one-time credential and abort the connection).
const reg = { registered: true, hasPassword: true };
const withSasl = make();
// sasl + the value-cap were both acked during registration
withSasl.cap('CAP * ACK :sasl draft/metadata-2', { registered: false, hasPassword: true });
// rehash re-advertises them (all already acked) → nothing to do
expect(withSasl.cap('CAP * NEW :draft/metadata-2=x sasl', reg).do).toBe('none');
// a post-registration ACK is bookkeeping only, never SASL/END
expect(make().cap('CAP * ACK :sasl', reg).do).toBe('none');
// but a genuinely new *feature* cap is still pulled in (never sasl)
const fresh = make().cap('CAP * NEW :draft/read-marker', reg);
expect(fresh.do).toBe('req');
if (fresh.do === 'req') expect(fresh.caps).not.toContain('sasl');
});
it('tracks acked caps; DEL and reset() forget them', () => { it('tracks acked caps; DEL and reset() forget them', () => {
const { ircv3, cap } = make(); const { ircv3, cap } = make();
cap('CAP * ACK :message-tags draft/read-marker'); cap('CAP * ACK :message-tags draft/read-marker');

View file

@ -98,6 +98,15 @@ export class Ircv3 {
} }
} }
if (more) return { do: 'none' }; if (more) return { do: 'none' };
// A cap-notify CAP NEW that arrives AFTER we're online (e.g. a server rehash
// re-advertising a cap): just track availability and pull in any genuinely
// new feature cap. NEVER re-request `sasl` or send CAP END on a live session
// — doing so re-authenticates with a one-time/expired credential (a keycard
// token is single-use) which fails and aborts the connection.
if (ctx.registered) {
const fresh = WANTED_CAPS.filter((c) => c !== 'sasl' && this.available.has(c) && !this.acked.has(c));
return fresh.length ? { do: 'req', caps: fresh } : { do: 'none' };
}
const req = WANTED_CAPS.filter((c) => this.available.has(c)); const req = WANTED_CAPS.filter((c) => this.available.has(c));
// Work around servers that drop a capability from CAP LS at a line-split // Work around servers that drop a capability from CAP LS at a line-split
// boundary (some servers lose the overflowing cap). message-tags is the // boundary (some servers lose the overflowing cap). message-tags is the
@ -113,6 +122,10 @@ export class Ircv3 {
if (sub === 'ACK') { if (sub === 'ACK') {
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) this.acked.add(c); for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) this.acked.add(c);
// A post-registration ACK (pulling in a cap advertised via cap-notify after a
// rehash) is bookkeeping only: never start SASL or send CAP END on an
// already-online session.
if (ctx.registered) return { do: 'none' };
// SASL if the server ACK'd it: a WebAuthn passkey (when requested and offered) // SASL if the server ACK'd it: a WebAuthn passkey (when requested and offered)
// takes precedence, else a password → PLAIN, else nothing to authenticate with. // takes precedence, else a password → PLAIN, else nothing to authenticate with.
if (this.acked.has('sasl')) { if (this.acked.has('sasl')) {

View file

@ -22,7 +22,7 @@ const filehost: { resolve: ((token: string) => void) | null; reject: ((err: Erro
export type Modal = '' | 'join' | 'settings' | 'explore' | 'friends' | 'chanadmin' | 'report' | 'switcher' | 'shortcuts'; export type Modal = '' | 'join' | 'settings' | 'explore' | 'friends' | 'chanadmin' | 'report' | 'switcher' | 'shortcuts' | 'cban';
export interface ChannelInfo { name: string; users: number; topic: string } export interface ChannelInfo { name: string; users: number; topic: string }
export interface KickInfo { channel: string; by: string; reason: string; kind: 'kick' | 'ban' | 'mute' } export interface KickInfo { channel: string; by: string; reason: string; kind: 'kick' | 'ban' | 'mute' }
@ -80,6 +80,7 @@ export interface ChatState {
whois: Record<string, WhoisInfo>; whois: Record<string, WhoisInfo>;
modal: Modal; modal: Modal;
reportSubject: string; // nick/channel prefilled into the report window reportSubject: string; // nick/channel prefilled into the report window
cban: { channel: string; reason: string } | null; // CBANed-join details for the cban window
kicked: KickInfo | null; // last time we got kicked — drives the dismissible toast kicked: KickInfo | null; // last time we got kicked — drives the dismissible toast
pmContext: Record<string, string>; // canon(nick) → channel this DM relates to (+draft/channel-context) pmContext: Record<string, string>; // canon(nick) → channel this DM relates to (+draft/channel-context)
@ -183,6 +184,7 @@ export function createChatStore(ns = '') {
pmContext: {}, pmContext: {},
modal: '', modal: '',
reportSubject: '', reportSubject: '',
cban: null,
kicked: null, kicked: null,
connect(opts) { connect(opts) {

View file

@ -248,6 +248,16 @@ export function makeNumerics({ get, set, helpers, closedChannels, lastCantSend,
set({ kicked: { channel: ch, by: '', reason: '', kind: 'ban' } }); set({ kicked: { channel: ch, by: '', reason: '', kind: 'ban' } });
return true; return true;
} }
case '926': { // ERR_BADCHANNEL (m_cban): join refused — channel is CBANed network-wide
const ch = msg.params[1] || '';
const full = msg.params.length > 1 ? msg.params[msg.params.length - 1] : '';
// Message is "Channel #x is CBANed: <reason>" — keep just the reason.
const reason = full.replace(/^Channel\s+\S+\s+is\s+CBANed:\s*/i, '').trim() || full;
if (isChannelName(ch)) { closedChannels.add(canon(ch)); dropBuffer(ch); }
if (get().prefs.sound) blip();
set({ cban: { channel: ch, reason }, modal: 'cban' });
return true;
}
} }
// Every remaining numeric is recognised (see irc/numerics.ts) and routed: // Every remaining numeric is recognised (see irc/numerics.ts) and routed: