mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 08:21:50 -06:00
ff6dd395c95753b9557ca3fcc441ee35cb9a41d3
254 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6ad3ad046b |
client: React client on the v3 API, replacing the Vue 2 client (#713)
* client-react: framework-free client core for the v3 API A new client is built next to the Vue 2 one (client/ keeps shipping until it is replaced at the end of this series). This first part is the core library in client-react/src/core: no UI framework, no runtime dependencies, written against master's protocol (server/pkg/types, webrtc payloads, openapi.yaml). - client.ts: NekoClient: login/logout/whoami (token or cookie, ?token=), websocket with backoff reconnect and stale-socket detection (the server heartbeats every ~10s), WebRTC as answerer (server candidates that arrive before signal/provide are buffered), the binary big-endian input data channel, control/screen/clipboard/broadcast/unicast messages, letterboxed video mount, microphone tracks via renegotiation. - overlay.ts: input layer over the video: mouse (throttled, last position always sent), wheel steps with sensitivity, keyboard through the vendored Apache Guacamole keyboard (also used by the Vue 2 client) with the macOS key remapping, native touch or touch-as-mouse, file drop, and the host's cursor drawn for everyone not in control. - store.ts: tiny proxy store (subscribe/watch/version) + typed emitter. - api.ts + api.gen.ts: fetch wrapper; types generated from server/openapi.yaml (npm run gen:api), no generated runtime code. - types.ts: client state and wire types mirroring server/pkg/types. The root .gitignore ignores "core" (core dumps); client-react/.gitignore re-includes src/core. Check: npm run check (types, prettier, store self-check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client-react: React GUI with the look and features of the Vue 2 client React 19 + Vite GUI on top of the core (client-react/src/app). The Vue 2 client's SCSS is reused with the same class names, so it looks the same. Features: login (display name + password, ?usr=/?pwd= invite links, OAuth button), video with fullscreen + keyboard lock, picture-in-picture, resolution menu, clipboard sync and fallback, play/unmute overlays, members with admin menu, control request/release/give/take, local controls lock, microphone, volume, emotes, chat, file transfer (streamed downloads, uploads with progress, multi-select delete), settings (scroll, autoplay, emotes, chat sound, links in app, keyboard layout, broadcast), locks for login/controls/file transfer, toasts and SweetAlert-style dialogs (native <dialog>), all 15 translations, URL params (embed, cast, volume, scroll, lang, show_side, mute_chat). Behaviour notes: - Kick/ban follow common chat-app semantics, documented at actions.kick in neko.ts: kick removes the session (may log in again); ban sets can_login:false and is only offered when the auth provider stores accounts (GET /api/members), with an unban list in Settings. Stale "IP ban / restart server" translations are dropped (English fallback). - Settings keep the Vue 2 localStorage keys and format, so preferences carry over; scroll sensitivity uses the v3 steps (new key), ?scroll= is mapped onto them. - Chat markdown (markdown.ts) parses to React elements: no HTML strings, links limited to http(s)/mailto. Emoji use the same emoji.json and twitter sprite sheet as before. - Icon controls are labelled buttons with keyboard activation. Check: npm run check (adds markdown self-check), npm run build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client-react: two-user end-to-end test e2e.mjs drives headless Chromium users (Playwright) through a running server with the legacy API off and the multiuser provider: login + video, members, chat (incl. markdown rendered safely and the emoji picker), control hand-off and admin force-release, emotes, file upload/download/ delete, language switch, locks, mute, ban hidden without stored accounts, keyboard activation, resolution change, keyboard layouts, about, implicit hosting, clipboard sync, microphone (fake device), reconnect after a network drop, typing into the desktop, kick, and URL params (usr/pwd/lang/show_side, embed). With NEKO_FILE_URL pointing at a file-provider server it also covers ban/unban. Failing steps save screenshots of both users and log their member lists. NEKO_URL=http://host:port/ npm run test:e2e Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client-react: Dockerfile, dev scripts, emoji tool, README - Dockerfile (node 24 LTS, npm ci) with the same contract as the Vue 2 client stage: npm run build -> /src/dist, used by Dockerfile.tmpl. - dev/ docker wrappers like the Vue 2 client: serve runs vite on :3001 with /api (incl. websocket) proxied to the backend on :3000 (override with NEKO_URL), plus npm/build/exec. - tools/emoji.ts: the Vue 2 emoji generator ported to ESM (runs on node directly); regenerates _emoji.scss byte-identically (npm run build:emoji). - README: core API, development, checks, e2e, behaviour notes. Check: npm run check; docker build of this directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: replace the Vue 2 client with the React client client-react/ becomes client/. The client uses only the v3 API (it makes no requests to the legacy compatibility layer, which stays in the server for other V2 clients), so images built from this tree also work with NEKO_LEGACY=false. The image build is unchanged: Dockerfile.tmpl still builds ./client and copies /src/dist to /var/www. - CI: node 24 LTS (React 19 / Vite 7 need >= 20.19), `npm run check` before the build, no browser download for the e2e tool. - Docs: developer guide (build, development, repository structure) and release notes. Tested on an image built with ./build (xfce, default config, legacy on): client e2e (28 steps), ban/unban on a file-provider server, no requests to /ws, /stats, /file or /screenshot.jpg and no V2 data channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: back off and give up when WebRTC keeps failing When the peer connection failed (no media route: firewall, wrong NAT1TO1, missing TURN, or a browser that cannot reach the server), the client immediately asked for a new peer, which failed again: ~9 peer setups per second on the server (551 in 60s in a test with Firefox). Retries now back off (1.5s up to 6s) and stop after 10 consecutive failures with "video connection failed (WebRTC)"; a successful connection resets the count. Tested: Firefox (ICE fails in this environment, for the Vue 2 client too) now makes 11 attempts in 75s and shows the Disconnected dialog; Chromium e2e 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: tell the user when the browser cannot play the stream's codec If the browser has no video codec in common with the server (e.g. an H264 stream and a Firefox without the OpenH264 plugin), it rejects the video section of the offer (m=video 0). The server then fails with "unable to start track, codec is not supported by remote" and never answers ICE, so the client spun and retried until it gave up with a generic WebRTC error. It now stops right away with "this browser cannot play H264 video". Tested with stock Firefox 157 (no OpenH264): H264 server -> message after 0.3s; VP8 server -> video plays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: build on Debian trixie like the rest of the repo Upstream moved its images to Debian 13 (trixie) in #581; the client Dockerfile and dev scripts still used node:24-bookworm-slim. Tested: client/Dockerfile builds on node:24-trixie-slim (Node 24.21) and produces dist/; dev/build builds dist/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: ask for a new peer when the server closes it; mic survives peer changes When the media path fails, the server notices within about 5 s, destroys its peer and sends signal/close. The client only closed its own peer: a locally closed RTCPeerConnection never reports "failed", so the backoff from 5c9418fb never ran, and the websocket's heartbeats kept the stale check quiet. Result: a frozen frame with status "connected" until the page was reloaded. Reproduced by dropping the media port for 12 s with the websocket up: no signal/request followed, and video.currentTime stayed frozen for 60 s after the port was open again. signal/close now takes the same path as a failure the browser noticed: close, back off, request again. With this the client re-requested 1.5 s after signal/close and video resumed about 5 s after the port opened. Recovering exposed the microphone: its RTCRtpSender belonged to the old peer connection, so removeTrack() on the new one threw InvalidAccessError before the track was stopped (mic stuck on; releasing control then threw inside an effect and the error boundary replaced the page). removeTrack() now ignores senders of a previous connection, and the mic is switched off whenever the connection is re-established, as the Vue client did on every disconnect. The core now runs under plain Node (imports with extensions, no parameter properties, erasableSyntaxOnly), which makes src/core/client.check.ts possible: a state-machine check with stub WebSocket/RTCPeerConnection that fails on the old signal/close handling. It is part of `npm run check`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: encode session ids in REST paths Session ids on the multiuser and noauth providers start with the name the user typed at login, and the client interpolated them into URLs as they were. The browser resolves "..", "?" and "#" before a request leaves, so a member could choose what an admin's action against them does: display name admin clicks request actually sent x/../../logout? Mute POST /api/logout (admin logged out) <someone else's id># Kick DELETE /api/sessions/<someone else> Both were reproduced against a test server. Every id that is part of a path now goes through encodeURIComponent; with that the first name no longer leaves /api/members/ and the second gets the right session kicked. The e2e test logs in a member named "evil#?x" and checks that the admin's mute lands on that member and the admin keeps the session. Names with "/" need the server to build ids from a sanitised name; that is a separate server change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: chat stays fast past the line limit Once the chat held CHAT_LIMIT lines, every new line replaced the list with `chat.slice(-CHAT_LIMIT)`. The elements of that slice are the proxies the store had handed out, the setter unwraps only the array itself, and the next read wrapped every element again: one more proxy layer on each of the 1,000 kept lines per incoming message. Measured in the UI, the main thread blocked 78 ms per line at the limit and 751 ms eighty lines later; a read overflowed the stack about 3,000 lines in and the error boundary replaced the page. Event lines count toward the limit, so a busy room reached it in an evening. Store.wrap() now returns a value that is already one of its proxies, and the chat is trimmed in place. Lines carry an increasing `seq` used as the React key and by the scroll-to-newest effect, so a trim no longer shifts spoiler state between messages, re-parses every message, or stops the scrolling (the length no longer changed once at the limit). store.check.ts asserts that an element put back into the store keeps its identity, and that 3,000 such round trips still read in constant depth; the old store fails both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: keep one identity per login, keep the session through an outage Three session-flow defects, all reproduced against a test server: - An invite link (?usr=&pwd=) opened in a browser that still had a valid session raced the autologin: the resumed session opened the websocket, the invite's login replaced the token, and connect() was a no-op because a connection was already wanted. The websocket then ran as the old session and every REST call as the new one (3 of 3 runs). login() now closes whatever was resumed before storing the new token. - After the core gave up reconnecting (about 80 s offline) the app asked /whoami and treated any failure as "session gone", including a network error, so logout() removed the saved token and the user landed on the login form although the session was still valid. The screen written for this case (logged in but disconnected, with a Connect button) was unreachable. Only a 401 logs out now. - A wrong ?pwd= was a dead end: the invite was cleared only on success, the password field stayed hidden and the URL was already cleaned. The invite is now used for the first attempt only, as in the Vue client. The disconnect dialog also names the common case where a second tab or window took over the session, instead of the raw "connection replaced". e2e: a second invite link in a profile with a live session must leave exactly the new session connected; a long outage must end at the Connect button, not the login form. The websocket is closed with status 1000 when the client closes it on purpose (login, logout, giving up). Without a status the server assumed a reconnect was coming and kept the session connected, and host, for 5 s; with it the old session drops at once, which the invite test relies on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: chat parser cannot be stalled by a message The chat markdown parser runs during render for every message from every member. Three inputs made that expensive or fatal: - A code fence with no closing fence followed by newlines: the fence regex backtracked for 4.3 s on a 512-character message that fits the normal chat box (the same expression the Vue client used). - Long runs of backticks: the inline-code regex with a backreference was quadratic per position. - Deep nesting or very long messages: 24,000 asterisks overflowed the stack inside render, which ends at the error boundary for everyone. Fences and inline code are now found with indexOf, the line-start and previous-word facts a rule needs are computed once per position instead of by regex over the whole prefix, and the UI parses through parseSafe(): messages above 10,000 characters are shown as plain text, and any parser failure falls back to plain text as well. The self-check covers the unclosed fence (must parse in milliseconds), 40,000 asterisks (must not throw) and the length cap, and keeps the earlier formatting cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: muting the sound keeps the desktop usable The "unmute" overlay was shown whenever the video played muted, so a user who muted on purpose got a click-catching layer over the desktop: the first click unmuted instead of reaching the remote, and the only way to work with the sound off was the volume slider at zero. The Vue client showed that overlay only until the first unmute. The core now remembers when it was autoplay that muted the video, and the overlay appears only in that case. e2e: muting from the controls must leave no overlay over the player. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: member menu works on narrow screens The member menu was rendered inside the members bar, which the layout hides at 768 px and below. Right-clicking a chat author there set the menu state but nothing appeared, so ignore, mute, kick and give-control were unreachable on phones. The Vue chat had its own menu. The menu is position: fixed anyway; it is now rendered by App, outside the hidden container. e2e: at 700 px wide, the menu opens from a chat author. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the video takes the keyboard only while input can flow Mouse-enter focused the hidden input layer for everyone, so a viewer typing in the chat lost the focus by brushing the video and the rest of the sentence went nowhere. The Vue client focused the overlay only while hosting and unlocked. The overlay now focuses on mouse-enter only when input is active, and also when control arrives while the pointer is already over the video. e2e: a viewer types half a sentence, crosses the video, and the chat box has the whole sentence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: emote repeat stops with the bar Holding an emote sends it every 350 ms; the interval was cleared only by mouse-up or mouse-leave on the bar. When an admin muted the member the bar was removed first, so neither event could arrive and the member kept sending that emote until reload (11 more in the 4 s after releasing the mouse, then on). The interval now dies with the component, and a muted member's sendEmote is a no-op, as the Vue client's action was. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: master's keyboard file; typed text as keys; Ctrl+V keeps both clipboards right The vendored Guacamole keyboard was the 2022 copy from the reference branch, not the one upstream master ships. It lacked two fixes from September 2025: typing of indirectly entered text (IME, on-screen keyboards) as key events (#577) and the GUACAMOLE-1823 Caps Lock fix for macOS Chrome; it also sent right Alt as AltGr on every platform and had wrong or missing Japanese keys. The file is now master's, byte for byte. With it, IME and phone text is typed as key events instead of going through control/paste, which overwrote the remote clipboard (and over https the local one) on every keystroke and did nothing in programs where Ctrl+V is not paste. Ctrl+V keeps using the browser's paste event, the reason the reference client introduced the textarea in 2022: only Chromium over https can read the clipboard programmatically, a paste event hands the text over in every browser. Two defects in that path are fixed: - Ctrl was released to let the paste through and the server's paste reset all keys, so with Ctrl still held the next key arrived as a plain letter ("xyzxyza"). Ctrl is no longer touched; the remote gets its paste keystroke from the overlay, which completes the chord with its own Ctrl if the user has let go by then. - The local text always won, so after copying inside the remote a paste on a plain-http site (no clipboard sync) pasted stale local text. Now the side that copied most recently wins: the server reports every remote change, and if the remote changed since the local clipboard was last seen, only the keystroke goes out; otherwise the local text is set first and the keystroke follows once the server confirms it. Verified in a browser on https and on plain http: a fresh session pastes local text; copy-then-paste inside the remote yields the remote text on both origins; Ctrl held across V and A selects all instead of typing a. client.check.ts covers the decision; e2e now asserts what the typing step delivered and that Ctrl+V inside the remote doubles it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: e2e asserts what its step names promise - Page errors and console errors now fail the run; they were only printed, so a crash to the error boundary passed. - The typing step checks what arrived in the remote (added with the keyboard change) and the resolution step waits for the revert to take effect instead of leaving later steps to run mid-resize. - "cannot log in" for a banned account requires a login error with the form still shown, not any open dialog. - The upload step is named for what it checks (the uploader's own list; the documented setup gives plain users no Files tab). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: core fixes from the review's minor list - A member who may not watch never receives an offer and was left on a full-screen spinner for good, chat included. Eight seconds without an offer now shows the room without video. e2e: can_watch off, reload, the chat is reachable. - The reconnect lookup (/whoami) is bounded to 5 s; on a black-holed network a fetch can otherwise hang for minutes. A close()+connect() while that lookup was pending could open a second socket; the decision now belongs to one connection generation. - A closed peer took no retry timer with it, so a websocket reconnect could trigger a second signal/request. - play() treated any rejection as a refused autoplay and muted the video; only NotAllowedError does now. - Upload errors showed xhr.statusText, which is empty under HTTP/2; the server's JSON message is used. - keyboard/map was re-sent, and setxkbmap run on the server, on every viewer setting change while hosting; the watcher now follows the layout, not the object. - A click waiting for control under implicit hosting was replayed whenever control arrived, even minutes later by an admin giving it; only a press from the last two seconds is replayed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: small behaviours the Vue client had - The side panel, the selected tab and the volume are remembered again, under the Vue client's keys (side, tab, volume 0..100). ?show_side= and ?volume= still win for the visit they are in; ?volume= is applied once, not on every reconnect. - The autoplay setting is read when a track arrives, so changing it no longer needs a reload. - Control events say who took the controls from whom and who released whose, with the strings every locale already had. - Logging out clears the chat, the ignore list, uploads and the broadcast URL for whoever uses the browser next, as the Vue client's reset did. The login name and password the Vue client stored in localStorage are removed on load; nothing reads them. - <html lang> gets zh-CN and zh-TW for the two Chinese locales instead of the invalid cn and tw. - ?token= is removed from the address bar after login, like ?pwd=. e2e opens the side panel instead of toggling it blindly, since the state now survives a reload. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: small UI defects from the review - The resolution menu was pinned to the window and sat on the side panel when that was open; it is now inside the player. It also no longer shares the member menu's class, so the member menu's row styles stop leaking into it. - The file icon lost its class when the list was ported, so the gap before the name was gone. - Choosing the same file twice in a row uploaded nothing: the file input is reset after each pick. - The banned list reloads after a ban or unban from anywhere. - A slow "can this member be banned" answer could land on another member's menu. - The clipboard box follows what is copied on the remote while it is open. Fullscreen and picture-in-picture rejections are no longer unhandled. The video's control button toggles through the same action as the one in the room bar, with its toast. - Emote names from other clients are checked against the known list before they become class names; link text in chat is not linked again, so [https://a](https://b) is one anchor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: keyboard access to menus and pickers - Escape closes the member menu, the emote and emoji pickers, the resolution and clipboard menus and the About panel; before, only a click anywhere did. - The member menu moves focus to its first item when it opens, and the emote picker opened from the keyboard appears at the icon instead of the top-left corner. - The 1,800 emoji were individual tab stops; they are reachable through the search box now, where Enter takes the first match. - The broadcast buttons, the setting switches and the control lock have accessible names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: remove what nothing uses - The generated OpenAPI types (2,492 lines, a devDependency and a script) served two type names in three places; those two shapes are now declared in types.ts next to the other hand-written wire types. The generated schemas were all-optional and already lacked two settings fields, so nothing is lost. - 47 emote PNGs that no stylesheet referenced (the SVGs are used). - Styles ported from Vue components that have no element any more: the About page's markdown loader and bounce animation (connect.scss has the same keyframes), `body > p`, the event line's `<i>`. - A re-export and a helper that nothing imported, and three locale keys nothing reads (one of them added by this branch). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs and build: release notes, Node version, dead build settings - Release notes cover the behaviour changes of the fix series: media recovery, paste semantics and IME typing, the microphone on reconnect, the second tab, sanitised session ids. - The build page said Node 20.19 works; `npm run check` runs TypeScript files with Node itself, which needs 22.18 or newer. - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD did nothing (Playwright 1.63 has no install script); removed from the Dockerfile, the workflow and the dev script. - Three comments used a private convention; reworded as plain notes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: lint the two React hook rules ESLint with only rules-of-hooks and exhaustive-deps (typescript-eslint for the parser), run by `npm run check` and therefore in CI. The five findings were effects that meant to run once but read a hook value: they now read the stable store instead, or list the dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: e2e can drive a real Chromium-based browser NEKO_CDP=http://host:port connects the suite to a browser started with --remote-debugging-port (Brave, Chrome, Edge) instead of launching Playwright's Chromium, so the same steps run against a browser people actually use. README: how to start it, the check script also lints, the typing step works with Xfce's launcher too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the on-screen keyboard toggle closes the keyboard Tapping the keyboard button while the keyboard was open closed and reopened it: the button is focusable, so the tap took the focus from the hidden input first (its blur handler cleared the flag), and the toggle then saw "closed" and focused again. Checked with a Pixel 7 emulation: focus went overlay, overlay, overlay. The button no longer takes the focus on press, and the toggle decides from its own flag before the blur handler changes it. Now: overlay, none, overlay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * server: default video pipeline without the mouse pointer The client draws the pointer itself from the data channel (cursor image + position, sent to every peer when its channel opens and on every host move): CSS cursor for the host, canvas overlay for everyone else. With the pointer also drawn into the stream, a second pointer trailed the real one by the stream latency, for the host and for watchers. Before #691 the default main pipeline had no pointer (the "legacy" stream carried one for V2 clients, and the V2-config path still forces show-pointer=false on main). #691 made the default visible; this goes back to the original default for the main stream. Broadcast and screencast keep following capture.video.show_pointer (default true), the legacy stream keeps its pointer, and capture.video.show_pointer=true puts it back on all video pipelines. Docs: custom pipeline examples use show-pointer=false, with a note why. Tested (xfce image built with ./build, default config, compat layer on): - stream: pointer moved into an empty region changes received frames 6/6 before (diff ~41k), 0/6 after (diff 0) - watcher (not in control) sees the host's pointer drawn at the right spot 5/5, and still sees it after the host releases control - client e2e passes Known gap (from the code): pointer moves that do not come from neko input (apps warping the pointer, touch-only hosts) are not broadcast, so watchers see the last position until the next mouse move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: an invite link logs in even when a saved session resumes first Opening a `?usr=&pwd=` link in a browser that still holds a session did the invite login only if the saved session had not finished resuming by the time the connect screen mounted. That is a race between the whoami request and React's first effect; WebKit loses it every time (the page stayed logged in as the old user, the server never saw the new one) and Chromium happens to win it. The link now always logs in; login() already drops a websocket the resumed session may have opened meanwhile. Found by running e2e.mjs in WebKit (url params step). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: no "connected" without video while the server is silent When a request for media gets no offer within 8 s, the room is shown without video (the server declines, e.g. can_watch is off). After a lost network the same timer ran for the wrong reason: the media path fails within seconds, the client asks for a new peer into a socket that is dead but not yet known to be (the stale check needs 25 s), and 8 s later the connect screen gave way to a frozen picture labelled connected, until the stale check reconnected. The fallback now applies only while the server has spoken within the last 16 s (it heartbeats every 10 s); otherwise it re-arms and leaves the outage to the stale check. client.check.ts drives the timers by hand and fails on the old code. Observed in WebKit with the server container paused for 40 s: before, the connect screen disappeared at +25 s while the server was still frozen; after, it stays until the real reconnect. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: e2e steps hold in WebKit and Firefox too The second-invite step gave the server 1.5 s to drop the first session. Chromium closes the old page's socket with a close code; Firefox closes it abruptly (1006), and the server then keeps the session for its 5 s reconnect grace. The step now polls for up to 8 s. Playwright's offline emulation freezes an open websocket only in Chromium; WebKit and Firefox keep delivering heartbeats to it (measured: one frame in 15 s offline), so the two outage steps would wait for nothing there. They say so and skip outside Chromium; the recovery path itself was verified in WebKit by pausing the server container. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: @/ path alias in tsconfig; design layer for tokens, reset and fonts First step of the layout requested in the #713 review: `src/design/` holds the SCSS tokens (`_variables`), the reset, the Whitney font faces and the global page styles that `assets/styles/main.scss` used to collect. The `@/` alias that only Vite knew is now also in `tsconfig.json` (`paths`), so cross-layer imports can use it everywhere. The emote and emoji sprite sheets are still under `assets/styles/vendor/` and are imported from `main.tsx` for now; they move next to their owners (emotes feature, chat plugin) in the following commits. No CSS rule changes (built CSS compared rule by rule). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: split neko.ts into state, i18n and a11y modules `src/app/neko.ts` held the stores, settings, actions, dialogs, chat log, event wiring and i18n in one module. It is now split by concern: state/storage.ts localStorage helpers (same keys and '1'/'0' booleans as before) state/stores.ts createStore(): every GUI store registers here so one hook can subscribe to all of them (plugins will add their own) state/app.ts the app store and the viewer-settings defaults state/client.ts the NekoClient instance; name(), isMuted(), hostId(), isLocked() state/settings.ts setSetting(), applySettings(), initSettings() (URL parameters) state/actions.ts room actions (login, control, locks, kick/ban, broadcast) state/dialogs.ts ask(), tell(), toast() state/api.ts REST call with a toast on failure state/chat.ts chat log, sendChat(), mute(), openInApp() state/files.ts file transfer actions and types state/emoji.ts emoji data for the picker state/emotes.ts EMOTES, showEmote(), sendEmote() state/events.ts server events -> chat lines and toasts state/hooks.ts useNeko() i18n/ t(), setLang(), the locale table (moved from app/locale) components/a11y.ts a11y(), closeOn() and the Enter/Space activation listener app/boot.ts start-up order: event wiring, settings, then the connection Components still live in `app/` and now import from these modules with `@/`. No behaviour change; built CSS identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: GUI split into app, layout, features and components; styles beside their components The four GUI files (`App.tsx`, `Room.tsx`, `Side.tsx`, `Video.tsx`) and the `app/styles/` folder become one folder per unit, each with its component and its `.scss` next to it, imported by the component: app/ App (shell), ErrorBoundary, boot, app.scss (the #neko grid) layout/ Header, Side, RoomBar (the bar under the video) features/ video (+ Resolution, Clipboard), members (+ MemberMenu), controls, room-menu, emotes (+ Emote animation, sprite sheet), settings (+ Banned), connect, about components/ Avatar, Dialog, Toasts, Logo, ContextMenu (the `.context` menu used by the member menu and the emote picker), a11y plugins/ chat (Chat, Markdown, EmojiPicker, parser, emoji sprite sheet) and filetransfer (Files): components and styles only for now, still wired statically; the manifest and registry follow in the next commit Units are imported through their `index.ts` with `@/`. `assets/` keeps only images. `openMenu()` moved to `state/actions.ts` so the member list and the chat open the same menu without importing each other. `tools/emoji.ts` and `build:emoji` write the sprite sheet to its new place. No markup, class-name or behaviour change; built CSS identical rule for rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: plugin manifest and registry; chat and file transfer become plugins `src/plugins/types.ts` describes what a plugin contributes (sidebar tab, top-bar items, member-menu items, locale strings, server event handler, init), and `src/plugins/index.ts` lists the plugins of the build in order: `[chat, filetransfer]`. The side panel, the header and the member menu render what the registry returns, and `message` events starting with `<id>/` go to that plugin's `onEvent`. Adding a plugin is its folder plus one entry in the registry; removing the entry removes its tab, header item and event handling, and the app still compiles (checked with each plugin and with both removed). Each plugin owns its store (`createStore` registers it so useNeko() re-renders on its changes), its actions, its locale strings (`locale.ts`, merged into the i18n table at start-up; the merged table is identical to before) and its components: plugins/chat Chat, Markdown, EmojiPicker, emoji data, the mute member-menu item, `chat/init` and `chat/message`, the muted/unmuted lines plugins/filetransfer Files, the header lock (via the shared LockButton), the `filetransfer/update` event, the lock event line, uploads `chat`, `texts`, `files` and `uploads` left the app store. Room events that the chat shows as lines ("bob took the controls") go through a small bus in `state/bus.ts`, so features and plugins never import each other; `logout` is a bus signal too. `openInApp`, `ignored` and `chat_sound` stay in the app store and settings: they are read by features (Settings, the member menu, emotes), not only by the chat. The file drop on the video is unchanged: it is handled in the core (room/upload/drop, the server's drop into the remote desktop) and the video feature never imported file transfer. No markup, class-name or behaviour change; built CSS identical rule for rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: publish the core as the @m1k1o/neko package `client/src/core` becomes `client/core`, its own npm package: core/package.json @m1k1o/neko 3.0.0, ESM (`type: module`), `exports` with types, `files: dist + README`, no runtime dependencies, `typescript` as the only dev dependency (own lockfile, so `npm ci && npm run build` works on its own) core/tsconfig.json declaration build into dist/ with `rewriteRelativeImportExtensions` (the sources import `./x.ts` so the self-checks run under plain Node) core/build.mjs after tsc: `.ts` -> `.js` in the .d.ts imports (tsc only rewrites the JavaScript), copies the vendored Guacamole keyboard core/src/index.ts the public entry point: NekoClient, Overlay, Store, Emitter, NekoApi, ApiError and the wire types core/README.md the API reference with usage examples The GUI imports only `@m1k1o/neko`: for development and the Vite build the name is aliased to `core/src/index.ts` in tsconfig.json (`paths`) and vite.config.ts, so there is no workspace and `client/Dockerfile` is unchanged. `npm run build:core` builds the package; `npm run check` keeps running the core's `*.check.ts` and lints both trees. `client/.gitignore` re-includes `core/` (the root .gitignore ignores core dumps). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: lint the layering rules and fail on import cycles `eslint.config.js` gets one `no-restricted-imports` rule set per layer: between folders only `@/` imports (a folder's own files relatively), features through their `index.ts`, plugins only through the registry `@/plugins` (and the contract `@/plugins/types`), the core only from `@m1k1o/neko`, and no layer imports one above it (app -> layout -> features -> components -> design; state and i18n below all but design). `tools/cycles.mjs` walks the import graph of `src/` (type-only imports excepted) and fails on a cycle. Both run in `npm run check`. Two imports did not fit the rules: the plugin entries used `../types` (now `@/plugins/types`), and the About dialog read the version from `../../../package.json`; Vite now defines `__APP_VERSION__` from it, same text in the dialog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: build the @m1k1o/neko package in the client workflow Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: client layering, plugin contract and the core package `client/README.md` documents the layout and its dependency rules, the plugin contract with the steps to add one, the core package and the scripts. `client/core/README.md` is the API reference of `@m1k1o/neko` (added in the package commit). The repository structure page and the release notes name `client/core` and the package. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * core: unit tests for the connection state machine node:test suites for @m1k1o/neko, run with `npm test` (from client/ or core/) and as part of `npm run check`. client.test.ts drives NekoClient headless against fake sockets, peers, fetch and a virtual clock: the handshake, signal/close and peer failures with backoff and the give-up after RECONNECT_MAX, the offer timeout, the stale socket check, socket loss and reconnect, disconnect(), heartbeats, the codec mismatch, system/disconnect, session/control/screen/clipboard/settings events, preparePaste precedence, login/authenticate/logout, send() and sendData(). store.test.ts ports store.check.ts. The *.check.ts self-checks stay; *.test.ts is excluded from the tsc builds like *.check.ts, so dist/ and the published package are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: vitest replaces node:test and the check scripts One test runner: `npm test` is `vitest run` (core/package.json delegates to it for core/src). client.test.ts and store.test.ts import the runner from vitest and keep node:assert; the custom clock in client.test.ts is now vi.useFakeTimers (setImmediate stays real to settle promises between timers). The three *.check.ts scripts are folded in: store.check.ts was already covered by store.test.ts, client.check.ts's stale-sender removeTrack case moved into client.test.ts, markdown.check.ts became markdown.test.ts. The tsconfigs, tools/cycles.mjs, dist.check.mjs and the READMEs no longer mention them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: Video.tsx -> Stage, Player and three hooks Pure split of the 156-line Video component, no markup or behaviour change (the view harness shows identical #root HTML for every view): Stage.tsx keeps the layout, the two popover menus and the play/unmute overlays; Player.tsx is the mount point the client puts the stream and the input overlay into; useFullscreen (with the keyboard lock), useClipboardSync (focus listener + the hover sync, and the canReadClipboard capability) and usePip (the capability and the request) carry the browser-API bits out of the component. The feature index exports Stage; App.tsx renders it in place of Video. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * core: StreamTransport seam, WebRTCTransport, input channels NekoClient no longer owns the RTCPeerConnection, the data channel or the <video>: it keeps auth, the websocket, the room state, the stale check, reconnects and heartbeats, the overlay and the letterboxing, and hands the stream to a StreamTransport (transport.ts). Modelled on guacamole-common-js (Client over a Tunnel). transport.ts: StreamTransport (kind, element, input?, connect(session), suspend(), close(), attach/detach, setPlaying/setVolume/setMuted, addTrack?/removeTrack?, on('state'|'stats'|'error'|'cursor.position'| 'cursor.image')), SessionInfo (url, token, the system/init payload, send/on over the main socket, lastMessage, autoplay) and InputChannel (send(op, ...fields)). suspend() is what the socket loss did to the peer (drop it, keep the picture and the backoff count); close() is the full stop; status 'unavailable' is today's "no offer while the server keeps talking", shown as connected without resetting the socket attempt counter, as before. transport/webrtc.ts: everything peer-related, unchanged in behaviour: signal/request, provide/offer/answer/restart/candidate/close, trickle ICE buffering, onnegotiationneeded for the microphone, the offer timeout, peer-failure backoff with the same constants and messages, the codec-mismatch check, ontrack -> its own <video> (same element, attributes and style as before), the autoplay-muted fallback and the mirrored video state, cursor frames from the data channel as events. input/datachannel.ts: DataChannelInput, the big-endian encoding that was sendData() plus the incoming cursor frames. input/websocket.ts: WebSocketInput, the same opcodes as the server's control/* events (delta_x/delta_y/control_key, keysym, code, touch_id/x/y/pressure), moves coalesced on a 16 ms timer and flushed before any other input. The client uses transport.input ?? new WebSocketInput(client); the overlay only calls client.input. Not used by default (WebRTC has the data channel), so nothing changes on the wire. NekoClient: the `transport` option (instance or factory, default WebRTCTransport); signal/* is forwarded to the transport and not handled; connection.status follows socket + transport state as the joint machine did; play/pause/mute/unmute/setVolume/addTrack/ removeTrack delegate; mount/unmount attach the transport and the overlay; sendData and the `video` field are gone (transport.element). App.tsx checks WebRTCTransport.supported() instead of typeof RTCPeerConnection; the hooks read client.transport.element. Tests: the 18 state-machine tests run unchanged against the real WebRTCTransport (sendData moved to input/datachannel.test.ts); the fake browser moved to test/browser.ts and grew elements, a data channel and a richer peer; new client.transport.test.ts (the seam with a fake transport), transport/webrtc.test.ts, input/websocket.test.ts. build.mjs and tsconfig: the dist now has subfolders, the harness is excluded from the builds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * core: document the transport seam, check the new exports README: Connection and Video and audio follow the transport, Control and input describes client.input and the two channels, a Transports section with the interface, SessionInfo, the state it reports and how a second transport plugs in; the options, state and types tables and the test notes updated. dist.check.mjs: the new exports, the default transport and input, a custom transport falling back to WebSocketInput, and the .d.ts specifier check walks the subfolders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * core: typed input channel, shareMedia, smaller transport surface InputChannel is now five typed methods (move, scroll, button, key, touch) instead of send(op, [bytes, value]...): DataChannelInput encodes them into the same frames as before (same opcodes, widths, order, big-endian; the OP table and the field tuple are private to input/datachannel.ts) and WebSocketInput sends the same control/* events and payloads, with the 16 ms move coalescing and the flush before a click kept. The overlay's call sites use the typed methods. The wire format and the control/* payloads are unchanged. StreamTransport carries no WebRTC types any more: addTrack/removeTrack become shareMedia(stream), which returns the function that stops sharing; NekoClient.shareMedia delegates (and throws "transport cannot send media" without it); the GUI's microphone holds that function. The transport surface shrinks to the maintainer's sketch: suspend() is gone (a socket loss calls close(), a reconnect connect() again; close() drops the media path, timers and counters, pauses the element and reports it not playable but keeps the last picture), attach() returns the detach function, SessionInfo is url/token/init/send/on (no lastMessage, no autoplay), TransportKind drops 'webtransport'. Autoplay is the client's: setPlaying(true) when the stream turns playable and the option is on; playback that was running when the socket was lost is resumed by the client the same way. The no-offer timeout reports 'unavailable' after OFFER_TIMEOUT_MS on its own; the stale-socket check stays the guard for a dead socket. A redundant clearTimeout in the signal/provide handler is dropped (closePeer clears it). Behaviour changes, both accepted: the peer-failure backoff counter starts over after a socket reconnect, and 'unavailable' no longer waits for the server to be heard from. Everything else is unchanged. Tests: every method's bytes and control/* payload, shareMedia, close and reconnect, autoplay and resume, mount/unmount (the fake browser gained the globals the overlay needs); README and dist.check.mjs follow the API. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * core: overlay is a separate control device, Player takes the transport The keyboard/mouse/touch overlay leaves NekoClient. `Overlay` (already exported) is now built by the consumer, `new Overlay(client)`, and attached next to the transport's element with `attach(box)`, which returns the detach function; it letterboxes the box it is given inside its parent (what the client's mount() used to do for its container), subscribes to the transport's cursor events, the store and the DOM itself, and takes everything down on detach. `mobileKeyboardToggle()` moves onto it. client.ts no longer imports overlay.ts or the Guacamole keyboard, which stays vendored and used by the overlay only; dist.check.mjs walks the built client.js import graph to assert it. `client.mount()/unmount()` and `canvasSize` are gone; `mount(client, el)` in core/src/mount.ts is the plain-page convenience (the transport, then an overlay, in a letterboxed box). The GUI builds its one Overlay next to its one client (src/state/client.ts); `<Player transport={client.transport} />` attaches the transport and then the overlay into a box it renders, Stage passes `client.transport`. onMessage no longer skips `signal/*`: every message already reaches the transport's subscribers first, and the client acts on none of the signal events, so they fall through to the `message` event like any other event it does not handle (the plugin registry ignores unknown prefixes). Close and outage rule: `WebRTCTransport.close()` is a true close - the peer, timers, counters go and the element is emptied (`srcObject = null`, paused, playable false), so a kick, system/disconnect, a give-up or disconnect() show black behind the Connect screen again. A socket loss leaves the transport alone: its peer may keep streaming while the socket reconnects, and a peer that fails meanwhile re-requests itself through session.send, which drops until the socket is back; the reconnect's system/init calls `connect()` again, which now replaces whatever the previous session left (peer, retry, failure count) while the last picture stays until the new stream shows. The transport's status reports count only while the socket is up (a new `introduced` flag in the client), so an 'unavailable' verdict during an outage cannot read as connected. Markup, CSS and the wire format are unchanged: the views harness shows 27/27 identical DOM dumps, the CSS rule diff is 0, the data-channel probe sends the same frames. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * e2e: KDE paste check collapses the selection with Home, not End The step types into KRunner, selects all and copies inside the remote, then pressed End and Ctrl+V expecting the text twice; the paste never arrived, on every build, and xdotool inside the container reproduces it without the client. KRunner's results view (milou ResultsView.qml, navigationKeyHandler) takes End when the query field's caret is already at the end of the text - which it is right after Ctrl+A - focuses the results list and jumps to the last result; the following Ctrl+V reaches the list, whose handler drops control characters, so nothing is pasted. Home goes to the field (caret not at 0), collapses the selection and the paste lands at the start, which doubles the text the same way. The client's paste path is correct: the browser paste event carries the remote text, no clipboard/set is sent, and the Control_L/v frames arrive in order (verified in headless Chromium and in Brave over CDP, identical frames). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * e2e: type into KRunner once it has the keyboard, not after a fixed sleep On a cold container the full suite dropped one character mid-text now and then ("react gu works"). Traced on a fresh container with the server's per-frame trace and xev attached to KRunner's window: the client sent and the server injected all 15 characters in order, the KRunner window received the KeyPress for the missing one - at the same X timestamp as the window's FocusIn. KRunner is started by the Alt+F2 and takes the keyboard focus only once it is up; under the suite's load that is later than the 1200 ms the step slept, and the key that lands on the focus change is lost by the freshly activated window. The step alone never reproduced it (5/5 cold), the suite did (1 of 2, plus the coordinator's run). The sleep is replaced by a readiness probe: type a character, select all and copy inside the remote, read the remote clipboard, until the character comes back; then the text is typed over the still selected probe. 15 s deadline, no retry of the measured typing, assertion unchanged. 5/5 cold full-suite runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * e2e: seed the remote clipboard before the hover-sync poll The first poll of GET /api/room/clipboard ran before the hover sync had set anything, so xclip found no owner and the server answered 500, which the browser logged as an error and the suite counted as a page error (exit 1 with every step passing). The step is about the hover sync, so give the X clipboard an owner first; the assertion still proves the sync replaced it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: replace the proxy store with zustand and selector hooks The core's hand-written deep-observable Store (proxies, microtask batching, watch/version) is gone: `client.store` is a zustand vanilla store with subscribeWithSelector and `client.state` a read-only getter of its current state. Every write is one immutable setState (what one server message changes is one update, so subscribers never see it half-applied); the former watch sites are selector subscriptions; selectSession, selectControlling and selectIsAdmin are exported for UIs. Emitter moved to emitter.ts. zustand is the package's one runtime dependency. GUI: the app, chat and filetransfer stores are zustand stores too; useNeko() (subscribe to everything, return the state whole) is replaced by useStore(store, selector) in every component, useShallow for several fields, getState/setState outside React. The plugin contract's visible()/badge() become the hooks useVisible()/useBadge() (member menu: useVisible(member)), each run in a component of its own (EachHook). A chat message now re-renders the chat and the badge, not the stage. Behaviour, markup and CSS are unchanged: same localStorage keys, URL parameters and events; the DOM views, the e2e (34/34), the input probe and the CSS rules match the previous build. READMEs, dist.check.mjs and the tests follow (66 tests: the store tests are rewritten for zustand semantics, every former watch site has a subscription test, and the transport test's status sequence no longer hides the first 'connecting' in a batched flush). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: locales as JSON per language and namespace, i18next with lazy loading The strings move from src/i18n/locale/*.ts and the two plugin tables to src/locales/<lang>/{common,chat,files}.json (15 languages, generated once from the tables: the same strings, `{x}` is now `{{x}}`). The runtime is i18next with react-i18next: components use useTranslation(), code outside components `t` from @/i18n. The files are lazy chunks (import.meta.glob) that a small backend hands to i18next as it asks for <lang>/<ns>, so only the active language is downloaded, with en as the fallback; before, all 15 shipped in the main chunk (379 kB -> 372 kB, plus 45 chunks of 0.4-4 kB). A plugin registers a namespace (`ns`) instead of handing over a language map; its keys that sat in shared groups (side.chat, context.mute, notifications.muted, locks.file_transfer.*, files.*, send_a_message) are `chat:` and `files:` keys now, and a tab carries its own label key. The language stays the short code in localStorage `lang` and ?lang=, and <html lang> keeps the zh-CN/zh-TW mapping. A key no language has is logged in development. src/i18n/i18n.test.ts checks the runtime, that every key the sources use exists in en, and that each language lacks exactly the known keys versus en (so a key added to en alone fails); tools/dist.check.mjs (npm run build) checks the main chunk carries no language. No plural forms yet (README). Behaviour and markup unchanged: the same strings in every language, the same storage keys and URL parameters; a language pick applies once its chunk has arrived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: plugin contract: typed events, named slots Server events are typed through PluginEvents (event name to payload, after server/internal/plugins/*/types.go): onEvent(...[event, payload]) narrows the payload on the event, no `any` in the contract. The registry dispatches through a map from the id prefix to the plugin, built once at initPlugins, instead of a find per message; an event without a slash reaches nobody, as before. tab, topBar and memberMenu go into named slots: initPlugins registers them in registry order (registerSlot 'side.tab' | 'header.item' | 'member.menu'), and Side, Header and App read useSlot(name). The manifest keeps the three fields, so adding a plugin is still a folder and one registry line, and removing the line removes its slot entries and its event handling (src/plugins/registry.test.ts). Behaviour, markup and the bundle unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: e2e selects by test id, not by class or icon name The e2e used 66 class selectors (15 of them Font Awesome icon classes), which the styling pass is about to delete. The elements it drives now carry `data-testid` (member, side, tabs, chat-*, control-request, mic, mute, lock-<resource>, dialog, emoji-*, emotes-*, file*, resolution-*, about-*, ...) and their state as data attributes (`data-self`, `data-host`, `data-locked`, `data-on`, `data-active`, `data-shown`, `data-status`); `e2e.mjs` selects those. `LockButton` takes the id of its resource for its test id. No style or behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: Tailwind v4 with the design tokens, cn(), shadcn config Adds `tailwindcss` 4.3.3 and `@tailwindcss/vite` (the Vite plugin), `src/index.css` with the theme and the utilities and an `@theme` block holding the 27 tokens of `src/design/_variables.scss` under their names and values (`--color-text-normal`, `--color-background-primary`, `--shadow-elevation-high`, `--spacing-side`, ...), `cn()` in `src/lib/utils.ts` (`clsx` + `tailwind-merge`, tested) and `components.json` for shadcn/ui (radix base, lucide icons). `lib/` is a bottom layer like `state/` (eslint, README). The preflight is not imported yet and no component is migrated: the 27 views render identically (0.00 % pixels differ). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: app shell and layout on Tailwind, room bar without its wrapper divs `src/index.css` now imports the whole of Tailwind (the preflight replaces `_reset.scss`), keeps the global rules the preflight does not have (the page's size and overflow, the 14px/line-height 1 text on body, the focus ring of the icon controls) and defines the layout's breakpoints as variants (`tablet:` under 1024px, `phone:` under 768px, stacked with Tailwind's `portrait:`/`landscape:` for the split of the small screen). App, ErrorBoundary, Header, Side and RoomBar carry their classes; the Logo is written out where it is used (its sizes differ per place). The room bar's three wrapper divs are gone: the row is a 3-column grid and the room menu, the controls and the emotes are its cells. Deleted: src/design/_reset.scss (432), src/app/app.scss (115), src/app/unsupported.scss (52), src/layout/Header/header.scss (101), src/layout/Side/side.scss (51). The 27 views: 0.6 % of pixels differ on average (max 1.8 %, the parts still on legacy scss shifted by the preflight's border-box, migrated next). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: features on Tailwind video (Stage, Player, Resolution, Clipboard), controls, members and the member menu, room menu, emotes, settings, connect and about carry Tailwind classes; so do Avatar and ContextMenu, whose rules lived in the features' scss. The glyphs the scss drew with Font Awesome pseudo-elements (the member badges, the lock on the control switch) are icon elements in the markup. The emote sprites are plain CSS in Tailwind's components layer (sprites.css, same 47 rules). The shake, loader and badge animations and the range slider are theme keyframes and an @utility in src/index.css; Font Awesome's stylesheet is imported there into a layer below the utilities. The Logo component is written out at its last two call sites. Deleted: video.scss (100), clipboard.scss (28), resolution.scss (79), controls.scss (183), members.scss (126), menu.scss (33), emotes.scss (65), emote.scss (17), sprites.scss (150), settings.scss (232), connect.scss (126), about.scss (86), avatar.scss (16), context.scss (79), components/Logo (12 + 1). The 27 views: 0.33 % of pixels differ on average (max 1.5 %, the chat and files panels still on scss, and the text of form controls, which now inherit the page font instead of the browser's Arial). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: plugins on Tailwind The chat (history, messages, markdown, the send box) with its emoji picker and the file transfer tab carry Tailwind classes. The picker's search glyph and its group tabs, drawn by Font Awesome pseudo-elements, are icon elements in the markup. The transfer progress bar is styled (the preflight's border reset drops Chromium's native theme). The generated emoji sprite sheet stays until the emoji change. Deleted: chat.scss (299), emoji.scss (227), files.scss (240). The 27 views: 0.3 % of pixels differ on average (max 2.2 % in the emoji picker, where the form controls inherit the page font). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: Dialog and Toasts on Tailwind The modal dialog (the look of the legacy SweetAlert theme) and the toasts (the legacy vue-notification palette) carry Tailwind classes; the last component scss is gone. Named text sizes are written in px throughout (Tailwind's `text-xs` and friends also set a line height, which the legacy layout did not). Deleted: dialog.scss (72), toasts.scss (40). What remains of scss: the Whitney font and the generated emoji sprite sheet. The 27 views: 0.2 % of pixels differ on average (max 0.9 %, the files panel: the download icon takes the text colour instead of the browser's link blue, the progress bar is styled). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: dialogs, menus and toasts on Radix (shadcn/ui) and sonner The kit under src/components/ui/: dialog, dropdown-menu and popover in shadcn's shape on @radix-ui/react-dialog, -dropdown-menu and -popover, restyled with the tokens; context-menu on @radix-ui/react-menu (what shadcn's context menu wraps), anchored to a point, since the member menu is one menu opened from the member list, a chat author and Shift+F10; sonner's Toaster in the look of the legacy toasts. They bring focus trapping, roving focus, Escape, outside click and ARIA. - the modal dialog (ask()/tell(), unchanged promise contract, tested) and About are Radix dialogs; a click outside dismisses them too - the member menu is the context menu; the emote picker and the resolution list are dropdown menus from their button; the clipboard textarea and the emoji picker are popovers from theirs, so each sits next to the control that opened it instead of at the pointer - toast() maps its kind to sonner (tested); "clear on connected" is dismiss() Deleted: closeOn() (the setTimeout that avoided the opening click), the toasts state and the Toasts component, the ContextMenu component. The e2e passes 34/34 (the dialog test id is the mounted content now). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: lucide icons, real buttons, IconButton; a11y.ts and Font Awesome gone Every icon control is a <button type="button"> through IconButton (src/components/IconButton.tsx: cva variants plain / header / video, the label as aria-label and title, tested), so Enter, Space and focus come from the browser; the text buttons are the kit's Button (variants primary, outline, confirm, cancel); the side panel's tabs are Radix tabs (@radix-ui/react-tabs, shadcn tabs.tsx); the files actions are buttons. Icons are lucide-react components (40 of them for the 40 Font Awesome names, the member badges and the switch knob included; the GitHub mark is an inline SVG); a plugin tab's `icon` is a lucide component. Deleted: src/components/a11y.ts (the a11y() spread and the global keydown listener), LockButton (written out in Header and FileLock), @fortawesome/fontawesome-free (the dist loses its four webfonts and its CSS). The e2e passes 34/34; the input probe types and pastes (wsControl=0); the keyboard and mobile checks (a11y-check.mjs) pass: Tab order, Enter/Space, Escape on the dialog and the member menu, the touch keyboard toggle, the clipboard fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: emoji as text, the sprite sheet gone The chat renders an emoji as its Unicode character (src/plugins/chat/Emoji.tsx, a 22px box, the browser's emoji font), the custom `neko` as its image. tools/emoji.ts emits the character of each name into public/emoji.json (`chars`, from emoji-datasource's unified code points) and no longer writes a stylesheet; every datasource emoji is listed (eye-in-speech-bubble, which had no Twitter image, included; the modifier components are not). A test checks the generated data against emoji-datasource, which is a devDependency now. Deleted: src/plugins/chat/emoji-sprites.scss (5,426 generated lines) and the 4.2 MB Twitter sheet it embedded in the build (dist: 6.7 MB -> 1.6 MB). The e2e passes 34/34. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the last scss, sass, the Whitney font and the one-line barrels gone - src/design/ is deleted (_variables.scss: the tokens live in src/index.css; _font-whitney.scss and the five woff files: the font is Tailwind's system stack); the scss block of vite.config.ts and the `sass` devDependency with it - the 13 one-line index.ts barrels are deleted: a feature is imported by its main file (`@/features/video/Stage`), the eslint rule that forced the barrel is dropped (the layer rules stay); the single-file folders Avatar, Dialog, Header, Side and RoomBar are single files, so their imports do not change - README: the tree and the layering bullets follow The dist has no font files left (1.2 MB, from 6.7 MB). The e2e passes 34/34, the input probe types and pastes, the keyboard and mobile checks pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: README: the design layer Tailwind v4 and src/index.css (the tokens by their old names, the global rules, the breakpoint variants, the animations and the slider utility), cn() and cva, the shadcn/ui kit under src/components/ui/ and what each piece is used for, IconButton and its variants, lucide icons and their sizes, emoji as text, and how to add a component or an icon. The scripts table names what build:emoji writes now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: tailwindcss and its Vite plugin are build-time dependencies Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the client through <NekoProvider> and hooks, the singleton gone src/state/client.ts (the module-level `client` and `overlay`) and src/app/boot.ts (the import-time start-up) are deleted. createNekoApp() (src/state/neko.ts) makes one instance of the GUI, a NekoApp (src/state/app.ts): the NekoClient with its WebRTC transport, the input Overlay, the GUI store and the bus, with the event wiring (state/events.ts, now wireEvents(neko)) and the settings (initSettings(neko), the URL parameters included; the ?cast= chat-sound rule moved there from App.tsx) applied. main.tsx composes the page in the same order as before: the strings, the instance, the plugins (initPlugins(neko)), then the connection and the first render under <NekoProvider neko={neko}> (src/state/provider.tsx). Components read the instance from the context: useNeko(), useClient(), useApp() (the GUI store) and useActions() (the room actions bound to the instance, one set per instance through scoped()); everything outside React takes it as its first argument (setSetting, ask/tell, api, event/line, the emotes). The plugin contract passes it to every callback (init(neko), onEvent(neko, ...), the member menu's label(neko, m) and onClick(neko, m)); the chat and file-transfer stores and the chat's mutedByMe are one per instance (scoped()), the slots stay the build's and are filled once. The emoji data is still downloaded once per page, each chat's store gets emojiReady from it. Behaviour, markup and CSS are unchanged: same localStorage keys, URL parameters, events and start-up order. Tests: an instance and two of them share nothing (stores, bus, event wiring, settings, actions, plugin stores and wiring), the provider's hooks (react-dom/server), the browser faked in src/test/browser.ts; the three tests that used the singleton or a module store now make their own. README: Composition (createNekoApp -> NekoProvider -> hooks, embedding) and the plugin contract's instance argument. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: npm run check runs its scripts in parallel with npm-run-all2 The chain in `check` (tsc, prettier, eslint, tools/cycles.mjs, vitest, the core build, core/dist.check.mjs) is split into scripts of their own: check:types, check:format, check:lint, check:cycles (and the existing test), run in parallel by `run-p` with one label per script and the output of each kept together (--aggregate-output); the first failure stops the rest and names its script. build:core and check:dist still follow in sequence (`run-s`), the latter reads the former's output. `format:check` is now `check:format`. `npm run check`, `npm run build` and `npm run build:core` keep their names, so CI (.github/workflows/client_build.yml) is unchanged. On this machine `npm run check` takes 3.2 s instead of 6.1 s (three runs each). core/package.json's `check` is left as it is: build -> test -> dist.check is a dependency chain with nothing to run in parallel. npm-run-all2 is the one new (dev) dependency. README: the scripts table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: crypto.randomUUID() for upload ids and emote keys, nextId() gone The counter nextId() in src/state/dialogs.ts (used for the ids of the upload list) and the Math.random().toString(36) key of a shown emote are replaced by the browser's crypto.randomUUID(). Upload.id is a string now (it was a number); nothing else read it but the React key and the progress updates. Toasts never had ids of their own (sonner keeps them), so nothing changes there. Tests: the upload list gives each file a UUID of its own and tracks progress by it (the core's upload call stubbed); each shown emote gets a UUID key and hideEmote drops it. README: the behaviour note, with the one constraint of crypto.randomUUID(): a secure context (https or localhost). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: chat markdown rendered by react-markdown, the hand-written parser gone Chat messages are other members' text, relayed by the server as they are, so the 160-line parser of src/plugins/chat/markdown.ts is replaced by react-markdown (CommonMark to React elements, no HTML string ever reaches the DOM) with remark-gfm (~~strike~~, tables, bare links; singleTilde off) and remark-breaks (a newline is a line break, as before). What remains ours in markdown.ts are four small rehype plugins on the hast tree, 15 lines each at most: - rehypeSafe: a link to anything but http(s)/mailto ([x](javascript:...), <data:...>, a relative path) and an image are replaced by the text they were typed as (the node's position in the source); a generated link without a position (a GFM footnote's) keeps its text only. - rehypeUnderline: __x__, which CommonMark reads as strong, is <u> when the marker in the source is an underscore. - rehypeSpoiler: ||x|| in a text node becomes a span Markdown.tsx renders as the click-to-reveal Spoiler. Plain text only: formatting inside a spoiler is split by the parser first and stays as typed (documented). - rehypeEmoji: :name: of a name the emoji data knows becomes a span rendered by Emoji.tsx (the character, or the image of a custom one). Code is left alone. Markdown.tsx passes urlTransform: safeUrl (the same whitelist, normalising the href, a second line behind rehypeSafe), disallowedElements: ['img'] with unwrapDisallowed (a third, should an image ever get through), and components for a (target=_blank, rel, open-in-app and its icon as before), the plugins' spans, code and pre; the other elements are styled from the message's wrapper (strong, em, blockquote as before; p+p one line apart; lists, headings, table cells minimal). Raw HTML is text by react-markdown's default. FORMAT_LIMIT (10,000 characters) is kept, and react-markdown's Markdown, a plain function, is called in a try/catch so a renderer error (5,000 nested quotes overflow the hast conversion's stack) shows the text as it is. The component is memo'd: a new line does not parse the others again. Dialect: bold, em, underline, strike, code, fences, spoilers, emoji, links, escapes and line breaks as before. Gone: the Discord `>>>` block quote (no CommonMark equivalent; `> ` quotes stay, and continue until a blank line as in CommonMark). New, by CommonMark/GFM: headings, lists, nested quotes, tables, www. and e-mail autolinks. Images are never rendered. Tests (src/plugins/chat/markdown.test.tsx, react-dom/server under a <NekoProvider>): every assertion of the old parser's tests has a successor on the rendered HTML, plus images, relative and reference links, raw HTML, the CommonMark extras, open-in-app, and hostile input of every marker at the 10,000-character limit under a time bound. Bundle: +158 kB minified (+48 kB gzip) in the main chunk. README: the behaviour note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: list keys from crypto.getRandomValues, which plain http also has crypto.randomUUID() is undefined outside a secure context, and a neko reached over plain http on a LAN is not one, so the first upload or emote would have thrown there. uid() takes 64 random bits from crypto.getRandomValues(), which every origin has; the two call sites and their tests follow, the README note too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: vendor chunks, locale chunks named by language manualChunks splits node_modules into react, radix, markdown, i18n, lucide and vendor; chunkFileNames names the lazily loaded locale files assets/locales/<lang>-<ns>-<hash>.js. Before: index 730.17 kB (gzip 232.95) + 45 locale chunks common/chat/files-<hash>.js; total JS 800863 B. After: react 222.29 (69.21), markdown 158.51 (48.21), index 119.85 (39.78), radix 96.71 (32.24), vendor 65.62 (19.69), i18n 48.97 (16.20), lucide 17.29 (6.23) + 45 locales/<lang>-<ns>; total JS 799935 B, no chunk-size warning, no module in two chunks. Nothing in src/ changed; tools/dist.check.mjs now also checks the locale chunk names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the keyboard stays in the chat box after an emoji pick Radix returns focus to the popover trigger when it closes, after the component had focused the textarea, so the first key typed after picking an emoji went to the Emoji button (found in a real browser; the old picker kept the textarea). The picker now hands focus back to the message box on close; the e2e emoji step types after the pick and checks it lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: a banned user goes back to the login screen; no mic button where getUserMedia is missing A closed connection asked /whoami to decide between the login screen and the Connect button, and only a 401 meant the login screen. A ban changes the profile first: the server disconnects the session with "profile changed" and /whoami then answers 403 "login is disabled for this session" (server/internal/api/router.go), which left the banned user on a Connect button that could never work. 403 now goes back to the login screen too. The two file-provider e2e steps pass. The microphone button called navigator.mediaDevices.getUserMedia without checking for mediaDevices, which a plain-http origin does not have, so clicking it raised a raw TypeError. The button is hidden there, the way the clipboard already picks its fallback by feature. Found by a real-world pass (file-provider ban, LAN origin), both pre-existing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: chat formats messages up to the server's default length, hostile input stays cheap on CI The hostile-input test failed on the CI runner: 5000 nested quotes took 1.9 s there (420 ms here), micromark's cost for nested block quotes growing with the depth squared. The formatting limit was 10,000 characters, a number of our own. It is now 512, the server's default chat.max_length, so a message only exceeds it on a server configured for longer ones, and the worst hostile case takes 23 ms here. The test gains the bare `>` form, which is the dense one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the hostile-markdown test has no wall-clock assertions; its timeout is the bound The second CI run failed on the other bound of the same test: the first render, which pays the pipeline's warm-up, took 57 ms on the runner against a 50 ms limit. A shared runner is about four times slower than a workstation and uneven, so any wall-clock assertion there is a flaky test. The test now asserts what is deterministic (never throws, the fallback to plain text above the limit) and is bounded by its own 3 s timeout, which a blow-up would hit; all the cases take about 60 ms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: release notes and repository structure match the client as it now is The core is packaged as @m1k1o/neko, not published to npm. The release notes gain the chat dialect (CommonMark/GFM, no `>>>`, plain text above 512 characters) and the system font and native emoji. The repository structure drops the deleted design/ folder and names locales/ and lib/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * client: filled icons, phosphor instead of lucide The v2 client's icons were all filled (FontAwesome solid); lucide is stroke-only. @phosphor-icons/react has a fill weight, set once through IconContext in App.tsx, with the same tree-shaken import style, so the icon components are renamed and nothing else changes. The GitHub mark in About is phosphor's GithubLogo instead of an inline svg. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: themed focus ring on text fields The old reset removed outlines globally and this client had no rule for inputs, so they showed the browser's default ring; now text fields get the ring the buttons use, hidden where a container is the visual field (the chat and clipboard textareas). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: remove ban, kick stays The v2 ban was an IP ban in the legacy layer with no v3 counterpart, so the ban/unban actions, the Banned list in Settings and their strings go; member removal belongs with a members tab in a follow-up. The e2e suite is 33 steps now, the optional NEKO_FILE_URL file-provider run is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: add npm publish workflow. * client: phosphor icons by their *Icon names phosphor 2.1 marks every bare export (`Shield`) deprecated in favour of `ShieldIcon`. `@typescript-eslint/no-deprecated` (type-aware, src and core/src without the tests) now fails the lint on deprecated APIs; it also caught `FormEvent` and `navigator.platform`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: the core library is published as @m1k1o/neko Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * client: the on-screen clipboard keeps the focus while open The overlay takes the keyboard focus whenever the host's mouse enters the video; the clipboard popover (the textarea fallback for browsers that cannot read the local clipboard) closed on that, before anything could be copied from it. The popover is modal now, so the video gets no pointer events while it is open. The e2e gets a step for it, with readText removed before the app loads to show the fallback; the step fails on the previous code ("the clipboard popover closed"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
65fba485de |
client: show clipboard fallback button in Safari (#709)
Safari's clipboard.readText() support is unreliable, so treat it the same way as Firefox and always show the manual clipboard fallback. Fixes #706 |
||
|
|
dae23a90b5 |
feat(auth): add OIDC-only member authentication (#686)
* feat(auth): add OIDC member authentication * docs(auth): document OIDC member login * fix(auth): address OAuth review feedback * refactor(auth): move OAuth flow into member provider |
||
|
|
ba098db663 | fix(markdown): sanitize attribute values and prevent Vue template compilation for rendered markdown. | ||
|
|
44ccf64257 |
feat(filetransfer): implement multi-file selection and bulk deletion (#687)
* feat(filetransfer): implement multi-file selection and bulk deletion * feat(filetransfer): enhance file selection with Ctrl/Meta key support. * feat(locale): add bulk delete and selection messages in multiple languages --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
cc014571cc |
feat: add openinapp plugin to open chat links in shared neko instance (#682)
* feat: add openinapp plugin to open chat links in shared neko instance * lint. * feat(openinapp): implement open link handler and route for opening links. * feat(openinapp): switched to http backend and removed example in docker-compose.yaml * lint. --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
d6a4200b36 |
feat(filetransfer): allow file deletion and add user delete config option (#683)
* feat(filetransfer): allow file deletion and add user delete config option * fix(client): make user_delete required boolean in payload * lint. * feat(filetransfer): add confirmation dialog for file deletion. * feat(filetransfer): add delete file confirmation messages in multiple languages * lint. --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
30d8e1c595 |
feat: add ?scroll query param to set scroll sensitivity on load (#681)
Follows the same pattern as the existing ?volume param. Lets users bake a preferred scroll sensitivity into the URL (useful for cast/embed setups where the settings panel isn't reachable), and it's persisted to localStorage afterwards so it sticks on future visits. Fixes #607 |
||
|
|
613647c72c |
fix(filetransfer): allow non-admin file uploads and downloads (#679)
* fix(filetransfer): remove auth.AdminsOnly middleware wrapper from routes * fix(filetransfer): add config option to allow non-admin users to download and upload files. * fix(filetransfer): implement user permissions for file download and upload in client. --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
3bb1959a55 | feat: sync clipboard on window focus. (#662) | ||
|
|
d74052bb84 | feat: auto focus displayname (#654) | ||
|
|
6c743c9c73 |
fix(i18n): Complete and standardize all locale translations (#549)
- Added missing translation keys across all language files - Completed file_transfer lock translations for all locales - Added missing control section translations (has/hasnot) - Fixed inconsistent translations and improved accuracy - Ensured all locale files match the English key structure - Corrected grammar errors and typos in various languages - Added broadcast_title, reconnecting, kicked translations - Standardized translation quality across all supported languages Languages updated: zh-cn, es-sp, fr-fr, de-de, ja-jp, ko-kr, ru-ru, fi-fi, sv-se, nb-no, sk-sk |
||
|
|
bc19ab866f |
feat(client): add microphone passthrough button to controls toolbar (#620)
* feat(client): add microphone passthrough button to controls toolbar Add mic toggle button to the bottom controls bar that enables users to share their local microphone with the remote neko session via WebRTC. The server already supports microphone capture (capture.microphone.enabled) but the legacy client had no UI to trigger getUserMedia and send an audio track to the peer connection. Changes: - base.ts: Add enableMicrophone/disableMicrophone methods that call getUserMedia and addTrack/removeTrack on the RTCPeerConnection. Mic is cleaned up automatically on disconnect. - controls.vue: Add mic button (fa-microphone/fa-microphone-slash) between play/pause and volume controls with tooltip and error handling. - en-us.ts: Add i18n strings for mic tooltips and error dialog. * if the error is not io.EOF, log it. Otherwise, it's a normal closure of the track. * tie microphone to active host and auto-disable on control loss --------- Co-authored-by: h1n054ur <admin@haniumer.com> Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
341bbc296b |
Add Polish (pl) translation (#582)
* Create pl-pl.ts * Update index.ts * Update client/src/locale/index.ts --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
aeef3800f0 | update GUACAMOLE-1823: Merge changes addressing unreliable capslock behavior under MacOS Chrome. | ||
|
|
b194e91b1a |
feat: support for entering text indirectly (#577)
* feat: support for entering text indirectly * fix: restore text area value after composition input * update guacamole keyboard. --------- Co-authored-by: fezhang <fezhang@tencent.com> Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
1bc31473ae | Add Indonesian translation (#552) | ||
|
|
b714663299 | implicit hosting: request control prior interacting with the screen. #499 (#540) | ||
|
|
0765352abd | fix regression for #522. #523 | ||
|
|
f145bd58c9 |
Fix mobile keyboard behavior (#522)
* keep only openMobileKeyboard, do not focus when touch device. * do not test for hover when checking touch device. |
||
|
|
0d62acfe04 | add mobile keyboard icon. (#497) | ||
|
|
d7e2e73945 |
Scroll to chat on mobile (#496)
* WIP: scroll to chat proof of concept on mobile, #381. * update screen size to 1024px. * login screen position fixed, #381. * show chat with a small video on portrait mode. |
||
|
|
dd412dd37f | lint client. | ||
|
|
8061d5b182 |
Revert "lint client."
This reverts commit
|
||
|
|
3918f8f1e2 | lint client. | ||
|
|
9e9cd6f486 | Merge remote-tracking branch 'origin/master' into v3 | ||
|
|
6dc21ec918 | Merge remote-tracking branch 'origin/master' into v3 | ||
|
|
212bf8a607 |
feat(i18n): enhance language management by saving user preference and auto detect browser language (#487)
* feat(i18n): enhance language management by saving user preference and detecting browser language - Added a watcher to save the selected language to localStorage on change. - Implemented browser language detection to set the default language based on user settings or browser preferences. * use ~/utils/localstorage, remove setting savedLang in mounted as this is already set in i18.ts. * do not repeat fallbackLocale and remove console log. --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
4e335efb1e |
disable textarea spellcheck (in firefox) (#482)
* Update policies.json added `browser.tabs.allowTabDetach` to prevent drag-detaching tabs into seperate windows * disable spellcheck on the text area |
||
|
|
220409e137 | Added Japanese localization (#471) | ||
|
|
e3b6e00648 |
feat(general): add heartbeat to websocket control (#460)
* chore: ignore .idea * feat: add heartbeat event to websocket * feat: add HeartbeatInterval to websocket config * feat: send HeartbeatInterval in SYSTEM_INIT message * feat: add heartbeat logic * fix: client events is not in WebSocketEvents type |
||
|
|
3082d3241b | implement client heartbeat #460. | ||
|
|
daf8f79eb5 | update client to include username when connecting to websocket. | ||
|
|
0d7887e9d2 |
workaround for firefox read clipboard, #373.
Firefox 122+ incorrectly reports that it can read the clipboard but it can't instead it hangs when reading clipboard, until user clicks on the page and the click itself is not handled by the page at all, also the clipboard reads always fail with "Clipboard read operation is not allowed." |
||
|
|
4ab5901ba9 | sync clipboard only if in focus #373. | ||
|
|
e26e4d2004 | Add zh_TW Traditional Chinese locale (#388) | ||
|
|
5f698330fc | hide controls on mobile when locked. #381 | ||
|
|
f32fc989b9 | overlay: no pointer events when not hosting. #381 | ||
|
|
db6f9c957e | add check for volume parameter in URL before setting volume (#372) | ||
|
|
2f9964580f | use 1 instead of true for consistency. | ||
|
|
355c0eac0d | add parameter for chat mute and show side | ||
|
|
cd4acb5eec |
lang url parameter (#296)
* lang url parameter * add ?lang to readme. --------- Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com> |
||
|
|
f9228e653d | Merge branch 'pu/touch' | ||
|
|
df634be1c5 | lint. | ||
|
|
aee7650d47 | add touch events on touch monitor | ||
|
|
98ba32c574 | add url param for audio volume. | ||
|
|
e868ad4061 | Merge branch 'pu/autoplay' | ||
|
|
b41d0bf956 | lint fix. | ||
|
|
8dba9cff44 | revert extra controls usecase. | ||
|
|
217cc451ea | add autoplay audio if possible |