Compare commits
6 commits
7f6675cea9
...
42de9cc491
| Author | SHA1 | Date | |
|---|---|---|---|
| 42de9cc491 | |||
| aed817a105 | |||
| 431432feb5 | |||
| 2eab043a33 | |||
| 5d90c54092 | |||
| f9a1ac016b |
12 changed files with 120 additions and 23 deletions
|
|
@ -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
29
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
5
public/theme-init.js
Normal 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) {}
|
||||||
42
src/components/modals/CbanModal.tsx
Normal file
42
src/components/modals/CbanModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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}} n’est 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…",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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')) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue