Compare commits
70 commits
544f1b0f5f
...
1a215ff912
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a215ff912 | |||
| 4f95f0ea66 | |||
| a6769d3a54 | |||
| c28a28645f | |||
| 231be23f0d | |||
| e36d3a1d34 | |||
| 7c074d7a8b | |||
| d817db9255 | |||
| e82b4b7f01 | |||
| dbb45e4d17 | |||
| d5ddbbff4f | |||
| 14cf38afa8 | |||
| 9b21adb9cc | |||
| 7ea1fd3e87 | |||
| 96ff3bef78 | |||
| b1b628bdc9 | |||
| 4f8d10c8a5 | |||
| f90aca5252 | |||
| 5d6de5a357 | |||
| b7265b5706 | |||
| d3300d4a4e | |||
| bbd8b81ea6 | |||
| 77d8a0a669 | |||
| c3b96c5b16 | |||
| 67e6493b71 | |||
| 8bc88557b3 | |||
| c0ec75d6dd | |||
| d5af169c73 | |||
| 88e689fc82 | |||
| 9a4757e839 | |||
| db1ba1420f | |||
| e2ecd093a6 | |||
| df3f9d4b92 | |||
| 04f229053a | |||
| b94b9f8994 | |||
| cf51efe339 | |||
| 8894974479 | |||
| 6f9df54864 | |||
| 8fd1af78bb | |||
| 726ce7b609 | |||
| 7f6fb14b19 | |||
| 6ad0d2bbd9 | |||
| c18604c426 | |||
| ad05f3e470 | |||
| 937a9dc38c | |||
| 4a0b6b51e0 | |||
| 27cfae944a | |||
| a390078876 | |||
| a141742868 | |||
| 2bae979b30 | |||
| efe0fb0df9 | |||
| f920d52847 | |||
| 5a3d6e737d | |||
| d5c8da0d50 | |||
| e620f72f84 | |||
| 4fe83e790a | |||
| 9b3db02721 | |||
| 900859acdf | |||
| 56ae05f56d | |||
| 69cd4eb024 | |||
| 68c7560212 | |||
| 1a82247a7d | |||
| c222eda4c1 | |||
| 66509f5adb | |||
| ddcb4d9765 | |||
| 80af3cbb21 | |||
| 9e243c6546 | |||
| a8fcdabcda | |||
| b87acb63b5 | |||
| a5919ee0cc |
102 changed files with 2147 additions and 679 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,4 @@
|
|||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by fontconfig.
|
||||
# For information about cache directory tags, see:
|
||||
# http://www.brynosaurus.com/cachedir/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
15
index.html
15
index.html
|
|
@ -5,7 +5,7 @@
|
|||
<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
|
||||
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;if(sessionStorage.getItem('orbit:just-updated')==='1'){var b=sessionStorage.getItem('orbit:update-bg');if(b)document.documentElement.style.background=b}}catch(e){}</script>
|
||||
<script>try{var t=localStorage.getItem('tchatou-theme');if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="manifest" href="/app/manifest.webmanifest" />
|
||||
<link rel="icon" href="/app/orbit-icon.svg" type="image/svg+xml" />
|
||||
|
|
@ -16,6 +16,19 @@
|
|||
<!-- Pre-JS placeholder only; the real title is set at runtime from
|
||||
config.branding.name in src/App.tsx. -->
|
||||
<title>Tchatou</title>
|
||||
<meta name="description" content="Tchat en direct dans votre navigateur : salons de discussion, messages privés, réactions, réponses, historique et notifications, sans installation." />
|
||||
<!-- Link-unfurl card. Unfurlers don't run JS, so these ship static (brand default);
|
||||
src/core/seo.ts refines them per deployment from config.json for JS consumers. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Tchatou" />
|
||||
<meta property="og:title" content="Tchatou" />
|
||||
<meta property="og:description" content="Tchat en direct dans votre navigateur : salons de discussion, messages privés, réactions, réponses, historique et notifications, sans installation." />
|
||||
<meta property="og:url" content="https://tchatou.fr/app/" />
|
||||
<meta property="og:image" content="https://tchatou.fr/app/screenshot-wide.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Tchatou" />
|
||||
<meta name="twitter:description" content="Tchat en direct dans votre navigateur : salons de discussion, messages privés, réactions, réponses, historique et notifications, sans installation." />
|
||||
<meta name="twitter:image" content="https://tchatou.fr/app/screenshot-wide.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,500;1,9..144,600;1,9..144,700&family=Geist:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -936,9 +936,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -62,5 +62,11 @@
|
|||
"/app/plugins/third/orbit-invite.js",
|
||||
"/app/plugins/third/orbit-navbar.js"
|
||||
],
|
||||
"builtins": []
|
||||
"builtins": [],
|
||||
"analytics": {
|
||||
"endpoint": "/accounts/api/analytics/",
|
||||
"siteId": "tchatou",
|
||||
"gaId": "G-1PQFZWGNFX"
|
||||
},
|
||||
"securityGroups": ["kiwiirc", "oper", "ssl", "plain", "registered", "staff", "trusted", "bots", "EU"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
function beep() { try { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); const t = audioCtx.currentTime, o = audioCtx.createOscillator(), g = audioCtx.createGain(); o.type = 'sine'; o.connect(g); g.connect(audioCtx.destination); o.frequency.setValueAtTime(660, t); o.frequency.setValueAtTime(880, t + 0.12); g.gain.setValueAtTime(0.05, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.32); o.start(t); o.stop(t + 0.33); } catch (e) { /* no audio */ } }
|
||||
function hideToast() { if (toastEl) toastEl.style.opacity = '0'; }
|
||||
function showToast(msg, id) {
|
||||
if (!toastEl) { toastEl = document.createElement('div'); Object.assign(toastEl.style, { position: 'fixed', right: '14px', bottom: '120px', zIndex: '70', maxWidth: '260px', background: '#15151a', color: '#eee', border: '1px solid rgba(255,255,255,.14)', borderRadius: '12px', padding: '.6rem .8rem', font: '600 .85rem/1.3 system-ui,sans-serif', boxShadow: '0 16px 40px -16px rgba(0,0,0,.7)', cursor: 'pointer', transition: 'opacity .2s', opacity: '0' }); document.body.appendChild(toastEl); }
|
||||
if (!toastEl) { toastEl = document.createElement('div'); Object.assign(toastEl.style, { position: 'fixed', right: '14px', bottom: '120px', zIndex: '70', maxWidth: '260px', background: 'var(--bg)', color: 'var(--ink)', border: '1px solid var(--border)', borderRadius: '12px', padding: '.6rem .8rem', font: '600 .85rem/1.3 system-ui,sans-serif', boxShadow: '0 14px 36px -14px rgba(0,0,0,.35)', cursor: 'pointer', transition: 'opacity .2s', opacity: '0' }); document.body.appendChild(toastEl); }
|
||||
toastEl.textContent = msg; toastEl.onclick = () => { store.open = true; store.active = id; store.showBoard = false; notify(); hideToast(); };
|
||||
toastEl.style.opacity = '1'; clearTimeout(toastTimer); toastTimer = setTimeout(hideToast, 6000);
|
||||
}
|
||||
|
|
@ -201,14 +201,14 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
const myPlayed = ms ? Object.keys(GAMES).filter((t) => ms[t] && (ms[t].wins + ms[t].losses + ms[t].draws) > 0) : [];
|
||||
const btn = { border: '1px solid var(--border,#444)', background: 'transparent', color: 'inherit', borderRadius: '8px', padding: '.4rem .7rem', font: 'inherit', fontSize: '.85rem', cursor: 'pointer' };
|
||||
|
||||
return html`<div style=${{ position: 'fixed', right: '14px', bottom: '74px', zIndex: 60, width: '344px', maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch', background: 'var(--bg2,#15151a)', color: 'var(--tx,#eee)', border: '1px solid var(--border,#333)', borderRadius: '14px', boxShadow: '0 24px 60px -20px rgba(0,0,0,.7)', padding: '.85rem' }}>
|
||||
return html`<div style=${{ '--bg2': 'var(--bg)', '--tx': 'var(--ink)', '--tx-dim': 'var(--muted)', '--ac': 'var(--accent)', '--card': 'var(--bg-soft-2)', position: 'fixed', right: '14px', bottom: '74px', zIndex: 60, width: '344px', maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch', background: 'var(--bg2,#15151a)', color: 'var(--tx,#eee)', border: '1px solid var(--border,#333)', borderRadius: '14px', boxShadow: '0 20px 50px -14px rgba(0,0,0,.35)', padding: '.85rem' }}>
|
||||
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.6rem' }}>
|
||||
<strong style=${{ fontWeight: 700, fontSize: '.95rem', lineHeight: '1' }}>${T('plugins.games.title')}</strong>
|
||||
<button onClick=${() => { s.open = false; notify(); }} style=${{ ...btn, padding: '.15rem .45rem' }}>✕</button>
|
||||
</div>
|
||||
|
||||
${myPlayed.length ? html`<div style=${{ display: 'flex', flexDirection: 'column', gap: '.3rem', margin: '-.15rem 0 .55rem' }}>
|
||||
${myPlayed.map((t) => { const r = ms[t]; return html`<div key=${t} style=${{ display: 'flex', alignItems: 'center', gap: '.45rem', fontSize: '.78rem', padding: '.32rem .55rem', background: 'rgba(255,255,255,.05)', borderRadius: '8px' }}>
|
||||
${myPlayed.map((t) => { const r = ms[t]; return html`<div key=${t} style=${{ display: 'flex', alignItems: 'center', gap: '.45rem', fontSize: '.78rem', padding: '.32rem .55rem', background: 'var(--bg-soft)', borderRadius: '8px' }}>
|
||||
<span style=${{ width: '1.1rem', textAlign: 'center' }}>${GAMES[t].icon}</span>
|
||||
<span style=${{ fontWeight: 700 }}>${RANK_BADGE[r.rank] || ''} ${T('plugins.games.rank.' + r.rank, { defaultValue: r.rank })}</span>
|
||||
<span style=${{ color: 'var(--tx-dim,#aaa)' }}>${r.points} pts</span>
|
||||
|
|
@ -216,10 +216,10 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
</div>`; })}
|
||||
</div>` : null}
|
||||
|
||||
${invites.map((g) => html`<div key=${g.id} style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '.5rem', background: 'rgba(255,255,255,.05)', borderRadius: '8px', padding: '.45rem .6rem', marginBottom: '.4rem' }}>
|
||||
${invites.map((g) => html`<div key=${g.id} style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '.5rem', background: 'var(--bg-soft)', borderRadius: '8px', padding: '.45rem .6rem', marginBottom: '.4rem' }}>
|
||||
<span style=${{ fontSize: '.84rem' }}>${T('plugins.games.toastOffer', { opp: g.opp, game: GAMES[g.type].name })}</span>
|
||||
<span style=${{ display: 'flex', gap: '.3rem' }}>
|
||||
<button onClick=${() => accept(g.id)} style=${{ ...btn, color: '#2ea043', borderColor: '#2ea043' }}>${T('plugins.games.accept')}</button>
|
||||
<button onClick=${() => accept(g.id)} style=${{ ...btn, color: 'var(--online)', borderColor: 'var(--online)' }}>${T('plugins.games.accept')}</button>
|
||||
<button onClick=${() => decline(g.id)} style=${btn}>${T('plugins.games.decline')}</button>
|
||||
</span>
|
||||
</div>`)}
|
||||
|
|
@ -263,7 +263,7 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
</div>`); })()}
|
||||
</div>` : html`<div>
|
||||
<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#aaa)', marginBottom: '.4rem' }}>${T('plugins.games.challengeIntro')}</div>
|
||||
<input value=${nick} onInput=${(e) => setNick(e.target.value)} placeholder=${T('plugins.games.oppPlaceholder')} style=${{ width: '100%', boxSizing: 'border-box', padding: '.5rem .6rem', borderRadius: '8px', border: '1px solid var(--border,#444)', background: 'var(--bg,#0e0e12)', color: 'inherit', font: 'inherit', marginBottom: '.5rem' }} />
|
||||
<input value=${nick} onInput=${(e) => setNick(e.target.value)} placeholder=${T('plugins.games.oppPlaceholder')} style=${{ width: '100%', boxSizing: 'border-box', padding: '.5rem .6rem', borderRadius: '8px', border: '1px solid var(--border,#444)', background: 'var(--bg-soft,#0e0e12)', color: 'inherit', font: 'inherit', marginBottom: '.5rem' }} />
|
||||
<div style=${{ display: 'flex', gap: '.4rem' }}>
|
||||
${Object.keys(GAMES).map((id) => html`<button key=${id} onClick=${() => nick.trim() && challenge(nick.trim(), id)} style=${{ ...btn, flex: 1, fontSize: '.8rem' }}>${GAMES[id].icon} ${GAMES[id].name}</button>`)}
|
||||
</div>
|
||||
|
|
@ -272,23 +272,33 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
function GamesButton() {
|
||||
// Games launcher — a tab in the app footer nav, styled like Accueil / Salons /
|
||||
// Amis (icon over label, accent when the panel is open, a dot when it's your
|
||||
// move or you have a pending challenge).
|
||||
function GamesTab() {
|
||||
const s = useStore();
|
||||
const games = Object.values(s.games);
|
||||
const alert = games.some((g) => g.status === 'offer') || games.some((g) => g.status === 'active' && g.turn === g.mySide);
|
||||
return html`<${orbit.Fragment}>
|
||||
<button title=${T('plugins.games.title')} onClick=${() => { s.open = !s.open; notify(); }} style=${{ position: 'relative', display: 'flex', alignItems: 'center', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', padding: '0 .4rem', alignSelf: 'center' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style=${{ display: 'block' }}>
|
||||
return html`<button className=${'tab' + (s.open ? ' is-active' : '')} title=${T('plugins.games.title')} aria-label=${T('plugins.games.title')} onClick=${() => { s.open = !s.open; notify(); }}>
|
||||
<span className="tab__ic">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" style=${{ display: 'block' }}>
|
||||
<line x1="6" x2="10" y1="11" y2="11" />
|
||||
<line x1="8" x2="8" y1="9" y2="13" />
|
||||
<line x1="15" x2="15.01" y1="12" y2="12" />
|
||||
<line x1="18" x2="18.01" y1="10" y2="10" />
|
||||
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.544-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z" />
|
||||
</svg>
|
||||
${alert ? html`<span style=${{ position: 'absolute', top: '0', right: '2px', width: '7px', height: '7px', borderRadius: '50%', background: '#2ea043' }}></span>` : null}
|
||||
</button>
|
||||
${s.open ? html`<${Panel} />` : null}
|
||||
</${orbit.Fragment}>`;
|
||||
${alert ? html`<span className="tab__dot"></span>` : null}
|
||||
</span>
|
||||
<span className="tab__lb">${T('plugins.games.title')}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// The games panel lives at the app root via the persistent 'overlay' slot, so
|
||||
// the launcher tab can sit in the footer while the panel opens over everything.
|
||||
function GamesPanel() {
|
||||
const s = useStore();
|
||||
return s.open ? html`<${Panel} />` : null;
|
||||
}
|
||||
|
||||
// Rank badge on a profile card (asks GameServ for that account's stats).
|
||||
|
|
@ -304,11 +314,12 @@ Orbit.plugin('games', (orbit, log) => {
|
|||
.sort((a, b) => b.points - a.points);
|
||||
if (!played.length) return null;
|
||||
const best = played[0]; // their strongest game
|
||||
return html`<span title=${T('plugins.games.bestRank', { game: GAMES[best.t].name })} style=${{ display: 'inline-flex', alignItems: 'center', gap: '.3rem', fontSize: '.8rem', fontWeight: 600, padding: '.3rem .6rem', borderRadius: '999px', background: 'rgba(255,255,255,.06)', border: '1px solid var(--border,#444)' }}>${GAMES[best.t].icon} ${RANK_BADGE[best.rank] || ''} ${T('plugins.games.rank.' + best.rank, { defaultValue: best.rank })} · ${best.points} pts</span>`;
|
||||
return html`<span title=${T('plugins.games.bestRank', { game: GAMES[best.t].name })} style=${{ display: 'inline-flex', alignItems: 'center', gap: '.3rem', fontSize: '.8rem', fontWeight: 600, padding: '.3rem .6rem', borderRadius: '999px', background: 'var(--bg-soft)', border: '1px solid var(--border,#444)' }}>${GAMES[best.t].icon} ${RANK_BADGE[best.rank] || ''} ${T('plugins.games.rank.' + best.rank, { defaultValue: best.rank })} · ${best.points} pts</span>`;
|
||||
}
|
||||
const refreshStatsFor = (nick) => cmd('STATS ' + nick);
|
||||
|
||||
orbit.addUi('topbar_item', () => orbit.h(GamesButton));
|
||||
orbit.addUi('nav_item', () => orbit.h(GamesTab));
|
||||
orbit.addUi('overlay', () => orbit.h(GamesPanel));
|
||||
orbit.addUserAction((u) => html`<${RankChip} nick=${u.nick} />`);
|
||||
orbit.addUserAction((u) => html`<button className="pm-chip" onClick=${() => { store.challengeNick = u.nick; store.active = null; store.showBoard = false; store.open = true; notify(); u.close(); }}>♟ ${T('plugins.games.challengeAction')}</button>`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
const CACHE = 'tchatou-__SW_BUILD__';
|
||||
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
|
||||
|
||||
// No skipWaiting: a new SW stays waiting until every tab closes, so a running tab
|
||||
// keeps its cached bundle (a manual refresh still loads the new build — navigate
|
||||
// is network-first). This is what makes the app not auto-reload.
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
|||
import { ConnectScreen } from './components/ConnectScreen';
|
||||
import { Chat } from './components/Chat';
|
||||
import { refreshPush } from './platform/push';
|
||||
import { dismissUpdateCurtain } from './ui/appUpdate';
|
||||
import { getConfig } from './core/config';
|
||||
import { usePluginRegistry, matchShortcut } from './modules/registry';
|
||||
import { activeStore, useAllNetworksUnread, useNetworks } from './core/networks';
|
||||
|
|
@ -41,10 +40,6 @@ export default function App() {
|
|||
document.title = unread > 0 ? `(${unread}) ${name}` : name;
|
||||
}, [unread]);
|
||||
|
||||
// App is mounted and painting — fade out any update curtain held over from a
|
||||
// seamless-refresh reload, revealing the fresh build.
|
||||
useEffect(() => { dismissUpdateCurtain(); }, []);
|
||||
|
||||
// Guard against an accidental tab close/reload while a session is (or has been)
|
||||
// live — the browser shows its native "Leave site?" prompt so you don't lose the
|
||||
// connection by mistake. The listener stays mounted and decides at fire-time, so
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function Avatar({ nick, size = 40, account }: { nick: string; size?: numb
|
|||
if (url) {
|
||||
return (
|
||||
<span className="avatar group__avatar avatar--img" style={{ width: size, height: size }}>
|
||||
<img src={url} alt={n} loading="lazy" width={size} height={size}
|
||||
<img src={url} alt={n} loading="lazy" decoding="async" width={size} height={size}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProfileModal } from './profile/ProfileModal';
|
||||
import { Modals } from './modals/Modals';
|
||||
|
|
@ -17,13 +17,18 @@ export function Chat() {
|
|||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
// A plugin-filled bar across the very top (branding + portal links). Nothing
|
||||
// renders it by default, so the shell collapses to just the app.
|
||||
const navbar = usePluginRegistry((s) => s.ui).filter((u) => u.slot === 'navbar');
|
||||
const ui = usePluginRegistry((s) => s.ui);
|
||||
const navbar = ui.filter((u) => u.slot === 'navbar');
|
||||
// Persistent, root-level home for plugin popovers/panels (see UiSlot 'overlay').
|
||||
const overlays = ui.filter((u) => u.slot === 'overlay');
|
||||
// Stable so the memoized room rows aren't invalidated on every render.
|
||||
const closeNav = useCallback(() => setNavOpen(false), []);
|
||||
return (
|
||||
<div className="shell">
|
||||
{navbar.map((u) => <PluginBoundary key={u.id} render={u.render} label="navbar" />)}
|
||||
<div className={`app ${navOpen ? 'nav-open' : ''} ${membersOpen ? 'members-open' : ''}`}>
|
||||
<a className="skip-link" href="#orbit-main">{t('a11y.skip')}</a>
|
||||
<Sidebar onNavigate={() => setNavOpen(false)} />
|
||||
<Sidebar onNavigate={closeNav} />
|
||||
<main className="main" id="orbit-main">
|
||||
<Topbar onMenu={() => setNavOpen(true)} onMembers={() => setMembersOpen(true)} />
|
||||
<MessageList />
|
||||
|
|
@ -38,6 +43,7 @@ export function Chat() {
|
|||
<KickToast />
|
||||
<ReconnectBanner />
|
||||
</div>
|
||||
{overlays.map((u) => <PluginBoundary key={u.id} render={u.render} label="overlay" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
93
src/components/Icon.tsx
Normal file
93
src/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
// Shared stroke-icon set. One family for the whole app: 24×24, currentColor, the
|
||||
// same 1.9 stroke and round caps/joins as the composer glyphs, so an icon inherits
|
||||
// its button's colour and reads consistently in every theme. Add a path, use <Icon>.
|
||||
const PATHS: Record<string, ReactNode> = {
|
||||
home: (
|
||||
<>
|
||||
<path d="M3 9.5 12 2.5l9 7v11a1.8 1.8 0 0 1-1.8 1.8H4.8A1.8 1.8 0 0 1 3 20.5z" />
|
||||
<path d="M9.2 21.3V13h5.6v8.3" />
|
||||
</>
|
||||
),
|
||||
compass: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9.3" />
|
||||
<path d="M16.2 7.8 14.1 14.1 7.8 16.2 9.9 9.9z" />
|
||||
</>
|
||||
),
|
||||
users: (
|
||||
<>
|
||||
<path d="M16 20.5v-1.8a4 4 0 0 0-4-4H6.2a4 4 0 0 0-4 4v1.8" />
|
||||
<circle cx="9.1" cy="7.3" r="3.8" />
|
||||
<path d="M21.8 20.5v-1.8a4 4 0 0 0-3-3.86" />
|
||||
<path d="M16.4 3.7a4 4 0 0 1 0 7.4" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 8 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H2a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 3.6 8a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H8a1.65 1.65 0 0 0 1-1.51V2a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V8a1.65 1.65 0 0 0 1.51 1H22a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7.5" />
|
||||
<path d="M21 21 16.65 16.65" />
|
||||
</>
|
||||
),
|
||||
close: <path d="M6 6 18 18M18 6 6 18" />,
|
||||
menu: <path d="M3.5 6.5h17M3.5 12h17M3.5 17.5h17" />,
|
||||
bell: (
|
||||
<>
|
||||
<path d="M18 8.5a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</>
|
||||
),
|
||||
bellOff: (
|
||||
<>
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
<path d="M18.6 13A17.9 17.9 0 0 1 18 8.5" />
|
||||
<path d="M6.3 6.3A5.9 5.9 0 0 0 6 8.5c0 7-3 9-3 9h14" />
|
||||
<path d="M18 8.5a6 6 0 0 0-9.3-5" />
|
||||
<path d="M2 2 22 22" />
|
||||
</>
|
||||
),
|
||||
pin: (
|
||||
<>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.3V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.7a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||
</>
|
||||
),
|
||||
sliders: (
|
||||
<>
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||
<path d="M1.5 14h5M9.5 8h5M17.5 16h5" />
|
||||
</>
|
||||
),
|
||||
more: (
|
||||
<>
|
||||
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
logout: (
|
||||
<>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17 21 12 16 7" />
|
||||
<path d="M21 12H9" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function Icon({ name, size = 22, className }: { name: IconName; size?: number; className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" width={size} height={size} fill="none"
|
||||
stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { avatarBg } from '../lib/format';
|
||||
import { buildSwitcherResults, type SwitcherItem } from '../lib/switcher';
|
||||
import { useActiveChat } from '../core/networks';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
// Command-palette-style quick switcher (Ctrl/⌘-K): fuzzy-jump to any open
|
||||
// channel/DM, any person in your channels, or join a channel by name.
|
||||
|
|
@ -54,7 +55,7 @@ export function QuickSwitcher() {
|
|||
<div className="qswitch-scrim" onClick={close}>
|
||||
<div className="qswitch" role="dialog" aria-label={t('switcher.title')} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="qswitch__bar">
|
||||
<span className="qswitch__ic" aria-hidden>🔍</span>
|
||||
<span className="qswitch__ic" aria-hidden><Icon name="search" size={15} /></span>
|
||||
<input className="qswitch__input" autoFocus value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }}
|
||||
onKeyDown={onKey} placeholder={t('switcher.placeholder')} aria-label={t('switcher.placeholder')} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function Turnstile({ sitekey, onVerify, onError, theme = 'auto' }: {
|
|||
const id = useRef<string | null>(null);
|
||||
// keep latest callbacks without re-rendering the widget
|
||||
const cb = useRef({ onVerify, onError });
|
||||
cb.current = { onVerify, onError };
|
||||
useEffect(() => { cb.current = { onVerify, onError }; });
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
export function ReconnectBanner() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SERVER } from '../../core/store';
|
||||
import { MIRC_PALETTE } from '../../lib/format';
|
||||
import { serialize, ircToHtml, caretIndex, selectRange, caretAtEdge, caretToEnd } from '../../lib/editor';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { SERVER } from '@/core/store';
|
||||
import { MIRC_PALETTE } from '@/lib/format';
|
||||
import { serialize, ircToHtml, caretIndex, selectRange, caretAtEdge, caretToEnd } from '@/lib/editor';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat, activeStore } from '../../core/networks';
|
||||
import { useActiveChat, activeStore } from '@/core/networks';
|
||||
import { EMOJIS, EMOJI_NAMES, SLASH_COMMANDS } from './composer/constants';
|
||||
import { TypingIndicator } from './composer/TypingIndicator';
|
||||
import { ReplyBar } from './composer/ReplyBar';
|
||||
|
|
@ -228,7 +228,7 @@ export function Composer() {
|
|||
if (!img) for (const it of Array.from(dt.items || [])) {
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) { img = it.getAsFile(); if (img) break; }
|
||||
}
|
||||
if (img) { uploadImage(img); return true; }
|
||||
if (img) { void uploadImage(img); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -262,7 +262,7 @@ export function Composer() {
|
|||
</>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadImage(f); e.target.value = ''; }} />
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadImage(f); e.target.value = ''; }} />
|
||||
<div className={`composer__box ${isConsole ? 'composer__box--console' : ''} ${dragOver ? 'is-drop' : ''}`}
|
||||
onDragOver={(e) => { if (!isConsole) { e.preventDefault(); setDragOver(true); } }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Member } from '../../core/irc/types';
|
||||
import type { Member } from '@/core/irc/types';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { MemberMenu } from './MemberMenu';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
const ROLES: Record<string, { key: string; cls: string }> = {
|
||||
'~': { key: 'owner', cls: 'owner' },
|
||||
'&': { key: 'admin', cls: 'admin' },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { getTheme } from '../../themes';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { getTheme } from '@/themes';
|
||||
|
||||
// mIRC-style right-click op menu for a nick in the member list. Whois for
|
||||
// everyone (printed inline to the active window under yomirc, like classic mIRC;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SERVER } from '../../core/store';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { SERVER } from '@/core/store';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { MsgRow } from './MsgRow';
|
||||
import { SystemLine } from './SystemLine';
|
||||
import { SearchResults } from './SearchResults';
|
||||
|
|
@ -22,6 +22,7 @@ export function MessageList() {
|
|||
const markReadHere = useActiveChat((s) => s.markReadHere);
|
||||
useActiveChat((s) => s.prefs.clock24); // re-render timestamps when the clock format changes
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const flowRef = useRef<HTMLDivElement>(null);
|
||||
const dividerRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevHeight = useRef(0);
|
||||
const prevActive = useRef(active);
|
||||
|
|
@ -64,27 +65,25 @@ export function MessageList() {
|
|||
// message would otherwise land below the fold, hidden behind the keyboard.
|
||||
// If we were pinned to the bottom, re-pin once the new layout has settled.
|
||||
useEffect(() => {
|
||||
const repin = () => {
|
||||
if (!atBottom.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = ref.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
};
|
||||
// Any time the list box shrinks — the composer growing (multi-line, reply
|
||||
// bar, format toolbar) or the mobile keyboard opening — the newest messages
|
||||
// would slide behind the composer. Re-pin on the resize so they stay visible
|
||||
// above it. ResizeObserver catches the composer growth (incl. desktop); the
|
||||
// events catch the mobile keyboard, which resizes the viewport not the list.
|
||||
const ro = new ResizeObserver(repin);
|
||||
if (ref.current)
|
||||
ro.observe(ref.current);
|
||||
window.addEventListener('tchatou:vh', repin);
|
||||
window.visualViewport?.addEventListener('resize', repin);
|
||||
const pin = () => { const el = ref.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||
// The newest lines slide out of view two ways: the list box SHRINKS (composer
|
||||
// growing, mobile keyboard) or its CONTENT GROWS after paint (a link-preview
|
||||
// card loads, an embed expands). Observe both — the box and the row flow.
|
||||
// ResizeObserver fires before paint, so re-pinning synchronously there keeps
|
||||
// the newest line in place with no visible jump (the shift never renders).
|
||||
const onResize = () => { if (atBottom.current) pin(); };
|
||||
// The mobile keyboard resizes the visual viewport, not the list, and its
|
||||
// scrollTop doesn't move — wait a frame for the new layout, then re-pin.
|
||||
const onViewport = () => { if (atBottom.current) requestAnimationFrame(pin); };
|
||||
const ro = new ResizeObserver(onResize);
|
||||
if (ref.current) ro.observe(ref.current);
|
||||
if (flowRef.current) ro.observe(flowRef.current);
|
||||
window.addEventListener('tchatou:vh', onViewport);
|
||||
window.visualViewport?.addEventListener('resize', onViewport);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('tchatou:vh', repin);
|
||||
window.visualViewport?.removeEventListener('resize', repin);
|
||||
window.removeEventListener('tchatou:vh', onViewport);
|
||||
window.visualViewport?.removeEventListener('resize', onViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -136,7 +135,7 @@ export function MessageList() {
|
|||
role="log" aria-label={t('a11y.messages')}>
|
||||
{histLoading && <div className="histload"><span className="histload__spin" /> {t('messages.loadingHistory')}</div>}
|
||||
{showJump && <button className="jump-unread" onClick={jumpToUnread}>↑ {t('messages.newMessages')}</button>}
|
||||
{rows}
|
||||
<div ref={flowRef}>{rows}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChatMessage } from '../../core/irc/types';
|
||||
import { fmtTime, nickColor, IRCOP_COLOR, formatIrc } from '../../lib/format';
|
||||
import { firstPreviewableUrl, LinkPreview } from '../../lib/link-preview';
|
||||
import { stripFormatting } from '../../core/store/text';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { useTheme } from '../../themes';
|
||||
import type { ChatMessage } from '@/core/irc/types';
|
||||
import { fmtTime, nickColor, IRCOP_COLOR, formatIrc } from '@/lib/format';
|
||||
import { firstPreviewableUrl, LinkPreview } from '@/lib/link-preview';
|
||||
import { stripFormatting } from '@/core/store/text';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { useTheme } from '@/themes';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { usePluginRegistry, type MessageInfo } from '../../modules/registry';
|
||||
import { usePluginRegistry, type MessageInfo } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
const QUICK = ['👍', '😂', '❤️', '🔥'];
|
||||
|
||||
|
|
@ -55,7 +56,9 @@ function MsgActions({ m }: { m: ChatMessage }) {
|
|||
|
||||
// A single privmsg/action row, in either the modern grouped-bubble layout or the
|
||||
// classic yomIRC single-log-line layout, with reactions + the hover action bar.
|
||||
export function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
|
||||
// memo: props (m, cont) are stable, so a new message re-renders only the new row,
|
||||
// not the whole list; per-row store subscriptions still update their own row.
|
||||
export const MsgRow = memo(function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const react = useActiveChat((s) => s.toggleReaction);
|
||||
const redact = useActiveChat((s) => s.redact);
|
||||
|
|
@ -199,4 +202,4 @@ export function MsgRow({ m, cont }: { m: ChatMessage; cont: boolean }) {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStore } from 'zustand';
|
||||
import { useNetworks, passKey, type NetworkEntry } from '../../core/networks';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { useNetworks, passKey, type NetworkEntry } from '@/core/networks';
|
||||
import { getConfig } from '@/core/config';
|
||||
|
||||
// One network chip: connection dot + label + (when it's not the active one) an
|
||||
// unread badge. Subscribes to THIS network's own store for its status/unread.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { Icon } from '../Icon';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
|
||||
const LEVELS = ['all', 'mentions', 'mute'] as const;
|
||||
|
|
@ -28,7 +29,7 @@ export function NotifyMenu() {
|
|||
<div className="nmenu" ref={ref}>
|
||||
<button className="topbar__search" aria-haspopup="menu" aria-expanded={open}
|
||||
title={t('notify.title')} aria-label={t('notify.aria', { level: t(`notify.${level}`) })}
|
||||
onClick={() => setOpen((o) => !o)}>{level === 'mute' ? '🔕' : '🔔'}</button>
|
||||
onClick={() => setOpen((o) => !o)}><Icon name={level === 'mute' ? 'bellOff' : 'bell'} size={19} /></button>
|
||||
{open && (
|
||||
<div className="nmenu__pop" role="menu" aria-label={t('notify.title')}>
|
||||
<div className="nmenu__head">{t('notify.title')}</div>
|
||||
|
|
@ -36,7 +37,7 @@ export function NotifyMenu() {
|
|||
<button key={lv} role="menuitemradio" aria-checked={level === lv}
|
||||
className={`nmenu__item ${level === lv ? 'is-on' : ''}`}
|
||||
onClick={() => { setNotifyLevel(active, lv); setOpen(false); }}>
|
||||
<span className="nmenu__ic" aria-hidden>{lv === 'mute' ? '🔕' : '🔔'}</span>
|
||||
<span className="nmenu__ic" aria-hidden><Icon name={lv === 'mute' ? 'bellOff' : 'bell'} size={18} /></span>
|
||||
<span className="nmenu__txt"><b>{t(`notify.${lv}`)}</b><span>{t(`notify.${lv}Desc`)}</span></span>
|
||||
{level === lv && <span className="nmenu__check" aria-hidden>✓</span>}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
import { nickColor } from '../../lib/format';
|
||||
import { stripFormatting } from '../../core/store/text';
|
||||
import type { Pin } from '../../core/store/persistence';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { nickColor } from '@/lib/format';
|
||||
import { stripFormatting } from '@/core/store/text';
|
||||
import type { Pin } from '@/core/store/persistence';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
const EMPTY: Pin[] = [];
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ export function PinMenu() {
|
|||
<div className="nmenu" ref={ref}>
|
||||
<button className="topbar__search" aria-haspopup="menu" aria-expanded={open}
|
||||
title={t('pins.title')} aria-label={t('pins.aria', { count: pins.length })}
|
||||
onClick={() => setOpen((o) => !o)}>📌<span className="nmenu__badge">{pins.length}</span></button>
|
||||
onClick={() => setOpen((o) => !o)}><Icon name="pin" size={18} /><span className="nmenu__badge">{pins.length}</span></button>
|
||||
{open && (
|
||||
<div className="nmenu__pop nmenu__pop--pins" role="menu" aria-label={t('pins.title')}>
|
||||
<div className="nmenu__head">{t('pins.title')}</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChatMessage } from '../../core/irc/types';
|
||||
import { fmtTime, nickColor } from '../../lib/format';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import type { ChatMessage } from '@/core/irc/types';
|
||||
import { fmtTime, nickColor } from '@/lib/format';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// In-buffer text search: flat list of matching privmsg/action/notice lines with
|
||||
// the hit highlighted, newest first.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { memo, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SERVER } from '../../core/store';
|
||||
import { avatarBg } from '../../lib/format';
|
||||
import { stripFormatting } from '../../core/store/text';
|
||||
import { useTheme } from '../../themes';
|
||||
import { SERVER } from '@/core/store';
|
||||
import { avatarBg } from '@/lib/format';
|
||||
import { stripFormatting } from '@/core/store/text';
|
||||
import { useTheme } from '@/themes';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { Icon } from '../Icon';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { NetworkTabs } from './NetworkTabs';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { StatusMenu } from './StatusMenu';
|
||||
import { getConfig } from '@/core/config';
|
||||
// The footer bar (TabBar) is in the DOM twice for the responsive layout — a
|
||||
// window-bottom bar on desktop (`.app > .appbar`) and docked in the drawer on
|
||||
// mobile (`.sidebar .appbar`), with CSS showing one at the 880px breakpoint. So
|
||||
|
|
@ -31,27 +33,28 @@ export function TabBar({ variant = 'desktop' }: { variant?: 'desktop' | 'drawer'
|
|||
const nick = useActiveChat((s) => s.nick);
|
||||
const myAccount = useActiveChat((s) => s.account);
|
||||
const away = useActiveChat((s) => s.away);
|
||||
const setAway = useActiveChat((s) => s.setAway);
|
||||
const setModal = useActiveChat((s) => s.setModal);
|
||||
const openUser = useActiveChat((s) => s.openUser);
|
||||
const footerItems = usePluginRegistry((s) => s.ui);
|
||||
const footerVisible = variant === (useMediaQuery('(max-width: 880px)') ? 'drawer' : 'desktop');
|
||||
const [statusAnchor, setStatusAnchor] = useState<DOMRect | null>(null);
|
||||
return (
|
||||
<footer className="appbar">
|
||||
<button className={`appbar__me ${away ? 'is-away' : ''}`} onClick={() => openUser(nick)} title={t('sidebar.viewProfile')}>
|
||||
<button className={`appbar__me ${away ? 'is-away' : ''}`} title={t('sidebar.status')}
|
||||
onClick={(e) => { const r = e.currentTarget.getBoundingClientRect(); setStatusAnchor((a) => (a ? null : r)); }}>
|
||||
<span className="appbar__av"><Avatar nick={nick} size={30} account={myAccount} /></span>
|
||||
<span className="appbar__meta">
|
||||
<span className="appbar__name">{nick}</span>
|
||||
<span className="appbar__status">{away ? t('sidebar.away') : t('sidebar.online')}</span>
|
||||
</span>
|
||||
</button>
|
||||
{statusAnchor && <StatusMenu nick={nick} away={away} anchor={statusAnchor} onClose={() => setStatusAnchor(null)} />}
|
||||
<nav className="appbar__nav" aria-label={t('a11y.conversations')}>
|
||||
<button className="tab is-active" aria-label={t('nav.home')}>
|
||||
<span className="tab__ic" aria-hidden="true">🏠</span>
|
||||
<span className="tab__ic"><Icon name="home" /></span>
|
||||
<span className="tab__lb">{t('nav.home')}</span>
|
||||
</button>
|
||||
<button className="tab" onClick={() => setModal('explore')} aria-label={t('nav.explore')}>
|
||||
<span className="tab__ic" aria-hidden="true">🧭</span>
|
||||
<span className="tab__ic"><Icon name="compass" /></span>
|
||||
<span className="tab__lb">{t('nav.tabRooms')}</span>
|
||||
</button>
|
||||
<TabFriends onOpen={() => setModal('friends')} />
|
||||
|
|
@ -59,10 +62,8 @@ export function TabBar({ variant = 'desktop' }: { variant?: 'desktop' | 'drawer'
|
|||
</nav>
|
||||
<div className="appbar__actions">
|
||||
{footerVisible && footerItems.filter((u) => u.slot === 'footer_item').map((u) => <PluginBoundary key={u.id} render={u.render} label="footer_item" />)}
|
||||
<button className={`appbar__act appbar__act--away ${away ? 'is-on' : ''}`} onClick={() => setAway(away ? '' : t('sidebar.away'))}
|
||||
title={away ? t('sidebar.markPresent') : t('sidebar.markAway')} aria-label={t('sidebar.presence')} aria-pressed={!!away}>💤</button>
|
||||
<button className="appbar__act" onClick={() => setModal('settings')}
|
||||
title={t('nav.settings')} aria-label={t('nav.settings')}>⚙️</button>
|
||||
title={t('nav.settings')} aria-label={t('nav.settings')}><Icon name="settings" size={20} /></button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
@ -70,17 +71,60 @@ export function TabBar({ variant = 'desktop' }: { variant?: 'desktop' | 'drawer'
|
|||
|
||||
type Filter = 'all' | 'rooms' | 'people';
|
||||
|
||||
// One conversation row. Memoized + subscribed to its OWN buffer, so a message in
|
||||
// channel X re-renders only X's row, not the whole list on every incoming line.
|
||||
const RoomRow = memo(function RoomRow({ name, mirc, onNavigate }: { name: string; mirc: boolean; onNavigate: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const b = useActiveChat((s) => s.buffers[name]);
|
||||
const isActive = useActiveChat((s) => s.active === name);
|
||||
const setActive = useActiveChat((s) => s.setActive);
|
||||
const closeBuffer = useActiveChat((s) => s.closeBuffer);
|
||||
const serverName = useActiveChat((s) => s.serverName);
|
||||
if (!b) return null;
|
||||
const isServer = name === SERVER;
|
||||
const label = isServer ? 'Status' : b.name.replace(/^#/, '');
|
||||
const glyph = isServer ? (mirc ? '●' : '✦') : label[0]?.toUpperCase() ?? '?';
|
||||
// A channel's topic is the row's subtitle; mark topic-less channels so the
|
||||
// minimal (yomirc) skin can hide the repetitive "Public channel" fallback.
|
||||
const channelTopic = b.isChannel ? stripFormatting(b.topic || '').trim() : '';
|
||||
const genericSub = !isServer && !channelTopic; // no real subtitle (topicless channel or a DM)
|
||||
return (
|
||||
<div className={`room ${isActive ? 'is-active' : ''} ${isServer ? 'room--status' : ''} ${b.unread > 0 ? 'has-unread' : ''} ${b.highlight ? 'has-mention' : ''}`}
|
||||
role="button" tabIndex={0}
|
||||
onClick={() => { setActive(name); onNavigate(); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActive(name); onNavigate(); } }}>
|
||||
<span className="room__av" data-server={isServer || undefined}
|
||||
style={isServer ? undefined : { background: avatarBg(name) }}>
|
||||
{b.isChannel ? <span className="room__hash">#</span> : glyph}
|
||||
</span>
|
||||
<span className="room__body">
|
||||
<span className="room__name">{label}</span>
|
||||
<span className={`room__sub${genericSub ? ' room__sub--generic' : ''}`}>{
|
||||
isServer ? (serverName || t('sidebar.system'))
|
||||
: b.isChannel ? (channelTopic || t('sidebar.publicRoom'))
|
||||
: t('sidebar.privateMessage')
|
||||
}</span>
|
||||
</span>
|
||||
{b.unread > 0 && <span className="room__badge">{b.unread}</span>}
|
||||
{!isServer && (
|
||||
<button
|
||||
type="button"
|
||||
className="room__close"
|
||||
title={b.isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}
|
||||
onClick={(e) => { e.stopPropagation(); closeBuffer(name); }}
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const order = useActiveChat((s) => s.order);
|
||||
const active = useActiveChat((s) => s.active);
|
||||
const buffers = useActiveChat((s) => s.buffers);
|
||||
const setActive = useActiveChat((s) => s.setActive);
|
||||
const setModal = useActiveChat((s) => s.setModal);
|
||||
const refreshChannels = useActiveChat((s) => s.refreshChannels);
|
||||
const closeBuffer = useActiveChat((s) => s.closeBuffer);
|
||||
const sidebarItems = usePluginRegistry((s) => s.ui);
|
||||
const serverName = useActiveChat((s) => s.serverName);
|
||||
const [q, setQ] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const mirc = useTheme().startsWith('yomirc');
|
||||
|
|
@ -93,42 +137,7 @@ export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
|
|||
const showChannels = filter !== 'people';
|
||||
const showQueries = filter !== 'rooms';
|
||||
|
||||
const item = (name: string) => {
|
||||
const b = buffers[name];
|
||||
const isServer = name === SERVER;
|
||||
const label = isServer ? 'Status' : b.name.replace(/^#/, '');
|
||||
const glyph = isServer ? (mirc ? '●' : '✦') : label[0]?.toUpperCase() ?? '?';
|
||||
// A channel's topic is the row's subtitle; mark topic-less channels so the
|
||||
// minimal (yomirc) skin can hide the repetitive "Public channel" fallback.
|
||||
const channelTopic = b.isChannel ? stripFormatting(b.topic || '').trim() : '';
|
||||
const genericSub = !isServer && !channelTopic; // no real subtitle (topicless channel or a DM)
|
||||
return (
|
||||
<button key={name} className={`room ${name === active ? 'is-active' : ''} ${isServer ? 'room--status' : ''} ${b.unread > 0 ? 'has-unread' : ''} ${b.highlight ? 'has-mention' : ''}`} onClick={() => { setActive(name); onNavigate(); }}>
|
||||
<span className="room__av" data-server={isServer || undefined}
|
||||
style={isServer ? undefined : { background: avatarBg(name) }}>
|
||||
{b.isChannel ? <span className="room__hash">#</span> : glyph}
|
||||
</span>
|
||||
<span className="room__body">
|
||||
<span className="room__name">{label}</span>
|
||||
<span className={`room__sub${genericSub ? ' room__sub--generic' : ''}`}>{
|
||||
isServer ? (serverName || t('sidebar.system'))
|
||||
: b.isChannel ? (channelTopic || t('sidebar.publicRoom'))
|
||||
: t('sidebar.privateMessage')
|
||||
}</span>
|
||||
</span>
|
||||
{b.unread > 0 && <span className="room__badge">{b.unread}</span>}
|
||||
{!isServer && (
|
||||
<span
|
||||
className="room__close"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
title={b.isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}
|
||||
onClick={(e) => { e.stopPropagation(); closeBuffer(name); }}
|
||||
>✕</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const item = (name: string) => <RoomRow key={name} name={name} mirc={mirc} onNavigate={onNavigate} />;
|
||||
|
||||
const totalShown = (showChannels ? channels.length : 0) + (showQueries ? queries.length : 0) + (hasServer ? 1 : 0);
|
||||
|
||||
|
|
@ -145,7 +154,7 @@ export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
|
|||
{getConfig().features.multiNetwork && <NetworkTabs />}
|
||||
|
||||
<div className="side-search">
|
||||
<span className="side-search__icon">🔍</span>
|
||||
<span className="side-search__icon"><Icon name="search" size={14} /></span>
|
||||
<input name="room-filter" type="search" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('sidebar.search')} aria-label={t('sidebar.search')} />
|
||||
</div>
|
||||
|
||||
|
|
@ -186,7 +195,7 @@ function TabFriends({ onOpen }: { onOpen: () => void }) {
|
|||
const onlineCount = friends.filter((f) => online[f.toLowerCase()]).length;
|
||||
return (
|
||||
<button className="tab" onClick={onOpen} aria-label={t('nav.friends')}>
|
||||
<span className="tab__ic" aria-hidden="true">👥{onlineCount > 0 && <span className="tab__badge">{onlineCount}</span>}</span>
|
||||
<span className="tab__ic"><Icon name="users" />{onlineCount > 0 && <span className="tab__badge">{onlineCount}</span>}</span>
|
||||
<span className="tab__lb">{t('nav.friends')}</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
68
src/components/chat/StatusMenu.tsx
Normal file
68
src/components/chat/StatusMenu.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// Presence menu opened by clicking your own avatar in the footer (Slack/Discord
|
||||
// style): pick Available or Away. Away reveals an editable reason with a default
|
||||
// so you can set an AWAY message in one place; Enter or "Set" applies it.
|
||||
export function StatusMenu({ nick, away, anchor, onClose }: { nick: string; away: boolean; anchor: DOMRect; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const setAway = useActiveChat((s) => s.setAway);
|
||||
const openUser = useActiveChat((s) => s.openUser);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [reason, setReason] = useState(t('sidebar.awayDefault'));
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: anchor.left, top: anchor.top });
|
||||
|
||||
// Open upward from the avatar, clamped to the viewport (drops below if no room).
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
let left = anchor.left;
|
||||
let top = anchor.top - r.height - 8;
|
||||
if (left + r.width > window.innerWidth - 8) left = window.innerWidth - r.width - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top < 8) top = anchor.bottom + 8;
|
||||
setPos({ left, top });
|
||||
}, [anchor, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest('.statusmenu, .appbar__me')) onClose(); };
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => { window.removeEventListener('mousedown', onDown); window.removeEventListener('keydown', onKey); };
|
||||
}, [onClose]);
|
||||
|
||||
const setAvailable = () => { setAway(''); onClose(); };
|
||||
const confirmAway = () => { setAway(reason.trim() || t('sidebar.awayDefault')); onClose(); };
|
||||
|
||||
return (
|
||||
<div className="statusmenu" ref={ref} style={{ left: pos.left, top: pos.top }} role="menu">
|
||||
<button className={`statusmenu__opt ${!away ? 'is-on' : ''}`} role="menuitemradio" aria-checked={!away} onClick={setAvailable}>
|
||||
<span className="statusmenu__dot statusmenu__dot--on" aria-hidden />
|
||||
<span className="statusmenu__lbl">{t('sidebar.online')}</span>
|
||||
{!away && <span className="statusmenu__check" aria-hidden>✓</span>}
|
||||
</button>
|
||||
<button className={`statusmenu__opt ${away ? 'is-on' : ''}`} role="menuitemradio" aria-checked={away} onClick={() => setEditing(true)}>
|
||||
<span className="statusmenu__dot statusmenu__dot--away" aria-hidden />
|
||||
<span className="statusmenu__lbl">{t('sidebar.away')}</span>
|
||||
{away && <span className="statusmenu__check" aria-hidden>✓</span>}
|
||||
</button>
|
||||
{editing && (
|
||||
<div className="statusmenu__reason">
|
||||
<input className="statusmenu__input" value={reason} maxLength={200} autoFocus
|
||||
placeholder={t('sidebar.awayReason')} aria-label={t('sidebar.awayReason')}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmAway(); }} />
|
||||
<button className="statusmenu__set" onClick={confirmAway}>{t('sidebar.awaySet')}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="statusmenu__sep" />
|
||||
<button className="statusmenu__opt statusmenu__opt--link" role="menuitem" onClick={() => { openUser(nick); onClose(); }}>
|
||||
{t('sidebar.viewProfile')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChatMessage } from '../../core/irc/types';
|
||||
import { fmtTime, nickColor, formatIrc } from '../../lib/format';
|
||||
import { useTheme } from '../../themes';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import type { ChatMessage } from '@/core/irc/types';
|
||||
import { fmtTime, nickColor, formatIrc } from '@/lib/format';
|
||||
import { useTheme } from '@/themes';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// Renders a non-message event (join/part/mode/topic/ban/notice/info/warning/…)
|
||||
// as its own status line. The container routes every kind that isn't a
|
||||
// privmsg/action here; MsgRow handles the message rows.
|
||||
export function SystemLine({ m }: { m: ChatMessage }) {
|
||||
export const SystemLine = memo(function SystemLine({ m }: { m: ChatMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const linkPreviews = useActiveChat((s) => s.prefs.linkPreviews);
|
||||
const mirc = useTheme().startsWith('yomirc');
|
||||
|
|
@ -114,4 +114,4 @@ export function SystemLine({ m }: { m: ChatMessage }) {
|
|||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SERVER } from '../../core/store';
|
||||
import { avatarBg, formatIrc } from '../../lib/format';
|
||||
import { setterMask, ago } from '../../lib/topic';
|
||||
import { stripFormatting } from '../../core/store/text';
|
||||
import { SERVER } from '@/core/store';
|
||||
import { avatarBg, formatIrc } from '@/lib/format';
|
||||
import { setterMask, ago } from '@/lib/topic';
|
||||
import { stripFormatting } from '@/core/store/text';
|
||||
import { NotifyMenu } from './NotifyMenu';
|
||||
import { PinMenu } from './PinMenu';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { TopbarMore } from './TopbarMore';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { Icon } from '../Icon';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language || 'fr';
|
||||
|
|
@ -33,8 +35,9 @@ export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: (
|
|||
const serverName = useActiveChat((s) => s.serverName);
|
||||
const topbarItems = usePluginRegistry((s) => s.ui);
|
||||
const [searching, setSearching] = useState(false);
|
||||
if (!bname) return <div className="topbar"><button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}>☰</button></div>;
|
||||
if (!bname) return <div className="topbar"><button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}><Icon name="menu" size={20} /></button></div>;
|
||||
const n = members ? Object.keys(members).length : 0;
|
||||
const plug = topbarItems.filter((u) => u.slot === 'topbar_item');
|
||||
const setter = topicBy ? setterMask(topicBy, members || {}) : '';
|
||||
const setterTitle = setter ? `${t('modals.chanadmin.topicBy')} ${setter}${topicAt ? ` · ${ago(topicAt, locale)}` : ''}` : '';
|
||||
const isServer = bname === SERVER;
|
||||
|
|
@ -43,18 +46,18 @@ export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: (
|
|||
if (searching || search) {
|
||||
return (
|
||||
<div className="topbar topbar--search">
|
||||
<button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}>☰</button>
|
||||
<span className="topbar__searchicon">🔍</span>
|
||||
<button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}><Icon name="menu" size={20} /></button>
|
||||
<span className="topbar__searchicon"><Icon name="search" size={16} /></span>
|
||||
<input className="topbar__searchinput" name="message-search" type="search" autoComplete="off" autoFocus placeholder={t('topbar.searchIn', { label })}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearching(false); } }} />
|
||||
<button className="topbar__searchclose" onClick={() => { setSearch(''); setSearching(false); }} aria-label={t('topbar.closeSearch')}>✕</button>
|
||||
<button className="topbar__searchclose" onClick={() => { setSearch(''); setSearching(false); }} aria-label={t('topbar.closeSearch')}><Icon name="close" size={16} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`topbar ${isServer ? 'topbar--console' : ''} ${isChannel ? 'topbar--channel' : ''}`}>
|
||||
<button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}>☰</button>
|
||||
<button className="nav-toggle" onClick={onMenu} aria-label={t('sidebar.channels')}><Icon name="menu" size={20} /></button>
|
||||
{isServer
|
||||
? <span className="term-lights"><i /><i /><i /></span>
|
||||
: <span className="topbar__av" style={{ background: avatarBg(bname) }}>{isChannel ? '#' : label[0]?.toUpperCase()}</span>}
|
||||
|
|
@ -82,17 +85,18 @@ export function Topbar({ onMenu, onMembers }: { onMenu: () => void; onMembers: (
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{topbarItems.filter((u) => u.slot === 'topbar_item').map((u) => <PluginBoundary key={u.id} render={u.render} label="topbar_item" />)}
|
||||
{!isServer && <button className="topbar__search" title={t('topbar.search')} aria-label={t('topbar.search')} onClick={() => setSearching(true)}>🔍</button>}
|
||||
{plug.length > 0 && <span className="topbar__plugins topbar__hide-mobile">{plug.map((u) => <PluginBoundary key={u.id} render={u.render} label="topbar_item" />)}</span>}
|
||||
{!isServer && <button className="topbar__search topbar__hide-mobile" title={t('topbar.search')} aria-label={t('topbar.search')} onClick={() => setSearching(true)}><Icon name="search" size={19} /></button>}
|
||||
{isChannel && <PinMenu />}
|
||||
{isChannel && <NotifyMenu />}
|
||||
{isChannel && amOp && <button className="topbar__search" title={t('topbar.manage')} aria-label={t('topbar.manage')} onClick={() => setModal('chanadmin')}>🛠️</button>}
|
||||
{isChannel && amOp && <button className="topbar__search topbar__hide-mobile" title={t('topbar.manage')} aria-label={t('topbar.manage')} onClick={() => setModal('chanadmin')}><Icon name="sliders" size={19} /></button>}
|
||||
{isChannel && <button className="topbar__pill" onClick={onMembers} title={t('topbar.membersTitle')} aria-label={t('topbar.members')}><span className="dot" />{n}</button>}
|
||||
{!isServer && (
|
||||
<button className="topbar__leave" onClick={() => closeBuffer(bname)}
|
||||
<button className="topbar__leave topbar__hide-mobile" onClick={() => closeBuffer(bname)}
|
||||
title={isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}
|
||||
aria-label={isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}>✕</button>
|
||||
aria-label={isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}><Icon name="close" size={18} /></button>
|
||||
)}
|
||||
{!isServer && <TopbarMore bname={bname} isChannel={isChannel} amOp={amOp} onSearch={() => setSearching(true)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
54
src/components/chat/TopbarMore.tsx
Normal file
54
src/components/chat/TopbarMore.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../Icon';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// Mobile overflow ("⋮") for the topbar's one-shot channel actions. A phone header
|
||||
// can't hold every button, so search / manage / leave collapse in here; the notify
|
||||
// and pin buttons stay inline because their glyph carries state (mute, pin count).
|
||||
export function TopbarMore({ bname, isChannel, amOp, onSearch }:
|
||||
{ bname: string; isChannel: boolean; amOp: boolean; onSearch: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useActiveChat((s) => s.setModal);
|
||||
const closeBuffer = useActiveChat((s) => s.closeBuffer);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onEsc);
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onEsc); };
|
||||
}, [open]);
|
||||
|
||||
const run = (fn: () => void) => { setOpen(false); fn(); };
|
||||
|
||||
return (
|
||||
<div className="nmenu topbar__more" ref={ref}>
|
||||
<button className="topbar__search" aria-haspopup="menu" aria-expanded={open}
|
||||
title={t('topbar.more')} aria-label={t('topbar.more')} onClick={() => setOpen((o) => !o)}>
|
||||
<Icon name="more" size={20} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="nmenu__pop" role="menu" aria-label={t('topbar.more')}>
|
||||
<button className="nmenu__item" role="menuitem" onClick={() => run(onSearch)}>
|
||||
<span className="nmenu__ic" aria-hidden><Icon name="search" size={18} /></span>
|
||||
<span className="nmenu__txt"><b>{t('topbar.search')}</b></span>
|
||||
</button>
|
||||
{isChannel && amOp && (
|
||||
<button className="nmenu__item" role="menuitem" onClick={() => run(() => setModal('chanadmin'))}>
|
||||
<span className="nmenu__ic" aria-hidden><Icon name="sliders" size={18} /></span>
|
||||
<span className="nmenu__txt"><b>{t('topbar.manage')}</b></span>
|
||||
</button>
|
||||
)}
|
||||
<button className="nmenu__item nmenu__item--danger" role="menuitem" onClick={() => run(() => closeBuffer(bname))}>
|
||||
<span className="nmenu__ic" aria-hidden><Icon name="logout" size={18} /></span>
|
||||
<span className="nmenu__txt"><b>{isChannel ? t('sidebar.leaveRoom') : t('sidebar.closeConversation')}</b></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { nickColor } from '../../../lib/format';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { nickColor } from '@/lib/format';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
export function ReplyBar() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
export function TypingIndicator() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { activeStore } from '../../../core/networks';
|
||||
import { activeStore } from '@/core/networks';
|
||||
|
||||
interface VoiceRecorderOpts {
|
||||
enabled: boolean; // canUpload && not the console buffer
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { formatIrc } from '../../lib/format';
|
||||
import { buildModeContext } from '../../core/irc/modes';
|
||||
import { setterMask, ago } from '../../lib/topic';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { formatIrc } from '@/lib/format';
|
||||
import { buildModeContext } from '@/core/irc/modes';
|
||||
import { setterMask, ago } from '@/lib/topic';
|
||||
import { availableExtbans, matchExtban, type ExtBan } from '@/lib/extbans';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
// Curated channel flags (no-parameter, type-D). Only the ones the server advertises
|
||||
|
|
@ -33,7 +35,62 @@ function fmtDate(sec: number, locale: string): string {
|
|||
return new Date(sec * 1000).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
type Tab = 'overview' | 'modes';
|
||||
type Tab = 'overview' | 'modes' | 'bans' | 'extbans';
|
||||
|
||||
// A compact combobox for the extban type: a button + an overlay menu (grouped into
|
||||
// restrictions / match-by, filterable). Opening it doesn't push the value input, so
|
||||
// picking a type and typing the mask stay on one row — no scrolling.
|
||||
function ExtbanSelect({ exts, value, onChange, maskOption }: { exts: ExtBan[]; value: string; onChange: (name: string) => void; maskOption?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cur = exts.find((e) => e.name === value);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
const f = filter.trim().toLowerCase();
|
||||
const label = (e: ExtBan) => t(`extbans.${e.name}`, e.name);
|
||||
const hit = (e: ExtBan) => !f || label(e).toLowerCase().includes(f) || e.name.includes(f);
|
||||
const pick = (name: string) => { onChange(name); setOpen(false); setFilter(''); };
|
||||
const group = (acting: boolean, head: string) => {
|
||||
const list = exts.filter((e) => e.acting === acting && hit(e));
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="ca-extsel__grp">{head}</div>
|
||||
{list.map((e) => (
|
||||
<button key={e.letter} type="button" role="option" aria-selected={e.name === value}
|
||||
className={`ca-extsel__opt${e.name === value ? ' is-on' : ''}`} onClick={() => pick(e.name)}>{label(e)}</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="ca-extsel" ref={ref}>
|
||||
<button type="button" className="ca-extsel__btn" aria-haspopup="listbox" aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}>
|
||||
<span>{cur ? label(cur) : (maskOption ? t('modals.chanadmin.plainMask') : '—')}</span>
|
||||
<span className="ca-extsel__chev" aria-hidden>▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ca-extsel__menu" role="listbox">
|
||||
<input className="ca-extsel__search modal__input" autoFocus value={filter} placeholder={t('modals.chanadmin.filter')}
|
||||
onChange={(e) => setFilter(e.target.value)} />
|
||||
{maskOption && (!f || t('modals.chanadmin.plainMask').toLowerCase().includes(f)) && (
|
||||
<button type="button" role="option" aria-selected={value === ''}
|
||||
className={`ca-extsel__opt${value === '' ? ' is-on' : ''}`} onClick={() => pick('')}>{t('modals.chanadmin.plainMask')}</button>
|
||||
)}
|
||||
{group(true, t('modals.chanadmin.extRestrict'))}
|
||||
{group(false, t('modals.chanadmin.extMatch'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChanAdminModal() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
|
@ -41,6 +98,8 @@ export function ChanAdminModal() {
|
|||
const setModal = useActiveChat((s) => s.setModal);
|
||||
const buffer = useActiveChat((s) => s.buffers[s.active]);
|
||||
const banlist = useActiveChat((s) => s.banlists[s.active] || []);
|
||||
const exceptlist = useActiveChat((s) => s.exceptlists[s.active] || []);
|
||||
const invexlist = useActiveChat((s) => s.invexlists[s.active] || []);
|
||||
const loadBanList = useActiveChat((s) => s.loadBanList);
|
||||
const setChannelMode = useActiveChat((s) => s.setChannelMode);
|
||||
const setChannelModeParam = useActiveChat((s) => s.setChannelModeParam);
|
||||
|
|
@ -54,15 +113,22 @@ export function ChanAdminModal() {
|
|||
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
const [newban, setNewban] = useState('');
|
||||
const [ebType, setEbType] = useState('');
|
||||
const [ebVal, setEbVal] = useState('');
|
||||
const [ebMode, setEbMode] = useState<'b' | 'e' | 'I'>('b');
|
||||
const [ebNest, setEbNest] = useState(''); // acting extban's nested matching extban (stacking)
|
||||
const [topic, setTopicVal] = useState(buffer?.topic || '');
|
||||
const [editingTopic, setEditingTopic] = useState(false);
|
||||
const [keyVal, setKeyVal] = useState(curKey);
|
||||
const [limitVal, setLimitVal] = useState(curLimit);
|
||||
|
||||
useEffect(() => { if (chan) loadBanList(chan); }, [chan, loadBanList]);
|
||||
// Re-sync the param inputs when the live modes change under us (someone else sets +k/+l).
|
||||
useEffect(() => { setKeyVal(curKey); }, [curKey]);
|
||||
useEffect(() => { setLimitVal(curLimit); }, [curLimit]);
|
||||
// Re-sync the param inputs when the live modes change under us (someone else sets
|
||||
// +k/+l) — derive during render, no effect setState.
|
||||
const [prevKey, setPrevKey] = useState(curKey);
|
||||
const [prevLimit, setPrevLimit] = useState(curLimit);
|
||||
if (curKey !== prevKey) { setPrevKey(curKey); setKeyVal(curKey); }
|
||||
if (curLimit !== prevLimit) { setPrevLimit(curLimit); setLimitVal(curLimit); }
|
||||
|
||||
if (!buffer || !buffer.isChannel) return null;
|
||||
const modes = buffer.modes || '';
|
||||
|
|
@ -78,6 +144,47 @@ export function ChanAdminModal() {
|
|||
client?.ban(chan, v.includes('@') || v.includes('!') ? v : `${v}!*@*`);
|
||||
setNewban(''); setTimeout(() => loadBanList(chan), 500);
|
||||
};
|
||||
// Extended bans the server advertises (core + reputation/securitygroups modules).
|
||||
const exts = availableExtbans(client?.server.isupport ?? {});
|
||||
const ebSel = ebType || exts[0]?.name || '';
|
||||
const curExt = exts.find((e) => e.name === ebSel);
|
||||
// Stacking: an acting extban can wrap a matching one (mute:account:baduser). nestExt
|
||||
// is that inner matching extban; the value we type belongs to the innermost extban.
|
||||
const matchingExts = exts.filter((e) => !e.acting);
|
||||
const nestExt = curExt?.acting && ebNest ? matchingExts.find((e) => e.name === ebNest) : undefined;
|
||||
const valueType = curExt?.acting ? nestExt : curExt;
|
||||
const stackMask = (v: string) => (curExt?.acting && nestExt ? `${curExt.name}:${nestExt.name}:${v}` : `${curExt?.name}:${v}`);
|
||||
// +b ban, +e exempt, +I invite exception — but only offer the modes the server
|
||||
// actually has as list modes (CHANMODES type A), and +I only for matching extbans.
|
||||
const ebModes = (['b', 'e', 'I'] as const).filter((m) => {
|
||||
if (m === 'b') return true;
|
||||
if (!ctx.typeA.has(m)) return false;
|
||||
if (m === 'I') return !!curExt && !curExt.acting;
|
||||
return true;
|
||||
});
|
||||
const ebModeSel = ebModes.includes(ebMode) ? ebMode : 'b';
|
||||
const modeVerb: Record<string, string> = { b: 'modals.chanadmin.ban', e: 'modals.chanadmin.exempt', I: 'modals.chanadmin.allow' };
|
||||
// Keep the two lists apart: plain nick!user@host bans on the right, typed extbans
|
||||
// in their own tab.
|
||||
const plainBans = banlist.filter((b) => !matchExtban(b.mask));
|
||||
// The Extbans tab lists the typed +b extbans plus the whole +e / +I lists, each
|
||||
// tagged with its mode. Plain +b masks stay in the Bans tab.
|
||||
const extEntries = [
|
||||
...banlist.filter((b) => matchExtban(b.mask)).map((b) => ({ mode: 'b' as const, mask: b.mask, by: b.by })),
|
||||
...exceptlist.map((b) => ({ mode: 'e' as const, mask: b.mask, by: b.by })),
|
||||
...invexlist.map((b) => ({ mode: 'I' as const, mask: b.mask, by: b.by })),
|
||||
];
|
||||
const removeExt = (mode: 'b' | 'e' | 'I', mask: string) => {
|
||||
if (mode === 'b') removeBan(chan, mask); else setChannelModeParam(chan, mode, false, mask);
|
||||
setTimeout(() => loadBanList(chan), 500);
|
||||
};
|
||||
const addExtban = () => {
|
||||
const v = ebVal.trim(); if (!v || !curExt) return;
|
||||
const mask = stackMask(v);
|
||||
if (ebModeSel === 'b') client?.ban(chan, mask);
|
||||
else setChannelModeParam(chan, ebModeSel, true, mask);
|
||||
setEbVal(''); setTimeout(() => loadBanList(chan), 500);
|
||||
};
|
||||
const applyKey = () => { const v = keyVal.trim(); if (v) setChannelModeParam(chan, 'k', true, v); };
|
||||
const clearKey = () => { setChannelModeParam(chan, 'k', false, curKey || '*'); setKeyVal(''); };
|
||||
const applyLimit = () => { const n = parseInt(limitVal, 10); if (n > 0) setChannelModeParam(chan, 'l', true, String(n)); };
|
||||
|
|
@ -128,6 +235,8 @@ export function ChanAdminModal() {
|
|||
<div className="ca-tabs" role="tablist">
|
||||
{tabBtn('overview', t('modals.chanadmin.tabOverview'))}
|
||||
{tabBtn('modes', t('modals.chanadmin.tabModes'))}
|
||||
{tabBtn('bans', t('modals.chanadmin.bans', { n: plainBans.length }))}
|
||||
{exts.length > 0 && tabBtn('extbans', t('modals.chanadmin.extbans'))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
|
|
@ -178,18 +287,65 @@ export function ChanAdminModal() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'extbans' && (
|
||||
<div className="ca-pane">
|
||||
<div className="ca-param ca-extban-add">
|
||||
<div className="ca-extmode">
|
||||
{ebModes.map((m) => (
|
||||
<button key={m} type="button" className={ebModeSel === m ? 'is-on' : ''}
|
||||
title={t(modeVerb[m])} onClick={() => setEbMode(m)}>+{m}</button>
|
||||
))}
|
||||
</div>
|
||||
<ExtbanSelect exts={exts} value={ebSel} onChange={setEbType} />
|
||||
{curExt?.acting && <ExtbanSelect exts={matchingExts} value={ebNest} onChange={setEbNest} maskOption />}
|
||||
<input className="modal__input" value={ebVal} placeholder={valueType?.hint ?? curExt?.hint}
|
||||
aria-label={t('modals.chanadmin.extbanType')}
|
||||
onChange={(e) => setEbVal(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addExtban()} />
|
||||
<button className="upbtn upbtn--primary" onClick={addExtban}>{t(modeVerb[ebModeSel])}</button>
|
||||
</div>
|
||||
{/* securitygroup takes a group name the ircd doesn't advertise — click to fill it. */}
|
||||
{valueType?.name === 'securitygroup' && (getConfig().securityGroups?.length ?? 0) > 0 && (
|
||||
<div className="ca-extgroups">
|
||||
{getConfig().securityGroups!.map((g) => (
|
||||
<button key={g} type="button" className="ca-extgroup" onClick={() => setEbVal(g)}>{g}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Show the full command for the picked type. */}
|
||||
{curExt && (
|
||||
<div className="ca-extexample">
|
||||
{t('modals.chanadmin.example')} <code>+{ebModeSel} {stackMask(ebVal.trim() || valueType?.hint || curExt.hint)}</code>
|
||||
</div>
|
||||
)}
|
||||
<ul className="ca-bans">
|
||||
{extEntries.length === 0 && <li className="ca-bans__empty">{t('modals.chanadmin.noExtbans')}</li>}
|
||||
{extEntries.map((e) => {
|
||||
const eb = matchExtban(e.mask);
|
||||
return (
|
||||
<li key={e.mode + e.mask} className="ca-ban">
|
||||
<span className={`ca-ban__mode ca-ban__mode--${e.mode}`}>+{e.mode}</span>
|
||||
{eb && <span className="ca-ban__type" title={eb.name}>{t(`extbans.${eb.name}`, eb.name)}</span>}
|
||||
<span className="ca-ban__mask">{eb ? e.mask.slice(e.mask.indexOf(':') + 1) : e.mask}</span>
|
||||
{e.by && <span className="ca-ban__by">{t('modals.chanadmin.by', { by: e.by })}</span>}
|
||||
<button className="friend__act friend__act--rm" title={t('modals.chanadmin.unban')}
|
||||
onClick={() => removeExt(e.mode, e.mask)}>✕</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside className="ca-banscol">
|
||||
<h4 className="ca-h">{t('modals.chanadmin.bans', { n: banlist.length })}</h4>
|
||||
<div className="modal__actions">
|
||||
{tab === 'bans' && (
|
||||
<div className="ca-pane">
|
||||
<div className="ca-param ca-extban-add">
|
||||
<input className="modal__input" value={newban} placeholder={t('modals.chanadmin.maskPlaceholder')}
|
||||
onChange={(e) => setNewban(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addBan()} />
|
||||
<button className="upbtn upbtn--primary" onClick={addBan}>{t('modals.chanadmin.ban')}</button>
|
||||
</div>
|
||||
<ul className="ca-bans">
|
||||
{banlist.length === 0 && <li className="ca-bans__empty">{t('modals.chanadmin.noBans')}</li>}
|
||||
{banlist.map((b) => (
|
||||
{plainBans.length === 0 && <li className="ca-bans__empty">{t('modals.chanadmin.noBans')}</li>}
|
||||
{plainBans.map((b) => (
|
||||
<li key={b.mask} className="ca-ban">
|
||||
<span className="ca-ban__mask">{b.mask}</span>
|
||||
{b.by && <span className="ca-ban__by">{t('modals.chanadmin.by', { by: b.by })}</span>}
|
||||
|
|
@ -198,7 +354,10 @@ export function ChanAdminModal() {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatIrc, avatarBg } from '../../lib/format';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { Icon } from '../Icon';
|
||||
import { formatIrc, avatarBg } from '@/lib/format';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function ExploreModal() {
|
||||
|
|
@ -38,7 +39,7 @@ export function ExploreModal() {
|
|||
<div className="explore">
|
||||
<div className="explore-bar">
|
||||
<div className="explore-search">
|
||||
<span className="explore-search__icon">🔍</span>
|
||||
<span className="explore-search__icon"><Icon name="search" size={15} /></span>
|
||||
<input name="channel-search" type="search" autoComplete="off" placeholder={t('modals.join.search')} value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && q.trim()) join(q); }} autoFocus />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function FriendsModal() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function JoinDialog() {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Shared modal shell: backdrop, centered card, title bar + close, Escape-to-close.
|
||||
// The specific dialogs (Join/Explore/Friends/…) render their body as children.
|
||||
export function Modal({ title, onClose, children, wide }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const returnTo = document.activeElement as HTMLElement | null; // what was focused before we opened
|
||||
const card = cardRef.current;
|
||||
const tabbables = () => card
|
||||
? [...card.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')].filter((el) => !el.hasAttribute('disabled'))
|
||||
: [];
|
||||
(tabbables()[0] || card)?.focus(); // move focus into the dialog
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
// Keep Tab inside the dialog — wrap at both ends.
|
||||
const f = tabbables();
|
||||
if (!f.length) return;
|
||||
const first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
return () => { window.removeEventListener('keydown', onKey); returnTo?.focus?.(); }; // give focus back on close
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className={`modal ${wide ? 'modal--wide' : ''}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div ref={cardRef} className={`modal ${wide ? 'modal--wide' : ''}`} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__head">
|
||||
<h3>{title}</h3>
|
||||
<button className="modal__x" onClick={onClose} aria-label={t('modals.closeButton')}>✕</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SettingsModal } from '../settings/SettingsModal';
|
||||
import { QuickSwitcher } from '../QuickSwitcher';
|
||||
import { Shortcuts } from '../Shortcuts';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { JoinDialog } from './JoinDialog';
|
||||
import { ExploreModal } from './ExploreModal';
|
||||
import { FriendsModal } from './FriendsModal';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function ReportModal() {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useState, useEffect, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { IRCOP_COLOR, hashHue, fmtDuration, formatUserModes } from '../../lib/format';
|
||||
import { IRCOP_COLOR, hashHue, fmtDuration, formatUserModes } from '@/lib/format';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// Which info rows take the full width — keyed by stable id (not the translated label).
|
||||
const PM_WIDE_KEYS = new Set(['identifier', 'server', 'channels', 'certfp', 'info', 'umodes']);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { ProfileSection } from './sections/ProfileSection';
|
||||
import { AppearanceSection } from './sections/AppearanceSection';
|
||||
import { NotificationsSection } from './sections/NotificationsSection';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useActiveChat } from '../../core/networks';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// One preference toggle row: icon · label/hint · switch. Shared by the
|
||||
// Appearance and Notifications sections.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { getConfig } from '@/core/config';
|
||||
|
||||
// About — the app's own identity card (version/build injected at build time +
|
||||
// the project's open-source links). Orbit is the client; branding.* is the host.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { escapeHtml } from '../../../lib/escape';
|
||||
import { escapeHtml } from '@/lib/escape';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { getTheme } from '../../../themes';
|
||||
import { Turnstile } from '../../Turnstile';
|
||||
import { useActiveChat, activeStore } from '../../../core/networks';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { getTheme } from '@/themes';
|
||||
import { Turnstile } from '@/components/Turnstile';
|
||||
import { useActiveChat, activeStore } from '@/core/networks';
|
||||
|
||||
// Change the current IRC nick — lives in the Compte section so you can align
|
||||
// your pseudo with your account name BEFORE identifying.
|
||||
|
|
@ -14,7 +14,8 @@ function ChangeNickField({ hint }: { hint: string }) {
|
|||
const nick = useActiveChat((s) => s.nick);
|
||||
const [newNick, setNewNick] = useState(nick);
|
||||
// Keep the field in sync if the server confirms a nick change elsewhere.
|
||||
useEffect(() => { setNewNick(nick); }, [nick]);
|
||||
const [prevNick, setPrevNick] = useState(nick);
|
||||
if (nick !== prevNick) { setPrevNick(nick); setNewNick(nick); }
|
||||
|
||||
function applyNick() {
|
||||
const n = newNick.trim();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LANGS, setLang, getLang } from '../../../core/i18n';
|
||||
import { getTheme, setTheme, usePluginThemes, type Theme } from '../../../themes';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { LANGS, setLang, getLang } from '@/core/i18n';
|
||||
import { getTheme, setTheme, usePluginThemes, type Theme } from '@/themes';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { ToggleRow } from '../rows';
|
||||
|
||||
const TEXT_SIZES: Array<{ v: number; label: string }> = [
|
||||
|
|
@ -23,8 +23,8 @@ export function AppearanceSection() {
|
|||
const THEME_OPTS: Array<{ id: Theme; icon: string; label: string }> = [
|
||||
{ id: 'light', icon: '☀️', label: 'themes.light' },
|
||||
{ id: 'dark', icon: '🌙', label: 'themes.dark' },
|
||||
{ id: 'orbit', icon: '🛰️', label: 'themes.orbit' },
|
||||
{ id: 'orbit-dark', icon: '🌑', label: 'themes.orbitDark' },
|
||||
{ id: 'orbit', icon: '🟢', label: 'themes.orbit' },
|
||||
{ id: 'orbit-dark', icon: '🟠', label: 'themes.orbitDark' },
|
||||
{ id: 'yomirc', icon: '🖥️', label: 'themes.yomirc' },
|
||||
{ id: 'yomirc-dark', icon: '🌑', label: 'themes.yomircDark' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { CAP_INFO } from '../../../core/irc/cap-info';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { CAP_INFO } from '@/core/irc/cap-info';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
const CAP_KEYS = Object.keys(CAP_INFO);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,34 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { isPushSupported, pushEnabledPref, enablePush, disablePush } from '../../../platform/push';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { isPushSupported, pushEnabledPref, enablePush, disablePush } from '@/platform/push';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { getConsent, setConsent } from '@/core/consent';
|
||||
import { setGaConsent } from '@/core/ga';
|
||||
import { ToggleRow } from '../rows';
|
||||
|
||||
// Withdraw / grant Google Analytics consent (only shown when GA is configured).
|
||||
function AnalyticsRow() {
|
||||
const { t } = useTranslation();
|
||||
const [on, setOn] = useState(getConsent() === 'granted');
|
||||
if (!getConfig().analytics?.gaId) return null;
|
||||
const toggle = () => {
|
||||
if (on) { setConsent('denied'); setGaConsent(false); setOn(false); }
|
||||
else { setConsent('granted'); setGaConsent(true); setOn(true); }
|
||||
};
|
||||
return (
|
||||
<div className="srow">
|
||||
<span className="srow__ic" aria-hidden>📊</span>
|
||||
<div className="srow__txt">
|
||||
<div className="srow__label">{t('consent.manageLabel')}</div>
|
||||
<div className="srow__hint">{on ? t('consent.manageOn') : t('consent.manageOff')}</div>
|
||||
</div>
|
||||
<button className={`switch ${on ? 'is-on' : ''}`} role="switch" aria-checked={on}
|
||||
aria-label={t('consent.manageLabel')} onClick={toggle}><span className="switch__dot" /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Web Push toggle — needs both browser support and a VAPID key from the server.
|
||||
function PushRow() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -99,6 +123,7 @@ export function NotificationsSection() {
|
|||
<ToggleRow icon="🔗" label={t('settings.notifications.linkPreviews')} hint={t('settings.notifications.linkPreviewsHint')} prefKey="linkPreviews" />
|
||||
)}
|
||||
<HighlightWordsRow />
|
||||
<AnalyticsRow />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { Avatar } from '../../Avatar';
|
||||
import { Modal } from '../../modals/Modal';
|
||||
import { clearResume } from '../../../core/resume';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Modal } from '@/components/modals/Modal';
|
||||
import { clearResume } from '@/core/resume';
|
||||
|
||||
export function ProfileSection() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { getConfig } from '../../../core/config';
|
||||
import { useActiveChat } from '../../../core/networks';
|
||||
import { getConfig } from '@/core/config';
|
||||
import { useActiveChat } from '@/core/networks';
|
||||
|
||||
// Server / IRCd facts, pulled live from the registration numerics + ISUPPORT.
|
||||
export function ServerSection() {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export interface AppConfig {
|
|||
sessionResume: boolean; // reopen the tab straight back into the last session (needs a same-origin keycard endpoint for logged-in members; guests just reconnect)
|
||||
passkeySasl: boolean; // offer passkey (WebAuthn) sign-in on the connect screen — needs the server's SASL WEBAUTHN mechanism enabled
|
||||
saslScram: boolean; // authenticate a typed password over SASL SCRAM-SHA-256 (password stays off the wire) with automatic PLAIN fallback; needs the server to offer SCRAM-SHA-256
|
||||
webmcp: boolean; // expose the chat as WebMCP tools for AI agents (browsers that support document.modelContext); no-op elsewhere
|
||||
};
|
||||
/**
|
||||
* Operator-listed plugin scripts loaded at startup. Each entry is a URL, or an
|
||||
|
|
@ -91,6 +92,33 @@ export interface AppConfig {
|
|||
brand?: { text?: string; logo?: string; href?: string };
|
||||
links?: { label: string; icon?: string; href: string }[];
|
||||
};
|
||||
/** Search/link-unfurl metadata. Applied to <head> at boot by src/core/seo.ts
|
||||
* (description + Open Graph/Twitter cards + canonical + JSON-LD). Fields default
|
||||
* to `branding` (title = name, image = a preview screenshot). index.html ships a
|
||||
* static copy for JS-less unfurlers; this overrides it per deployment. */
|
||||
seo?: {
|
||||
description?: string; // meta + og/twitter description (else branding.subtitle)
|
||||
image?: string; // absolute URL to a ~1200×630 preview image (og:image)
|
||||
keywords?: string;
|
||||
canonical?: string; // canonical URL (else branding.url + current path)
|
||||
twitterSite?: string; // @handle
|
||||
};
|
||||
/** Analytics — two independent, opt-in backends (set either, both, or neither):
|
||||
* - `endpoint`: the cookieless first-party collector. Enables the sandboxed
|
||||
* analytics feature; beacons `session`+`pageview` (POST, no cookies) via the
|
||||
* gated analytics.track verb. Same-origin needs no CSP change.
|
||||
* - `gaId`: a Google Analytics 4 measurement id (G-XXXX). Loads gtag.js IN-PAGE
|
||||
* (see src/core/ga.ts) and sends a page_view per channel switch. Third-party +
|
||||
* cookies: needs googletagmanager.com/google-analytics.com in the app CSP and,
|
||||
* on an EU site, a cookie-consent banner. DMs/console are redacted either way. */
|
||||
analytics?: {
|
||||
endpoint?: string; // POST target for the cookieless first-party collector
|
||||
siteId?: string; // tag each first-party beacon to separate deployments
|
||||
gaId?: string; // Google Analytics 4 measurement id (G-XXXXXXX)
|
||||
};
|
||||
/** The network's securitygroup names, for the securitygroup extban quick-pick.
|
||||
* The ircd doesn't advertise them, so list them here to click instead of type. */
|
||||
securityGroups?: string[];
|
||||
}
|
||||
|
||||
/** A plugin to load: a bare URL, or a URL with options.
|
||||
|
|
@ -118,7 +146,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
turnstile: { enabled: true, sitekey: '0x4AAAAAADlXGeFQ-Aj3Kitp' },
|
||||
report: { service: 'ReportServ', target: '#staff' },
|
||||
defaults: { theme: 'light', compact: false, sound: true, hideJoinQuit: false, clock24: true },
|
||||
features: { push: true, imageUpload: true, register: true, linkPreviews: true, multiNetwork: false, sessionResume: false, passkeySasl: false, saslScram: false },
|
||||
features: { push: true, imageUpload: true, register: true, linkPreviews: true, multiNetwork: false, sessionResume: false, passkeySasl: false, saslScram: false, webmcp: true },
|
||||
plugins: [],
|
||||
builtins: [],
|
||||
};
|
||||
|
|
|
|||
22
src/core/consent.ts
Normal file
22
src/core/consent.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Analytics consent (opt-in) for Google Analytics. SHARED with the Django site:
|
||||
// tchatou.fr and tchatou.fr/app are the same origin, so we read/write the exact same
|
||||
// localStorage entry the marketing site's static/js/consent.js uses — key
|
||||
// `tchatou-consent`, value `{v:'granted'|'denied', t:epochMs}`, re-asked after ~6
|
||||
// months. A visitor who already chose on tchatou.fr is never asked again in the app;
|
||||
// the app's own banner only appears for someone who opens /app/ directly (e.g. the
|
||||
// installed PWA) and writes the same shared entry so the choice stays in sync.
|
||||
const KEY = 'tchatou-consent';
|
||||
const TTL = 1000 * 60 * 60 * 24 * 180; // re-ask after ~6 months (matches consent.js)
|
||||
export type Consent = 'granted' | 'denied' | 'unset';
|
||||
|
||||
export function getConsent(): Consent {
|
||||
try {
|
||||
const o = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||
if (o && o.t && Date.now() - o.t < TTL && (o.v === 'granted' || o.v === 'denied')) return o.v;
|
||||
} catch { /* bad JSON / no storage */ }
|
||||
return 'unset';
|
||||
}
|
||||
|
||||
export function setConsent(v: 'granted' | 'denied'): void {
|
||||
try { localStorage.setItem(KEY, JSON.stringify({ v, t: Date.now() })); } catch { /* private mode / quota */ }
|
||||
}
|
||||
77
src/core/ga.ts
Normal file
77
src/core/ga.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Google Analytics 4 — OPT-IN via config.analytics.gaId, using Google Consent
|
||||
// Mode v2 (advanced), matching the marketing site. gtag.js loads for everyone but
|
||||
// starts DENIED: no cookies, only anonymous cookieless pings until the visitor opts
|
||||
// in via the consent banner / settings toggle, which flips it with setGaConsent().
|
||||
// Consent is SHARED with tchatou.fr through the tchatou-consent key (same origin),
|
||||
// so a prior grant is restored here before the first hit. Needs googletagmanager.com
|
||||
// + google-analytics.com in the app CSP.
|
||||
//
|
||||
// SPA-aware: gtag only auto-counts the initial load, so we send a page_view on every
|
||||
// channel switch (buffer.active). DM and console names are redacted before they
|
||||
// reach Google — only public channels keep their name.
|
||||
import { getConfig } from './config';
|
||||
import { getConsent } from './consent';
|
||||
import { bus } from '../modules/bus';
|
||||
|
||||
function pagePath(name: string): string {
|
||||
if (!name) return '/';
|
||||
const c = name[0];
|
||||
return c === '#' || c === '&' ? '/channel/' + name.replace(/^[#&]+/, '') : '/direct';
|
||||
}
|
||||
function pageTitle(name: string): string {
|
||||
const c = name[0];
|
||||
return c === '#' || c === '&' ? name : 'Direct';
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// Load gtag.js in the denied-by-default consent state. Called once at boot. A prior
|
||||
// grant (from this app or the marketing site) is restored before gtag.js runs.
|
||||
export function initGa(): void {
|
||||
if (loaded) return;
|
||||
const id = getConfig().analytics?.gaId;
|
||||
if (!id) return;
|
||||
loaded = true;
|
||||
const w = window as unknown as { dataLayer: unknown[]; gtag: (...a: unknown[]) => void };
|
||||
w.dataLayer = w.dataLayer || [];
|
||||
// eslint-disable-next-line prefer-rest-params -- the canonical gtag shim pushes the live `arguments` object; gtag.js relies on that exact shape
|
||||
w.gtag = function gtag() { w.dataLayer.push(arguments); };
|
||||
w.gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', wait_for_update: 500 });
|
||||
if (getConsent() === 'granted') setGaConsent(true);
|
||||
const s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id);
|
||||
document.head.appendChild(s);
|
||||
w.gtag('js', new Date());
|
||||
w.gtag('config', id);
|
||||
bus.on('connected', () => w.gtag('event', 'login', { method: 'irc' }));
|
||||
bus.on('buffer.active', (name: unknown) => {
|
||||
const n = String(name ?? '');
|
||||
w.gtag('event', 'page_view', { page_path: pagePath(n), page_title: pageTitle(n) });
|
||||
});
|
||||
}
|
||||
|
||||
// Flip the consent state (banner Accept/Refuse, settings toggle). The tag is already
|
||||
// loaded by initGa(); this tells gtag whether it may use storage. This is an ad-free
|
||||
// site, so ONLY audience measurement is ever granted — the ad signals (ad_storage /
|
||||
// ad_user_data / ad_personalization) stay denied, matching the marketing site.
|
||||
// Denying also clears any GA cookies already set.
|
||||
export function setGaConsent(granted: boolean): void {
|
||||
const w = window as unknown as { gtag?: (...a: unknown[]) => void };
|
||||
if (typeof w.gtag !== 'function') return;
|
||||
w.gtag('consent', 'update', { analytics_storage: granted ? 'granted' : 'denied' });
|
||||
if (!granted) clearGaCookies();
|
||||
}
|
||||
|
||||
function clearGaCookies(): void {
|
||||
try {
|
||||
const host = location.hostname;
|
||||
const base = host.replace(/^www\./, '');
|
||||
for (const c of document.cookie.split(';')) {
|
||||
const nm = c.split('=')[0].trim();
|
||||
if (!(nm.startsWith('_ga') || nm === '_gid' || nm === '_gat')) continue;
|
||||
for (const dom of ['', `; domain=${host}`, `; domain=.${base}`])
|
||||
document.cookie = `${nm}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/${dom}`;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
// Only the default + fallback locale ships in the initial chunk so the app paints
|
||||
// without parsing ~360 KB of other-language JSON it doesn't need yet. French is the
|
||||
// primary audience, so most users never fetch a second locale; the other nine are
|
||||
// separate chunks loaded on demand (language switch, or a config-pinned default).
|
||||
import fr from './locales/fr.json';
|
||||
import de from './locales/de.json';
|
||||
import tr from './locales/tr.json';
|
||||
import ne from './locales/ne.json';
|
||||
import es from './locales/es.json';
|
||||
import ru from './locales/ru.json';
|
||||
import it from './locales/it.json';
|
||||
import ptPT from './locales/pt-PT.json';
|
||||
import ptBR from './locales/pt-BR.json';
|
||||
|
||||
const loaders: Record<string, () => Promise<{ default: Record<string, unknown> }>> = {
|
||||
en: () => import('./locales/en.json'),
|
||||
de: () => import('./locales/de.json'),
|
||||
es: () => import('./locales/es.json'),
|
||||
it: () => import('./locales/it.json'),
|
||||
'pt-PT': () => import('./locales/pt-PT.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR.json'),
|
||||
ru: () => import('./locales/ru.json'),
|
||||
tr: () => import('./locales/tr.json'),
|
||||
ne: () => import('./locales/ne.json'),
|
||||
};
|
||||
|
||||
// Supported UI languages — code + native label (shown in the selector).
|
||||
export const LANGS = [
|
||||
|
|
@ -45,18 +52,7 @@ function detect(): string {
|
|||
}
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
fr: { translation: fr },
|
||||
de: { translation: de },
|
||||
tr: { translation: tr },
|
||||
ne: { translation: ne },
|
||||
es: { translation: es },
|
||||
ru: { translation: ru },
|
||||
it: { translation: it },
|
||||
'pt-PT': { translation: ptPT },
|
||||
'pt-BR': { translation: ptBR },
|
||||
},
|
||||
resources: { fr: { translation: fr } },
|
||||
lng: detect(),
|
||||
fallbackLng: 'fr',
|
||||
interpolation: { escapeValue: false },
|
||||
|
|
@ -65,6 +61,22 @@ void i18n.use(initReactI18next).init({
|
|||
|
||||
document.documentElement.lang = i18n.language;
|
||||
|
||||
// Fetch a locale's chunk (once), register it, and switch to it. changeLanguage
|
||||
// re-asserts the language so react-i18next re-renders with the freshly-added
|
||||
// bundle even when it was already the active (fallback-rendered) language.
|
||||
async function loadLang(code: string): Promise<void> {
|
||||
const load = loaders[code];
|
||||
if (load && !i18n.hasResourceBundle(code, 'translation')) {
|
||||
const { default: table } = await load();
|
||||
i18n.addResourceBundle(code, 'translation', table, true, true);
|
||||
}
|
||||
await i18n.changeLanguage(code);
|
||||
document.documentElement.lang = code;
|
||||
}
|
||||
|
||||
// The detected language isn't the bundled fallback — pull its chunk and swap in.
|
||||
if (i18n.language !== 'fr') void loadLang(i18n.language);
|
||||
|
||||
// Apply a deployment's default UI language (config.defaults.lang) — but only for
|
||||
// users who haven't explicitly picked one (setLang writes KEY). Called after the
|
||||
// runtime config loads, so it can override the browser-detected language.
|
||||
|
|
@ -73,8 +85,7 @@ export function applyConfigDefaultLang(code?: string): void {
|
|||
if (localStorage.getItem(KEY)) return; // explicit user choice — honour it
|
||||
if (!CODES.includes(code)) return; // unknown language — ignore
|
||||
if (i18n.language === code) return;
|
||||
void i18n.changeLanguage(code);
|
||||
document.documentElement.lang = code;
|
||||
void loadLang(code);
|
||||
}
|
||||
|
||||
export function getLang(): string {
|
||||
|
|
@ -83,8 +94,7 @@ export function getLang(): string {
|
|||
|
||||
export function setLang(code: string): void {
|
||||
localStorage.setItem(KEY, code);
|
||||
void i18n.changeLanguage(code);
|
||||
document.documentElement.lang = code;
|
||||
void loadLang(code);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Mein Profil ansehen",
|
||||
"online": "Online",
|
||||
"away": "Abwesend",
|
||||
"status": "Status",
|
||||
"awayDefault": "Momentan abwesend",
|
||||
"awayReason": "Abwesenheitsnachricht",
|
||||
"awaySet": "Setzen",
|
||||
"markPresent": "Als anwesend markieren",
|
||||
"markAway": "Als abwesend markieren",
|
||||
"presence": "Anwesenheit",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Server-Terminal · gib einen IRC-Befehl ein (/list, /whois, /join…)",
|
||||
"muteOn": "Benachrichtigungen stummschalten",
|
||||
"muteOff": "Stummschaltung aufheben",
|
||||
"membersTitle": "Mitglieder"
|
||||
"membersTitle": "Mitglieder",
|
||||
"more": "Mehr"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} Personen online",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Entfernen",
|
||||
"topicBy": "Festgelegt von",
|
||||
"tabOverview": "Übersicht",
|
||||
"tabModes": "Modi"
|
||||
"tabModes": "Modi",
|
||||
"extbans": "Erweiterte Bans",
|
||||
"extbanType": "Extban-Typ",
|
||||
"noExtbans": "Noch keine erweiterten Bans",
|
||||
"filter": "Filtern…",
|
||||
"extRestrict": "Einschränkungen",
|
||||
"extMatch": "Bannen nach",
|
||||
"example": "Beispiel:",
|
||||
"exempt": "Ausnehmen",
|
||||
"allow": "Erlauben",
|
||||
"plainMask": "(Maske)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "Was ist los?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Erneut beitreten"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit dunkel",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC Nacht"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Ungültiger oder abgelaufener Code.",
|
||||
"errWeak": "Passwort zu kurz (mindestens 8 Zeichen).",
|
||||
"errMatch": "Die beiden Passwörter stimmen nicht überein."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Statistik-Cookies",
|
||||
"manageOn": "Aktiviert — Google Analytics ist aktiv",
|
||||
"manageOff": "Deaktiviert — es wird nichts an Google gesendet"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Stumm",
|
||||
"blockcolor": "Farben sperren",
|
||||
"noctcp": "CTCP sperren",
|
||||
"nonick": "Kein Nick-Wechsel",
|
||||
"nonotice": "Keine Notices",
|
||||
"stripcolor": "Farben entfernen",
|
||||
"nokick": "Kein Kick",
|
||||
"opmoderated": "Op-moderiert",
|
||||
"blockinvite": "Einladungen sperren",
|
||||
"redirect": "Umleiten",
|
||||
"account": "Konto",
|
||||
"unauthed": "Nicht angemeldet",
|
||||
"securitygroup": "Sicherheitsgruppe",
|
||||
"score": "Reputationswert",
|
||||
"country": "Land",
|
||||
"server": "Server",
|
||||
"class": "Verbindungsklasse",
|
||||
"realname": "Echter Name",
|
||||
"realmask": "Maske + Name",
|
||||
"fingerprint": "TLS-Fingerprint",
|
||||
"oper": "Oper-Konto",
|
||||
"opertype": "Oper-Typ",
|
||||
"channel": "In einem Channel",
|
||||
"share": "Anderswo gebannt",
|
||||
"gateway": "WebIRC-Gateway"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "View my profile",
|
||||
"online": "Online",
|
||||
"away": "Away",
|
||||
"status": "Status",
|
||||
"awayDefault": "Away for now",
|
||||
"awayReason": "Away message",
|
||||
"awaySet": "Set",
|
||||
"markPresent": "Mark as present",
|
||||
"markAway": "Mark as away",
|
||||
"presence": "Presence",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Server terminal · type an IRC command (/list, /whois, /join…)",
|
||||
"muteOn": "Mute notifications",
|
||||
"muteOff": "Unmute notifications",
|
||||
"membersTitle": "Members"
|
||||
"membersTitle": "Members",
|
||||
"more": "More"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} people online",
|
||||
|
|
@ -228,7 +233,17 @@
|
|||
"clear": "Remove",
|
||||
"topicBy": "Set by",
|
||||
"tabOverview": "Overview",
|
||||
"tabModes": "Modes"
|
||||
"tabModes": "Modes",
|
||||
"extbans": "Extended bans",
|
||||
"extbanType": "Extban type",
|
||||
"noExtbans": "No extended bans yet",
|
||||
"filter": "Filter…",
|
||||
"extRestrict": "Restrictions",
|
||||
"extMatch": "Ban by",
|
||||
"example": "Example:",
|
||||
"exempt": "Exempt",
|
||||
"allow": "Allow",
|
||||
"plainMask": "(mask)"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
|
@ -494,11 +509,11 @@
|
|||
},
|
||||
"themes": {
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit dark",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC night"
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"name": "Name (optional)",
|
||||
"password": "Password (optional)",
|
||||
"connect": "Connect"
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Analytics cookies",
|
||||
"manageOn": "Enabled — Google Analytics is active",
|
||||
"manageOff": "Disabled — nothing is sent to Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Mute",
|
||||
"blockcolor": "Block colours",
|
||||
"noctcp": "Block CTCP",
|
||||
"nonick": "No nick change",
|
||||
"nonotice": "No notices",
|
||||
"stripcolor": "Strip colours",
|
||||
"nokick": "No kicks",
|
||||
"opmoderated": "Op-moderated",
|
||||
"blockinvite": "Block invites",
|
||||
"redirect": "Redirect",
|
||||
"account": "Account",
|
||||
"unauthed": "Unauthenticated",
|
||||
"securitygroup": "Security group",
|
||||
"score": "Reputation score",
|
||||
"country": "Country",
|
||||
"server": "Server",
|
||||
"class": "Connect class",
|
||||
"realname": "Real name",
|
||||
"realmask": "Mask + real name",
|
||||
"fingerprint": "TLS fingerprint",
|
||||
"oper": "Oper account",
|
||||
"opertype": "Oper type",
|
||||
"channel": "On a channel",
|
||||
"share": "Banned elsewhere",
|
||||
"gateway": "WebIRC gateway"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Ver mi perfil",
|
||||
"online": "En línea",
|
||||
"away": "Ausente",
|
||||
"status": "Estado",
|
||||
"awayDefault": "Ausente por ahora",
|
||||
"awayReason": "Mensaje de ausencia",
|
||||
"awaySet": "Aplicar",
|
||||
"markPresent": "Marcar presente",
|
||||
"markAway": "Marcar ausente",
|
||||
"presence": "Presencia",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Terminal del servidor · escribe un comando IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Silenciar notificaciones",
|
||||
"muteOff": "Reactivar notificaciones",
|
||||
"membersTitle": "Miembros"
|
||||
"membersTitle": "Miembros",
|
||||
"more": "Más"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} personas en línea",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Quitar",
|
||||
"topicBy": "Puesto por",
|
||||
"tabOverview": "Resumen",
|
||||
"tabModes": "Modos"
|
||||
"tabModes": "Modos",
|
||||
"extbans": "Bans extendidos",
|
||||
"extbanType": "Tipo de extban",
|
||||
"noExtbans": "Sin bans extendidos",
|
||||
"filter": "Filtrar…",
|
||||
"extRestrict": "Restricciones",
|
||||
"extMatch": "Banear por",
|
||||
"example": "Ejemplo:",
|
||||
"exempt": "Eximir",
|
||||
"allow": "Permitir",
|
||||
"plainMask": "(máscara)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "¿Qué está pasando?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Volver a entrar"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit oscuro",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC noche"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Código no válido o caducado.",
|
||||
"errWeak": "Contraseña demasiado corta (8 caracteres mínimo).",
|
||||
"errMatch": "Las dos contraseñas no coinciden."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookies de estadísticas",
|
||||
"manageOn": "Activado — Google Analytics está activo",
|
||||
"manageOff": "Desactivado — no se envía nada a Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Silenciar",
|
||||
"blockcolor": "Bloquear colores",
|
||||
"noctcp": "Bloquear CTCP",
|
||||
"nonick": "Sin cambio de nick",
|
||||
"nonotice": "Sin notices",
|
||||
"stripcolor": "Quitar colores",
|
||||
"nokick": "Sin kicks",
|
||||
"opmoderated": "Moderado por ops",
|
||||
"blockinvite": "Bloquear invitaciones",
|
||||
"redirect": "Redirigir",
|
||||
"account": "Cuenta",
|
||||
"unauthed": "Sin autenticar",
|
||||
"securitygroup": "Grupo de seguridad",
|
||||
"score": "Puntuación de reputación",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"class": "Clase de conexión",
|
||||
"realname": "Nombre real",
|
||||
"realmask": "Máscara + nombre real",
|
||||
"fingerprint": "Huella TLS",
|
||||
"oper": "Cuenta oper",
|
||||
"opertype": "Tipo de oper",
|
||||
"channel": "En un canal",
|
||||
"share": "Baneado en otro sitio",
|
||||
"gateway": "Gateway WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Voir mon profil",
|
||||
"online": "En ligne",
|
||||
"away": "Absent",
|
||||
"status": "Statut",
|
||||
"awayDefault": "Absent pour le moment",
|
||||
"awayReason": "Raison de votre absence",
|
||||
"awaySet": "Définir",
|
||||
"markPresent": "Marquer présent",
|
||||
"markAway": "Marquer absent",
|
||||
"presence": "Absence",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Terminal serveur · tape une commande IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Couper les notifications",
|
||||
"muteOff": "Réactiver les notifications",
|
||||
"membersTitle": "Membres"
|
||||
"membersTitle": "Membres",
|
||||
"more": "Plus"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} personnes en ligne",
|
||||
|
|
@ -228,7 +233,17 @@
|
|||
"clear": "Retirer",
|
||||
"topicBy": "Défini par",
|
||||
"tabOverview": "Aperçu",
|
||||
"tabModes": "Modes"
|
||||
"tabModes": "Modes",
|
||||
"extbans": "Bans étendus",
|
||||
"extbanType": "Type de ban étendu",
|
||||
"noExtbans": "Aucun ban étendu",
|
||||
"filter": "Filtrer…",
|
||||
"extRestrict": "Restrictions",
|
||||
"extMatch": "Bannir par",
|
||||
"example": "Exemple :",
|
||||
"exempt": "Exempter",
|
||||
"allow": "Autoriser",
|
||||
"plainMask": "(masque)"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
|
@ -493,12 +508,12 @@
|
|||
"rejoin": "Rejoindre"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit sombre",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC nuit"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"name": "Nom (facultatif)",
|
||||
"password": "Mot de passe (facultatif)",
|
||||
"connect": "Se connecter"
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookies de statistiques",
|
||||
"manageOn": "Activé — Google Analytics est actif",
|
||||
"manageOff": "Désactivé — rien n'est envoyé à Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Muet",
|
||||
"blockcolor": "Bloquer les couleurs",
|
||||
"noctcp": "Bloquer CTCP",
|
||||
"nonick": "Pseudo figé",
|
||||
"nonotice": "Pas de notices",
|
||||
"stripcolor": "Retirer les couleurs",
|
||||
"nokick": "Pas de kick",
|
||||
"opmoderated": "Modéré par les ops",
|
||||
"blockinvite": "Bloquer les invitations",
|
||||
"redirect": "Rediriger",
|
||||
"account": "Compte",
|
||||
"unauthed": "Non authentifié",
|
||||
"securitygroup": "Groupe de sécurité",
|
||||
"score": "Score de réputation",
|
||||
"country": "Pays",
|
||||
"server": "Serveur",
|
||||
"class": "Classe de connexion",
|
||||
"realname": "Nom réel",
|
||||
"realmask": "Masque + nom réel",
|
||||
"fingerprint": "Empreinte TLS",
|
||||
"oper": "Compte oper",
|
||||
"opertype": "Type d'oper",
|
||||
"channel": "Sur un salon",
|
||||
"share": "Banni ailleurs",
|
||||
"gateway": "Passerelle WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Vedi il mio profilo",
|
||||
"online": "Online",
|
||||
"away": "Assente",
|
||||
"status": "Stato",
|
||||
"awayDefault": "Assente per ora",
|
||||
"awayReason": "Messaggio di assenza",
|
||||
"awaySet": "Imposta",
|
||||
"markPresent": "Segna presente",
|
||||
"markAway": "Segna assente",
|
||||
"presence": "Presenza",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Terminale server · digita un comando IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Silenzia notifiche",
|
||||
"muteOff": "Riattiva notifiche",
|
||||
"membersTitle": "Membri"
|
||||
"membersTitle": "Membri",
|
||||
"more": "Altro"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} persone online",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Rimuovi",
|
||||
"topicBy": "Impostato da",
|
||||
"tabOverview": "Panoramica",
|
||||
"tabModes": "Modalità"
|
||||
"tabModes": "Modalità",
|
||||
"extbans": "Ban estesi",
|
||||
"extbanType": "Tipo di ban esteso",
|
||||
"noExtbans": "Nessun ban esteso",
|
||||
"filter": "Filtra…",
|
||||
"extRestrict": "Restrizioni",
|
||||
"extMatch": "Banna per",
|
||||
"example": "Esempio:",
|
||||
"exempt": "Esenta",
|
||||
"allow": "Consenti",
|
||||
"plainMask": "(maschera)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "Cosa sta succedendo?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Rientra"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit scuro",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC notte"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Codice non valido o scaduto.",
|
||||
"errWeak": "Password troppo corta (minimo 8 caratteri).",
|
||||
"errMatch": "Le due password non corrispondono."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookie statistici",
|
||||
"manageOn": "Attivo — Google Analytics è attivo",
|
||||
"manageOff": "Disattivato — non viene inviato nulla a Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Muto",
|
||||
"blockcolor": "Blocca colori",
|
||||
"noctcp": "Blocca CTCP",
|
||||
"nonick": "Nick bloccato",
|
||||
"nonotice": "Niente notice",
|
||||
"stripcolor": "Rimuovi colori",
|
||||
"nokick": "Niente kick",
|
||||
"opmoderated": "Moderato dagli op",
|
||||
"blockinvite": "Blocca inviti",
|
||||
"redirect": "Reindirizza",
|
||||
"account": "Account",
|
||||
"unauthed": "Non autenticato",
|
||||
"securitygroup": "Gruppo di sicurezza",
|
||||
"score": "Punteggio reputazione",
|
||||
"country": "Paese",
|
||||
"server": "Server",
|
||||
"class": "Classe di connessione",
|
||||
"realname": "Nome reale",
|
||||
"realmask": "Maschera + nome reale",
|
||||
"fingerprint": "Impronta TLS",
|
||||
"oper": "Account oper",
|
||||
"opertype": "Tipo di oper",
|
||||
"channel": "In un canale",
|
||||
"share": "Bannato altrove",
|
||||
"gateway": "Gateway WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "मेरो प्रोफाइल हेर्नुहोस्",
|
||||
"online": "अनलाइन",
|
||||
"away": "टाढा",
|
||||
"status": "स्थिति",
|
||||
"awayDefault": "अहिले अनुपस्थित",
|
||||
"awayReason": "अनुपस्थिति सन्देश",
|
||||
"awaySet": "सेट गर्नुहोस्",
|
||||
"markPresent": "उपस्थित चिन्ह लगाउनुहोस्",
|
||||
"markAway": "टाढा चिन्ह लगाउनुहोस्",
|
||||
"presence": "उपस्थिति",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "सर्भर टर्मिनल · IRC आदेश टाइप गर्नुहोस् (/list, /whois, /join…)",
|
||||
"muteOn": "सूचनाहरू म्युट गर्नुहोस्",
|
||||
"muteOff": "सूचनाहरू सक्रिय गर्नुहोस्",
|
||||
"membersTitle": "सदस्यहरू"
|
||||
"membersTitle": "सदस्यहरू",
|
||||
"more": "थप"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} जना अनलाइन",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "हटाउनुहोस्",
|
||||
"topicBy": "सेट गर्ने",
|
||||
"tabOverview": "सिंहावलोकन",
|
||||
"tabModes": "मोडहरू"
|
||||
"tabModes": "मोडहरू",
|
||||
"extbans": "विस्तारित प्रतिबन्ध",
|
||||
"extbanType": "एक्स्टब्यान प्रकार",
|
||||
"noExtbans": "अहिलेसम्म कुनै विस्तारित प्रतिबन्ध छैन",
|
||||
"filter": "फिल्टर…",
|
||||
"extRestrict": "प्रतिबन्धहरू",
|
||||
"extMatch": "यसद्वारा प्रतिबन्ध",
|
||||
"example": "उदाहरण:",
|
||||
"exempt": "छुट",
|
||||
"allow": "अनुमति",
|
||||
"plainMask": "(मास्क)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "के भइरहेको छ?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "पुनः सामेल हुनुहोस्"
|
||||
},
|
||||
"themes": {
|
||||
"light": "उज्यालो",
|
||||
"dark": "अँध्यारो",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit अँध्यारो",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC रात"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "अमान्य वा म्याद सकिएको कोड।",
|
||||
"errWeak": "पासवर्ड धेरै छोटो छ (कम्तीमा ८ अक्षर)।",
|
||||
"errMatch": "दुई पासवर्ड मिलेनन्।"
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "तथ्याङ्क कुकिज",
|
||||
"manageOn": "सक्रिय — Google Analytics चालु छ",
|
||||
"manageOff": "निष्क्रिय — Google लाई केही पनि पठाइँदैन"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "म्युट",
|
||||
"blockcolor": "रङहरू रोक्नुहोस्",
|
||||
"noctcp": "CTCP रोक्नुहोस्",
|
||||
"nonick": "निक परिवर्तन बन्द",
|
||||
"nonotice": "नोटिस बन्द",
|
||||
"stripcolor": "रङहरू हटाउनुहोस्",
|
||||
"nokick": "किक बन्द",
|
||||
"opmoderated": "अपद्वारा नियन्त्रित",
|
||||
"blockinvite": "निमन्त्रणा रोक्नुहोस्",
|
||||
"redirect": "पुनर्निर्देशन",
|
||||
"account": "खाता",
|
||||
"unauthed": "अप्रमाणित",
|
||||
"securitygroup": "सुरक्षा समूह",
|
||||
"score": "प्रतिष्ठा अंक",
|
||||
"country": "देश",
|
||||
"server": "सर्भर",
|
||||
"class": "जडान वर्ग",
|
||||
"realname": "वास्तविक नाम",
|
||||
"realmask": "मास्क + वास्तविक नाम",
|
||||
"fingerprint": "TLS फिंगरप्रिन्ट",
|
||||
"oper": "अपर खाता",
|
||||
"opertype": "अपर प्रकार",
|
||||
"channel": "च्यानलमा",
|
||||
"share": "अन्यत्र प्रतिबन्धित",
|
||||
"gateway": "WebIRC गेटवे"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Ver meu perfil",
|
||||
"online": "Online",
|
||||
"away": "Ausente",
|
||||
"status": "Status",
|
||||
"awayDefault": "Ausente no momento",
|
||||
"awayReason": "Mensagem de ausência",
|
||||
"awaySet": "Definir",
|
||||
"markPresent": "Marcar presente",
|
||||
"markAway": "Marcar ausente",
|
||||
"presence": "Presença",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Terminal do servidor · digite um comando IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Silenciar notificações",
|
||||
"muteOff": "Reativar notificações",
|
||||
"membersTitle": "Membros"
|
||||
"membersTitle": "Membros",
|
||||
"more": "Mais"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} pessoas online",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Remover",
|
||||
"topicBy": "Definido por",
|
||||
"tabOverview": "Visão geral",
|
||||
"tabModes": "Modos"
|
||||
"tabModes": "Modos",
|
||||
"extbans": "Bans estendidos",
|
||||
"extbanType": "Tipo de extban",
|
||||
"noExtbans": "Nenhum ban estendido ainda",
|
||||
"filter": "Filtrar…",
|
||||
"extRestrict": "Restrições",
|
||||
"extMatch": "Banir por",
|
||||
"example": "Exemplo:",
|
||||
"exempt": "Isentar",
|
||||
"allow": "Permitir",
|
||||
"plainMask": "(máscara)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "O que está acontecendo?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Voltar a entrar"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Claro",
|
||||
"dark": "Escuro",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit escuro",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC noite"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Código inválido ou expirado.",
|
||||
"errWeak": "Senha muito curta (mínimo de 8 caracteres).",
|
||||
"errMatch": "As duas senhas não conferem."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookies de estatísticas",
|
||||
"manageOn": "Ativado — o Google Analytics está ativo",
|
||||
"manageOff": "Desativado — nada é enviado ao Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Silenciar",
|
||||
"blockcolor": "Bloquear cores",
|
||||
"noctcp": "Bloquear CTCP",
|
||||
"nonick": "Sem troca de nick",
|
||||
"nonotice": "Sem notices",
|
||||
"stripcolor": "Remover cores",
|
||||
"nokick": "Sem kicks",
|
||||
"opmoderated": "Moderado por ops",
|
||||
"blockinvite": "Bloquear convites",
|
||||
"redirect": "Redirecionar",
|
||||
"account": "Conta",
|
||||
"unauthed": "Não autenticado",
|
||||
"securitygroup": "Grupo de segurança",
|
||||
"score": "Pontuação de reputação",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"class": "Classe de conexão",
|
||||
"realname": "Nome real",
|
||||
"realmask": "Máscara + nome real",
|
||||
"fingerprint": "Impressão TLS",
|
||||
"oper": "Conta oper",
|
||||
"opertype": "Tipo de oper",
|
||||
"channel": "Em um canal",
|
||||
"share": "Banido em outro lugar",
|
||||
"gateway": "Gateway WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Ver o meu perfil",
|
||||
"online": "Online",
|
||||
"away": "Ausente",
|
||||
"status": "Estado",
|
||||
"awayDefault": "Ausente de momento",
|
||||
"awayReason": "Mensagem de ausência",
|
||||
"awaySet": "Definir",
|
||||
"markPresent": "Marcar presente",
|
||||
"markAway": "Marcar ausente",
|
||||
"presence": "Presença",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Terminal do servidor · escreve um comando IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Silenciar notificações",
|
||||
"muteOff": "Reativar notificações",
|
||||
"membersTitle": "Membros"
|
||||
"membersTitle": "Membros",
|
||||
"more": "Mais"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} pessoas online",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Remover",
|
||||
"topicBy": "Definido por",
|
||||
"tabOverview": "Resumo",
|
||||
"tabModes": "Modos"
|
||||
"tabModes": "Modos",
|
||||
"extbans": "Bans estendidos",
|
||||
"extbanType": "Tipo de extban",
|
||||
"noExtbans": "Ainda sem bans estendidos",
|
||||
"filter": "Filtrar…",
|
||||
"extRestrict": "Restrições",
|
||||
"extMatch": "Banir por",
|
||||
"example": "Exemplo:",
|
||||
"exempt": "Isentar",
|
||||
"allow": "Permitir",
|
||||
"plainMask": "(máscara)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "O que se passa?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Voltar a entrar"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Claro",
|
||||
"dark": "Escuro",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit escuro",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC noite"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Código inválido ou expirado.",
|
||||
"errWeak": "Palavra-passe demasiado curta (mínimo 8 caracteres).",
|
||||
"errMatch": "As duas palavras-passe não coincidem."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookies de estatísticas",
|
||||
"manageOn": "Ativado — o Google Analytics está ativo",
|
||||
"manageOff": "Desativado — nada é enviado ao Google"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Silenciar",
|
||||
"blockcolor": "Bloquear cores",
|
||||
"noctcp": "Bloquear CTCP",
|
||||
"nonick": "Sem troca de nick",
|
||||
"nonotice": "Sem notices",
|
||||
"stripcolor": "Remover cores",
|
||||
"nokick": "Sem kicks",
|
||||
"opmoderated": "Moderado pelos ops",
|
||||
"blockinvite": "Bloquear convites",
|
||||
"redirect": "Redirecionar",
|
||||
"account": "Conta",
|
||||
"unauthed": "Não autenticado",
|
||||
"securitygroup": "Grupo de segurança",
|
||||
"score": "Pontuação de reputação",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"class": "Classe de ligação",
|
||||
"realname": "Nome real",
|
||||
"realmask": "Máscara + nome real",
|
||||
"fingerprint": "Impressão TLS",
|
||||
"oper": "Conta oper",
|
||||
"opertype": "Tipo de oper",
|
||||
"channel": "Num canal",
|
||||
"share": "Banido noutro lado",
|
||||
"gateway": "Gateway WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Мой профиль",
|
||||
"online": "В сети",
|
||||
"away": "Отошёл",
|
||||
"status": "Статус",
|
||||
"awayDefault": "Сейчас отошёл",
|
||||
"awayReason": "Причина отсутствия",
|
||||
"awaySet": "Готово",
|
||||
"markPresent": "Отметить «на месте»",
|
||||
"markAway": "Отметить «отошёл»",
|
||||
"presence": "Статус",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Терминал сервера · введите команду IRC (/list, /whois, /join…)",
|
||||
"muteOn": "Отключить уведомления",
|
||||
"muteOff": "Включить уведомления",
|
||||
"membersTitle": "Участники"
|
||||
"membersTitle": "Участники",
|
||||
"more": "Ещё"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} человек онлайн",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Убрать",
|
||||
"topicBy": "Задал",
|
||||
"tabOverview": "Обзор",
|
||||
"tabModes": "Режимы"
|
||||
"tabModes": "Режимы",
|
||||
"extbans": "Расширенные баны",
|
||||
"extbanType": "Тип расширенного бана",
|
||||
"noExtbans": "Пока нет расширенных банов",
|
||||
"filter": "Фильтр…",
|
||||
"extRestrict": "Ограничения",
|
||||
"extMatch": "Бан по",
|
||||
"example": "Пример:",
|
||||
"exempt": "Исключить",
|
||||
"allow": "Разрешить",
|
||||
"plainMask": "(маска)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "Что случилось?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Войти снова"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit тёмная",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC ночь"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Неверный или устаревший код.",
|
||||
"errWeak": "Пароль слишком короткий (минимум 8 символов).",
|
||||
"errMatch": "Пароли не совпадают."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "Cookie статистики",
|
||||
"manageOn": "Включено — Google Analytics активен",
|
||||
"manageOff": "Отключено — в Google ничего не отправляется"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Мьют",
|
||||
"blockcolor": "Блок цветов",
|
||||
"noctcp": "Блок CTCP",
|
||||
"nonick": "Ник закреплён",
|
||||
"nonotice": "Без notice",
|
||||
"stripcolor": "Убрать цвета",
|
||||
"nokick": "Без киков",
|
||||
"opmoderated": "Модерация опами",
|
||||
"blockinvite": "Блок приглашений",
|
||||
"redirect": "Перенаправить",
|
||||
"account": "Аккаунт",
|
||||
"unauthed": "Не авторизован",
|
||||
"securitygroup": "Группа безопасности",
|
||||
"score": "Рейтинг репутации",
|
||||
"country": "Страна",
|
||||
"server": "Сервер",
|
||||
"class": "Класс подключения",
|
||||
"realname": "Настоящее имя",
|
||||
"realmask": "Маска + имя",
|
||||
"fingerprint": "Отпечаток TLS",
|
||||
"oper": "Аккаунт опера",
|
||||
"opertype": "Тип опера",
|
||||
"channel": "На канале",
|
||||
"share": "Забанен в другом месте",
|
||||
"gateway": "Шлюз WebIRC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"viewProfile": "Profilimi gör",
|
||||
"online": "Çevrimiçi",
|
||||
"away": "Uzakta",
|
||||
"status": "Durum",
|
||||
"awayDefault": "Şu an uzakta",
|
||||
"awayReason": "Uzakta mesajı",
|
||||
"awaySet": "Ayarla",
|
||||
"markPresent": "Müsait olarak işaretle",
|
||||
"markAway": "Uzakta olarak işaretle",
|
||||
"presence": "Durum",
|
||||
|
|
@ -46,7 +50,8 @@
|
|||
"serverTerminal": "Sunucu terminali · bir IRC komutu yaz (/list, /whois, /join…)",
|
||||
"muteOn": "Bildirimleri sustur",
|
||||
"muteOff": "Bildirimleri aç",
|
||||
"membersTitle": "Üyeler"
|
||||
"membersTitle": "Üyeler",
|
||||
"more": "Daha fazla"
|
||||
},
|
||||
"connect": {
|
||||
"onlineBadge": "{{n}} kişi çevrimiçi",
|
||||
|
|
@ -200,7 +205,17 @@
|
|||
"clear": "Kaldır",
|
||||
"topicBy": "Belirleyen",
|
||||
"tabOverview": "Genel bakış",
|
||||
"tabModes": "Modlar"
|
||||
"tabModes": "Modlar",
|
||||
"extbans": "Genişletilmiş banlar",
|
||||
"extbanType": "Extban türü",
|
||||
"noExtbans": "Henüz genişletilmiş ban yok",
|
||||
"filter": "Filtrele…",
|
||||
"extRestrict": "Kısıtlamalar",
|
||||
"extMatch": "Şuna göre banla",
|
||||
"example": "Örnek:",
|
||||
"exempt": "Muaf tut",
|
||||
"allow": "İzin ver",
|
||||
"plainMask": "(maske)"
|
||||
},
|
||||
"report": {
|
||||
"reasonLabel": "Neler oluyor?",
|
||||
|
|
@ -474,12 +489,12 @@
|
|||
"rejoin": "Tekrar katıl"
|
||||
},
|
||||
"themes": {
|
||||
"light": "Açık",
|
||||
"dark": "Koyu",
|
||||
"orbit": "Orbit",
|
||||
"orbitDark": "Orbit koyu",
|
||||
"yomirc": "yomIRC",
|
||||
"yomircDark": "yomIRC gece"
|
||||
"light": "Light",
|
||||
"dark": "Night",
|
||||
"orbit": "Night-green",
|
||||
"orbitDark": "Night-orange",
|
||||
"yomirc": "yomirc",
|
||||
"yomircDark": "yomirc-Night"
|
||||
},
|
||||
"chanFlags": {
|
||||
"invite": {
|
||||
|
|
@ -912,5 +927,37 @@
|
|||
"errCode": "Kod geçersiz ya da süresi dolmuş.",
|
||||
"errWeak": "Şifre çok kısa (en az 8 karakter).",
|
||||
"errMatch": "İki şifre birbiriyle eşleşmiyor."
|
||||
},
|
||||
"consent": {
|
||||
"manageLabel": "İstatistik çerezleri",
|
||||
"manageOn": "Etkin — Google Analytics aktif",
|
||||
"manageOff": "Devre dışı — Google'a hiçbir şey gönderilmez"
|
||||
},
|
||||
"extbans": {
|
||||
"mute": "Sustur",
|
||||
"blockcolor": "Renkleri engelle",
|
||||
"noctcp": "CTCP engelle",
|
||||
"nonick": "Nick değişimi yok",
|
||||
"nonotice": "Notice yok",
|
||||
"stripcolor": "Renkleri kaldır",
|
||||
"nokick": "Kick yok",
|
||||
"opmoderated": "Op denetimli",
|
||||
"blockinvite": "Davetleri engelle",
|
||||
"redirect": "Yönlendir",
|
||||
"account": "Hesap",
|
||||
"unauthed": "Kimliksiz",
|
||||
"securitygroup": "Güvenlik grubu",
|
||||
"score": "İtibar puanı",
|
||||
"country": "Ülke",
|
||||
"server": "Sunucu",
|
||||
"class": "Bağlantı sınıfı",
|
||||
"realname": "Gerçek ad",
|
||||
"realmask": "Maske + gerçek ad",
|
||||
"fingerprint": "TLS parmak izi",
|
||||
"oper": "Oper hesabı",
|
||||
"opertype": "Oper türü",
|
||||
"channel": "Bir kanalda",
|
||||
"share": "Başka yerde yasaklı",
|
||||
"gateway": "WebIRC ağ geçidi"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,9 @@ export class IrcClient {
|
|||
}
|
||||
action(target: string, text: string): void {
|
||||
// Proper CTCP ACTION (\x01ACTION …\x01); reserve 9 bytes for the wrapper.
|
||||
for (const part of this.splitForLine('PRIVMSG', target, text, 9)) this.send(`PRIVMSG ${target} :ACTION ${part}`);
|
||||
// Without the \x01 delimiters other clients (mIRC, …) show a literal "ACTION"
|
||||
// in a normal line instead of rendering "* nick …".
|
||||
for (const part of this.splitForLine('PRIVMSG', target, text, 9)) this.send(`PRIVMSG ${target} :\x01ACTION ${part}\x01`);
|
||||
}
|
||||
setNick(nick: string): void { this.send(`NICK ${nick}`); }
|
||||
// User modes (global, per-user). Query with no modestring → RPL_UMODEIS (221).
|
||||
|
|
|
|||
67
src/core/seo.ts
Normal file
67
src/core/seo.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Config-driven document metadata, applied to <head> at boot once config.json is
|
||||
// resolved: description, Open Graph + Twitter cards (so a shared /app/ link unfurls
|
||||
// with the brand), a canonical link, and a WebApplication JSON-LD block.
|
||||
//
|
||||
// This is core (trusted), not a plugin: it writes <head>, which the plugin sandbox
|
||||
// deliberately can't reach. index.html ships static tags for JS-less unfurlers
|
||||
// (Slack/Discord/etc. don't run scripts); this refines them per deployment for
|
||||
// consumers that do render JS (search crawlers, in-app), and keeps the metadata
|
||||
// consistent when config re-brands the client.
|
||||
import { getConfig } from './config';
|
||||
|
||||
function meta(attr: 'name' | 'property', key: string, content: string): void {
|
||||
if (!content) return;
|
||||
let el = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`);
|
||||
if (!el) { el = document.createElement('meta'); el.setAttribute(attr, key); document.head.appendChild(el); }
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function linkRel(rel: string, href: string): void {
|
||||
if (!href) return;
|
||||
let el = document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!el) { el = document.createElement('link'); el.rel = rel; document.head.appendChild(el); }
|
||||
el.href = href;
|
||||
}
|
||||
|
||||
export function applySeo(): void {
|
||||
try {
|
||||
const { branding: b, seo = {} } = getConfig();
|
||||
const name = b.name || document.title;
|
||||
const desc = seo.description || b.subtitle
|
||||
|| document.head.querySelector<HTMLMetaElement>('meta[name="description"]')?.content || '';
|
||||
const image = seo.image || '';
|
||||
const url = seo.canonical || (b.url ? b.url.replace(/\/+$/, '') + location.pathname : location.href);
|
||||
|
||||
meta('name', 'description', desc);
|
||||
if (seo.keywords) meta('name', 'keywords', seo.keywords);
|
||||
linkRel('canonical', url);
|
||||
|
||||
meta('property', 'og:type', 'website');
|
||||
meta('property', 'og:site_name', name);
|
||||
meta('property', 'og:title', name);
|
||||
meta('property', 'og:description', desc);
|
||||
meta('property', 'og:url', url);
|
||||
if (image) meta('property', 'og:image', image);
|
||||
|
||||
meta('name', 'twitter:card', image ? 'summary_large_image' : 'summary');
|
||||
if (seo.twitterSite) meta('name', 'twitter:site', seo.twitterSite);
|
||||
meta('name', 'twitter:title', name);
|
||||
meta('name', 'twitter:description', desc);
|
||||
if (image) meta('name', 'twitter:image', image);
|
||||
|
||||
const ld: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name,
|
||||
applicationCategory: 'CommunicationApplication',
|
||||
operatingSystem: 'Any',
|
||||
url,
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
||||
};
|
||||
if (desc) ld.description = desc;
|
||||
if (image) ld.image = image;
|
||||
let s = document.getElementById('orbit-jsonld') as HTMLScriptElement | null;
|
||||
if (!s) { s = document.createElement('script'); s.id = 'orbit-jsonld'; s.type = 'application/ld+json'; document.head.appendChild(s); }
|
||||
s.textContent = JSON.stringify(ld);
|
||||
} catch { /* head stays as index.html shipped it */ }
|
||||
}
|
||||
|
|
@ -52,6 +52,8 @@ export interface ChatState {
|
|||
addFriend: (nick: string) => void;
|
||||
removeFriend: (nick: string) => void;
|
||||
banlists: Record<string, { mask: string; by: string; ts: number }[]>; // channel key → +b list
|
||||
exceptlists: Record<string, { mask: string; by: string; ts: number }[]>; // +e ban exceptions
|
||||
invexlists: Record<string, { mask: string; by: string; ts: number }[]>; // +I invite exceptions
|
||||
loadBanList: (channel: string) => void;
|
||||
setChannelMode: (channel: string, mode: string, add: boolean) => void;
|
||||
setChannelModeParam: (channel: string, mode: string, add: boolean, param?: string) => void;
|
||||
|
|
@ -162,6 +164,8 @@ export function createChatStore(ns = '') {
|
|||
friends: loadFriends(),
|
||||
friendsOnline: {},
|
||||
banlists: {},
|
||||
exceptlists: {},
|
||||
invexlists: {},
|
||||
notifyLevel: loadNotify(ns),
|
||||
highlightWords: loadStr(HIGHLIGHT_KEY),
|
||||
drafts: {},
|
||||
|
|
@ -246,7 +250,27 @@ export function createChatStore(ns = '') {
|
|||
set({ reconnectIn: secs as number });
|
||||
sysLine(SERVER, i18n.t('system.reconnecting', { secs }), 'system');
|
||||
});
|
||||
client.on('message', (m) => handle(m));
|
||||
// Coalesce inbound messages so a burst renders once, not once per line. The
|
||||
// server frames each IRC line as its own WebSocket message (text.ircv3.net),
|
||||
// so a join burst — NAMES plus a WHO reply per member, across every channel
|
||||
// joined at once — would otherwise fire one React render PER LINE. Buffering
|
||||
// to a timer lets React batch the burst into a single render; under load the
|
||||
// timer macrotasks back up, so each drain naturally swallows more lines.
|
||||
// A 0ms timer (not rAF) keeps processing unread counts + mention notifications
|
||||
// in a backgrounded tab, where rAF is paused. Protocol-critical lines
|
||||
// (PING/PONG, CAP, SASL) are answered in the client before it emits 'message',
|
||||
// so this never delays the handshake or keepalive.
|
||||
const inbox: Parameters<typeof handle>[0][] = [];
|
||||
let pending = 0;
|
||||
const drain = () => {
|
||||
pending = 0;
|
||||
// Splice so anything arriving mid-drain rides the next tick, in order.
|
||||
for (const m of inbox.splice(0, inbox.length)) handle(m);
|
||||
};
|
||||
client.on('message', (m) => {
|
||||
inbox.push(m);
|
||||
if (!pending) pending = setTimeout(drain, 0) as unknown as number;
|
||||
});
|
||||
client.connect(opts);
|
||||
},
|
||||
|
||||
|
|
@ -325,6 +349,9 @@ export function createChatStore(ns = '') {
|
|||
|
||||
setAway(reason) {
|
||||
get().client?.setAway(reason);
|
||||
// away-notify tells OTHER members we're away, never us — patch our own entry
|
||||
// in every channel so the member list reflects it immediately.
|
||||
helpers.patchMemberEverywhere(get().nick, { away: !!reason });
|
||||
set({ away: !!reason });
|
||||
},
|
||||
|
||||
|
|
@ -349,8 +376,17 @@ export function createChatStore(ns = '') {
|
|||
|
||||
loadBanList(channel) {
|
||||
const key = canon(channel);
|
||||
set({ banlists: { ...get().banlists, [key]: [] } }); // reset; 367 fills it
|
||||
get().client?.modeList(channel, 'b');
|
||||
const c = get().client;
|
||||
// Reset all three lists; the 367/348/346 replies refill them.
|
||||
set({
|
||||
banlists: { ...get().banlists, [key]: [] },
|
||||
exceptlists: { ...get().exceptlists, [key]: [] },
|
||||
invexlists: { ...get().invexlists, [key]: [] },
|
||||
});
|
||||
const typeA = (c?.server.isupport.CHANMODES || '').split(',')[0] || 'b';
|
||||
c?.modeList(channel, 'b');
|
||||
if (typeA.includes('e')) c?.modeList(channel, 'e'); // ban exceptions, if supported
|
||||
if (typeA.includes('I')) c?.modeList(channel, 'I'); // invite exceptions, if supported
|
||||
},
|
||||
setChannelModeParam(channel, mode, add, param) {
|
||||
if (isChannelName(channel)) get().client?.setChannelModeParam(channel, mode, add, param);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// change and Turnstile completion hit the same-origin Django endpoints (which keep
|
||||
// Anope + the website in sync). Split out of store.ts.
|
||||
import i18n from '../i18n';
|
||||
import { fetchTimeout } from '../../lib/net';
|
||||
import { fetchTimeout } from '@/lib/net';
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { ChatState } from '../store';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// plain-message path (with reply + channel-context tags and the optimistic echo).
|
||||
// Split out of store.ts; the store wires `sendInput: makeCommands(...).sendInput`.
|
||||
import i18n from '../i18n';
|
||||
import { getTheme } from '../../themes';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { getTheme } from '@/themes';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { isService, maskSecret, detectServiceLeak } from '../services';
|
||||
import { stripFormatting, tidyOutgoing } from './text';
|
||||
import { SERVER, newId, canon, isChannelName } from './context';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import i18n from '../i18n';
|
||||
import { desktopNotify, blip } from '../../platform/notify';
|
||||
import { desktopNotify, blip } from '@/platform/notify';
|
||||
import { makeWhois } from './whois';
|
||||
import { makeMembership } from './membership';
|
||||
import { makeBatch } from './batch';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// JOIN/PART/KICK, the buffer + active window). Split out of handler.ts; the
|
||||
// dispatcher calls handleMembership(msg, me) before its command switch.
|
||||
import i18n from '../i18n';
|
||||
import { desktopNotify, blip } from '../../platform/notify';
|
||||
import { desktopNotify, blip } from '@/platform/notify';
|
||||
import { hostmask } from './text';
|
||||
import { SERVER, canon, isChannelName, inQuietBatch } from './context';
|
||||
import type { IrcMessage } from '../irc/types';
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// collection, and mention/notify. Split out of handler.ts; the dispatcher calls
|
||||
// handleMessaging(msg, me) before its command switch.
|
||||
import i18n from '../i18n';
|
||||
import { desktopNotify, blip } from '../../platform/notify';
|
||||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { desktopNotify, blip } from '@/platform/notify';
|
||||
import { usePluginRegistry } from '@/modules/registry';
|
||||
import { getConfig } from '../config';
|
||||
import { isService, maskSecret, routeMessage, hasServiceTag } from '../services';
|
||||
import { SERVER, newId, isupport, canon, isChannelName, historyCollect, multilineCollect, inHistoryBatch, inMultilineBatch } from './context';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function setup(over: Record<string, unknown> = {}) {
|
|||
const state = {
|
||||
client: { numerics: new Numerics() },
|
||||
active: '#x', account: '', channels: [], listLoading: false, away: false,
|
||||
buffers: {}, banlists: {}, friends: [], friendsOnline: {},
|
||||
buffers: {}, banlists: {}, exceptlists: {}, invexlists: {}, friends: [], friendsOnline: {},
|
||||
prefs: { sound: false }, whois: {},
|
||||
...over,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import i18n from '../i18n';
|
||||
import { desktopNotify, blip } from '../../platform/notify';
|
||||
import { desktopNotify, blip } from '@/platform/notify';
|
||||
import type { IrcMessage } from '../irc/types';
|
||||
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
|
||||
import { SERVER, canon, isChannelName } from './context';
|
||||
|
|
@ -152,13 +152,21 @@ export function makeNumerics({ get, set, helpers, closedChannels, lastCantSend,
|
|||
}
|
||||
return true;
|
||||
}
|
||||
case '348': // RPL_EXCEPTLIST
|
||||
case '346': { // RPL_INVEXLIST
|
||||
case '348': // RPL_EXCEPTLIST (+e)
|
||||
case '346': { // RPL_INVEXLIST (+I) — collect into state for the channel panel
|
||||
const ch = msg.params[1];
|
||||
const mask = msg.params[2];
|
||||
const tag = msg.command === '348' ? 'Exception' : 'Invitation';
|
||||
const who = msg.params[3] ? ` — par ${msg.params[3].split('!')[0]}` : '';
|
||||
if (isChannelName(ch) && mask) sysLine(ch, `📋 ${tag} : ${mask}${who}`, 'info');
|
||||
if (isChannelName(ch) && mask) {
|
||||
const key = canon(ch);
|
||||
const entry = { mask, by: (msg.params[3] || '').split('!')[0], ts: Number(msg.params[4]) * 1000 || 0 };
|
||||
if (msg.command === '348') {
|
||||
const cur = get().exceptlists[key] || [];
|
||||
if (cur.length < 5000) set({ exceptlists: { ...get().exceptlists, [key]: [...cur, entry] } });
|
||||
} else {
|
||||
const cur = get().invexlists[key] || [];
|
||||
if (cur.length < 5000) set({ invexlists: { ...get().invexlists, [key]: [...cur, entry] } });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case '368': // RPL_ENDOFBANLIST
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// request / POST / error mapping factored into helpers (they were duplicated
|
||||
// verbatim between the two).
|
||||
import i18n from '../i18n';
|
||||
import { fetchTimeout } from '../../lib/net';
|
||||
import { fetchTimeout } from '@/lib/net';
|
||||
import { SERVER, newId } from './context';
|
||||
import type { IrcClient } from '../irc/client';
|
||||
import type { StoreApi } from 'zustand';
|
||||
|
|
@ -45,10 +45,12 @@ export function makeUpload({ get, filehost, helpers }: UploadDeps) {
|
|||
}
|
||||
|
||||
// Share a caption as a CTCP ACTION (+ optimistic echo if the server won't echo).
|
||||
// Bold + italic (reset with \x0F) so the share stands out in the log everywhere.
|
||||
function share(client: IrcClient, active: string, caption: string): void {
|
||||
client.action(active, caption);
|
||||
const styled = `\x02\x1D${caption}\x0F`;
|
||||
client.action(active, styled);
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
|
||||
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: styled, ts: Date.now(), kind: 'action', self: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// `handleWhois()` before its main switch and uses `clearWhois()` for the 401
|
||||
// (no-such-nick) fallback. RPL_AWAY (301) intentionally stays in the dispatcher —
|
||||
// it also drives the "user is away" query notice.
|
||||
import { fmtDuration, formatUserModes } from '../../lib/format-text';
|
||||
import { fmtDuration, formatUserModes } from '@/lib/format-text';
|
||||
import type { IrcMessage, WhoisInfo } from '../irc/types';
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { ChatState } from '../store';
|
||||
|
|
|
|||
123
src/core/webmcp.ts
Normal file
123
src/core/webmcp.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// WebMCP (developer.chrome.com/docs/ai/webmcp): expose the chat as structured tools
|
||||
// for an AI agent when the browser supports it. Progressive enhancement — a no-op
|
||||
// where document.modelContext is absent. Gated by features.webmcp.
|
||||
import { activeStore } from './networks';
|
||||
import { getConfig } from './config';
|
||||
import { SERVER } from './store';
|
||||
import { stripFormatting } from './store/text';
|
||||
|
||||
interface ModelContext {
|
||||
registerTool(tool: {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: unknown;
|
||||
execute: (args: Record<string, unknown>) => Promise<string> | string;
|
||||
annotations?: Record<string, unknown>;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export function initWebMcp(): void {
|
||||
const mc = (document as unknown as { modelContext?: ModelContext }).modelContext;
|
||||
if (!mc?.registerTool || !getConfig().features.webmcp) return;
|
||||
|
||||
const state = () => activeStore().getState();
|
||||
const chan = (c: string) => (/^[#&]/.test(c) ? c : `#${c}`);
|
||||
|
||||
const tools: Parameters<ModelContext['registerTool']>[0][] = [
|
||||
{
|
||||
name: 'list_conversations',
|
||||
description: 'List the open IRC channels and direct messages, with which one is active and each unread count.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: { readOnlyHint: true },
|
||||
execute: () => {
|
||||
const s = state();
|
||||
return JSON.stringify(Object.values(s.buffers).map((b) => ({
|
||||
name: b.name,
|
||||
kind: b.name === SERVER ? 'console' : b.isChannel ? 'channel' : 'dm',
|
||||
active: b.name === s.active,
|
||||
unread: b.unread,
|
||||
})));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'read_messages',
|
||||
description: 'Read the most recent messages from a conversation. Omit "conversation" to read the active one.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversation: { type: 'string', description: 'channel (#name) or nick; omit for the active conversation' },
|
||||
limit: { type: 'number', description: 'how many recent messages (default 30, max 100)' },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
execute: (a) => {
|
||||
const s = state();
|
||||
const b = s.buffers[(a.conversation as string) || s.active];
|
||||
if (!b) return 'No such conversation.';
|
||||
const n = Math.min(Number(a.limit) || 30, 100);
|
||||
const out = b.messages.slice(-n).map((m) => {
|
||||
const t = new Date(m.ts).toISOString().slice(11, 16);
|
||||
const text = stripFormatting(m.text);
|
||||
return m.from ? `[${t}] <${m.from}> ${text}` : `[${t}] * ${text}`;
|
||||
});
|
||||
return out.join('\n') || '(no messages yet)';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send a chat message to a channel or user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversation: { type: 'string', description: 'channel (#name) or nick; omit for the active conversation' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
execute: (a) => {
|
||||
const s = state();
|
||||
const target = (a.conversation as string) || s.active;
|
||||
if (!target || target === SERVER) return 'No channel or user to send to.';
|
||||
s.client?.privmsg(target, String(a.text ?? ''));
|
||||
return `Sent to ${target}.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'join_channel',
|
||||
description: 'Join an IRC channel and make it active.',
|
||||
inputSchema: { type: 'object', properties: { channel: { type: 'string' } }, required: ['channel'] },
|
||||
execute: (a) => {
|
||||
const c = chan(String(a.channel));
|
||||
const s = state();
|
||||
s.client?.join(c);
|
||||
s.setActive(c);
|
||||
return `Joined ${c}.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'switch_conversation',
|
||||
description: 'Make an already-open channel or DM the active conversation.',
|
||||
inputSchema: { type: 'object', properties: { conversation: { type: 'string' } }, required: ['conversation'] },
|
||||
execute: (a) => {
|
||||
const name = String(a.conversation);
|
||||
if (!state().buffers[name]) return 'No such conversation.';
|
||||
state().setActive(name);
|
||||
return `Now on ${name}.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_members',
|
||||
description: 'List the members of a channel (defaults to the active one).',
|
||||
inputSchema: { type: 'object', properties: { channel: { type: 'string' } } },
|
||||
annotations: { readOnlyHint: true },
|
||||
execute: (a) => {
|
||||
const s = state();
|
||||
const b = s.buffers[(a.channel as string) || s.active];
|
||||
if (!b?.isChannel) return 'Not a channel.';
|
||||
return Object.values(b.members).map((m) => (m.prefix || '') + m.nick).join(', ') || '(no members)';
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tools) { try { void mc.registerTool(t); } catch { /* ignore */ } }
|
||||
}
|
||||
253
src/index.css
253
src/index.css
|
|
@ -49,12 +49,30 @@ body {
|
|||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
-webkit-text-size-adjust: 100%; /* iOS: don't auto-inflate text in landscape */
|
||||
-webkit-tap-highlight-color: transparent; /* no grey flash on tap — native feel */
|
||||
overscroll-behavior: none; /* no pull-to-refresh / bounce past the app */
|
||||
}
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
button { font-family: inherit; cursor: pointer; -webkit-user-select: none; user-select: none; }
|
||||
input, textarea { font-family: inherit; }
|
||||
::selection { background: rgba(42,107,255,.22); }
|
||||
.aurora { display: none; } /* no decorative aurora in the Element look */
|
||||
|
||||
/* Native tactile press — touch devices only. Controls dip + dim the instant you
|
||||
touch them, like native buttons. The transition lives on :active so it never
|
||||
overrides a control's own base transition; release snaps back. */
|
||||
@media (hover: none) {
|
||||
button, a.btn, [role="button"] {
|
||||
touch-action: manipulation; /* no 300ms tap delay / double-tap zoom on controls */
|
||||
-webkit-touch-callout: none; /* no long-press "copy/look up" callout on chrome */
|
||||
}
|
||||
button:active:not(:disabled), a.btn:active, [role="button"]:active {
|
||||
transform: scale(.96);
|
||||
opacity: .82;
|
||||
transition: transform .08s ease, opacity .08s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ CONNECT SCREEN ============ */
|
||||
/* ============ "Already on air" — an open canvas + a real live transcript,
|
||||
not a boxed auth card next to a boxed chat-bubble mockup. The signature is
|
||||
|
|
@ -281,7 +299,12 @@ input, textarea { font-family: inherit; }
|
|||
.app > .main { flex: 1; min-height: 0; }
|
||||
/* no bar over the conversation on mobile — it docks inside the drawer instead */
|
||||
.app > .appbar { display: none; }
|
||||
.sidebar .appbar { display: flex; padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Docked footer: give the nav its own full-width row so the tabs aren't crushed. */
|
||||
.sidebar .appbar { display: flex; flex-wrap: wrap; height: auto; align-items: center; row-gap: .3rem;
|
||||
padding: .45rem .4rem calc(.45rem + env(safe-area-inset-bottom, 0)); }
|
||||
.sidebar .appbar__me { order: 1; }
|
||||
.sidebar .appbar__actions { order: 2; margin-left: auto; }
|
||||
.sidebar .appbar__nav { order: 3; flex: 1 1 100%; }
|
||||
.appbar__meta { display: none; }
|
||||
.appbar__me { padding: .3rem; }
|
||||
.tab { max-width: none; }
|
||||
|
|
@ -293,7 +316,7 @@ input, textarea { font-family: inherit; }
|
|||
.app.nav-open .sidebar { transform: none; }
|
||||
.nav-toggle {
|
||||
display: inline-grid; place-items: center; width: 36px; height: 36px; flex: none; margin-right: .15rem;
|
||||
border: 1px solid var(--border); border-radius: 9px; background: var(--bg-soft); color: var(--ink-2); font-size: 1.1rem;
|
||||
border: 1px solid var(--border); border-radius: 9px; background: var(--bg-soft); color: var(--ink-2);
|
||||
}
|
||||
.nav-backdrop { position: fixed; inset: 0; z-index: 55; background: rgba(20,30,45,.4); }
|
||||
}
|
||||
|
|
@ -331,8 +354,13 @@ input, textarea { font-family: inherit; }
|
|||
border-radius: 12px; transition: color .12s, background .12s;
|
||||
}
|
||||
.tab:hover { color: var(--ink); background: var(--bg); }
|
||||
.tab.is-active { color: var(--accent); }
|
||||
.tab__ic { position: relative; font-size: 1.2rem; line-height: 1; display: inline-flex; align-items: center; }
|
||||
.tab:active, .appbar__act:active, .appbar__me:active { transform: scale(.93); transition: transform .08s ease; }
|
||||
.tab.is-active { color: var(--accent); background: var(--accent-soft); }
|
||||
.tab:focus-visible, .appbar__act:focus-visible, .appbar__me:focus-visible {
|
||||
outline: none; box-shadow: 0 0 0 2px var(--bg-soft, var(--bg)), 0 0 0 4px var(--accent-ring, rgba(20, 82, 204, .45));
|
||||
}
|
||||
.tab__ic { position: relative; line-height: 1; display: inline-flex; align-items: center; }
|
||||
.tab__ic svg, .appbar__act svg { display: block; }
|
||||
.tab__lb { font-size: .68rem; font-weight: 600; letter-spacing: .01em; }
|
||||
.tab__badge {
|
||||
position: absolute; top: -5px; right: -9px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
|
||||
|
|
@ -342,7 +370,7 @@ input, textarea { font-family: inherit; }
|
|||
.appbar__actions { display: flex; align-items: center; gap: .1rem; flex: 0 0 auto; }
|
||||
.appbar__act {
|
||||
width: 40px; height: 40px; display: grid; place-items: center; border: 0; background: transparent;
|
||||
border-radius: 11px; font-size: 1.1rem; color: var(--muted); cursor: pointer; transition: background .12s, color .12s;
|
||||
border-radius: 11px; color: var(--muted); cursor: pointer; transition: background .12s, color .12s;
|
||||
}
|
||||
.appbar__act:hover { background: var(--bg); color: var(--ink); }
|
||||
/* host mount point for a sandboxed plugin's iframe (sized to content by the host) */
|
||||
|
|
@ -351,11 +379,28 @@ input, textarea { font-family: inherit; }
|
|||
bottom-pinned so the player card grows upward, not off-screen. */
|
||||
.appbar__nav .sbx-slot { flex: 1 1 0; min-width: 0; max-width: 120px; align-self: stretch; position: relative; z-index: 30; }
|
||||
.appbar__nav .sbx-slot > iframe { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); }
|
||||
/* away toggle: dim = present (click to step away), amber-lit = away */
|
||||
.appbar__act--away { filter: grayscale(1); opacity: .55; }
|
||||
.appbar__act--away:hover { filter: grayscale(.5); opacity: .85; }
|
||||
.appbar__act--away.is-on { filter: none; opacity: 1; background: rgba(232,161,58,.16); box-shadow: inset 0 0 0 1px rgba(232,161,58,.4); }
|
||||
.appbar__act--away.is-on:hover { background: rgba(232,161,58,.24); }
|
||||
/* presence menu opened from the account avatar (Available / Away + reason) */
|
||||
.statusmenu { position: fixed; z-index: 1400; min-width: 224px; padding: .35rem;
|
||||
background: var(--bg-soft); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow-pop);
|
||||
display: flex; flex-direction: column; gap: .1rem; }
|
||||
.statusmenu__opt { display: flex; align-items: center; gap: .55rem; width: 100%; text-align: left;
|
||||
background: none; border: 0; border-radius: 8px; padding: .5rem .6rem; cursor: pointer; font: inherit; font-size: .88rem; color: var(--ink); }
|
||||
.statusmenu__opt:hover { background: var(--bg-active); }
|
||||
.statusmenu__opt.is-on { background: var(--accent-soft); }
|
||||
.statusmenu__lbl { flex: 1; }
|
||||
.statusmenu__dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
||||
.statusmenu__dot--on { background: var(--online, #2ec27e); }
|
||||
.statusmenu__dot--away { background: #e8a13a; }
|
||||
.statusmenu__check { color: var(--accent); font-weight: 700; }
|
||||
.statusmenu__opt--link { color: var(--muted); font-size: .82rem; }
|
||||
.statusmenu__sep { height: 1px; background: var(--border); margin: .25rem .3rem; }
|
||||
.statusmenu__reason { display: flex; gap: .35rem; padding: .15rem .3rem .35rem; }
|
||||
.statusmenu__input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: .4rem .55rem; font: inherit; font-size: .85rem; color: var(--ink); }
|
||||
.statusmenu__input:focus { outline: none; border-color: var(--accent); }
|
||||
.statusmenu__set { flex: none; background: var(--accent-soft); color: var(--accent); border: 0; border-radius: 8px;
|
||||
padding: .4rem .7rem; font: inherit; font-size: .82rem; font-weight: 700; cursor: pointer; }
|
||||
.statusmenu__set:hover { filter: brightness(1.08); }
|
||||
/* desktop only: the bar spans the window bottom; the in-drawer copy is hidden.
|
||||
Scoped to min-width so it never overrides the mobile rule that shows it. */
|
||||
@media (min-width: 881px) { .sidebar .appbar { display: none; } }
|
||||
|
|
@ -368,7 +413,7 @@ input, textarea { font-family: inherit; }
|
|||
.side-compose:hover { background: var(--accent-soft); color: var(--accent-d); border-color: rgba(20,82,204,.35); }
|
||||
|
||||
.side-search { position: relative; margin: .1rem .8rem .55rem; }
|
||||
.side-search__icon { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); font-size: .85rem; opacity: .55; pointer-events: none; }
|
||||
.side-search__icon { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); display: inline-flex; opacity: .55; pointer-events: none; }
|
||||
.side-search input {
|
||||
width: 100%; padding: .58rem .8rem .58rem 2.1rem; font-size: .92rem; color: var(--ink);
|
||||
background: var(--bg-soft); border: 1px solid var(--border); border-radius: 999px; outline: none; transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
|
|
@ -398,6 +443,7 @@ input, textarea { font-family: inherit; }
|
|||
.room {
|
||||
position: relative; display: flex; align-items: center; gap: .55rem; width: 100%; text-align: left;
|
||||
padding: .42rem .5rem; margin: 1px 0; border: 0; background: none; border-radius: 10px; font: inherit;
|
||||
cursor: pointer; -webkit-user-select: none; user-select: none; /* it's a div[role=button] now */
|
||||
transition: background .13s;
|
||||
}
|
||||
.room:hover { background: var(--bg-soft); }
|
||||
|
|
@ -420,7 +466,7 @@ input, textarea { font-family: inherit; }
|
|||
}
|
||||
/* close / leave button on a room row — appears on hover, replaces the unread badge */
|
||||
.room__close {
|
||||
flex: none; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px;
|
||||
flex: none; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 6px; border: 0; background: none;
|
||||
color: var(--faint); font-size: .78rem; opacity: 0; cursor: pointer; transition: opacity .12s, background .12s, color .12s;
|
||||
}
|
||||
.room:hover .room__close, .room.is-active .room__close { opacity: 1; }
|
||||
|
|
@ -463,12 +509,17 @@ input, textarea { font-family: inherit; }
|
|||
|
||||
|
||||
/* ============ MESSAGE LIST ============ */
|
||||
.messages { position: relative; flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 1rem 0 .6rem; }
|
||||
.messages { position: relative; flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 1rem 0 .6rem; }
|
||||
.daysep { display: flex; align-items: center; gap: 1rem; margin: 1.1rem 1.3rem .5rem; }
|
||||
.daysep::before, .daysep::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||
.daysep span { font-size: .74rem; font-weight: 700; color: var(--faint); padding: .2rem .8rem; border: 1px solid var(--border); border-radius: 999px; background: var(--bg); text-transform: capitalize; }
|
||||
|
||||
.group { display: flex; gap: .8rem; padding: .18rem 1.3rem; position: relative; }
|
||||
/* content-visibility: the browser skips layout/paint of off-screen message rows
|
||||
(big win on channel switch with a long history). contain-intrinsic-size `auto`
|
||||
remembers each row's real height once rendered, so the scrollbar stays honest;
|
||||
off-screen height changes are re-pinned by the ResizeObserver before paint. */
|
||||
.group { display: flex; gap: .8rem; padding: .18rem 1.3rem; position: relative;
|
||||
content-visibility: auto; contain-intrinsic-size: auto 44px; }
|
||||
.group:hover { background: var(--bg-soft); }
|
||||
.group__avatar { width: 40px; height: 40px; flex: none; }
|
||||
.group__time-rail { display: grid; place-items: center; font-size: .66rem; color: var(--faint); opacity: 0; }
|
||||
|
|
@ -633,15 +684,25 @@ html[data-actions="off"] .msg-actions { display: none; }
|
|||
|
||||
/* ---- in-buffer search ---- */
|
||||
.topbar--search { gap: .5rem; }
|
||||
.topbar__search { flex: none; width: 34px; height: 34px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 1rem; cursor: pointer; transition: background .13s; }
|
||||
.topbar__search { flex: none; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--muted); cursor: pointer; transition: background .13s; }
|
||||
.topbar__search:hover { background: var(--bg-soft-2); color: var(--ink); }
|
||||
/* Leave/close the current window (parts a channel, closes a DM) — far-right of the header. */
|
||||
.topbar__leave { flex: none; width: 34px; height: 34px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 1rem; cursor: pointer; transition: background .13s, color .13s; }
|
||||
.topbar__leave { flex: none; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--muted); cursor: pointer; transition: background .13s, color .13s; }
|
||||
.topbar__leave:hover { background: var(--danger-soft, rgba(224,49,80,.14)); color: var(--danger); }
|
||||
.topbar__searchicon { font-size: 1rem; opacity: .7; }
|
||||
.topbar .topbar__more { display: none; } /* overflow "⋮" is mobile-only (shown below the 880px breakpoint) */
|
||||
.topbar__plugins { display: inline-flex; align-items: center; flex: none; }
|
||||
.topbar__searchicon { display: inline-flex; opacity: .7; }
|
||||
.topbar__searchinput { flex: 1; min-width: 0; border: 0; background: transparent; font: inherit; font-size: .95rem; color: var(--ink); outline: none; }
|
||||
.topbar__searchclose { flex: none; border: 0; background: none; color: var(--muted); cursor: pointer; font-size: .95rem; padding: .3rem .5rem; border-radius: 8px; }
|
||||
.topbar__searchclose { flex: none; display: inline-flex; align-items: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: .3rem .5rem; border-radius: 8px; }
|
||||
.topbar__searchclose:hover { background: var(--bg-soft-2); color: var(--ink); }
|
||||
/* Themed toolbar icons: render the stroke SVGs crisply, and give the buttons the
|
||||
same tactile press + focus ring as the footer tabs. */
|
||||
.topbar__search svg, .topbar__leave svg, .topbar__searchclose svg, .topbar__searchicon svg,
|
||||
.nav-toggle svg, .side-search__icon svg, .explore-search__icon svg, .qswitch__ic svg, .nmenu__ic svg { display: block; }
|
||||
.topbar__search:active, .topbar__leave:active, .nav-toggle:active { transform: scale(.92); transition: transform .08s ease; }
|
||||
.topbar__search:focus-visible, .topbar__leave:focus-visible, .nav-toggle:focus-visible {
|
||||
outline: none; box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring, rgba(20, 82, 204, .45));
|
||||
}
|
||||
.messages--search { padding: .6rem 1rem; }
|
||||
.search-count { font-size: .82rem; font-weight: 700; color: var(--muted); padding: .3rem .2rem .6rem; }
|
||||
.search-hit { padding: .5rem .6rem; border-radius: 10px; margin-bottom: .25rem; transition: background .12s; }
|
||||
|
|
@ -770,9 +831,9 @@ html[data-actions="off"] .msg-actions { display: none; }
|
|||
|
||||
|
||||
|
||||
/* away members dimmed + amber presence dot */
|
||||
.member.is-away { opacity: .5; }
|
||||
.member.is-away .avatar { filter: grayscale(.55); }
|
||||
/* away members: amber/orange name, dimmed grayscale avatar with an amber presence dot */
|
||||
.member.is-away .member__name { color: #e0a93a; }
|
||||
.member.is-away .avatar { filter: grayscale(.55); opacity: .7; }
|
||||
.member.is-away .avatar::after { background: #e0a93a; }
|
||||
|
||||
/* bot tag */
|
||||
|
|
@ -1261,7 +1322,7 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
.explore { display: flex; flex-direction: column; gap: .55rem; }
|
||||
.explore-bar { display: flex; gap: .5rem; align-items: center; }
|
||||
.explore-search { position: relative; flex: 1; display: flex; align-items: center; min-width: 0; }
|
||||
.explore-search__icon { position: absolute; left: .7rem; font-size: .85rem; opacity: .55; pointer-events: none; }
|
||||
.explore-search__icon { position: absolute; left: .7rem; display: inline-flex; opacity: .55; pointer-events: none; }
|
||||
.explore-search input { width: 100%; padding: .62rem .8rem .62rem 2.1rem; border-radius: 11px; border: 1px solid var(--border); background: var(--bg-soft); color: var(--ink); font: inherit; outline: none; transition: border-color .12s, box-shadow .12s; }
|
||||
.explore-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(20,82,204,.14); }
|
||||
.explore-search__clear { position: absolute; right: .55rem; border: 0; background: none; color: var(--muted); cursor: pointer; font-size: .82rem; padding: .2rem; }
|
||||
|
|
@ -1348,10 +1409,13 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
@keyframes pm-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||
|
||||
|
||||
/* ============ CONSOLE = REAL TERMINAL (theme-independent dark) ============ */
|
||||
/* ============ CONSOLE = TERMINAL SKIN THAT FOLLOWS THE THEME ============ */
|
||||
/* --term-* inherit the active theme's surfaces, text AND accent, so the status
|
||||
window fully matches the theme — green on Night-green, orange on Night-orange,
|
||||
blue on Light/Night, etc. No hardcoded terminal green. */
|
||||
:root {
|
||||
--term-bg: #0b1016; --term-bg-2: #0e1722; --term-fg: #c6d3c9; --term-dim: #6f8076;
|
||||
--term-green: #56e08a; --term-cyan: #5fd3e6; --term-border: rgba(180, 220, 200, .12);
|
||||
--term-bg: var(--rail-bg); --term-bg-2: var(--bg-soft); --term-fg: var(--ink); --term-dim: var(--muted);
|
||||
--term-accent: var(--accent); --term-accent-2: var(--accent-d, var(--accent)); --term-border: var(--border);
|
||||
}
|
||||
/* terminal title bar */
|
||||
.topbar--console {
|
||||
|
|
@ -1370,7 +1434,7 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
/* terminal output pane */
|
||||
.messages--console {
|
||||
background:
|
||||
radial-gradient(120% 60% at 50% 0%, rgba(86,224,138,.06), transparent 60%),
|
||||
radial-gradient(120% 60% at 50% 0%, var(--accent-soft), transparent 60%),
|
||||
var(--term-bg);
|
||||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
padding: .7rem 0 .6rem;
|
||||
|
|
@ -1380,19 +1444,19 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
.messages--console .sysline::before { display: none; } /* drop the bullet — pure terminal */
|
||||
.messages--console .sysline .who { color: var(--term-cyan); font-weight: 700; }
|
||||
.messages--console .sysline--cmd { color: var(--term-green); font-weight: 700; } /* your echoed commands */
|
||||
.messages--console .sysline--cmd::first-letter { color: var(--term-green); }
|
||||
.messages--console .infoline { background: rgba(95,211,230,.08); border-color: rgba(95,211,230,.28); box-shadow: inset 3px 0 0 var(--term-cyan); }
|
||||
.messages--console .infoline__tag { background: #14808f; color: #d9f7fc; }
|
||||
.messages--console .sysline .who { color: var(--term-accent-2); font-weight: 700; }
|
||||
.messages--console .sysline--cmd { color: var(--term-accent); font-weight: 700; } /* your echoed commands */
|
||||
.messages--console .sysline--cmd::first-letter { color: var(--term-accent); }
|
||||
.messages--console .infoline { background: var(--accent-soft); border-color: transparent; box-shadow: inset 3px 0 0 var(--term-accent-2); }
|
||||
.messages--console .infoline__tag { background: var(--accent-soft); color: var(--term-accent-2); }
|
||||
.messages--console .infoline__txt { color: var(--term-fg); }
|
||||
.messages--console .daysep span { background: var(--term-bg-2); color: var(--term-dim); border-color: var(--term-border); }
|
||||
.messages--console .daysep::before, .messages--console .daysep::after { background: var(--term-border); }
|
||||
.messages--console .unread-divider span { color: var(--term-green); }
|
||||
.messages--console .unread-divider::before { background: linear-gradient(90deg, transparent, var(--term-green)); }
|
||||
.messages--console .unread-divider::after { background: linear-gradient(90deg, var(--term-green), transparent); }
|
||||
.messages--console::-webkit-scrollbar-thumb { background: #243; background-clip: padding-box; }
|
||||
.messages--console::-webkit-scrollbar-thumb:hover { background: #2f5a40; background-clip: padding-box; }
|
||||
.messages--console .unread-divider span { color: var(--term-accent); }
|
||||
.messages--console .unread-divider::before { background: linear-gradient(90deg, transparent, var(--term-accent)); }
|
||||
.messages--console .unread-divider::after { background: linear-gradient(90deg, var(--term-accent), transparent); }
|
||||
.messages--console::-webkit-scrollbar-thumb { background: var(--border-2, var(--border)); background-clip: padding-box; }
|
||||
.messages--console::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
|
||||
|
||||
/* terminal input line */
|
||||
.composer--console { background: var(--term-bg); border-top: 1px solid var(--term-border); }
|
||||
|
|
@ -1400,15 +1464,15 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
background: var(--term-bg-2); border-color: var(--term-border);
|
||||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
.composer--console .composer__box:focus-within { border-color: var(--term-green); box-shadow: 0 0 0 3px rgba(86,224,138,.16); }
|
||||
.composer--console .composer__box:focus-within { border-color: var(--term-accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.composer--console .composer__add { display: none; } /* no attach button in the terminal */
|
||||
.composer--console .composer__box::before {
|
||||
content: "❯"; align-self: center; color: var(--term-green); font-weight: 800; font-size: 1.05rem; padding: 0 .1rem 0 .55rem;
|
||||
content: "❯"; align-self: center; color: var(--term-accent); font-weight: 800; font-size: 1.05rem; padding: 0 .1rem 0 .55rem;
|
||||
}
|
||||
.composer--console textarea { color: #d6efdd; caret-color: var(--term-green); font-size: .92rem; }
|
||||
.composer--console textarea { color: var(--term-fg); caret-color: var(--term-accent); font-size: .92rem; }
|
||||
.composer--console textarea::placeholder { color: var(--term-dim); }
|
||||
.composer--console .composer__send { background: #1c7a44; box-shadow: none; }
|
||||
.composer--console .composer__send:hover:not(:disabled) { background: var(--term-green); color: #06210f; }
|
||||
.composer--console .composer__send { background: var(--accent); box-shadow: none; }
|
||||
.composer--console .composer__send:hover:not(:disabled) { background: var(--term-accent-2); }
|
||||
|
||||
/* "You were kicked" box — centred on screen */
|
||||
.kicktoast {
|
||||
|
|
@ -1493,17 +1557,11 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
/* Two-column channel panel: settings on the left, bans on the right. Widen the
|
||||
shared modal shell only when this layout is present. */
|
||||
.modal:has(.ca-layout) { width: min(800px, 96vw); max-height: 92vh; overflow-y: auto; }
|
||||
.ca-layout { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 1.4rem; }
|
||||
/* Single column: bans + extbans are their own tabs now, so the panes get the full
|
||||
modal width (mode flags flow into more columns; masks list wide and readable). */
|
||||
.ca-layout { display: block; }
|
||||
.ca-main { min-width: 0; }
|
||||
/* Topic spans the full modal width so a long nick!user@host mask (e.g. an IPv6
|
||||
host) fits on one line without being truncated. */
|
||||
.ca-topicrow { grid-column: 1 / -1; margin-top: 0; min-width: 0; }
|
||||
.ca-banscol { min-width: 0; border-left: 1px solid var(--border-2); padding-left: 1.4rem; display: flex; flex-direction: column; }
|
||||
.ca-banscol .ca-h { margin-top: 0; }
|
||||
@media (max-width: 680px) {
|
||||
.ca-layout { grid-template-columns: 1fr; gap: .2rem; }
|
||||
.ca-banscol { border-left: 0; padding-left: 0; border-top: 1px solid var(--border-2); padding-top: 1rem; margin-top: 1rem; }
|
||||
}
|
||||
.ca-topicrow { margin: 0 0 1rem; min-width: 0; }
|
||||
.ca-tabs { display: flex; gap: .1rem; margin: -.2rem 0 1rem; border-bottom: 1px solid var(--border-2); }
|
||||
.ca-tab { appearance: none; border: 0; background: none; padding: .5rem .85rem; font: inherit; font-size: .86rem; font-weight: 600; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; display: inline-flex; align-items: center; gap: .4rem; transition: color .15s, border-color .15s; }
|
||||
.ca-tab:hover { color: var(--ink); }
|
||||
|
|
@ -1543,8 +1601,7 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
.ca-param .modal__input { flex: 1; min-width: 0; }
|
||||
.ca-param .upbtn { flex: none; }
|
||||
/* Mode flags — card grid with on-state highlight + mode-letter chip */
|
||||
.ca-flags { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .3rem; }
|
||||
@media (max-width: 680px) { .ca-flags { grid-template-columns: 1fr; } }
|
||||
.ca-flags { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: .3rem; }
|
||||
/* Compact single-line rows: label always visible, description trails and truncates
|
||||
(full text on hover via title) so all modes fit the fixed pane height. */
|
||||
.ca-flag { display: flex; align-items: center; gap: .5rem; padding: .3rem .55rem; border: 1px solid var(--border-2); border-radius: 9px; background: var(--bg-soft); cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
|
|
@ -1556,11 +1613,58 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
.ca-flag__m { flex: none; min-width: 2rem; text-align: center; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .74rem; font-weight: 700; padding: .1rem .3rem; border-radius: 5px; background: var(--bg-2, rgba(127,127,127,.16)); color: var(--muted); }
|
||||
.ca-flag.is-on .ca-flag__m { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
|
||||
.ca-flag__label { flex: 1; min-width: 0; font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ca-bans { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; max-height: 30vh; overflow-y: auto; }
|
||||
.ca-banscol .ca-bans { flex: 1 1 auto; max-height: 42vh; }
|
||||
/* The composer stays put; the list scrolls inside the fixed-height pane. */
|
||||
.ca-bans { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; max-height: 14.5rem; overflow-y: auto; }
|
||||
.ca-ban { display: flex; align-items: center; gap: .6rem; padding: .35rem .5rem; border-radius: 9px; background: var(--bg-soft); }
|
||||
.ca-ban__mask { font-family: ui-monospace, Menlo, monospace; font-size: .82rem; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.ca-ban__by { font-size: .76rem; color: var(--muted); margin-left: auto; }
|
||||
.ca-ban__type { flex: none; font-size: .6rem; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||
color: var(--accent-d, var(--accent)); background: var(--accent-soft); border-radius: 5px; padding: .1rem .32rem; }
|
||||
.ca-ban__mode { flex: none; font-family: ui-monospace, Menlo, monospace; font-size: .68rem; font-weight: 700; border-radius: 5px; padding: .05rem .3rem; }
|
||||
.ca-ban__mode--b { color: #d6465f; background: rgba(214,70,95,.13); }
|
||||
.ca-ban__mode--e { color: #2ea043; background: rgba(46,160,67,.13); }
|
||||
.ca-ban__mode--I { color: #3b82f6; background: rgba(59,130,246,.14); }
|
||||
/* Extbans tab: a compact combobox (button + overlay menu) beside the value input, so
|
||||
picking a type and typing the mask stay on one row — no scrolling. */
|
||||
.ca-extban-add { margin-bottom: .9rem; align-items: stretch; }
|
||||
.ca-extsel { position: relative; flex: 0 0 auto; }
|
||||
.ca-extsel__btn { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem;
|
||||
min-width: 11rem; height: 100%; padding: .5rem .7rem; border: 1px solid var(--border); border-radius: 9px;
|
||||
background: var(--bg-soft); color: var(--ink); font: inherit; font-size: .85rem; cursor: pointer; white-space: nowrap; }
|
||||
.ca-extsel__btn:hover { border-color: var(--accent); }
|
||||
.ca-extsel__chev { color: var(--muted); font-size: .7rem; }
|
||||
.ca-extsel__menu { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60; width: 15rem; max-height: 17rem;
|
||||
overflow-y: auto; padding: .35rem; border: 1px solid var(--border); border-radius: 10px; background: var(--bg);
|
||||
box-shadow: var(--shadow-pop, 0 12px 34px -10px rgba(0,0,0,.45)); }
|
||||
.ca-extsel__search { width: 100%; margin-bottom: .35rem; }
|
||||
.ca-extsel__grp { font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--muted); padding: .4rem .5rem .15rem; }
|
||||
.ca-extsel__opt { display: block; width: 100%; text-align: left; padding: .34rem .5rem; border: 0; border-radius: 7px;
|
||||
background: transparent; color: var(--ink); font: inherit; font-size: .85rem; cursor: pointer; }
|
||||
.ca-extsel__opt:hover { background: var(--bg-soft); }
|
||||
.ca-extsel__opt.is-on { background: var(--accent-soft); color: var(--accent-d, var(--accent)); font-weight: 600; }
|
||||
.ca-extexample { margin: -.35rem 0 1rem; font-size: .8rem; color: var(--muted); }
|
||||
.ca-extexample code { font-family: ui-monospace, Menlo, monospace; color: var(--ink);
|
||||
background: var(--bg-soft); padding: .12rem .4rem; border-radius: 6px; }
|
||||
.ca-extgroups { display: flex; flex-wrap: wrap; gap: .35rem; margin: -.35rem 0 .9rem; }
|
||||
.ca-extgroup { border: 1px solid var(--border); background: var(--bg-soft); color: var(--ink);
|
||||
border-radius: 7px; padding: .25rem .55rem; font: inherit; font-size: .8rem; cursor: pointer; }
|
||||
.ca-extgroup:hover { border-color: var(--accent); color: var(--accent-d, var(--accent)); }
|
||||
.ca-extmode { display: inline-flex; flex: none; align-self: stretch; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.ca-extmode button { border: 0; border-right: 1px solid var(--border); background: var(--bg-soft); color: var(--muted);
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: .8rem; padding: 0 .5rem; cursor: pointer; }
|
||||
.ca-extmode button:last-child { border-right: 0; }
|
||||
.ca-extmode button:hover { color: var(--ink); }
|
||||
.ca-extmode button.is-on { background: var(--accent); color: #fff; }
|
||||
/* Phone: the extban composer wraps, and the value input takes its own row. */
|
||||
@media (max-width: 560px) {
|
||||
.modal:has(.ca-layout) { width: 100vw; height: var(--app-h, 100dvh); max-height: none; border-radius: 0; }
|
||||
.ca-extban-add { flex-wrap: wrap; }
|
||||
.ca-extban-add .ca-extsel { flex: 1 1 auto; min-width: 0; }
|
||||
.ca-extban-add .ca-extsel__btn { min-width: 0; width: 100%; }
|
||||
.ca-extban-add > .modal__input { flex: 1 1 100%; }
|
||||
.ca-extsel__menu { width: min(15rem, calc(100vw - 4rem)); }
|
||||
}
|
||||
.ca-bans__empty { font-size: .82rem; color: var(--muted); padding: .3rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
||||
|
|
@ -1579,19 +1683,21 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
component rules (media queries add no specificity; later rule wins).
|
||||
============================================================================ */
|
||||
@media (max-width: 880px) {
|
||||
/* Pin the app to the VISUAL viewport (the area above the on-screen keyboard).
|
||||
height: min(100dvh, --app-h) lets the browser's NATIVE resize win during
|
||||
the keyboard animation: with interactive-widget=resizes-content (Chrome/
|
||||
Android), 100dvh shrinks smoothly in step with the keyboard, so the smaller
|
||||
value drives the transition with no lag — while the JS-tracked --app-h only
|
||||
constrains things further on iOS, where dvh does NOT shrink for the
|
||||
keyboard. top:--app-top follows iOS Safari's upward scroll. Falls back to
|
||||
100dvh on browsers without the VisualViewport API. */
|
||||
.app {
|
||||
/* Pin the app to the visual viewport (above the keyboard); --app-h tracks iOS.
|
||||
The .shell > .app selector must match to beat the desktop height:auto rule. */
|
||||
.app, .shell > .app {
|
||||
position: fixed; left: 0; right: 0;
|
||||
top: var(--app-top, 0px);
|
||||
height: min(100dvh, var(--app-h, 100dvh));
|
||||
}
|
||||
/* iOS only (JS adds .ios): the keyboard's own resize event lands late, so we
|
||||
drop --app-h on focus and glide the height up in step with the keyboard.
|
||||
Height only — never `top`, which must track scroll instantly. */
|
||||
.ios .app, .ios .shell > .app { transition: height .25s cubic-bezier(.32, .72, 0, 1); }
|
||||
|
||||
/* iOS zooms into any focused text field under 16px — keep the small ones at 16px
|
||||
on phones so tapping search / filter / age never zooms the whole page. */
|
||||
.topbar__searchinput, .members__search input, .cjoin__mini { font-size: 16px; }
|
||||
|
||||
/* Composer: the text field gets its own full-width row; the controls
|
||||
(image / format / emoji / send) wrap onto the row below. Fixes the
|
||||
|
|
@ -1605,11 +1711,17 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
|
||||
/* Topbar: tighter, and drop the channel-mode chip (advanced info) so the
|
||||
title/topic and the online pill always fit. */
|
||||
.topbar { padding: .55rem .7rem; gap: .5rem; min-height: 56px; }
|
||||
.topbar { padding: calc(.55rem + env(safe-area-inset-top, 0px)) .6rem .55rem; gap: .35rem; min-height: 56px; }
|
||||
.topbar__modes { display: none; }
|
||||
.topbar__setby { display: none; }
|
||||
/* Native two-line header: channel glyph · name over topic, then the stateful
|
||||
notify/pin toggles, member count, and a "⋮" overflow for the rest. */
|
||||
.topbar { gap: .5rem; }
|
||||
.topbar__av { display: grid; width: 36px; height: 36px; font-size: 1rem; border-radius: 11px; }
|
||||
.topbar__title { font-size: 1rem; }
|
||||
.topbar__pill { cursor: pointer; }
|
||||
.topbar__pill { cursor: pointer; min-width: 0; padding: .38rem .6rem; }
|
||||
.topbar .topbar__hide-mobile { display: none; } /* search / manage / leave / plugins collapse into ⋮ */
|
||||
.topbar .topbar__more { display: inline-flex; }
|
||||
|
||||
/* Members: hidden in flow, slide in from the right as a drawer when the
|
||||
online pill is tapped (mirrors the left room-list drawer). */
|
||||
|
|
@ -1679,7 +1791,7 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
background: var(--bg); border: 1px solid var(--border); border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,.35); overflow: hidden; }
|
||||
.qswitch__bar { display: flex; align-items: center; gap: .6rem; padding: .8rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.qswitch__ic { opacity: .55; font-size: .95rem; }
|
||||
.qswitch__ic { opacity: .55; display: inline-flex; }
|
||||
.qswitch__input { flex: 1; border: 0; background: transparent; color: var(--ink); font-size: 1.02rem; outline: none; min-width: 0; }
|
||||
.qswitch__list { list-style: none; margin: 0; padding: .35rem; overflow-y: auto; }
|
||||
.qswitch__item { display: flex; align-items: center; gap: .6rem; padding: .5rem .55rem; border-radius: 9px; cursor: pointer; }
|
||||
|
|
@ -1727,7 +1839,7 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
border: 0; background: transparent; color: var(--ink); padding: .5rem .55rem; border-radius: 8px; cursor: pointer; }
|
||||
.nmenu__item:hover { background: var(--accent-soft); }
|
||||
.nmenu__item.is-on { background: var(--accent-soft); }
|
||||
.nmenu__ic { flex: none; font-size: 1rem; }
|
||||
.nmenu__ic { flex: none; display: inline-flex; }
|
||||
.nmenu__txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.nmenu__txt b { font-size: .88rem; font-weight: 600; }
|
||||
.nmenu__txt span { font-size: .73rem; color: var(--muted); }
|
||||
|
|
@ -1740,6 +1852,9 @@ html[data-density="compact"] .sysline { padding-top: .06rem; padding-bottom: .06
|
|||
|
||||
/* pinned-messages popover */
|
||||
.nmenu__pop--pins { width: 300px; max-height: 60vh; overflow-y: auto; }
|
||||
.nmenu__item--danger b, .nmenu__item--danger .nmenu__ic { color: var(--danger); }
|
||||
/* Alert dot on a footer nav tab (the Games tab: your move / a pending challenge). */
|
||||
.tab__dot { position: absolute; top: -3px; right: -5px; width: 8px; height: 8px; border-radius: 50%; background: var(--online, #2ec27e); box-shadow: 0 0 0 2px var(--bg-soft, var(--bg)); }
|
||||
.pinrow { display: flex; align-items: stretch; gap: .2rem; }
|
||||
.pinrow__go { flex: 1; min-width: 0; display: flex; flex-direction: column; text-align: left;
|
||||
border: 0; background: transparent; color: var(--ink); padding: .45rem .55rem; border-radius: 8px; cursor: pointer; }
|
||||
|
|
|
|||
25
src/lib/extbans.test.ts
Normal file
25
src/lib/extbans.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { availableExtbans, matchExtban } from './extbans';
|
||||
|
||||
describe('extbans', () => {
|
||||
it('parses the ISUPPORT EXTBAN token (<prefix>,<letters>)', () => {
|
||||
const names = availableExtbans({ EXTBAN: ',ACGNOQRSTUabcdgjmnorsuwyz' }).map((e) => e.name);
|
||||
expect(names).toContain('mute');
|
||||
expect(names).toContain('securitygroup'); // custom module (letter g)
|
||||
expect(names).toContain('score'); // reputation module (letter y)
|
||||
});
|
||||
it('offers only advertised letters, in catalogue order', () => {
|
||||
expect(availableExtbans({ EXTBAN: ',m' }).map((e) => e.name)).toEqual(['mute']);
|
||||
expect(availableExtbans({})).toEqual([]);
|
||||
expect(availableExtbans({ EXTBAN: '' })).toEqual([]);
|
||||
});
|
||||
it('recognises a typed extban mask by name or letter', () => {
|
||||
expect(matchExtban('mute:*!*@x')?.name).toBe('mute');
|
||||
expect(matchExtban('m:*!*@x')?.name).toBe('mute');
|
||||
expect(matchExtban('!score:-5')?.name).toBe('score');
|
||||
expect(matchExtban('*!*@host')).toBeNull();
|
||||
});
|
||||
it('is IPv6-mask safe (a colon in the host is not a type prefix)', () => {
|
||||
expect(matchExtban('*!*@2001:db8::1')).toBeNull();
|
||||
});
|
||||
});
|
||||
56
src/lib/extbans.ts
Normal file
56
src/lib/extbans.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// InspIRCd extban catalogue. Each entry is keyed by the single letter the server
|
||||
// advertises in ISUPPORT `EXTBAN` (`<prefix>,<letters>`); `name` is the named form
|
||||
// we send — InspIRCd 4's default extbanformat="any" accepts either the name or the
|
||||
// letter, and the name is self-describing in the ban list (e.g. `mute:*!*@x`). Only
|
||||
// entries whose letter the server actually advertises are offered, so the picker
|
||||
// always reflects the modules that are loaded (core + reputation `score` +
|
||||
// securitygroups). Labels resolve via i18n (extbans.<name>); `hint` is an example of
|
||||
// the value shape. `acting` extbans restrict behaviour; the rest match/ban by a trait.
|
||||
export interface ExtBan { letter: string; name: string; acting: boolean; hint: string; }
|
||||
|
||||
export const EXTBANS: ExtBan[] = [
|
||||
// Acting — restrict what matching users may do.
|
||||
{ letter: 'm', name: 'mute', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'c', name: 'blockcolor', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'C', name: 'noctcp', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'N', name: 'nonick', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'T', name: 'nonotice', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'S', name: 'stripcolor', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'Q', name: 'nokick', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'u', name: 'opmoderated', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'A', name: 'blockinvite', acting: true, hint: 'test!test@test.test' },
|
||||
{ letter: 'd', name: 'redirect', acting: true, hint: '#offtopic:test!test@test.test' },
|
||||
// Matching — ban by a trait.
|
||||
{ letter: 'R', name: 'account', acting: false, hint: 'baduser' },
|
||||
{ letter: 'U', name: 'unauthed', acting: false, hint: 'test!test@test.test' },
|
||||
{ letter: 'g', name: 'securitygroup', acting: false, hint: 'registered' },
|
||||
{ letter: 'y', name: 'score', acting: false, hint: '-5' },
|
||||
{ letter: 'G', name: 'country', acting: false, hint: 'RU' },
|
||||
{ letter: 's', name: 'server', acting: false, hint: '*.example.net' },
|
||||
{ letter: 'n', name: 'class', acting: false, hint: 'main' },
|
||||
{ letter: 'r', name: 'realname', acting: false, hint: 'spam_bot' },
|
||||
{ letter: 'a', name: 'realmask', acting: false, hint: 'test!test@test.test+spam_bot' },
|
||||
{ letter: 'z', name: 'fingerprint', acting: false, hint: 'a1b2c3d4e5f6' },
|
||||
{ letter: 'o', name: 'oper', acting: false, hint: 'admin' },
|
||||
{ letter: 'O', name: 'opertype', acting: false, hint: 'NetAdmin' },
|
||||
{ letter: 'j', name: 'channel', acting: false, hint: '#staff' },
|
||||
{ letter: 'b', name: 'share', acting: false, hint: '#staff' },
|
||||
{ letter: 'w', name: 'gateway', acting: false, hint: 'mibbit' },
|
||||
];
|
||||
|
||||
// Parse ISUPPORT EXTBAN ("<prefix>,<letters>", prefix usually empty) into the
|
||||
// catalogue entries the server supports, preserving catalogue order.
|
||||
export function availableExtbans(isupport: Record<string, string>): ExtBan[] {
|
||||
const raw = isupport.EXTBAN || '';
|
||||
const letters = raw.includes(',') ? raw.slice(raw.indexOf(',') + 1) : raw;
|
||||
if (!letters) return [];
|
||||
const set = new Set(letters.split(''));
|
||||
return EXTBANS.filter((e) => set.has(e.letter));
|
||||
}
|
||||
|
||||
// Recognise a ban mask that is a typed extban ("name:..." or "letter:...") so the UI
|
||||
// can label it. Returns the catalogue entry, or null for a plain nick!user@host ban.
|
||||
export function matchExtban(mask: string): ExtBan | null {
|
||||
const head = mask.replace(/^!/, '').split(':', 1)[0];
|
||||
return EXTBANS.find((e) => e.name === head || e.letter === head) || null;
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ export function LinkPreview({ url }: { url: string }) {
|
|||
<div className="lp-anchor" ref={ref}>
|
||||
{data && (
|
||||
<a className="lpcard" href={data.url || url} target="_blank" rel="noopener noreferrer">
|
||||
{data.image && <img className="lpcard__img" src={data.image} alt="" loading="lazy" />}
|
||||
{data.image && <img className="lpcard__img" src={data.image} alt="" loading="lazy" decoding="async" width={92} height={92} />}
|
||||
<span className="lpcard__body">
|
||||
{data.siteName && <span className="lpcard__site">{data.siteName}</span>}
|
||||
{data.title && <span className="lpcard__title">{data.title}</span>}
|
||||
|
|
|
|||
21
src/main.tsx
21
src/main.tsx
|
|
@ -2,23 +2,26 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import { getTheme, hydrateTheme, useThemeStore } from './themes'
|
||||
import { applyConfigDefaultLang } from './core/i18n'
|
||||
import { applySeo } from './core/seo.ts'
|
||||
import { initGa } from './core/ga.ts'
|
||||
import { initWebMcp } from './core/webmcp.ts'
|
||||
import './index.css'
|
||||
// Alternate themes, loaded after the base so their [data-theme] rules win.
|
||||
import './themes/dark.css'
|
||||
import './themes/orbit.css'
|
||||
import './themes/yomirc.css'
|
||||
import { initViewport } from './ui/viewport.ts'
|
||||
import { registerAppUpdates, markUpdateCurtain } from './ui/appUpdate.ts'
|
||||
import { registerAppUpdates } from './ui/appUpdate.ts'
|
||||
import { loadConfig, getConfig } from './core/config.ts'
|
||||
import { AppErrorBoundary } from './components/AppErrorBoundary'
|
||||
|
||||
// If the last load reloaded to pick up a new build, hold a themed curtain up until
|
||||
// the app mounts (dismissed in App) so the fresh page fades in instead of popping.
|
||||
markUpdateCurtain()
|
||||
|
||||
// Track the visual viewport so the layout shrinks above the on-screen keyboard.
|
||||
initViewport()
|
||||
|
||||
// iOS only applies :active tap-feedback styles when a touch listener exists, so
|
||||
// register an empty one — enables the native press effect across all tappables.
|
||||
document.addEventListener('touchstart', () => {}, { passive: true })
|
||||
|
||||
// Coordinated sign-out. Logging out on the tchatou.fr website (any same-origin
|
||||
// tab) writes a `tchatou-logout` beacon to localStorage; if this tab holds an
|
||||
// IDENTIFIED primary (tchatou) session, drop it too — so signing out means signed
|
||||
|
|
@ -83,12 +86,15 @@ loadConfig().then(async () => {
|
|||
hydrateTheme() // first-time visitors adopt the config default now config is loaded
|
||||
applyConfigDefaultLang(getConfig().defaults.lang) // honour a config-pinned default language
|
||||
applyBrandIcon() // browser tab favicon follows config.branding.icon
|
||||
applySeo() // <head> description + OG/Twitter/canonical/JSON-LD from config
|
||||
const { default: App } = await import('./App.tsx') // also creates the store
|
||||
// Plugin subsystem: publish window.Orbit, bridge app/IRC events onto the bus,
|
||||
// then load operator-listed plugins from config. After the store exists,
|
||||
// before render (plugin-contributed UI registers reactively).
|
||||
const { initPlugins } = await import('./modules')
|
||||
initPlugins()
|
||||
initGa() // Consent Mode v2: gtag loads denied-by-default; restores a prior grant, banner/settings flip it
|
||||
initWebMcp() // expose chat tools to AI agents (WebMCP) where supported
|
||||
|
||||
// Core sandboxed features the app ships itself (isolated + capability-gated),
|
||||
// mounted by core — no config.json entry needed.
|
||||
|
|
@ -156,7 +162,6 @@ loadConfig().then(async () => {
|
|||
)
|
||||
})
|
||||
|
||||
// PWA + seamless updates: registers the service worker (installable, offline app
|
||||
// shell, web push) and polls version.json so a fresh deploy refreshes the tab in
|
||||
// place — a themed cross-fade instead of a manual hard reload. See ./ui/appUpdate.
|
||||
// PWA: register the service worker (installable, offline app shell, web push).
|
||||
// A new deploy applies on the next manual refresh/reopen — no auto-reload.
|
||||
registerAppUpdates()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import type { ReactNode } from 'react';
|
|||
// 'nav_item' sits in the bottom nav next to Home/Rooms/Friends (for a prominent,
|
||||
// tab-like control such as the radio player); 'navbar' is a full-width bar across
|
||||
// the very top of the app, for network branding + portal links (orbit-navbar).
|
||||
export type UiSlot = 'composer_button' | 'settings_section' | 'topbar_item' | 'sidebar_item' | 'footer_item' | 'nav_item' | 'navbar';
|
||||
// 'overlay' renders at the app root, always mounted and free of any transformed
|
||||
// or hidden ancestor — the home for a plugin's own fixed popover/panel, so its
|
||||
// launcher button can live (and move) anywhere without unmounting the panel.
|
||||
export type UiSlot = 'composer_button' | 'settings_section' | 'topbar_item' | 'sidebar_item' | 'footer_item' | 'nav_item' | 'navbar' | 'overlay';
|
||||
|
||||
export interface PluginUi {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
// the opaque-origin guest, so a bundled feature still can't reach the page/session.
|
||||
import diceSource from './features/dice.js?raw';
|
||||
import pulseSource from './features/pulse.js?raw';
|
||||
import analyticsSource from './features/analytics.js?raw';
|
||||
import { mountSandboxed } from './host';
|
||||
import { getConfig } from '../../core/config';
|
||||
import { getConfig } from '@/core/config';
|
||||
|
||||
// The registry of built-in feature modules. To add one, see features/README.md.
|
||||
const BUILTINS = [
|
||||
|
|
@ -19,4 +20,8 @@ const BUILTINS = [
|
|||
export function mountBuiltins(): void {
|
||||
const enabled = getConfig().builtins ?? [];
|
||||
for (const b of BUILTINS) if (enabled.includes(b.name)) mountSandboxed(b);
|
||||
// Analytics is driven by its own config block — an endpoint is required anyway —
|
||||
// so enabling it is a single knob: set analytics.endpoint.
|
||||
if (getConfig().analytics?.endpoint)
|
||||
mountSandboxed({ name: 'analytics', source: analyticsSource, permissions: ['analytics'] });
|
||||
}
|
||||
|
|
|
|||
34
src/modules/sandbox/features/analytics.js
Normal file
34
src/modules/sandbox/features/analytics.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Orbit analytics — a SANDBOXED, cookieless, first-party pageview counter.
|
||||
*
|
||||
* Runs in an opaque-origin iframe. It cannot read the page, cookies, the store or
|
||||
* the network; it can only hand a small { t, p } payload to the host via the gated
|
||||
* `analytics.track` verb, and the HOST posts it to config.analytics.endpoint — the
|
||||
* plugin never chooses the destination. Enabled by setting that endpoint:
|
||||
* "analytics": { "endpoint": "/accounts/api/analytics/", "siteId": "app" }
|
||||
*/
|
||||
Orbit.plugin('analytics', function (orbit, log) {
|
||||
// Never leak a private conversation: only public channels (#/&) keep their name;
|
||||
// DMs and the server console collapse to a category.
|
||||
function page(name) {
|
||||
if (!name) return '@root';
|
||||
var c = name.charAt(0);
|
||||
return c === '#' || c === '&' ? name : '@direct';
|
||||
}
|
||||
|
||||
var last = '';
|
||||
var timer = 0;
|
||||
function view(name) {
|
||||
var p = page(name);
|
||||
if (p === last) return; // ignore no-op re-selects of the same buffer
|
||||
last = p;
|
||||
if (timer) clearTimeout(timer);
|
||||
// Debounce: only count a view once the user lingers, so flicking through
|
||||
// channels doesn't spam beacons (the host also rate-limits as a backstop).
|
||||
timer = setTimeout(function () { orbit.track({ t: 'pageview', p: p }); }, 700);
|
||||
}
|
||||
|
||||
orbit.on('connected', function () { orbit.track({ t: 'session' }); });
|
||||
orbit.on('buffer.active', function (name) { view(name); });
|
||||
log('ready');
|
||||
});
|
||||
|
|
@ -204,6 +204,9 @@ import type { RpcMethod, StateSnapshot, HostToGuest } from '../protocol';
|
|||
list: () => rpc('irc.list'),
|
||||
},
|
||||
notify: (title: string, body: string) => rpc('notify', title, body),
|
||||
// Hand a small { t, p } pageview payload to the host, which posts it to the
|
||||
// configured analytics endpoint (needs the 'analytics' grant). No return value.
|
||||
track: (data: unknown) => rpc('analytics.track', data),
|
||||
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
|
||||
// Returns an unregister fn. Built-in commands win over a plugin's.
|
||||
command(nameStr: string, run: (...a: unknown[]) => void, help?: string) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
// store directly — only through the gated calls below. It powers both first-party
|
||||
// features the app bundles (see ./builtins) and operator-listed sandboxed plugins.
|
||||
import { createElement } from 'react';
|
||||
import { activeStore } from '../../core/networks';
|
||||
import { useThemeStore } from '../../themes';
|
||||
import { pluginDebug, type PluginEntry } from '../../core/config';
|
||||
import { activeStore } from '@/core/networks';
|
||||
import { useThemeStore } from '@/themes';
|
||||
import { getConfig, pluginDebug, type PluginEntry } from '@/core/config';
|
||||
import { bus } from '../bus';
|
||||
import { usePluginRegistry, type UiSlot } from '../registry';
|
||||
import { pluginNotify } from '../../platform/notify';
|
||||
import { pluginNotify } from '@/platform/notify';
|
||||
import { SandboxFrame } from './SandboxFrame';
|
||||
import {
|
||||
isGranted, sanitizePermissions, FORWARDED_EVENTS, THEME_VARS,
|
||||
|
|
@ -87,6 +87,7 @@ export function mountSandboxed(spec: SandboxSpec): void {
|
|||
|
||||
let claimed = false;
|
||||
let lastNotify = 0;
|
||||
let lastBeacon = 0;
|
||||
let removeUi: () => void = () => {};
|
||||
let currentPort: MessagePort | null = null; // the live guest port (rebuilt each handshake)
|
||||
const hooks: Record<string, () => void> = {}; // command/shortcut disposers, keyed by guest hook id
|
||||
|
|
@ -107,6 +108,30 @@ export function mountSandboxed(spec: SandboxSpec): void {
|
|||
pluginNotify(String(a[0] ?? ''), a[1] != null ? String(a[1]) : undefined);
|
||||
},
|
||||
'storage.set': (a) => persistStorage(name, String(a[0]), a[1]),
|
||||
// Host-mediated egress: the guest supplies only { t, p }; WE choose the URL
|
||||
// (config.analytics.endpoint), send no cookies, and cap rate + payload. So an
|
||||
// isolated plugin can beacon a pageview but can't exfiltrate or pick a target.
|
||||
'analytics.track': (a) => {
|
||||
const an = getConfig().analytics;
|
||||
if (!an?.endpoint) return; // disabled unless an endpoint is configured
|
||||
const now = Date.now();
|
||||
// Runaway-loop backstop only — kept well under the plugin's own debounce so a
|
||||
// session event and a closely following pageview don't drop each other. Real
|
||||
// abuse limiting is per-IP at the endpoint.
|
||||
if (now - lastBeacon < 250) return;
|
||||
lastBeacon = now;
|
||||
const p = (a[0] && typeof a[0] === 'object' ? a[0] : {}) as Record<string, unknown>;
|
||||
const body = JSON.stringify({
|
||||
t: String(p.t ?? 'event').slice(0, 32),
|
||||
p: p.p != null ? String(p.p).slice(0, 128) : undefined,
|
||||
site: an.siteId || undefined,
|
||||
path: location.pathname,
|
||||
ref: document.referrer || undefined,
|
||||
lang: navigator.language,
|
||||
});
|
||||
try { void fetch(an.endpoint, { method: 'POST', credentials: 'omit', keepalive: true, headers: { 'Content-Type': 'application/json' }, body }); }
|
||||
catch { /* fire-and-forget */ }
|
||||
},
|
||||
'ui.claim': (a) => {
|
||||
if (claimed) return; claimed = true;
|
||||
const raw = String(a[0] ?? 'footer_item');
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@
|
|||
// join/part/list) are far less dangerous than raw wire access (irc.send can issue
|
||||
// ANY command as the user — MODE, KICK, QUIT, …), so an operator must grant it on
|
||||
// purpose rather than getting it bundled with 'irc'.
|
||||
export const PERMISSIONS = ['irc', 'irc-raw', 'notify', 'storage'] as const;
|
||||
// 'analytics' lets a plugin hand a small pageview payload to the host, which posts
|
||||
// it to the ONE endpoint in config.analytics.endpoint — the plugin never chooses
|
||||
// the destination and gets no other network reach. Only the first-party analytics
|
||||
// feature should hold it; granting it elsewhere just lets a plugin pad your counts.
|
||||
export const PERMISSIONS = ['irc', 'irc-raw', 'notify', 'storage', 'analytics'] as const;
|
||||
export type Permission = (typeof PERMISSIONS)[number];
|
||||
|
||||
// An explicit, self-documenting way to declare that a plugin wants ZERO
|
||||
|
|
@ -35,6 +39,8 @@ export const RPC_CAPABILITY: Record<string, Permission | null> = {
|
|||
'irc.list': 'irc',
|
||||
'notify': 'notify',
|
||||
'storage.set': 'storage',
|
||||
// Egress is host-mediated to the single configured endpoint (never a plugin URL).
|
||||
'analytics.track': 'analytics',
|
||||
// UI stays inside the plugin's own sandboxed iframe, so it needs no grant.
|
||||
'ui.claim': null,
|
||||
'ui.resize': null,
|
||||
|
|
|
|||
|
|
@ -54,11 +54,14 @@ function fetchAvatar(account: string): Promise<string | null> {
|
|||
|
||||
// React hook: returns the avatar URL for an account (or null while loading / none).
|
||||
export function useAvatarUrl(account?: string | null): string | null {
|
||||
const [url, setUrl] = useState<string | null>(
|
||||
account ? cache.get(account.toLowerCase()) ?? null : null,
|
||||
);
|
||||
const seed = () => (account ? cache.get(account.toLowerCase()) ?? null : null);
|
||||
const [url, setUrl] = useState<string | null>(seed);
|
||||
// Account switched: reset to its cached value (or null) during render — no
|
||||
// effect setState, so no cascading re-render.
|
||||
const [prev, setPrev] = useState(account);
|
||||
if (account !== prev) { setPrev(account); setUrl(seed()); }
|
||||
useEffect(() => {
|
||||
if (!account) { setUrl(null); return; }
|
||||
if (!account) return;
|
||||
let dead = false;
|
||||
fetchAvatar(account).then((u) => { if (!dead) setUrl(u); });
|
||||
return () => { dead = true; };
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@
|
|||
[data-theme="orbit"] .pm-avring {
|
||||
background: conic-gradient(from 130deg, #2ea043, #22813a, #176430, #2ea043);
|
||||
}
|
||||
/* ============ ORBIT-DARK — mirrors tchatou.fr (near-black, monochrome white accent, Geist) ============ */
|
||||
/* ============ ORBIT-DARK — mirrors tchatou.fr (near-black, tchatou orange accent, Geist) ============ */
|
||||
[data-theme="orbit-dark"] {
|
||||
--ink: #f3f4f6; --ink-2: #c9cace; --muted: #8b8c95; --faint: #5c5d66;
|
||||
--bg: #0d0d0f; --bg-soft: #141416; --bg-soft-2: #1b1b1e; --bg-active: #212126; --rail-bg: #060607;
|
||||
--border: #232327; --border-2: #34343a;
|
||||
--accent: #f2f3f5; --accent-d: #ffffff; --accent-soft: rgba(255,255,255,.10);
|
||||
--accent: #ff6b00; --accent-d: #ff8a3c; --accent-soft: rgba(255,107,0,.16);
|
||||
--online: #2ec27e; --online-d: #25a76c; --online-soft: rgba(46,194,126,.16);
|
||||
--danger: #f0556e;
|
||||
--shadow-pop: 0 14px 44px -14px rgba(0,0,0,.8), 0 2px 8px -2px rgba(0,0,0,.6);
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
color-scheme: dark;
|
||||
}
|
||||
[data-theme="orbit-dark"] body { background: #060607; }
|
||||
/* white accent -> dark text on accent-filled controls (Grok-style white buttons) */
|
||||
/* orange accent -> dark text on accent-filled controls (readable on #ff6b00) */
|
||||
[data-theme="orbit-dark"] .composer__send,
|
||||
[data-theme="orbit-dark"] .cjoin__send,
|
||||
[data-theme="orbit-dark"] .pill.is-on,
|
||||
|
|
@ -59,26 +59,27 @@
|
|||
[data-theme="orbit-dark"] .room__badge,
|
||||
[data-theme="orbit-dark"] .settings__navitem.is-on .settings__navic { color: #0a0a0b; }
|
||||
[data-theme="orbit-dark"] .switch.is-on .switch__dot { background: #0a0a0b; }
|
||||
/* drop the hardcoded blue glows now that the controls are white */
|
||||
/* neutral drop shadow on the accent-filled controls */
|
||||
[data-theme="orbit-dark"] .composer__send,
|
||||
[data-theme="orbit-dark"] .pm-btn--primary,
|
||||
[data-theme="orbit-dark"] .pm-check,
|
||||
[data-theme="orbit-dark"] .pill.is-on,
|
||||
[data-theme="orbit-dark"] .sseg button.is-on { box-shadow: 0 2px 12px -3px rgba(0,0,0,.6); }
|
||||
[data-theme="orbit-dark"] .room.is-active::before { box-shadow: none; }
|
||||
[data-theme="orbit-dark"] .composer__box:focus-within { box-shadow: 0 0 0 3px rgba(255,255,255,.12); }
|
||||
/* connect/welcome screen -> near-black monochrome (not the green cosmic) */
|
||||
[data-theme="orbit-dark"] .composer__box:focus-within { box-shadow: 0 0 0 3px rgba(255,107,0,.22); }
|
||||
/* connect/welcome screen -> near-black with a faint orange glow */
|
||||
[data-theme="orbit-dark"] .connect {
|
||||
color: var(--ink);
|
||||
background: radial-gradient(70% 55% at 50% -8%, rgba(255,255,255,.05), transparent 60%), linear-gradient(180deg, #0c0c0e, #08080a 62%, #050506);
|
||||
background: radial-gradient(70% 55% at 50% -8%, rgba(255,107,0,.07), transparent 60%), linear-gradient(180deg, #0c0c0e, #08080a 62%, #050506);
|
||||
}
|
||||
[data-theme="orbit-dark"] .cjoin__composer:focus-within { border-color: rgba(255,255,255,.5); }
|
||||
/* profile banner + avatar ring: monochrome, not the per-nick hue */
|
||||
[data-theme="orbit-dark"] .cjoin__composer:focus-within { border-color: rgba(255,107,0,.6); }
|
||||
/* profile banner + avatar ring: tchatou orange, not the per-nick hue */
|
||||
[data-theme="orbit-dark"] .pm-cover {
|
||||
background:
|
||||
radial-gradient(130% 150% at 18% 0%, rgba(255,255,255,.10), transparent 60%),
|
||||
linear-gradient(135deg, #1a1a1f, #0d0d10);
|
||||
radial-gradient(130% 150% at 18% 0%, rgba(255,107,0,.30), transparent 60%),
|
||||
radial-gradient(120% 160% at 92% 0%, rgba(214,90,0,.26), transparent 55%),
|
||||
linear-gradient(135deg, #7a3a00, #3a1c00);
|
||||
}
|
||||
[data-theme="orbit-dark"] .pm-avring {
|
||||
background: conic-gradient(from 130deg, #3a3a42, #1c1c21, #2a2a30, #3a3a42);
|
||||
background: conic-gradient(from 130deg, #ff6b00, #cc5600, #8a3a00, #ff6b00);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,10 @@
|
|||
[data-theme^="yomirc"] .mircline--action .mircline__txt { font-style: italic; }
|
||||
[data-theme^="yomirc"] .mircline.is-redacted { opacity: .55; font-style: italic; }
|
||||
[data-theme^="yomirc"] .mircline .reactions--inline { display: inline-flex; gap: .25rem; margin-left: .4rem; vertical-align: middle; }
|
||||
[data-theme^="yomirc"] .mircline .msg-actions { top: 0; }
|
||||
/* Float the hover toolbar ABOVE the dense log line. `top: 0` used to sit it on the
|
||||
line and 14px INTO the next one — that overlap blocked the next line's hover and
|
||||
made hovering flicker/jump. Above the line it clears both neighbours. */
|
||||
[data-theme^="yomirc"] .mircline .msg-actions { top: auto; bottom: calc(100% - 4px); }
|
||||
/* The reply arrow (↩) is a text glyph, not an emoji, so on the pure-black canvas it
|
||||
falls back to the UA button colour (black) and vanishes. Paint the action glyphs
|
||||
the classic terminal green so they read against the black. */
|
||||
|
|
|
|||
|
|
@ -1,139 +1,8 @@
|
|||
// Seamless in-place updates.
|
||||
//
|
||||
// A deploy stamps dist/version.json with the git commit; the running app carries
|
||||
// its own commit in __GIT_COMMIT__. We poll version.json when the tab regains
|
||||
// focus (so a fresh deploy is picked up WITHOUT a manual reload) and, when it
|
||||
// differs, cross-fade a themed curtain and reload — masking the browser's hard
|
||||
// navigation so it reads as an internal refresh, not a page load. If the user is
|
||||
// mid-message we defer until the composer is empty (or the tab is hidden) so a
|
||||
// half-typed line is never yanked away.
|
||||
|
||||
const FLAG = 'orbit:just-updated'; // set right before reload, read on next boot
|
||||
const BG_KEY = 'orbit:update-bg'; // themed --bg, restored pre-paint (index.html) to avoid a flash
|
||||
const TRIED = 'orbit:update-tried'; // commit we already reloaded for — a reload-loop guard
|
||||
|
||||
const VERSION_URL = `${import.meta.env.BASE_URL}version.json`;
|
||||
|
||||
let reloading = false;
|
||||
let pendingCommit = '';
|
||||
|
||||
// The user has unsent text in the composer — don't reload out from under them.
|
||||
function composerBusy(): boolean {
|
||||
const el = document.querySelector('.composer__rich');
|
||||
return !!el?.textContent && el.textContent.trim().length > 0;
|
||||
}
|
||||
|
||||
// A full-viewport curtain in the theme's own --bg (so it and the reloaded page's
|
||||
// background are one continuous colour) with a centred spinner. Reused for both
|
||||
// the fade-out before reload and the cover-until-mounted after it.
|
||||
function buildCurtain(opacity: string): HTMLElement {
|
||||
const existing = document.getElementById('orbit-update');
|
||||
if (existing) return existing;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = cs.getPropertyValue('--bg').trim() || '#101012';
|
||||
const accent = cs.getPropertyValue('--accent').trim() || '#888';
|
||||
const el = document.createElement('div');
|
||||
el.id = 'orbit-update';
|
||||
el.setAttribute('style',
|
||||
`position:fixed;inset:0;z-index:2147483647;background:${bg};` +
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
`opacity:${opacity};transition:opacity .3s ease`);
|
||||
const spin = document.createElement('div');
|
||||
spin.setAttribute('style',
|
||||
'width:28px;height:28px;border-radius:50%;' +
|
||||
`border:2.5px solid ${accent}33;border-top-color:${accent};` +
|
||||
'animation:orbit-update-spin .7s linear infinite');
|
||||
el.appendChild(spin);
|
||||
if (!document.getElementById('orbit-update-kf')) {
|
||||
const st = document.createElement('style');
|
||||
st.id = 'orbit-update-kf';
|
||||
st.textContent = '@keyframes orbit-update-spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyUpdate(toCommit: string): void {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
try {
|
||||
sessionStorage.setItem(FLAG, '1');
|
||||
if (toCommit) sessionStorage.setItem(TRIED, toCommit);
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||
if (bg) sessionStorage.setItem(BG_KEY, bg);
|
||||
} catch { /* private mode: no persistence, still reloads */ }
|
||||
const el = buildCurtain('0');
|
||||
requestAnimationFrame(() => { el.style.opacity = '1'; });
|
||||
setTimeout(() => window.location.reload(), 320); // let the curtain fade in first
|
||||
}
|
||||
|
||||
// Reload now if it's a safe moment; otherwise remember the target and wait.
|
||||
function requestUpdate(toCommit: string): void {
|
||||
if (reloading) return;
|
||||
if (composerBusy() && document.visibilityState === 'visible') { pendingCommit = toCommit; return; }
|
||||
applyUpdate(toCommit);
|
||||
}
|
||||
|
||||
async function checkVersion(): Promise<void> {
|
||||
if (reloading || !__GIT_COMMIT__) return;
|
||||
let commit: string;
|
||||
try {
|
||||
const res = await fetch(VERSION_URL, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
commit = (await res.json())?.commit || '';
|
||||
} catch { return; }
|
||||
if (!commit || commit === __GIT_COMMIT__) return;
|
||||
try { if (sessionStorage.getItem(TRIED) === commit) return; } catch { /* ignore */ }
|
||||
requestUpdate(commit);
|
||||
}
|
||||
|
||||
// Boot: if the last load reloaded FOR an update, hold a themed curtain up until the
|
||||
// app has mounted (dismissUpdateCurtain), so the fresh page fades in, never pops.
|
||||
export function markUpdateCurtain(): void {
|
||||
try { if (sessionStorage.getItem(FLAG) !== '1') return; } catch { return; }
|
||||
try { sessionStorage.removeItem(FLAG); sessionStorage.removeItem(BG_KEY); } catch { /* ignore */ }
|
||||
buildCurtain('1');
|
||||
}
|
||||
|
||||
export function dismissUpdateCurtain(): void {
|
||||
const el = document.getElementById('orbit-update');
|
||||
document.documentElement.style.background = ''; // drop the pre-paint bg set in index.html
|
||||
if (!el) return;
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 320);
|
||||
}
|
||||
|
||||
let lastCheck = 0;
|
||||
// Registers the service worker (installable PWA, offline shell, web push).
|
||||
// No auto-reload: a new build applies on the next manual refresh or app reopen.
|
||||
export function registerAppUpdates(): void {
|
||||
const maybeCheck = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastCheck < 20000) return; // throttle focus storms
|
||||
lastCheck = now;
|
||||
void checkVersion();
|
||||
};
|
||||
|
||||
// Pick up a fresh deploy on the next focus/visibility, plus a slow heartbeat for
|
||||
// always-open sessions. Also flush a deferred update when it's finally safe.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (pendingCommit && document.visibilityState === 'hidden') { const c = pendingCommit; pendingCommit = ''; applyUpdate(c); }
|
||||
maybeCheck();
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }).catch(() => { /* ignore */ });
|
||||
});
|
||||
window.addEventListener('focus', maybeCheck);
|
||||
document.addEventListener('input', () => {
|
||||
if (pendingCommit && !composerBusy()) { const c = pendingCommit; pendingCommit = ''; requestUpdate(c); }
|
||||
}, true);
|
||||
setInterval(maybeCheck, 15 * 60 * 1000);
|
||||
maybeCheck();
|
||||
|
||||
// The service worker still powers offline + web push; a changed SW (cache bump)
|
||||
// is an update signal too. Registration stays here so it's the single owner.
|
||||
if ('serviceWorker' in navigator) {
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => { if (hadController) requestUpdate(''); });
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }).catch(() => { /* ignore */ });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +1,108 @@
|
|||
// Mobile virtual-keyboard handling.
|
||||
//
|
||||
// When the on-screen keyboard opens, `100dvh`/`100vh` do NOT shrink on most
|
||||
// mobile browsers — and on Firefox for Android they don't shrink at all; only
|
||||
// the VisualViewport shrinks, and Firefox fires `visualViewport.resize` only
|
||||
// once the keyboard animation has FINISHED. Driving the layout purely off that
|
||||
// event looks laggy (full-height during the slide-in, then a snap at the end).
|
||||
// The app is `position: fixed; top: var(--app-top); height: min(100dvh, var(--app-h))`
|
||||
// on phones (see index.css). We mirror the VisualViewport — the slice of screen NOT
|
||||
// covered by the keyboard — into --app-h / --app-top.
|
||||
//
|
||||
// Fix: remember the keyboard's height and PRE-RESIZE the layout instantly on
|
||||
// focus, then correct to the exact size when the real resize event lands.
|
||||
// Android: `interactive-widget=resizes-content` (index.html) shrinks the layout in
|
||||
// step with the keyboard; the layout and visual viewport shrink together so our
|
||||
// height inset reads ~0 and the JS stays out of the way — 100dvh does the work.
|
||||
//
|
||||
// Keyboard open/closed state is driven by FOCUS, not by a height heuristic:
|
||||
// browsers like Firefox Android collapse the URL bar after the first keyboard
|
||||
// interaction, which changes the full viewport height and would fool any
|
||||
// "is the viewport shorter than usual?" guess. We instead capture the true
|
||||
// full height at focusin (before the keyboard opens) on every open, so URL-bar
|
||||
// changes can't corrupt our reference.
|
||||
//
|
||||
// We mirror the live height into `--app-h` and the VisualViewport offset into
|
||||
// `--app-top` (used by `.app` on mobile); CSS falls back to 100dvh without the
|
||||
// VisualViewport API.
|
||||
// iOS is the hard case: the layout does NOT shrink and `visualViewport.resize` fires
|
||||
// only once the keyboard has finished sliding, so reacting to it alone snaps late.
|
||||
// Approach (matches the community's "native-feeling" recipe):
|
||||
// 1. On focus, drop --app-h to the remembered keyboard height so the composer
|
||||
// starts rising at t=0, and a CSS transition on `.ios .app`
|
||||
// (height .25s cubic-bezier(.32,.72,0,1) — the iOS keyboard curve) glides it up
|
||||
// in step with the keyboard.
|
||||
// 2. HEIGHT only changes on `resize` (settled on iOS) and only when the open/close
|
||||
// state actually flips (>60px) — this ignores the predictive/suggestion bar
|
||||
// toggling, whose small resizes were making the layout jitter.
|
||||
// 3. OFFSET (--app-top) tracks every `scroll` frame so the fixed app follows the
|
||||
// visual viewport.
|
||||
|
||||
const root = document.documentElement;
|
||||
const KB_KEY = 'tchatou-kbh'; // remembered keyboard height, persisted across reloads
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iP(hone|od|ad)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const OPEN_THRESHOLD = 60; // px; below this a viewport change isn't the keyboard (suggestion bar, url bar)
|
||||
|
||||
let baseH = 0; // full viewport height with the keyboard CLOSED (captured at focusin)
|
||||
let kbInset = 0; // last measured keyboard height (px)
|
||||
let kbOpen = false; // is a text field currently focused (keyboard intended open)?
|
||||
let lastH = -1, lastTop = -1;
|
||||
let raf = 0;
|
||||
|
||||
let kbInset = 0;
|
||||
let kbOpen = false;
|
||||
let lastH = -1, lastTop = -1, hRaf = 0, topRaf = 0;
|
||||
try { kbInset = parseInt(localStorage.getItem(KB_KEY) || '0', 10) || 0; } catch { /* ignore */ }
|
||||
|
||||
function setH(px: number): void {
|
||||
const v = Math.round(px);
|
||||
if (v !== lastH) {
|
||||
root.style.setProperty('--app-h', `${v}px`);
|
||||
lastH = v;
|
||||
// Let the message list re-pin to the bottom in step with the resize.
|
||||
window.dispatchEvent(new Event('tchatou:vh'));
|
||||
}
|
||||
if (v === lastH) return;
|
||||
root.style.setProperty('--app-h', `${v}px`);
|
||||
lastH = v;
|
||||
window.dispatchEvent(new Event('tchatou:vh')); // re-pin the message list to the bottom
|
||||
}
|
||||
function setTop(px: number): void {
|
||||
const v = Math.round(px);
|
||||
if (v !== lastTop) { root.style.setProperty('--app-top', `${v}px`); lastTop = v; }
|
||||
if (v === lastTop) return;
|
||||
root.style.setProperty('--app-top', `${v}px`);
|
||||
lastTop = v;
|
||||
}
|
||||
|
||||
function isEditable(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
|
||||
const t = el.tagName;
|
||||
return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
|
||||
}
|
||||
|
||||
// Authoritative update from the actual VisualViewport size. While the keyboard
|
||||
// is open this gives us the exact geometry to remember & correct to.
|
||||
function apply(): void {
|
||||
raf = 0;
|
||||
// Height: only when the keyboard genuinely opens/closes (state flip past the
|
||||
// threshold), so the suggestion bar can't jitter the layout. On the flip we snap
|
||||
// to the exact above-keyboard height; the CSS transition smooths the tiny gap from
|
||||
// the focus-time estimate.
|
||||
function onResize(): void {
|
||||
hRaf = 0;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const h = vv.height;
|
||||
if (kbOpen) {
|
||||
const inset = baseH - h;
|
||||
if (inset > 40) { // keyboard genuinely up — remember its height
|
||||
kbInset = inset;
|
||||
try { localStorage.setItem(KB_KEY, String(Math.round(inset))); } catch { /* ignore */ }
|
||||
} else if (!isEditable(document.activeElement)) {
|
||||
// We thought the keyboard was open, but the viewport is back to full
|
||||
// height and nothing editable is focused — e.g. the focused input was
|
||||
// unmounted (login succeeded) so no focusout fired. Self-heal.
|
||||
kbOpen = false;
|
||||
baseH = h;
|
||||
const inset = window.innerHeight - vv.height;
|
||||
const open = inset > OPEN_THRESHOLD;
|
||||
if (open !== kbOpen) {
|
||||
kbOpen = open;
|
||||
if (open) {
|
||||
kbInset = Math.round(inset);
|
||||
try { localStorage.setItem(KB_KEY, String(kbInset)); } catch { /* ignore */ }
|
||||
setH(vv.height);
|
||||
} else {
|
||||
setH(window.innerHeight);
|
||||
}
|
||||
} else {
|
||||
baseH = h; // keyboard closed: track the true full height
|
||||
}
|
||||
setH(h);
|
||||
setTop(vv.offsetTop);
|
||||
}
|
||||
|
||||
function schedule(): void {
|
||||
if (!raf) raf = requestAnimationFrame(apply);
|
||||
function onScroll(): void {
|
||||
topRaf = 0;
|
||||
const vv = window.visualViewport;
|
||||
if (vv) setTop(vv.offsetTop);
|
||||
}
|
||||
|
||||
export function initViewport(): void {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return; // older browsers: CSS falls back to 100dvh
|
||||
baseH = vv.height;
|
||||
if (!vv) return; // older browsers fall back to CSS 100dvh
|
||||
|
||||
document.addEventListener('focusin', (e) => {
|
||||
if (!isEditable(e.target)) return;
|
||||
if (kbOpen) return; // switching between fields — keyboard already up
|
||||
kbOpen = true;
|
||||
baseH = vv.height; // true full height, captured BEFORE the keyboard opens
|
||||
if (kbInset > 0) setH(baseH - kbInset); // pre-resize instantly; keyboard slides into the gap
|
||||
});
|
||||
if (isIOS) {
|
||||
root.classList.add('ios'); // enables the CSS height transition
|
||||
let kbTimer = 0;
|
||||
document.addEventListener('focusin', (e) => {
|
||||
if (!isEditable(e.target)) return;
|
||||
const kb = kbInset || Math.round(window.innerHeight * 0.4); // estimate on the first-ever open
|
||||
setH(window.innerHeight - kb); // start the glide now; the transition matches the keyboard
|
||||
clearTimeout(kbTimer);
|
||||
// Self-heal: no keyboard came up (hardware keyboard / programmatic focus) → undo.
|
||||
kbTimer = window.setTimeout(() => { if (vv.height >= window.innerHeight - 40) { kbOpen = false; setH(vv.height); } }, 500);
|
||||
});
|
||||
document.addEventListener('focusout', (e) => {
|
||||
if (!isEditable(e.target)) return;
|
||||
clearTimeout(kbTimer);
|
||||
setTimeout(() => { if (!isEditable(document.activeElement)) { kbOpen = false; setH(window.innerHeight); } }, 0);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('focusout', (e) => {
|
||||
if (!isEditable(e.target)) return;
|
||||
// Defer a tick: focus may just be moving to another field.
|
||||
setTimeout(() => {
|
||||
if (isEditable(document.activeElement)) return;
|
||||
kbOpen = false;
|
||||
setH(baseH); // grow back instantly
|
||||
setTop(0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// `resize` fires when the keyboard finishes opening/closing; `scroll` fires as
|
||||
// the visual viewport shifts while a field is focused.
|
||||
vv.addEventListener('resize', schedule);
|
||||
vv.addEventListener('scroll', schedule);
|
||||
window.addEventListener('orientationchange', () => { kbOpen = false; baseH = vv.height; schedule(); });
|
||||
apply();
|
||||
vv.addEventListener('resize', () => { if (!hRaf) hRaf = requestAnimationFrame(onResize); });
|
||||
vv.addEventListener('scroll', () => { if (!topRaf) topRaf = requestAnimationFrame(onScroll); });
|
||||
window.addEventListener('orientationchange', () => { kbOpen = false; if (!hRaf) hRaf = requestAnimationFrame(onResize); });
|
||||
onResize();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue