Compare commits

...

70 commits

Author SHA1 Message Date
1a215ff912 Update dependency @types/node to v24.13.3 2026-07-11 02:02:05 +00:00
4f95f0ea66
Theme the games panel instead of hardcoded dark colours
The panel referenced its own token names (--bg2/--tx/--tx-dim/--ac/--card)
that don't exist in the app, so it always fell back to hardcoded dark
values — mismatched on the light and Night-orange/green themes. Alias
those tokens to the app's theme tokens (--bg / --ink / --muted / --accent
/ --bg-soft-2) at the panel root, fix the active white-overlay fills that
vanished on light backgrounds (rows, input, toast), map the accept action
to --online, and soften the shadows. The panel now follows the active
theme; the game-piece colours (X/O, chess, Connect-4) stay as-is.
2026-07-11 01:59:55 +00:00
a6769d3a54
Move Games from the topbar into the footer nav
Games is now a "Jeux" tab in the footer alongside Accueil / Salons / Amis
— icon over label, accent when its panel is open, an alert dot when it's
your move or a challenge is pending — a native bottom-nav home for it. The
panel still opens from the persistent 'overlay' slot, and the new-game /
challenge ("invite to game") flow lives inside it (plus the profile Défier
action). Drops the now-unused topbar-plugin-in-overflow machinery; the
clock stays in the desktop header.
2026-07-11 01:48:18 +00:00
c28a28645f
Hide the clock from the mobile overflow menu
The clock is a passive HH:MM display (a sandboxed .sbx-slot), so it doesn't
belong in the action menu — and it's redundant on phones, where the OS
status bar already shows the time. Drop sandboxed topbar plugins from the
⋮ overflow; the bar copy is already hidden on mobile, so the clock is now
gone on phones while staying in the desktop header.
2026-07-11 01:07:13 +00:00
231be23f0d
Add an 'overlay' plugin slot; move Games into the mobile ⋮ menu
A topbar plugin's popover was rendered as a child of its launcher button,
so the button had to stay mounted for the popover to survive — which is
why the games button couldn't move off the crowded mobile header. Add a
persistent 'overlay' UI slot at the app root and render the games panel
there, decoupling it from the button. The launcher is now stateless, so
on mobile it drops into the ⋮ overflow as a labelled "Jeux" row (freeing
the header for the channel name + topic) while its panel stays anchored.
Desktop keeps the icon in the bar.
2026-07-11 00:54:36 +00:00
e36d3a1d34
Keep the games button in the mobile topbar
The games panel is a fixed overlay rendered as a child of its button, so
the button has to stay mounted for the panel to survive — it can't move
into the ephemeral overflow menu, where it would unmount (and the panel
vanish) the moment the menu closes. Now that search / manage / leave live
in the overflow, the bar has room, so show the games plugin button inline
on mobile instead of hiding it. Reachable again, and the title and topic
still fit.
2026-07-11 00:28:18 +00:00
7c074d7a8b
Rework the mobile topbar into a native header
On phones the channel header crammed every button into one row: the title
sat behind the games-plugin button and the topic was pushed off screen.
Collapse the one-shot actions (search, manage, leave) into a "⋮" overflow
menu, hide topbar plugin items on mobile, and keep only the stateful notify
and pin toggles, the member count and the overflow inline. The channel
glyph, name and topic now have room to show as a proper two-line header.
2026-07-11 00:11:56 +00:00
d817db9255
Theme the topbar toolbar icons to match the footer set
The channel header's search, notify, pin, manage and close buttons — plus
the app-wide search-field magnifiers and the menu toggle — used OS emoji
glyphs. Extend the shared Icon component with search, close, menu, bell,
bell-off, pin and sliders, and use it across the topbar, the notify and
pin menus, and the explore / switcher / side-search fields. The icons
inherit each button's colour and get the same press and focus states as
the footer, so the whole chrome now reads as one icon family.
2026-07-10 23:51:15 +00:00
e82b4b7f01
Replace footer emoji glyphs with a themed icon set
The account bar's tabs and settings button used OS emoji (home, compass,
people, gear), which render off-theme and differ across platforms. Add a
shared stroke-icon component — 24×24, currentColor, the same 1.9 stroke as
the composer glyphs — and use it for Accueil, Salons, Amis and Settings.
The icons inherit each button's colour, so the active tab now reads as a
soft accent pill, and the buttons gain a press dip and focus ring so the
bar feels native on desktop, not just touch.
2026-07-10 22:57:37 +00:00
dbb45e4d17
Load only the active UI locale on first paint
All ten translations (~400 KB of JSON) were bundled into the entry
chunk, nearly all of it never used in a session. Ship only French — the
default and fallback — and fetch the other nine as separate chunks on
demand (language switch or a config-pinned default). The entry chunk
drops from 653 KB to 361 KB (200 KB to 118 KB gzipped), cutting the
parse/execute work that was delaying the largest paint.
2026-07-10 22:26:24 +00:00
d5ddbbff4f
Coalesce inbound messages into batched renders
Each IRC line arrives as its own WebSocket frame (text.ircv3.net), so
the store rendered once per line. Joining several busy channels at once
— NAMES plus a WHO reply per member — fired a render for every line and
stalled the main thread, which also inflated LCP. Buffer messages onto a
0ms timer so a whole burst drains in one render; under load the timers
back up and each drain swallows more lines. A timer rather than rAF keeps
unread counts and mention notifications live in a backgrounded tab.
2026-07-10 22:14:24 +00:00
14cf38afa8
Fix Turnstile ref write during render and the gtag lint error
Turnstile refreshed its callback ref in the render body; move it to an
effect. The gtag shim legitimately pushes the live arguments object, so
scope a disable to that line with a reason. Lint is now error-clean.
2026-07-10 21:49:55 +00:00
9b21adb9cc
Derive prop-synced state during render, not in effects
The nick field, channel +k/+l inputs and avatar hook re-synced from
props by calling setState inside an effect, which triggers an extra
render pass each time. Track the previous prop and adjust during
render instead — one pass, and the avatar now resets on account switch.
2026-07-10 21:48:08 +00:00
7ea1fd3e87
Add @/ path alias, replace deep relative imports
Set up an @/* -> src/* alias (tsconfig paths + vite resolve) and
convert every ../../-and-deeper import to it. Same-dir ./ and
one-level ../ stay relative. Build-time only, so the bundle is
unchanged; the deep imports were just hard to read and broke on
file moves.
2026-07-10 21:30:32 +00:00
96ff3bef78
Image/voice shares: bold + italic so they stand out in the log 2026-07-10 21:09:36 +00:00
b1b628bdc9
Fix CTCP ACTION: wrap in \x01 so /me + image shares render as '* nick …' in every client (mIRC etc.) 2026-07-10 21:09:36 +00:00
4f8d10c8a5
yomirc: float the hover toolbar above the log line so it no longer overlaps the next line (fixes hover jump) 2026-07-10 20:04:46 +00:00
f90aca5252
Sidebar rows: div[role=button] + a real close button — valid HTML, keyboard-reachable close 2026-07-10 19:54:40 +00:00
5d6de5a357
Sidebar: memoize each room row (subscribes to its own buffer) so a message updates only that row 2026-07-10 19:50:37 +00:00
b7265b5706
Composer: mark fire-and-forget image uploads with void (intent; store already catches) 2026-07-10 19:45:06 +00:00
d3300d4a4e
Images: decode avatars + link previews off-thread (decoding=async), pin preview img size 2026-07-10 19:43:33 +00:00
bbd8b81ea6
Modals: trap Tab inside the dialog and return focus to the trigger on close (a11y) 2026-07-10 19:41:51 +00:00
77d8a0a669
Message rows use content-visibility to skip off-screen layout/paint (faster channel switch) 2026-07-10 19:17:13 +00:00
c3b96c5b16
Memoize message rows: a new message re-renders only the new row, not the whole list 2026-07-10 19:07:11 +00:00
67e6493b71
Native tactile press feedback: controls dip+dim on touch, no tap delay/callout 2026-07-10 18:54:21 +00:00
8bc88557b3
Mobile native-feel: kill iOS input-zoom (16px), overscroll-contain, no tap-flash, safe-area top, non-selectable chrome 2026-07-10 18:37:30 +00:00
c0ec75d6dd
iOS keyboard: native easing curve + 60px threshold to stop suggestion-bar jitter 2026-07-10 18:21:24 +00:00
d5af169c73
Self-heal the iOS pre-resize when no keyboard opens (hardware keyboard / programmatic focus) 2026-07-10 17:54:51 +00:00
88e689fc82
Smooth iOS keyboard open: pre-resize on focus + CSS height glide, corrected by the resize event 2026-07-10 17:53:14 +00:00
9a4757e839
Track mobile keyboard via VisualViewport directly; drop the pre-resize guess that broke the first open 2026-07-10 17:41:50 +00:00
db1ba1420f
Analytics consent: grant only analytics_storage in the app (ad signals stay denied, ad-free) 2026-07-10 16:26:02 +00:00
e2ecd093a6
Make the console/status window follow the theme accent (drop hardcoded terminal green) 2026-07-10 15:43:30 +00:00
df3f9d4b92
Reflect own away state in the member list (patch self entry) + orange away name 2026-07-10 15:04:23 +00:00
04f229053a
Fix avatar click crash: capture rect before the state updater runs 2026-07-10 14:48:22 +00:00
b94b9f8994
Open a presence menu (Available/Away + reason) from the account avatar 2026-07-10 14:45:28 +00:00
cf51efe339
Rework theme presets: rename, orange Night-orange accent, theme-aware console 2026-07-10 14:25:26 +00:00
8894974479
Drop the app's consent banner; consent is handled on the site (same origin) 2026-07-10 14:01:44 +00:00
6f9df54864
Switch analytics to Google Consent Mode v2 (advanced) 2026-07-10 13:29:01 +00:00
8fd1af78bb
Fix stale app-update comment to match no-auto-reload behavior 2026-07-10 10:30:47 +00:00
726ce7b609
Strip IRC formatting codes from WebMCP read_messages output 2026-07-10 10:27:21 +00:00
7f6fb14b19
Expose the chat as WebMCP tools for AI agents (list/read/send/join/switch/members) 2026-07-10 10:21:10 +00:00
6ad0d2bbd9
Fix mobile drawer footer: give the nav tabs their own full-width row 2026-07-10 09:25:46 +00:00
c18604c426
Stop the app from auto-reloading on new builds; update on next manual refresh instead 2026-07-10 09:04:31 +00:00
ad05f3e470
Fix mobile topbar overflow: drop the redundant channel avatar and tighten spacing 2026-07-10 08:13:30 +00:00
937a9dc38c
Fix mobile: wrap the extban composer and full-screen the channel panel 2026-07-10 08:06:22 +00:00
4a0b6b51e0
Fix mobile layout: constrain the app to the viewport height when a navbar shell wraps it 2026-07-10 08:00:46 +00:00
27cfae944a
Support extban stacking: nest a matching extban inside an acting one (mute:account:...) 2026-07-10 07:42:11 +00:00
a390078876
List +e ban-exceptions and +I invite-exceptions in the channel panel, removable per mode 2026-07-10 07:27:02 +00:00
a141742868
Gate the extban +e/+I modes on the server's CHANMODES list modes 2026-07-10 07:17:41 +00:00
2bae979b30
Let extbans be set as +b, +e or +I from the channel panel, with the example following the mode 2026-07-10 07:11:33 +00:00
efe0fb0df9
Add a security-group quick-pick for the securitygroup extban (config-driven) 2026-07-10 07:01:13 +00:00
f920d52847
Show the full +b command example for the selected extban type 2026-07-10 06:45:29 +00:00
5a3d6e737d
Type the new bans tab in the Tab union 2026-07-10 06:36:39 +00:00
d5c8da0d50
Give plain bans their own tab and reflow the channel panel to a single full-width column 2026-07-10 06:34:45 +00:00
e620f72f84
Replace the extban chip grid with a compact searchable type selector 2026-07-10 06:18:49 +00:00
4fe83e790a
Move extbans into their own channel-panel tab, separate from plain bans 2026-07-10 06:13:06 +00:00
9b3db02721
Add an extban picker to the channel panel, driven by ISUPPORT EXTBAN 2026-07-10 05:52:46 +00:00
900859acdf
Share the Django site's analytics consent instead of asking twice (same-origin tchatou-consent) 2026-07-10 05:28:23 +00:00
56ae05f56d
Clear any lingering GA cookies at boot when analytics consent isn't granted 2026-07-10 05:19:09 +00:00
69cd4eb024
Gate Google Analytics behind a consent banner (opt-in, withdrawable in Settings) 2026-07-10 05:16:43 +00:00
68c7560212
Add opt-in Google Analytics (gtag.js) with per-channel page views, DMs redacted 2026-07-10 04:32:33 +00:00
1a82247a7d
Enable cookieless analytics on the app (config.analytics endpoint) 2026-07-10 04:19:00 +00:00
c222eda4c1
Loosen the analytics beacon backstop below the debounce so session events aren't dropped 2026-07-10 04:12:27 +00:00
66509f5adb
Add a sandboxed cookieless analytics feature (host-mediated beacon, off by default) 2026-07-10 04:03:29 +00:00
ddcb4d9765
Add config-driven SEO metadata: description, Open Graph/Twitter cards, canonical, JSON-LD 2026-07-10 03:45:05 +00:00
80af3cbb21
Add a meta description to the app document head 2026-07-10 03:30:06 +00:00
9e243c6546
Re-pin the message list synchronously so a late card load doesn't paint a shift 2026-07-10 03:12:08 +00:00
a8fcdabcda
Keep the newest lines pinned when a link card or embed loads after paint 2026-07-10 03:07:53 +00:00
b87acb63b5
Remove the loading skeleton 2026-07-10 03:01:29 +00:00
a5919ee0cc
Add a loading skeleton while a channel fetches its first messages 2026-07-10 02:50:36 +00:00
102 changed files with 2147 additions and 679 deletions

View file

@ -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/

View file

@ -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
View file

@ -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": {

View file

@ -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"]
}

View file

@ -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>`);
});

View file

@ -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) => {

View file

@ -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

View file

@ -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>
);

View file

@ -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
View 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>
);
}

View file

@ -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>

View file

@ -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;

View file

@ -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();

View file

@ -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)}

View file

@ -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' },

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}
});

View file

@ -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.

View file

@ -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>

View file

@ -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>

View file

@ -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.

View file

@ -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>
);

View 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>
);
}

View file

@ -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;
}
});

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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();

View file

@ -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();

View file

@ -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

View file

@ -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>
);

View file

@ -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 />

View file

@ -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() {

View file

@ -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() {

View file

@ -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>

View file

@ -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';

View file

@ -1,4 +1,4 @@
import { usePluginRegistry } from '../../modules/registry';
import { usePluginRegistry } from '@/modules/registry';
import { PluginBoundary } from '../PluginBoundary';
import { Modal } from './Modal';

View file

@ -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() {

View file

@ -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']);

View file

@ -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';

View file

@ -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.

View file

@ -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.

View file

@ -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();

View file

@ -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' },
];

View file

@ -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);

View file

@ -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>
);

View file

@ -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();

View file

@ -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() {

View file

@ -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
View 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
View 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 */ }
}

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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 गेटवे"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": "ı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"
}
}

View file

@ -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
View 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 */ }
}

View file

@ -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);

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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,
};

View file

@ -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

View file

@ -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 });
}
}

View file

@ -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
View 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 */ } }
}

View file

@ -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
View 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
View 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;
}

View file

@ -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>}

View file

@ -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()

View file

@ -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;

View file

@ -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'] });
}

View 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');
});

View file

@ -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) {

View file

@ -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');

View file

@ -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,

View file

@ -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; };

View file

@ -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);
}

View file

@ -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. */

View file

@ -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 */ });
});
}
}

View file

@ -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