Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35ecb61e6 | ||
|
|
2883c61965 | ||
|
|
165e8ef54f | ||
|
|
5c7942d600 | ||
|
|
30a75a0629 | ||
|
|
dc12d092b9 | ||
|
|
2a5213c80c | ||
|
|
7ce351c46a | ||
|
|
645f83545d | ||
|
|
909d756e56 | ||
|
|
a0550834dc | ||
|
|
cbd71216a8 | ||
|
|
2498cf0d2d | ||
|
|
873ee236dd | ||
|
|
f9718e80a1 | ||
|
|
d0543688cd | ||
|
|
260080b474 | ||
|
|
fa5b92b906 | ||
|
|
f8608a09db | ||
|
|
8522dbc9ca | ||
|
|
fad79d23e8 | ||
|
|
769788dc1e |
@@ -6,6 +6,11 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
||||
|
||||
### Added
|
||||
|
||||
- 95 more color schemes, 100 in all: six made by hand (Forest, Ember, Ocean, Plum, Paper, High contrast) and 89 generated from a hue and an accent (`npm run themes`), 64 dark and 25 light. The picker can be narrowed by name and by dark or light.
|
||||
- Voice setting "Give the microphone back while muted", on by default on phones and tablets: a Bluetooth headset then stays out of its telephone mode while you are muted, so what you hear keeps its full quality.
|
||||
- A second interface, "Rooms" (Settings, Appearance, Interface), made for phones first: one screen at a time with a bar for Rooms, Chat, Voice, People and You. Rooms are a list with the people inside shown inline and a Join button; your room and a mute and deafen dock stay in reach; the voice screen shows the room as tiles with large controls. Wide windows keep the rooms next to the chosen screen. It uses the same dialogs and menus as the standard interface. Not in it yet: moving channels and people by dragging.
|
||||
- Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them.
|
||||
- Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it.
|
||||
- Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there.
|
||||
- Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button.
|
||||
- Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles.
|
||||
@@ -28,6 +33,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A description preview could open after dropping a channel and stay on screen.
|
||||
- Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
@@ -27,6 +27,7 @@ npm run dev:web # browser build with hot reload: starts the proxy (any se
|
||||
npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required)
|
||||
npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first)
|
||||
npm run test:e2e:share # screen sharing between two instances of the built app
|
||||
npm run themes # regenerate src/themes-generated.css and src/lib/themes-generated.ts (scripts/gen-themes.mjs)
|
||||
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
|
||||
```
|
||||
|
||||
@@ -49,6 +50,9 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets); where a browser has no WebCodecs audio it uses libopus in WebAssembly (`audio/opus-wasm.ts`, `localStorage mumh5.forceWasmOpus = 1` forces it). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
|
||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||
- `src/ui2/`: the Rooms interface (`settings.interface === 'rooms'`), a second shell on the same state: `Shell.svelte` (screens and bar, rooms beside the screen from 900px), `Rooms`, `ChatScreen`, `VoiceScreen`, `People`, `You`, `Dock`. It reuses `ChatLog`, `Composer`, `VoiceStage` (`fill`), `RightPanel` (as a sheet) and every dialog and menu from `src/ui/`. A feature added to one interface needs its place in the other. Class names there must not collide with ones `themes.css` styles (`.tabs`, `.rail`, `.tile`).
|
||||
|
||||
- Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:<id>` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`.
|
||||
|
||||
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
|
||||
|
||||
@@ -79,7 +83,8 @@ Voice runs on one server at a time (where you last joined a channel); background
|
||||
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
|
||||
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
|
||||
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
|
||||
- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it; keep packets at 900 bytes and few.
|
||||
- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it (measured: burst 15, then 4 per second, per sender, dropped silently); keep packets at 900 bytes and few.
|
||||
- Murmur gives the id of a removed channel to the next one created; anything kept per channel id must be dropped on ChannelRemove.
|
||||
- Renaming while connected is not possible; mumh5 reconnects with the new name.
|
||||
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
|
||||
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
|
||||
|
||||
@@ -1,102 +1,92 @@
|
||||
# mumh5
|
||||
|
||||
**Mumble, the way it should feel in 2026.**
|
||||
mumh5 is a client for [Mumble](https://www.mumble.info/), the open source voice chat. It runs on Windows, macOS and Linux, and in a browser through a small proxy you host yourself.
|
||||
|
||||
mumh5 is a modern client for [Mumble](https://www.mumble.info/), the free, open source, low-latency voice chat. It gives Mumble the kind of interface people know from Discord: servers on the left, channels and people in the middle, chat that can do more than text. Underneath, it's plain Mumble, so it connects to **any existing Mumble server** without changing anything on the server side.
|
||||
|
||||
Your community keeps its own server, its own rules and its own data. You get an app that's nice to use.
|
||||
It connects to any Mumble server as it is. Nothing has to be installed or changed on the server, and people on the regular Mumble client see and hear you like anyone else.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Why mumh5
|
||||
## What it does
|
||||
|
||||
Mumble has the best foundation of any voice chat: tiny latency, strong encryption, self-hosting, no accounts, no ads, no tracking. What held it back was the desktop client, which still looks and behaves like 2010.
|
||||
- **Voice**: Opus over encrypted UDP, with voice activity, push to talk or always on.
|
||||
- **Chat**: a separate chat per channel and person, with files, pictures, formatting and link previews.
|
||||
- **Several servers at once**: text on all of them, voice on the one where you joined a channel.
|
||||
- **Screen and camera sharing** between mumh5 users in a channel.
|
||||
- **Text channels** between mumh5 users: read and written without joining them.
|
||||
- **Channel management**: create, edit, move and link channels, and edit permissions and groups.
|
||||
- **Two interfaces**: Standard (channel tree, chat and members side by side) and Rooms (one screen at a time, made for phones).
|
||||
- **100 color schemes**, from Dark and Light to Windows 95.
|
||||
|
||||
mumh5 keeps the foundation and replaces the experience.
|
||||
Some features only work between mumh5 users, because Mumble itself has no such thing: screen and camera sharing, text channels, and inline players for shared files. People on other Mumble clients are not disturbed by them; they see a normal channel, a normal link, or nothing.
|
||||
|
||||
- **Works with the servers you already use.** No plugins, no server changes, no migration. Friends on the regular Mumble client see you and hear you like anyone else.
|
||||
- **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are.
|
||||
- **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client.
|
||||
- **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are.
|
||||
- **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube links stay plain links unless you turn on the click-to-play player.
|
||||
- **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens.
|
||||
### Not in mumh5 yet
|
||||
|
||||
### mumh5 compared to the Mumble desktop client
|
||||
|
||||
| | Mumble desktop | mumh5 |
|
||||
|---|---|---|
|
||||
| Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client |
|
||||
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges |
|
||||
| Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one |
|
||||
| YouTube links | Plain links | Plain links by default; optional click-to-play player (privacy mode) and thumbnails |
|
||||
| Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP |
|
||||
| Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own |
|
||||
| Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit |
|
||||
| Identities | One certificate per installation | Several identities, a different one per server if you like; imports your desktop certificate |
|
||||
| Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer |
|
||||
| Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost |
|
||||
| Right-click on mute or deafen | Nothing | Quick device and volume options |
|
||||
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
|
||||
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
|
||||
|
||||
**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
|
||||
System-wide push to talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. The Mumble desktop client has these.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Voice
|
||||
- Opus voice with voice activity detection, push to talk or always-on
|
||||
- Live level meter with an adjustable threshold, so you set it once and forget it
|
||||
- Voice activity detection with a live level meter and an adjustable threshold, push to talk, or always on
|
||||
- Noise suppression, echo cancellation and automatic gain control
|
||||
- Microphone and output device selection, input and output volume
|
||||
- Microphone and output selection, input and output volume
|
||||
- Per-person volume (0 to 300%) and "mute for me"
|
||||
- Speaking indicators in the channel tree, member list and your own panel
|
||||
- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand
|
||||
- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch
|
||||
- Bitrate automatically limited to what the server allows
|
||||
- Right-click the mute or deafen button for quick device and volume options
|
||||
- Hear-yourself microphone test
|
||||
- Speaking indicators in the channel tree, the member list and your own panel
|
||||
- Encrypted UDP (Mumble's OCB2-AES128), with fallback to the TCP connection and a TCP-only switch
|
||||
- Bitrate limited to what the server allows
|
||||
- Right-click mute or deafen for quick device and volume options
|
||||
- Microphone test that plays your voice back to you
|
||||
- Optional: give the microphone back while muted, so a Bluetooth headset stays out of its telephone mode. On by default on phones; not yet tried on a real phone
|
||||
|
||||

|
||||
|
||||
### Channels and people
|
||||
- Channel tree that starts collapsed; channels with people in them open up
|
||||
- Single click previews a channel's description, double click joins
|
||||
- Single click shows a channel's description, double click joins
|
||||
- Side chat: write in any channel without leaving yours
|
||||
- Profiles with rich descriptions (formatting, lists, links, pictures), local nicknames, registration status
|
||||
- Change your name (mumh5 reconnects for you) and register on a server with one click
|
||||
- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain
|
||||
- Profiles with formatted descriptions, local nicknames and registration status
|
||||
- Change your name (mumh5 reconnects for you) and register on a server
|
||||
- Connection information: ping, packet loss, client version and, for admins, the certificate chain
|
||||
- Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register
|
||||
- Channel management: create, edit, delete, link, and drag and drop channels and people
|
||||
- Full permission editor: rules per channel with inheritance, groups and members, like the desktop ACL editor
|
||||
- If you get kicked, you see who kicked you and why, with a Reconnect button
|
||||
- Create, edit, delete and link channels. Drag a channel into another, or in front of or behind it; drag people between channels
|
||||
- Permission editor: rules per channel with inheritance, groups and members
|
||||
- If you are kicked, you see who did it and why, with a Reconnect button
|
||||
|
||||

|
||||
|
||||
### Screen sharing
|
||||
|
||||
- Camera rounds: everyone who turns their camera on appears in the tiles at once, like a video meeting; one click joins or leaves the round, and a click on a tile enlarges that camera
|
||||
- Share a screen, a window or a camera with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent
|
||||
- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers
|
||||
- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. Viewers set the stream's volume
|
||||
- No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams
|
||||
- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy, and the proxy's relay when no direct connection is possible (mobile networks, Vanadium); in the desktop app you choose a STUN server in Settings, Voice (Google, Cloudflare, or any address), and can enter the address of a mumh5 web version as a relay, which then takes over by itself when no direct connection comes up. Nothing is contacted unless you set it
|
||||
- Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
|
||||
|
||||
### Chat
|
||||
- Messages grouped by author, direct messages, unread badges
|
||||
- File sharing: drag and drop, paste, or the attach button, with upload progress
|
||||
- Inline images, video and audio players for shared media
|
||||
- Files by drag and drop, paste or the attach button, with upload progress (needs an [upload host](#file-sharing-with-f0ckm); pictures work without one)
|
||||
- Inline pictures, video and audio for shared files
|
||||
- YouTube links as click-to-play players (off by default)
|
||||
- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it
|
||||
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded
|
||||
- Messages stay compatible with the desktop client, including picture previews
|
||||
- Link previews with title, description and picture
|
||||
- Incoming HTML is sanitized, and pictures from unknown hosts are never loaded
|
||||
- Messages stay readable in the Mumble desktop client
|
||||
|
||||
### Color schemes
|
||||
Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme with beveled buttons, navy title bars and the teal desktop.
|
||||
### Text channels
|
||||
- A channel marked as a text channel is read and written without joining it, from any voice channel
|
||||
- It is a normal Mumble channel on the server, created and removed with the usual channel permissions
|
||||
- The server keeps no messages. Each client keeps the last 200 per channel and passes the last 50 to people who connect later
|
||||
- Everyone on the server who uses mumh5 can read them; there are no private text channels yet
|
||||
- Needs a server from version 1.4 that allows HTML
|
||||
|
||||
### Screen and camera sharing
|
||||
- Share a screen, a window or a camera with the mumh5 users in your channel. A dialog shows a preview and lets you choose the sound and the quality first
|
||||
- Cameras appear in the voice tiles, several at once; a click enlarges one
|
||||
- A screen opens large, with the people as a strip of tiles below, and can move to its own window
|
||||
- Sound: on Linux one program or everything except mumh5 (through PipeWire); on Windows the whole system; in a browser what the browser offers
|
||||
- The stream goes directly between the two clients (WebRTC), up to 8 viewers per stream. Only the setup messages pass through the Mumble server
|
||||
- Sharer and viewer see each other's IP address; mumh5 says so before the first use
|
||||
- Across the internet both sides need a STUN server. The browser version uses its proxy's, with a relay when no direct connection works. In the desktop app you set one in Settings, Voice. Nothing is contacted unless you set it
|
||||
- Tested between two instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
|
||||
|
||||
### Interfaces and look
|
||||
- Standard interface in three layouts: channels left and members right, chat left and channels right, or channels on top
|
||||
- Rooms interface: rooms as a list with the people inside, a voice screen, a people screen, and mute and deafen always in reach. New and not complete: channels and people cannot be moved by dragging there yet
|
||||
- 100 color schemes with a search box. Eleven are made by hand, the rest are generated from a hue and an accent
|
||||
|
||||
| Windows 95 | Light | Frost | Midnight |
|
||||
|---|---|---|---|
|
||||
@@ -104,15 +94,15 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w
|
||||
|
||||
### Sounds
|
||||
- Notification sounds for joins, leaves, messages, mute, push to talk, kicks and more
|
||||
- Replace any sound with your own file, or load a whole sound pack at once (matched by file name)
|
||||
- Replace any sound with your own file, or load a sound pack at once (matched by file name)
|
||||
|
||||
### Servers and identity
|
||||
- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts
|
||||
- Public server browser with live user counts and ping, like the desktop client
|
||||
- Several servers connected at once; voice follows the channel you join
|
||||
- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity
|
||||
- Password-protected backups of your identity
|
||||
- Trust on first use for server certificates, a green header for CA-signed ones, and a certificate viewer
|
||||
- Server list with custom icons, your own order, connection status and unread counts
|
||||
- Public server browser with user counts and ping (desktop app)
|
||||
- Identity setup: create a certificate, import a `.p12`, or reuse the one from the Mumble desktop client
|
||||
- Several identities, and a different one per server if you like
|
||||
- Password-protected backups of an identity
|
||||
- Server certificates are pinned on first use; a certificate viewer shows the chain
|
||||
- Tray icon that shows whether you are talking, muted or deafened
|
||||
|
||||
<p align="center"><img src="docs/screenshots/mobile.png" alt="mumh5 in a narrow window" width="300"></p>
|
||||
@@ -281,6 +271,8 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
|
||||
- System-wide push to talk
|
||||
- Whisper and shout
|
||||
- Text channels: private ones, and history while nobody is online (needs a helper on the server)
|
||||
- Rooms interface: moving channels and people without dragging, gestures, more polish
|
||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||
- Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8)
|
||||
- Release builds for all platforms
|
||||
@@ -321,11 +313,9 @@ test/ unit, server and end-to-end tests
|
||||
|
||||
## AI disclosure and credits
|
||||
|
||||
mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code.
|
||||
mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code. Kibi decides what mumh5 should be and whether a result is good enough. Claude writes most of the code, the tests and this documentation. Commits written with Claude carry a `Co-Authored-By: Claude` line.
|
||||
|
||||
Kibi sets the direction: what mumh5 should be, how it should look and feel, which trade-offs to make, and whether a result is good enough. Claude writes most of the code, the tests and this documentation, and checks each feature against a real Mumble server before calling it done. Commits written with Claude carry a `Co-Authored-By: Claude` line.
|
||||
|
||||
We say this openly because you should know how the software you run was made. Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue.
|
||||
Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:e2e": "node test/e2e/app.e2e.ts",
|
||||
"test:e2e:web": "node test/e2e/web.e2e.ts",
|
||||
"test:e2e:share": "node test/e2e/share.e2e.ts",
|
||||
"themes": "node scripts/gen-themes.mjs",
|
||||
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
|
||||
"screenshots": "node test/e2e/screenshots.ts"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Writes the generated color schemes: src/themes-generated.css and src/lib/themes-generated.ts.
|
||||
// Each scheme is a few numbers (surface hue and saturation, accent hue and saturation); the
|
||||
// shades are derived from them and nudged until text, links and accent labels are readable.
|
||||
// npm run themes
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function hex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360; s = Math.max(0, Math.min(100, s)) / 100; l = Math.max(0, Math.min(100, l)) / 100;
|
||||
const k = n => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
|
||||
return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
function luminance(c) {
|
||||
const n = parseInt(c.slice(1), 16);
|
||||
const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map(v => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; });
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
|
||||
// Moves the lightness away from the backgrounds until the color is readable on all of them
|
||||
function readable(h, s, l, backgrounds, min, dir) {
|
||||
let c = hex(h, s, l);
|
||||
while (backgrounds.some(bg => contrast(c, bg) < min) && l > 0 && l < 100) { l += dir; c = hex(h, s, l); }
|
||||
return c;
|
||||
}
|
||||
|
||||
// [id, name, note, surface hue, surface saturation, accent hue, accent saturation, options]
|
||||
const DARK = [
|
||||
['slate', 'Slate', 'Blue-grey, calm', 215, 14, 215, 70],
|
||||
['graphite', 'Graphite', 'Neutral grey, no color', 0, 0, 0, 0],
|
||||
['charcoal', 'Charcoal', 'Warm grey, amber accents', 30, 5, 36, 85],
|
||||
['obsidian', 'Obsidian', 'Near black, violet accents', 260, 10, 265, 85, { base: 3 }],
|
||||
['navy', 'Navy', 'Dark blue', 222, 45, 215, 90],
|
||||
['cobalt', 'Cobalt', 'Blue, yellow accents', 218, 60, 46, 95, { base: 8, link: 190 }],
|
||||
['indigo', 'Indigo', 'Blue-violet', 240, 35, 245, 90],
|
||||
['violet', 'Violet', 'Purple', 270, 35, 275, 85],
|
||||
['orchid', 'Orchid', 'Soft purple-pink', 295, 28, 300, 70],
|
||||
['magenta', 'Magenta', 'Dark pink', 320, 30, 325, 85],
|
||||
['crimson', 'Crimson', 'Dark red', 350, 30, 352, 80],
|
||||
['ruby', 'Ruby', 'Deep red, orange accents', 340, 40, 12, 85, { base: 5 }],
|
||||
['rust', 'Rust', 'Red-brown', 15, 35, 18, 80],
|
||||
['copper', 'Copper', 'Brown, muted orange', 25, 30, 28, 65],
|
||||
['amber', 'Amber', 'Dark yellow-brown', 38, 30, 42, 95],
|
||||
['olive', 'Olive', 'Yellow-green', 70, 22, 75, 60],
|
||||
['moss', 'Moss', 'Muted green', 95, 22, 100, 50],
|
||||
['emerald', 'Emerald', 'Rich green', 150, 35, 152, 70],
|
||||
['jade', 'Jade', 'Blue-green', 165, 30, 168, 65],
|
||||
['teal', 'Teal', 'Green-blue', 180, 35, 180, 70],
|
||||
['lagoon', 'Lagoon', 'Bright turquoise accents', 192, 45, 190, 85],
|
||||
['arctic', 'Arctic', 'Lighter cool grey-blue', 200, 25, 195, 75, { base: 9 }],
|
||||
['storm', 'Storm', 'Lighter blue-grey', 225, 18, 200, 60, { base: 10 }],
|
||||
['dusk', 'Dusk', 'Purple evening, orange accents', 255, 22, 20, 85, { base: 9 }],
|
||||
['wine', 'Wine', 'Dark red-purple', 335, 35, 340, 60, { base: 5 }],
|
||||
['cocoa', 'Cocoa', 'Brown', 22, 25, 30, 55, { base: 7 }],
|
||||
['espresso', 'Espresso', 'Very dark brown', 28, 30, 35, 70, { base: 4 }],
|
||||
['terminal', 'Terminal', 'Green on black', 120, 30, 120, 90, { base: 2, textHue: 120, textSat: 85, link: 150 }],
|
||||
['phosphor', 'Phosphor', 'Amber on black', 40, 30, 40, 95, { base: 2, textHue: 40, textSat: 90, link: 25 }],
|
||||
['neon', 'Neon', 'Black, pink and cyan', 280, 20, 320, 100, { base: 3, link: 185 }],
|
||||
['synthwave', 'Synthwave', 'Purple, pink and cyan', 265, 45, 320, 90, { base: 8, link: 185 }],
|
||||
['aurora', 'Aurora', 'Dark blue, green accents', 210, 30, 160, 75],
|
||||
['abyss', 'Abyss', 'Blue-black, turquoise accents', 230, 50, 195, 90, { base: 3 }],
|
||||
['carbon', 'Carbon', 'Black, red accents', 0, 0, 0, 85, { base: 3 }],
|
||||
['ash', 'Ash', 'Lighter grey, blue accents', 0, 0, 210, 60, { base: 12 }],
|
||||
['steel', 'Steel', 'Grey-blue, orange accents', 210, 10, 30, 80, { base: 11 }],
|
||||
['ink', 'Ink', 'Blue-black, yellow accents', 235, 30, 50, 90, { base: 3 }],
|
||||
['sapphire', 'Sapphire', 'Strong blue', 228, 55, 228, 95],
|
||||
['azure', 'Azure', 'Bright blue accents', 208, 50, 205, 95],
|
||||
['peacock', 'Peacock', 'Turquoise, purple accents', 188, 50, 300, 70],
|
||||
['pine', 'Pine', 'Dark green, orange accents', 160, 40, 35, 85, { base: 4 }],
|
||||
['fern', 'Fern', 'Soft green', 125, 25, 130, 55],
|
||||
['lime', 'Lime', 'Yellow-green accents', 85, 30, 82, 85],
|
||||
['citrus', 'Citrus', 'Dark yellow, orange accents', 60, 25, 28, 95],
|
||||
['sunset', 'Sunset', 'Purple, orange accents', 280, 30, 25, 95],
|
||||
['flamingo', 'Flamingo', 'Dark rose, bright pink', 330, 25, 345, 90],
|
||||
['cherry', 'Cherry', 'Deep red', 355, 40, 355, 90, { base: 5 }],
|
||||
['brick', 'Brick', 'Red-brown, gold accents', 8, 35, 40, 80],
|
||||
['terracotta', 'Terracotta', 'Clay, teal accents', 18, 30, 175, 55, { base: 8 }],
|
||||
['sandstone', 'Sandstone', 'Lighter warm grey', 35, 18, 20, 70, { base: 10 }],
|
||||
['mocha', 'Mocha', 'Brown, lilac accents', 20, 18, 280, 50, { base: 9 }],
|
||||
['walnut', 'Walnut', 'Dark brown, gold accents', 25, 35, 45, 80, { base: 5 }],
|
||||
['bronze', 'Bronze', 'Yellow-brown', 40, 35, 38, 70],
|
||||
['brass', 'Brass', 'Dark gold', 48, 30, 50, 85],
|
||||
['mulberry', 'Mulberry', 'Dark purple-pink', 310, 35, 310, 65, { base: 5 }],
|
||||
['grape', 'Grape', 'Strong purple', 280, 40, 280, 90],
|
||||
['iris', 'Iris', 'Lighter blue-violet', 250, 30, 255, 90, { base: 9 }],
|
||||
['twilight', 'Twilight', 'Night blue, pink accents', 245, 35, 330, 75, { base: 5 }],
|
||||
['nebula', 'Nebula', 'Purple, turquoise accents', 270, 45, 190, 85, { base: 4 }],
|
||||
['cathode', 'Cathode', 'Cyan on black', 185, 30, 185, 90, { base: 2, textHue: 185, textSat: 80, link: 160 }],
|
||||
['bubblegum', 'Bubblegum', 'Purple-pink, cyan links', 300, 25, 330, 95, { base: 8, link: 185 }],
|
||||
['volcano', 'Volcano', 'Black, lava orange', 10, 30, 20, 100, { base: 3 }],
|
||||
['glacier', 'Glacier', 'The lightest dark, cool', 205, 30, 190, 70, { base: 12 }],
|
||||
['seaweed', 'Seaweed', 'Dark teal, green accents', 170, 35, 90, 60, { base: 4 }]
|
||||
];
|
||||
const LIGHT = [
|
||||
['snow', 'Snow', 'Cool white', 220, 10, 225, 75],
|
||||
['linen', 'Linen', 'Warm white', 40, 30, 25, 60],
|
||||
['sky', 'Sky', 'Light blue', 205, 60, 210, 85],
|
||||
['mint', 'Mint', 'Light green', 150, 40, 160, 70],
|
||||
['lavender', 'Lavender', 'Light purple', 265, 45, 265, 60],
|
||||
['blush', 'Blush', 'Light pink', 345, 50, 345, 65],
|
||||
['peach', 'Peach', 'Light orange', 25, 65, 18, 75],
|
||||
['lemon', 'Lemon', 'Light yellow', 55, 60, 45, 90],
|
||||
['sage', 'Sage', 'Grey-green', 100, 20, 110, 35],
|
||||
['latte', 'Latte', 'Milky brown', 30, 35, 25, 45],
|
||||
['ice', 'Ice', 'Pale turquoise', 190, 45, 195, 80],
|
||||
['cloud', 'Cloud', 'Soft blue-grey', 210, 25, 210, 60],
|
||||
['pearl', 'Pearl', 'Near white, rose accents', 300, 8, 320, 45],
|
||||
['cream', 'Cream', 'Soft yellow', 48, 55, 30, 70],
|
||||
['sand', 'Sand', 'Beige', 38, 40, 20, 55],
|
||||
['rose', 'Rose', 'Pink-red', 355, 55, 350, 70],
|
||||
['coral', 'Coral', 'Light red-orange', 12, 65, 8, 75],
|
||||
['apricot', 'Apricot', 'Warm orange', 32, 70, 28, 85],
|
||||
['pistachio', 'Pistachio', 'Yellow-green', 95, 35, 110, 50],
|
||||
['seafoam', 'Seafoam', 'Green-turquoise', 165, 40, 172, 75],
|
||||
['aqua', 'Aqua', 'Turquoise', 182, 50, 186, 85],
|
||||
['periwinkle', 'Periwinkle', 'Blue-violet', 232, 55, 235, 70],
|
||||
['lilac', 'Lilac', 'Soft purple', 285, 40, 285, 55],
|
||||
['petal', 'Petal', 'Purple-pink', 320, 45, 320, 60],
|
||||
['fog', 'Fog', 'Grey with a little blue', 200, 8, 200, 25]
|
||||
];
|
||||
|
||||
function dark([id, name, note, h, s, a, as, o = {}]) {
|
||||
const b = o.base ?? 6;
|
||||
const bg = [b, b + 3, b + 5.5, b + 10].map(l => hex(h, s, l));
|
||||
const th = o.textHue ?? h, ts = o.textSat ?? Math.min(s, 20);
|
||||
const accent = hex(a, as, as ? 62 : 80);
|
||||
const v = {
|
||||
'--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': bg[3],
|
||||
'--bg-hover': hex(h, s, b + 13), '--bg-active': hex(h, s, b + 18), '--line': hex(h, s, b + 11),
|
||||
'--text': readable(th, ts, o.textSat ? 70 : 91, bg, 7.5, 1),
|
||||
'--text-dim': readable(th, o.textSat ? ts * 0.7 : Math.min(s, 15), o.textSat ? 50 : 66, bg, 4.6, 1),
|
||||
'--text-faint': hex(th, o.textSat ? ts * 0.5 : Math.min(s, 12), 45),
|
||||
'--accent': accent, '--accent-hover': hex(a, as, as ? 69 : 88),
|
||||
'--accent-text': contrast('#ffffff', accent) >= contrast('#0b0b0b', accent) ? '#ffffff' : '#0b0b0b',
|
||||
'--speaking': a > 90 && a < 170 ? '#f2c14e' : '#3bd16f',
|
||||
'--danger': readable(4, 85, 62, bg, 4.6, 1), '--warn': '#e8b23c',
|
||||
'--link': readable(o.link ?? (a > 190 && a < 250 ? 185 : 210), 85, 70, bg, 4.6, 1)
|
||||
};
|
||||
return { id, name, note, kind: 'dark', v };
|
||||
}
|
||||
|
||||
function light([id, name, note, h, s, a, as]) {
|
||||
const bg = [hex(h, s, 89), hex(h, s, 94.5), hex(h, s, 98)];
|
||||
const accent = readable(a, as, 42, ['#ffffff'], 4.6, -1);
|
||||
const v = {
|
||||
'--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': hex(h, s, 91),
|
||||
'--bg-hover': hex(h, s, 87), '--bg-active': hex(h, s, 82), '--line': hex(h, Math.min(s, 30), 82),
|
||||
'--text': readable(h, 20, 13, bg, 7.5, -1),
|
||||
'--text-dim': readable(h, 12, 34, bg, 4.6, -1),
|
||||
'--text-faint': hex(h, 10, 52),
|
||||
'--accent': accent, '--accent-hover': readable(a, as, 34, ['#ffffff'], 6, -1), '--accent-text': '#ffffff',
|
||||
'--speaking': readable(140, 65, 34, bg, 3.2, -1), '--danger': readable(4, 70, 44, bg, 4.6, -1), '--warn': readable(38, 85, 36, bg, 3.2, -1),
|
||||
'--link': readable(215, 75, 40, bg, 4.6, -1)
|
||||
};
|
||||
return { id, name, note, kind: 'light', v };
|
||||
}
|
||||
|
||||
const themes = [...DARK.map(dark), ...LIGHT.map(light)];
|
||||
|
||||
const css = '/* Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand. */\n\n' + themes.map(t =>
|
||||
`:root[data-theme="${t.id}"], [data-theme-preview="${t.id}"] {\n --rail: var(--bg-0);\n${Object.entries(t.v).map(([k, c]) => ` ${k}: ${c};`).join('\n')}\n color-scheme: ${t.kind};\n}\n`).join('\n');
|
||||
writeFileSync(path.join(root, 'src/themes-generated.css'), css);
|
||||
|
||||
const ts = '// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand.\n' +
|
||||
'export const GENERATED_THEMES = [\n' + themes.map(t => ` { id: '${t.id}', name: '${t.name}', note: '${t.note}', light: ${t.kind === 'light'} }`).join(',\n') + '\n] as const;\n';
|
||||
writeFileSync(path.join(root, 'src/lib/themes-generated.ts'), ts);
|
||||
console.log(`${themes.length} schemes written`);
|
||||
@@ -19,6 +19,7 @@
|
||||
import ShareDialog from './ui/ShareDialog.svelte';
|
||||
import { share } from './lib/share.svelte.ts';
|
||||
import ResizeHandle from './ui/ResizeHandle.svelte';
|
||||
import Shell from './ui2/Shell.svelte';
|
||||
import { identities } from './lib/identities.svelte.ts';
|
||||
import { session } from './lib/session.svelte.ts';
|
||||
import { ui } from './lib/ui.svelte.ts';
|
||||
@@ -92,6 +93,9 @@
|
||||
onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
||||
{/snippet}
|
||||
|
||||
{#if store.settings.interface === 'rooms'}
|
||||
<Shell onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} />
|
||||
{:else}
|
||||
<div class="app {layout} {arrangement}">
|
||||
{#if arrangement === 'classic'}
|
||||
{@render rail()}
|
||||
@@ -127,6 +131,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
|
||||
{#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Where a channel sits among its siblings. Mumble sorts by position, then by name, so a
|
||||
// channel dropped between two others needs a position that puts it exactly there.
|
||||
export interface Ordered { id: number; position: number; name: string }
|
||||
|
||||
export const byOrder = (a: Ordered, b: Ordered) => a.position - b.position || a.name.localeCompare(b.name);
|
||||
|
||||
// Position changes that put `moved` at `index` among `siblings` (which need not contain it).
|
||||
// One change when there is room between the neighbours; otherwise the row is numbered again.
|
||||
export function planMove(siblings: Ordered[], moved: Ordered, index: number): { id: number; position: number }[] {
|
||||
const rest = siblings.filter(c => c.id !== moved.id).sort(byOrder);
|
||||
const at = Math.max(0, Math.min(index, rest.length));
|
||||
const prev = rest[at - 1];
|
||||
const next = rest[at];
|
||||
const fits = (position: number) => {
|
||||
const m = { ...moved, position };
|
||||
return (!prev || byOrder(prev, m) < 0) && (!next || byOrder(m, next) < 0);
|
||||
};
|
||||
const tries = [moved.position, prev?.position, next?.position, prev && prev.position + 1, next && next.position - 1];
|
||||
for (const p of tries) {
|
||||
if (p == null || !fits(p)) continue;
|
||||
return p === moved.position ? [] : [{ id: moved.id, position: p }];
|
||||
}
|
||||
const order = [...rest.slice(0, at), moved, ...rest.slice(at)];
|
||||
return order.map((c, i) => ({ id: c.id, position: i })).filter((u, i) => order[i].position !== u.position);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function inflate(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
}
|
||||
|
||||
// One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data]
|
||||
export async function encodeShare(type: ShareType, text?: string): Promise<Uint8Array[]> {
|
||||
export async function encodeSignal(type: number, text?: string): Promise<Uint8Array[]> {
|
||||
if (text == null) return [new Uint8Array([type])];
|
||||
const data = await deflate(new TextEncoder().encode(text));
|
||||
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
||||
@@ -62,15 +62,23 @@ export async function encodeShare(type: ShareType, text?: string): Promise<Uint8
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShareSignal { type: ShareType; text?: string }
|
||||
export const encodeShare = (type: ShareType, text?: string) => encodeSignal(type, text);
|
||||
|
||||
export interface Signal<T extends number> { type: T; text?: string }
|
||||
export type ShareSignal = Signal<ShareType>;
|
||||
|
||||
// Puts chunked signals back together, per sender
|
||||
export class ShareAssembler {
|
||||
export class SignalAssembler<T extends number> {
|
||||
private pending = new Map<string, { parts: (Uint8Array | undefined)[]; got: number; at: number }>();
|
||||
private types: readonly T[];
|
||||
|
||||
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<ShareSignal | null> {
|
||||
const type = packet[0] as ShareType;
|
||||
if (!Object.values(SHARE).includes(type)) return null;
|
||||
constructor(types: readonly T[]) {
|
||||
this.types = types;
|
||||
}
|
||||
|
||||
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<Signal<T> | null> {
|
||||
const type = packet[0] as T;
|
||||
if (!this.types.includes(type)) return null;
|
||||
if (packet.length === 1) return { type };
|
||||
if (packet.length < 4) return null;
|
||||
const [, id, index, total] = packet;
|
||||
@@ -95,3 +103,9 @@ export class ShareAssembler {
|
||||
return plain ? { type, text: new TextDecoder().decode(plain) } : null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ShareAssembler extends SignalAssembler<ShareType> {
|
||||
constructor() {
|
||||
super(Object.values(SHARE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Text channels between mumh5 clients. Mumble only delivers a channel message to the people
|
||||
// sitting in that channel, so a channel you read while you are in voice elsewhere cannot be
|
||||
// a plain Mumble chat. A text channel is a normal Mumble channel with a marker in its
|
||||
// description; its messages travel as plugin data to everyone on the server, which other
|
||||
// clients ignore. The server stores nothing: clients keep the messages and hand the recent
|
||||
// ones to whoever connects later.
|
||||
import { encodeSignal, SignalAssembler } from './share-signal.ts';
|
||||
|
||||
export const TEXT_DATA_ID = 'mumh5.text';
|
||||
|
||||
export const TEXT = {
|
||||
hello: 1, // I just connected: who else runs mumh5?
|
||||
here: 2, // answer to hello
|
||||
msg: 3, // a message: { i, c, h }
|
||||
want: 4, // send me the recent messages
|
||||
history: 5 // answer to want, one per channel: { c, m: [...] }
|
||||
} as const;
|
||||
export type TextType = typeof TEXT[keyof typeof TEXT];
|
||||
|
||||
// An HTML comment: invisible in every client, and Murmur keeps it
|
||||
export const TEXT_MARK = '<!--mumh5:text-->';
|
||||
export const isTextChannel = (description: string) => description.includes(TEXT_MARK);
|
||||
export const stripMark = (description: string) => description.split(TEXT_MARK).join('');
|
||||
export const markText = (description: string, on: boolean) => (on ? TEXT_MARK : '') + stripMark(description);
|
||||
|
||||
// Kept per channel, and handed to a newcomer
|
||||
export const KEEP = 200;
|
||||
export const HAND_OVER = 50;
|
||||
// Compressed, this stays well inside the 32 packets a signal may take
|
||||
export const MAX_HTML = 24000;
|
||||
|
||||
// i: id, c: channel, t: time, n: author name, k: author certificate hash, h: html
|
||||
export interface TextMsg { i: string; c: number; t: number; n: string; k: string; h: string }
|
||||
|
||||
export const encodeText = (type: TextType, body?: unknown) => encodeSignal(type, body === undefined ? undefined : JSON.stringify(body));
|
||||
|
||||
export class TextAssembler extends SignalAssembler<TextType> {
|
||||
constructor() {
|
||||
super(Object.values(TEXT));
|
||||
}
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return Date.now().toString(36) + Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(36)).join('');
|
||||
}
|
||||
|
||||
function parse(text: string | undefined): Record<string, unknown> | null {
|
||||
try {
|
||||
const v = JSON.parse(text ?? '');
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const okId = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 40;
|
||||
const okChannel = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
|
||||
const okHtml = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= MAX_HTML;
|
||||
|
||||
// A live message; who sent it and when is filled in by the receiver
|
||||
export function parseMsg(text: string | undefined): { i: string; c: number; h: string } | null {
|
||||
const v = parse(text);
|
||||
return v && okId(v.i) && okChannel(v.c) && okHtml(v.h) ? { i: v.i, c: v.c, h: v.h } : null;
|
||||
}
|
||||
|
||||
// Messages another client kept. Times from the future are pulled back to now.
|
||||
export function parseHistory(text: string | undefined, now = Date.now()): TextMsg[] {
|
||||
const v = parse(text);
|
||||
if (!v || !okChannel(v.c) || !Array.isArray(v.m)) return [];
|
||||
const c = v.c;
|
||||
const out: TextMsg[] = [];
|
||||
for (const m of v.m.slice(-HAND_OVER) as Record<string, unknown>[]) {
|
||||
if (!m || !okId(m.i) || !okHtml(m.h) || typeof m.t !== 'number' || !Number.isFinite(m.t)) continue;
|
||||
if (typeof m.n !== 'string' || m.n.length > 200 || typeof m.k !== 'string' || m.k.length > 200) continue;
|
||||
out.push({ i: m.i, c, t: Math.min(m.t, now), n: m.n, k: m.k, h: m.h });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Adds what is not there yet, in order of time. Returns the new list and what was added.
|
||||
export function mergeMsgs(have: TextMsg[], incoming: TextMsg[], keep = KEEP): { all: TextMsg[]; added: TextMsg[] } {
|
||||
const ids = new Set(have.map(m => m.i));
|
||||
const added: TextMsg[] = [];
|
||||
for (const m of incoming) {
|
||||
if (ids.has(m.i)) continue;
|
||||
ids.add(m.i);
|
||||
added.push(m);
|
||||
}
|
||||
if (!added.length) return { all: have, added };
|
||||
const all = [...have, ...added].sort((a, b) => a.t - b.t).slice(-keep);
|
||||
return { all, added: added.filter(m => all.includes(m)) };
|
||||
}
|
||||
|
||||
// The newest messages of a channel that still fit one signal
|
||||
export async function encodeHistory(c: number, msgs: TextMsg[]): Promise<Uint8Array[]> {
|
||||
for (let n = Math.min(HAND_OVER, msgs.length); n > 0; n = Math.floor(n / 2)) {
|
||||
try {
|
||||
return await encodeText(TEXT.history, { c, m: msgs.slice(-n).map(({ i, t, n: name, k, h }) => ({ i, t, n: name, k, h })) });
|
||||
} catch { /* too large: hand over fewer */ }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
+9
-3
@@ -36,11 +36,17 @@ export function channelMenu(c: Channel): MenuItem[] {
|
||||
})
|
||||
});
|
||||
}
|
||||
// A text channel is opened, not joined
|
||||
const open: MenuItem[] = session.isText(c.id)
|
||||
? [{ label: 'Open text channel', action: () => session.selectView(`ch:${c.id}`) }]
|
||||
: [
|
||||
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
|
||||
// Opens the channel's chat in the side panel, ready to type, without joining it
|
||||
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }
|
||||
];
|
||||
return [
|
||||
{ header: true, label: c.name || 'Root' },
|
||||
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
|
||||
// Opens the channel's chat in the side panel, ready to type, without joining it
|
||||
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
|
||||
...open,
|
||||
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
|
||||
{ separator: true, label: '' },
|
||||
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
|
||||
|
||||
@@ -24,11 +24,17 @@ export interface VoiceSettings {
|
||||
frameMs: 10 | 20 | 40 | 60;
|
||||
jitterMs: number;
|
||||
forceTcp: boolean; // send voice through the TCP connection even when UDP works
|
||||
// Give the microphone back while muted. An open microphone puts a Bluetooth headset into its
|
||||
// telephone mode, which also makes what you hear sound like a phone call.
|
||||
releaseMicMuted: boolean;
|
||||
// Per-user local volume and mute, keyed by certificate hash (or name without one)
|
||||
userVolumes: Record<string, number>;
|
||||
localMutes: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Phones and tablets, where Bluetooth headsets are the usual case
|
||||
const handheld = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
const defaults: VoiceSettings = {
|
||||
inputDevice: 'default',
|
||||
outputDevice: 'default',
|
||||
@@ -45,6 +51,7 @@ const defaults: VoiceSettings = {
|
||||
frameMs: 20,
|
||||
jitterMs: 60,
|
||||
forceTcp: false,
|
||||
releaseMicMuted: handheld,
|
||||
userVolumes: {},
|
||||
localMutes: {}
|
||||
};
|
||||
@@ -63,6 +70,7 @@ class VoiceEngine {
|
||||
inputDevices = $state<DeviceInfo[]>([]);
|
||||
outputDevices = $state<DeviceInfo[]>([]);
|
||||
running = $state(false);
|
||||
micOpen = $state(false); // the microphone is held right now
|
||||
error = $state('');
|
||||
level = $state(-100); // current input level, dBFS
|
||||
transmitting = $state(false);
|
||||
@@ -122,7 +130,8 @@ class VoiceEngine {
|
||||
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
|
||||
if ('outputDevice' in patch) this.applyOutputDevice();
|
||||
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
|
||||
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput();
|
||||
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.syncInput(true);
|
||||
else if ('releaseMicMuted' in patch) this.syncInput();
|
||||
}
|
||||
|
||||
// ─── Devices ────────────────────────────────────────────────────────────────
|
||||
@@ -174,7 +183,7 @@ class VoiceEngine {
|
||||
this.inputGainNode.connect(this.capture);
|
||||
|
||||
await this.applyOutputDevice();
|
||||
await this.restartInput();
|
||||
await this.syncInput();
|
||||
this.configureEncoder();
|
||||
await ctx.resume();
|
||||
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
|
||||
@@ -192,6 +201,7 @@ class VoiceEngine {
|
||||
this.levelTimer = null;
|
||||
this.stream?.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
this.micOpen = false;
|
||||
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
|
||||
this.decoders.clear();
|
||||
try { this.encoder?.close(); } catch { /* closed */ }
|
||||
@@ -205,13 +215,39 @@ class VoiceEngine {
|
||||
this.level = -100;
|
||||
}
|
||||
|
||||
// The microphone is not needed while we cannot talk anyway (muted or deafened, by us or the server)
|
||||
private micWanted(): boolean {
|
||||
if (!this.settings.releaseMicMuted || this.testing) return true;
|
||||
const self = this.client?.self;
|
||||
return !self || !(self.selfMute || self.mute || self.suppress || self.selfDeaf || self.deaf);
|
||||
}
|
||||
|
||||
// Opens or gives back the microphone as the mute state asks. restart: open it anew (settings changed).
|
||||
private async syncInput(restart = false): Promise<void> {
|
||||
if (!this.ctx) return;
|
||||
const wanted = this.micWanted();
|
||||
if (wanted && (restart || !this.stream)) return this.restartInput();
|
||||
if (!wanted && this.stream) {
|
||||
this.endTransmission();
|
||||
this.source?.disconnect();
|
||||
this.source = null;
|
||||
this.stream.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
this.micOpen = false;
|
||||
this.levelRaw = -100;
|
||||
}
|
||||
}
|
||||
|
||||
private async restartInput(): Promise<void> {
|
||||
if (!this.ctx || !this.inputGainNode) return;
|
||||
this.source?.disconnect();
|
||||
this.stream?.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
this.micOpen = false;
|
||||
const s = this.settings;
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
|
||||
echoCancellation: s.echoCancellation,
|
||||
@@ -225,6 +261,10 @@ class VoiceEngine {
|
||||
this.error = `Microphone unavailable: ${(e as Error).message}`;
|
||||
return;
|
||||
}
|
||||
// Muted again, or opened a second time, while the browser was getting the microphone
|
||||
if (!this.ctx || this.stream || !this.micWanted()) return stream.getTracks().forEach(t => t.stop());
|
||||
this.stream = stream;
|
||||
this.micOpen = true;
|
||||
this.error = '';
|
||||
this.source = this.ctx.createMediaStreamSource(this.stream);
|
||||
this.source.connect(this.inputGainNode);
|
||||
@@ -249,7 +289,8 @@ class VoiceEngine {
|
||||
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
|
||||
this.unsubscribe = [
|
||||
client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
|
||||
client.on('userRemove', u => this.dropSession(u.session))
|
||||
client.on('userRemove', u => this.dropSession(u.session)),
|
||||
client.on('user', u => { if (this.client === client && u.session === client.session) this.syncInput(); })
|
||||
];
|
||||
this.limitBitrate();
|
||||
this.start();
|
||||
@@ -490,6 +531,7 @@ class VoiceEngine {
|
||||
|
||||
setTesting(on: boolean): void {
|
||||
this.testing = on;
|
||||
this.syncInput();
|
||||
if (on) this.start();
|
||||
else {
|
||||
this.playback?.port.postMessage({ drop: -1 });
|
||||
|
||||
@@ -21,4 +21,7 @@ function run<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<
|
||||
|
||||
export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key));
|
||||
export const getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(key));
|
||||
export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key));
|
||||
// Plain data that outgrows localStorage (text channel messages)
|
||||
export const putData = (key: string, value: unknown) => run('readwrite', s => s.put(value, key));
|
||||
export const getData = <T>(key: string) => run<T | undefined>('readonly', s => s.get(key));
|
||||
export const deleteBlob =(key: string) => run('readwrite', s => s.delete(key));
|
||||
|
||||
@@ -35,5 +35,10 @@ export const ICON_PATHS: Record<string, string> = {
|
||||
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
|
||||
image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
|
||||
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
|
||||
more: 'M4 11h2v2H4zM11 11h2v2h-2zM18 11h2v2h-2z',
|
||||
back: 'M19 12H5M11 6l-6 6 6 6',
|
||||
search: 'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zM16 16l5 5',
|
||||
user: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1',
|
||||
rooms: 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
|
||||
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createCodec } from '../core/proto.ts';
|
||||
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
|
||||
import { native, openTransport } from './native.ts';
|
||||
import { share, type ShareKind } from './share.svelte.ts';
|
||||
import { textChannels } from './text-channels.ts';
|
||||
import { MAX_HTML, type TextMsg } from '../core/text-signal.ts';
|
||||
import type { ServerCertInfo } from '../core/transport.ts';
|
||||
import { store, type SavedServer } from './settings.svelte.ts';
|
||||
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
|
||||
@@ -188,6 +190,12 @@ export class Session {
|
||||
}
|
||||
|
||||
// Whether a channel lies somewhere below another (a channel cannot move into its own subtree)
|
||||
// A channel that is read and written without joining it (mumh5 clients only)
|
||||
isText(channelId: number): boolean {
|
||||
void this.tick;
|
||||
return textChannels.isText(this, channelId);
|
||||
}
|
||||
|
||||
isInside(channelId: number, ancestor: number): boolean {
|
||||
let c = this.client?.channels.get(channelId);
|
||||
while (c && c.parent != null) {
|
||||
@@ -292,6 +300,7 @@ export class Session {
|
||||
this.client = null;
|
||||
client?.disconnect();
|
||||
share.closed(this);
|
||||
textChannels.closed(this);
|
||||
this.sharing = {};
|
||||
sessions.onClosed(this);
|
||||
this.status = 'idle';
|
||||
@@ -318,11 +327,17 @@ export class Session {
|
||||
for (const id of client.channels.keys()) client.requestPermissions(id);
|
||||
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
|
||||
share.entered(this);
|
||||
textChannels.entered(this);
|
||||
bump();
|
||||
});
|
||||
client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); });
|
||||
client.on('pluginData', (sender, dataId, data) => {
|
||||
if (!live()) return;
|
||||
share.receive(this, sender, dataId, data);
|
||||
textChannels.receive(this, sender, dataId, data);
|
||||
});
|
||||
client.on('channel', ch => {
|
||||
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
|
||||
if (live() && client.synced) textChannels.channelChanged(this, ch.id);
|
||||
bump();
|
||||
});
|
||||
client.on('permissions', () => {
|
||||
@@ -334,6 +349,11 @@ export class Session {
|
||||
client.on('channelRemove', id => {
|
||||
if (!live()) return;
|
||||
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
|
||||
textChannels.channelRemoved(this, id);
|
||||
// The chat on screen is gone: back to the channel we are in
|
||||
if (this.view === `ch:${id}`) this.view = `ch:${client.self?.channelId ?? 0}`;
|
||||
delete this.logs[`ch:${id}`];
|
||||
delete this.unread[`ch:${id}`];
|
||||
bump();
|
||||
});
|
||||
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
|
||||
@@ -378,6 +398,7 @@ export class Session {
|
||||
}
|
||||
const self = client.self;
|
||||
share.userMoved(this, user, true);
|
||||
textChannels.userLeft(this, user.session);
|
||||
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
|
||||
const byOther = actor != null && actor !== user.session;
|
||||
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
|
||||
@@ -427,6 +448,7 @@ export class Session {
|
||||
const kind = this.disconnectInfo?.kind;
|
||||
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
|
||||
this.client = null;
|
||||
textChannels.closed(this);
|
||||
sessions.onClosed(this);
|
||||
this.status = 'idle';
|
||||
this.error = reason === 'Disconnected' ? '' : reason;
|
||||
@@ -519,11 +541,27 @@ export class Session {
|
||||
|
||||
// ─── Chat ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void {
|
||||
// time: for messages from earlier, which go where they belong in the log. quiet: not counted as unread.
|
||||
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>, opts: { time?: number; quiet?: boolean } = {}): void {
|
||||
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
|
||||
if (!this.logs[key]) this.logs[key] = [];
|
||||
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() });
|
||||
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1;
|
||||
const log = this.logs[key];
|
||||
const time = opts.time ?? Date.now();
|
||||
let at = log.length;
|
||||
while (at > 0 && log[at - 1].time > time) at--;
|
||||
log.splice(at, 0, { ...entry, id: nextId++, time });
|
||||
if (!opts.quiet && (sessions.active !== this || !this.visibleKeys().includes(key))) this.unread[key] = (this.unread[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// A text channel message: live, kept from earlier, or handed over by another client
|
||||
addText(m: TextMsg, self: boolean, quiet: boolean, sender: number | null = null): void {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
const online = sender != null ? client.users.get(sender) : self ? client.self : m.k ? [...client.users.values()].find(u => u.hash === m.k) : undefined;
|
||||
this.push(`ch:${m.c}`, {
|
||||
kind: 'text', author: online ? store.displayName(online) : m.n, authorSession: online?.session ?? null, self,
|
||||
...renderIncoming(m.h, store.trustedHosts)
|
||||
}, { time: m.t, quiet });
|
||||
}
|
||||
|
||||
private system(key: string, html: string, trusted = false): void {
|
||||
@@ -592,6 +630,16 @@ export class Session {
|
||||
sendHtml(html: string, key: string = this.view): boolean {
|
||||
const client = this.client;
|
||||
if (!client || this.status !== 'connected') return false;
|
||||
if (key.startsWith('ch:') && this.isText(Number(key.slice(3)))) {
|
||||
if (html.length > MAX_HTML) {
|
||||
this.system(key, `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`);
|
||||
return false;
|
||||
}
|
||||
// Shown in the log by the text channel code once it is on its way
|
||||
textChannels.post(this, Number(key.slice(3)), html).then(problem => { if (problem) this.system(key, escapeHtml(problem)); });
|
||||
sounds.play('messageSent');
|
||||
return true;
|
||||
}
|
||||
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
|
||||
if (limit && html.length > limit) {
|
||||
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
|
||||
@@ -623,6 +671,7 @@ export class Session {
|
||||
const problem = checkFile(info, f);
|
||||
if (problem) this.system(key, escapeHtml(problem));
|
||||
}
|
||||
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
|
||||
for (const file of files.filter(f => !checkFile(info, f))) {
|
||||
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
|
||||
this.uploads.push(job);
|
||||
@@ -633,7 +682,8 @@ export class Session {
|
||||
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
|
||||
let html = link;
|
||||
const cfg = this.client?.config;
|
||||
if (sendThumbnails && cfg?.allowHtml !== false) {
|
||||
// Text channels only reach mumh5, which shows the file itself instead of a thumbnail
|
||||
if (sendThumbnails && cfg?.allowHtml !== false && !textKey) {
|
||||
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
|
||||
const thumb = await makeThumbnail(file, budget);
|
||||
if (thumb) html += `<br/><img src="${thumb}"/>`;
|
||||
@@ -654,17 +704,18 @@ export class Session {
|
||||
// scaled to the server's image limit. Other files need an upload host.
|
||||
private async sendInlineImages(files: File[], key: string): Promise<void> {
|
||||
const cfg = this.client?.config;
|
||||
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`);
|
||||
continue;
|
||||
}
|
||||
if (cfg?.allowHtml === false) {
|
||||
if (cfg?.allowHtml === false && !textKey) {
|
||||
this.system(key, 'This server does not allow images in messages.');
|
||||
return;
|
||||
}
|
||||
// 0 means no limit; stay reasonable anyway
|
||||
const limit = cfg?.imageMessageLength || 1024 * 1024;
|
||||
const limit = textKey ? MAX_HTML : cfg?.imageMessageLength || 1024 * 1024;
|
||||
const uri = await makeInlineImage(file, limit - 64);
|
||||
if (!uri) {
|
||||
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { load, loadList, save } from './storage.ts';
|
||||
import { GENERATED_THEMES } from './themes-generated.ts';
|
||||
|
||||
export interface SavedServer {
|
||||
id: string;
|
||||
@@ -24,6 +25,8 @@ export interface Settings {
|
||||
theme: ThemeId;
|
||||
publicListUrl: string; // where the public server list comes from
|
||||
arrangement: Arrangement;
|
||||
// standard: the channel tree in one of the layouts above. rooms: one screen at a time, made for phones.
|
||||
interface: 'standard' | 'rooms';
|
||||
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
|
||||
autoCameras: boolean; // show other people's cameras as they come on
|
||||
shareVolume: number; // volume of streams we watch, 0 to 1
|
||||
@@ -36,14 +39,22 @@ export interface Settings {
|
||||
// Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name
|
||||
export type Arrangement = 'side' | 'classic' | 'stacked';
|
||||
|
||||
export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95';
|
||||
export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'forest' | 'ember' | 'ocean' | 'plum' | 'paper' | 'contrast' | 'win95' | typeof GENERATED_THEMES[number]['id'];
|
||||
|
||||
export const THEMES: { id: ThemeId; name: string; note: string }[] = [
|
||||
{ id: 'dark', name: 'Dark', note: 'The default' },
|
||||
{ id: 'light', name: 'Light', note: 'For bright rooms' },
|
||||
{ id: 'midnight', name: 'Midnight', note: 'True black, red accents' },
|
||||
{ id: 'frost', name: 'Frost', note: 'Cool blue-grey' },
|
||||
{ id: 'win95', name: 'Windows 95', note: 'Start me up' }
|
||||
// Hand-made schemes first, then the generated ones (scripts/gen-themes.mjs), dark before light
|
||||
export const THEMES: { id: ThemeId; name: string; note: string; light: boolean }[] = [
|
||||
{ id: 'dark', name: 'Dark', note: 'The default', light: false },
|
||||
{ id: 'light', name: 'Light', note: 'For bright rooms', light: true },
|
||||
{ id: 'midnight', name: 'Midnight', note: 'True black, red accents', light: false },
|
||||
{ id: 'frost', name: 'Frost', note: 'Cool blue-grey', light: false },
|
||||
{ id: 'forest', name: 'Forest', note: 'Deep green, warm light', light: false },
|
||||
{ id: 'ember', name: 'Ember', note: 'Warm dark, orange accents', light: false },
|
||||
{ id: 'ocean', name: 'Ocean', note: 'Deep navy, cyan accents', light: false },
|
||||
{ id: 'plum', name: 'Plum', note: 'Dark purple, pink accents', light: false },
|
||||
{ id: 'paper', name: 'Paper', note: 'Warm light, easy on the eyes', light: true },
|
||||
{ id: 'contrast', name: 'High contrast', note: 'Black and white, strong lines', light: false },
|
||||
{ id: 'win95', name: 'Windows 95', note: 'Start me up', light: true },
|
||||
...GENERATED_THEMES
|
||||
];
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -58,6 +69,7 @@ const defaults: Settings = {
|
||||
theme: 'dark',
|
||||
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
|
||||
arrangement: 'side',
|
||||
interface: 'standard',
|
||||
shareQuality: 'medium',
|
||||
autoCameras: true,
|
||||
shareVolume: 1,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Text channels: channels you read and write without sitting in them (see core/text-signal.ts).
|
||||
// Messages go to everyone on the server as plugin data; the server has checked the sender's
|
||||
// certificate, so a live message is known to come from that user. Messages handed over by
|
||||
// another client after connecting are that client's word for what was said.
|
||||
import { TEXT, TEXT_DATA_ID, KEEP, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, newId, isTextChannel, type TextMsg, type TextType } from '../core/text-signal.ts';
|
||||
import type { Session } from './session.svelte.ts';
|
||||
import { getData, putData } from './blobstore.ts';
|
||||
import { sounds } from './audio/sounds.svelte.ts';
|
||||
|
||||
// Murmur allows a burst of 15 plugin messages per user and 4 a second after that, and drops
|
||||
// the rest silently. Screen sharing signals count against the same limit, so stay well under it.
|
||||
const BURST = 6;
|
||||
const PER_SECOND = 2.5;
|
||||
// How long a client may take to hand over its messages before the next one is asked
|
||||
const ANSWER_MS = 6000;
|
||||
const MAX_ASKED = 3;
|
||||
|
||||
interface State {
|
||||
assembler: TextAssembler;
|
||||
msgs: Map<number, TextMsg[]>;
|
||||
// Channels whose kept messages are in the chat log already
|
||||
shown: Set<number>;
|
||||
// What this computer kept has been read; nothing is shown before, or it would come out of order
|
||||
loaded: boolean;
|
||||
queue: { to: number[]; packet: Uint8Array }[];
|
||||
level: number;
|
||||
levelAt: number;
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Catching up: who answered our hello, who we asked, and whether that is settled
|
||||
peers: number[];
|
||||
asked: number[];
|
||||
caughtUp: boolean;
|
||||
askTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
storeKey: string;
|
||||
}
|
||||
|
||||
class TextChannels {
|
||||
private states = new WeakMap<Session, State>();
|
||||
|
||||
// Plugin data needs a 1.4 server; older ones would not pass the messages on
|
||||
supported(s: Session): boolean {
|
||||
return !!s.client && s.client.serverVersionNum >= 0x010400;
|
||||
}
|
||||
|
||||
isText(s: Session, channelId: number): boolean {
|
||||
return this.supported(s) && isTextChannel(s.client!.channels.get(channelId)?.description ?? '');
|
||||
}
|
||||
|
||||
// After connecting: bring back what this computer kept, then ask the others for what we missed
|
||||
async entered(s: Session): Promise<void> {
|
||||
const client = s.client;
|
||||
if (!client || !this.supported(s) || !s.server) return;
|
||||
const st: State = {
|
||||
assembler: new TextAssembler(), msgs: new Map(), shown: new Set(), loaded: false, queue: [], level: 0, levelAt: Date.now(), timer: undefined,
|
||||
peers: [], asked: [], caughtUp: false, askTimer: undefined, saveTimer: undefined,
|
||||
storeKey: `text:${s.server.host.toLowerCase()}:${s.server.port}`
|
||||
};
|
||||
this.states.set(s, st);
|
||||
// The marker is in the description, and long descriptions are only announced by hash
|
||||
for (const c of client.channels.values()) s.loadDescription(c.id);
|
||||
try {
|
||||
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
|
||||
if (s.client !== client) return;
|
||||
for (const [id, list] of Object.entries(kept ?? {})) {
|
||||
if (Array.isArray(list)) st.msgs.set(Number(id), mergeMsgs(st.msgs.get(Number(id)) ?? [], list).all);
|
||||
}
|
||||
} catch { /* no storage: start empty */ }
|
||||
if (s.client !== client) return;
|
||||
st.loaded = true;
|
||||
this.reveal(s);
|
||||
this.send(s, this.everyone(s), TEXT.hello);
|
||||
}
|
||||
|
||||
closed(s: Session): void {
|
||||
const st = this.states.get(s);
|
||||
if (!st) return;
|
||||
clearTimeout(st.timer);
|
||||
clearTimeout(st.askTimer);
|
||||
if (st.saveTimer) { clearTimeout(st.saveTimer); this.save(st); }
|
||||
this.states.delete(s);
|
||||
}
|
||||
|
||||
// A channel appeared or its description changed
|
||||
channelChanged(s: Session, channelId: number): void {
|
||||
if (!this.states.has(s)) return;
|
||||
s.loadDescription(channelId);
|
||||
this.reveal(s);
|
||||
}
|
||||
|
||||
// Murmur gives the id of a removed channel to a later one
|
||||
channelRemoved(s: Session, channelId: number): void {
|
||||
const st = this.states.get(s);
|
||||
if (!st) return;
|
||||
st.shown.delete(channelId);
|
||||
st.msgs.delete(channelId);
|
||||
this.forget(st, channelId);
|
||||
}
|
||||
|
||||
userLeft(s: Session, session: number): void {
|
||||
const st = this.states.get(s);
|
||||
if (!st) return;
|
||||
st.peers = st.peers.filter(p => p !== session);
|
||||
// The one we were waiting for is gone: ask the next
|
||||
if (!st.caughtUp && st.asked[st.asked.length - 1] === session) this.askNext(s, st);
|
||||
}
|
||||
|
||||
// Sends a message to a text channel. The caller shows it in the log.
|
||||
async post(s: Session, channelId: number, html: string): Promise<string | null> {
|
||||
const st = this.states.get(s);
|
||||
const self = s.client?.self;
|
||||
if (!st || !self) return 'Not connected.';
|
||||
if (html.length > MAX_HTML) return `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`;
|
||||
const m: TextMsg = { i: newId(), c: channelId, t: Date.now(), n: self.name, k: self.hash, h: html };
|
||||
let packets: Uint8Array[];
|
||||
try {
|
||||
packets = await encodeText(TEXT.msg, { i: m.i, c: m.c, h: m.h });
|
||||
} catch {
|
||||
return 'Message too long for a text channel.';
|
||||
}
|
||||
const to = this.everyone(s);
|
||||
for (const packet of packets) st.queue.push({ to, packet });
|
||||
this.flush(s, st);
|
||||
this.keep(st, [m]);
|
||||
st.shown.add(channelId);
|
||||
s.addText(m, true, false);
|
||||
return null;
|
||||
}
|
||||
|
||||
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
|
||||
const st = this.states.get(s);
|
||||
if (dataId !== TEXT_DATA_ID || !st) return;
|
||||
const signal = await st.assembler.push(sender, data);
|
||||
const client = s.client;
|
||||
if (!signal || !client || this.states.get(s) !== st) return;
|
||||
switch (signal.type) {
|
||||
case TEXT.hello:
|
||||
this.send(s, [sender], TEXT.here);
|
||||
break;
|
||||
case TEXT.here:
|
||||
if (!st.peers.includes(sender)) st.peers.push(sender);
|
||||
if (!st.caughtUp && !st.asked.length) this.askNext(s, st);
|
||||
break;
|
||||
case TEXT.msg: {
|
||||
const m = parseMsg(signal.text);
|
||||
const from = client.users.get(sender);
|
||||
const ch = m && client.channels.get(m.c);
|
||||
// Only into text channels, or channels whose description has not arrived yet
|
||||
if (!m || !from || !ch || !(isTextChannel(ch.description) || (ch.descriptionHash && !ch.description))) return;
|
||||
const msg: TextMsg = { ...m, t: Date.now(), n: from.name, k: from.hash };
|
||||
if (!this.keep(st, [msg]).length) return;
|
||||
if (st.shown.has(m.c)) {
|
||||
s.addText(msg, false, false, sender);
|
||||
sounds.play('message');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TEXT.want: {
|
||||
for (const [c, list] of st.msgs) {
|
||||
if (!this.isText(s, c) || !list.length) continue;
|
||||
for (const packet of await encodeHistory(c, list)) st.queue.push({ to: [sender], packet });
|
||||
}
|
||||
// Also when there is nothing, so the asker stops waiting
|
||||
for (const packet of await encodeText(TEXT.history, { c: 0, m: [] })) st.queue.push({ to: [sender], packet });
|
||||
this.flush(s, st);
|
||||
break;
|
||||
}
|
||||
case TEXT.history: {
|
||||
// Only from the client we asked
|
||||
if (st.asked[st.asked.length - 1] !== sender) return;
|
||||
st.caughtUp = true;
|
||||
clearTimeout(st.askTimer);
|
||||
const list = parseHistory(signal.text);
|
||||
if (!list.length || !client.channels.has(list[0].c)) return;
|
||||
const added = this.keep(st, list);
|
||||
if (st.shown.has(list[0].c)) for (const m of added) s.addText(m, this.mine(s, m), true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mine(s: Session, m: TextMsg): boolean {
|
||||
const self = s.client?.self;
|
||||
return !!self && (m.k ? m.k === self.hash : m.n === self.name);
|
||||
}
|
||||
|
||||
private everyone(s: Session): number[] {
|
||||
const client = s.client;
|
||||
return client ? [...client.users.keys()].filter(id => id !== client.session) : [];
|
||||
}
|
||||
|
||||
private askNext(s: Session, st: State): void {
|
||||
clearTimeout(st.askTimer);
|
||||
const next = st.peers.find(p => !st.asked.includes(p));
|
||||
if (next == null || st.asked.length >= MAX_ASKED) return;
|
||||
st.asked.push(next);
|
||||
this.send(s, [next], TEXT.want);
|
||||
st.askTimer = setTimeout(() => { if (!st.caughtUp && this.states.get(s) === st) this.askNext(s, st); }, ANSWER_MS);
|
||||
}
|
||||
|
||||
// Puts the kept messages of text channels into their chat logs, once per channel
|
||||
private reveal(s: Session): void {
|
||||
const st = this.states.get(s);
|
||||
if (!st?.loaded) return;
|
||||
for (const [c, list] of st.msgs) {
|
||||
if (st.shown.has(c) || !this.isText(s, c)) continue;
|
||||
st.shown.add(c);
|
||||
for (const m of list) s.addText(m, this.mine(s, m), true);
|
||||
}
|
||||
for (const c of s.client?.channels.keys() ?? []) if (this.isText(s, c)) st.shown.add(c);
|
||||
}
|
||||
|
||||
private keep(st: State, list: TextMsg[]): TextMsg[] {
|
||||
if (!list.length) return [];
|
||||
const c = list[0].c;
|
||||
const { all, added } = mergeMsgs(st.msgs.get(c) ?? [], list, KEEP);
|
||||
if (added.length) {
|
||||
st.msgs.set(c, all);
|
||||
this.saveSoon(st);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
private saveSoon(st: State): void {
|
||||
clearTimeout(st.saveTimer);
|
||||
st.saveTimer = setTimeout(() => this.save(st), 1000);
|
||||
}
|
||||
|
||||
// Merged into what is stored: a second connection to the same server writes there too
|
||||
private async save(st: State): Promise<void> {
|
||||
st.saveTimer = undefined;
|
||||
try {
|
||||
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey) ?? {};
|
||||
for (const [c, list] of st.msgs) kept[c] = mergeMsgs(Array.isArray(kept[c]) ? kept[c] : [], list).all;
|
||||
await putData(st.storeKey, kept);
|
||||
} catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
private async forget(st: State, channelId: number): Promise<void> {
|
||||
try {
|
||||
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
|
||||
if (!kept || !(channelId in kept)) return;
|
||||
delete kept[channelId];
|
||||
await putData(st.storeKey, kept);
|
||||
} catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
private async send(s: Session, to: number[], type: TextType, body?: unknown): Promise<void> {
|
||||
const st = this.states.get(s);
|
||||
if (!st || !to.length) return;
|
||||
for (const packet of await encodeText(type, body)) st.queue.push({ to, packet });
|
||||
this.flush(s, st);
|
||||
}
|
||||
|
||||
private flush(s: Session, st: State): void {
|
||||
if (st.timer) return;
|
||||
const client = s.client;
|
||||
while (st.queue.length && client && this.states.get(s) === st) {
|
||||
const now = Date.now();
|
||||
st.level = Math.max(0, st.level - (now - st.levelAt) / 1000 * PER_SECOND);
|
||||
st.levelAt = now;
|
||||
if (st.level > BURST - 1) {
|
||||
st.timer = setTimeout(() => { st.timer = undefined; this.flush(s, st); }, 1000 / PER_SECOND);
|
||||
return;
|
||||
}
|
||||
st.level++;
|
||||
const { to, packet } = st.queue.shift()!;
|
||||
// Queued packets can outlive the people they were meant for
|
||||
const there = to.filter(id => client.users.has(id));
|
||||
if (there.length) client.sendPluginData(there, TEXT_DATA_ID, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const textChannels = new TextChannels();
|
||||
@@ -0,0 +1,92 @@
|
||||
// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand.
|
||||
export const GENERATED_THEMES = [
|
||||
{ id: 'slate', name: 'Slate', note: 'Blue-grey, calm', light: false },
|
||||
{ id: 'graphite', name: 'Graphite', note: 'Neutral grey, no color', light: false },
|
||||
{ id: 'charcoal', name: 'Charcoal', note: 'Warm grey, amber accents', light: false },
|
||||
{ id: 'obsidian', name: 'Obsidian', note: 'Near black, violet accents', light: false },
|
||||
{ id: 'navy', name: 'Navy', note: 'Dark blue', light: false },
|
||||
{ id: 'cobalt', name: 'Cobalt', note: 'Blue, yellow accents', light: false },
|
||||
{ id: 'indigo', name: 'Indigo', note: 'Blue-violet', light: false },
|
||||
{ id: 'violet', name: 'Violet', note: 'Purple', light: false },
|
||||
{ id: 'orchid', name: 'Orchid', note: 'Soft purple-pink', light: false },
|
||||
{ id: 'magenta', name: 'Magenta', note: 'Dark pink', light: false },
|
||||
{ id: 'crimson', name: 'Crimson', note: 'Dark red', light: false },
|
||||
{ id: 'ruby', name: 'Ruby', note: 'Deep red, orange accents', light: false },
|
||||
{ id: 'rust', name: 'Rust', note: 'Red-brown', light: false },
|
||||
{ id: 'copper', name: 'Copper', note: 'Brown, muted orange', light: false },
|
||||
{ id: 'amber', name: 'Amber', note: 'Dark yellow-brown', light: false },
|
||||
{ id: 'olive', name: 'Olive', note: 'Yellow-green', light: false },
|
||||
{ id: 'moss', name: 'Moss', note: 'Muted green', light: false },
|
||||
{ id: 'emerald', name: 'Emerald', note: 'Rich green', light: false },
|
||||
{ id: 'jade', name: 'Jade', note: 'Blue-green', light: false },
|
||||
{ id: 'teal', name: 'Teal', note: 'Green-blue', light: false },
|
||||
{ id: 'lagoon', name: 'Lagoon', note: 'Bright turquoise accents', light: false },
|
||||
{ id: 'arctic', name: 'Arctic', note: 'Lighter cool grey-blue', light: false },
|
||||
{ id: 'storm', name: 'Storm', note: 'Lighter blue-grey', light: false },
|
||||
{ id: 'dusk', name: 'Dusk', note: 'Purple evening, orange accents', light: false },
|
||||
{ id: 'wine', name: 'Wine', note: 'Dark red-purple', light: false },
|
||||
{ id: 'cocoa', name: 'Cocoa', note: 'Brown', light: false },
|
||||
{ id: 'espresso', name: 'Espresso', note: 'Very dark brown', light: false },
|
||||
{ id: 'terminal', name: 'Terminal', note: 'Green on black', light: false },
|
||||
{ id: 'phosphor', name: 'Phosphor', note: 'Amber on black', light: false },
|
||||
{ id: 'neon', name: 'Neon', note: 'Black, pink and cyan', light: false },
|
||||
{ id: 'synthwave', name: 'Synthwave', note: 'Purple, pink and cyan', light: false },
|
||||
{ id: 'aurora', name: 'Aurora', note: 'Dark blue, green accents', light: false },
|
||||
{ id: 'abyss', name: 'Abyss', note: 'Blue-black, turquoise accents', light: false },
|
||||
{ id: 'carbon', name: 'Carbon', note: 'Black, red accents', light: false },
|
||||
{ id: 'ash', name: 'Ash', note: 'Lighter grey, blue accents', light: false },
|
||||
{ id: 'steel', name: 'Steel', note: 'Grey-blue, orange accents', light: false },
|
||||
{ id: 'ink', name: 'Ink', note: 'Blue-black, yellow accents', light: false },
|
||||
{ id: 'sapphire', name: 'Sapphire', note: 'Strong blue', light: false },
|
||||
{ id: 'azure', name: 'Azure', note: 'Bright blue accents', light: false },
|
||||
{ id: 'peacock', name: 'Peacock', note: 'Turquoise, purple accents', light: false },
|
||||
{ id: 'pine', name: 'Pine', note: 'Dark green, orange accents', light: false },
|
||||
{ id: 'fern', name: 'Fern', note: 'Soft green', light: false },
|
||||
{ id: 'lime', name: 'Lime', note: 'Yellow-green accents', light: false },
|
||||
{ id: 'citrus', name: 'Citrus', note: 'Dark yellow, orange accents', light: false },
|
||||
{ id: 'sunset', name: 'Sunset', note: 'Purple, orange accents', light: false },
|
||||
{ id: 'flamingo', name: 'Flamingo', note: 'Dark rose, bright pink', light: false },
|
||||
{ id: 'cherry', name: 'Cherry', note: 'Deep red', light: false },
|
||||
{ id: 'brick', name: 'Brick', note: 'Red-brown, gold accents', light: false },
|
||||
{ id: 'terracotta', name: 'Terracotta', note: 'Clay, teal accents', light: false },
|
||||
{ id: 'sandstone', name: 'Sandstone', note: 'Lighter warm grey', light: false },
|
||||
{ id: 'mocha', name: 'Mocha', note: 'Brown, lilac accents', light: false },
|
||||
{ id: 'walnut', name: 'Walnut', note: 'Dark brown, gold accents', light: false },
|
||||
{ id: 'bronze', name: 'Bronze', note: 'Yellow-brown', light: false },
|
||||
{ id: 'brass', name: 'Brass', note: 'Dark gold', light: false },
|
||||
{ id: 'mulberry', name: 'Mulberry', note: 'Dark purple-pink', light: false },
|
||||
{ id: 'grape', name: 'Grape', note: 'Strong purple', light: false },
|
||||
{ id: 'iris', name: 'Iris', note: 'Lighter blue-violet', light: false },
|
||||
{ id: 'twilight', name: 'Twilight', note: 'Night blue, pink accents', light: false },
|
||||
{ id: 'nebula', name: 'Nebula', note: 'Purple, turquoise accents', light: false },
|
||||
{ id: 'cathode', name: 'Cathode', note: 'Cyan on black', light: false },
|
||||
{ id: 'bubblegum', name: 'Bubblegum', note: 'Purple-pink, cyan links', light: false },
|
||||
{ id: 'volcano', name: 'Volcano', note: 'Black, lava orange', light: false },
|
||||
{ id: 'glacier', name: 'Glacier', note: 'The lightest dark, cool', light: false },
|
||||
{ id: 'seaweed', name: 'Seaweed', note: 'Dark teal, green accents', light: false },
|
||||
{ id: 'snow', name: 'Snow', note: 'Cool white', light: true },
|
||||
{ id: 'linen', name: 'Linen', note: 'Warm white', light: true },
|
||||
{ id: 'sky', name: 'Sky', note: 'Light blue', light: true },
|
||||
{ id: 'mint', name: 'Mint', note: 'Light green', light: true },
|
||||
{ id: 'lavender', name: 'Lavender', note: 'Light purple', light: true },
|
||||
{ id: 'blush', name: 'Blush', note: 'Light pink', light: true },
|
||||
{ id: 'peach', name: 'Peach', note: 'Light orange', light: true },
|
||||
{ id: 'lemon', name: 'Lemon', note: 'Light yellow', light: true },
|
||||
{ id: 'sage', name: 'Sage', note: 'Grey-green', light: true },
|
||||
{ id: 'latte', name: 'Latte', note: 'Milky brown', light: true },
|
||||
{ id: 'ice', name: 'Ice', note: 'Pale turquoise', light: true },
|
||||
{ id: 'cloud', name: 'Cloud', note: 'Soft blue-grey', light: true },
|
||||
{ id: 'pearl', name: 'Pearl', note: 'Near white, rose accents', light: true },
|
||||
{ id: 'cream', name: 'Cream', note: 'Soft yellow', light: true },
|
||||
{ id: 'sand', name: 'Sand', note: 'Beige', light: true },
|
||||
{ id: 'rose', name: 'Rose', note: 'Pink-red', light: true },
|
||||
{ id: 'coral', name: 'Coral', note: 'Light red-orange', light: true },
|
||||
{ id: 'apricot', name: 'Apricot', note: 'Warm orange', light: true },
|
||||
{ id: 'pistachio', name: 'Pistachio', note: 'Yellow-green', light: true },
|
||||
{ id: 'seafoam', name: 'Seafoam', note: 'Green-turquoise', light: true },
|
||||
{ id: 'aqua', name: 'Aqua', note: 'Turquoise', light: true },
|
||||
{ id: 'periwinkle', name: 'Periwinkle', note: 'Blue-violet', light: true },
|
||||
{ id: 'lilac', name: 'Lilac', note: 'Soft purple', light: true },
|
||||
{ id: 'petal', name: 'Petal', note: 'Purple-pink', light: true },
|
||||
{ id: 'fog', name: 'Fog', note: 'Grey with a little blue', light: true }
|
||||
] as const;
|
||||
@@ -3,6 +3,7 @@ import './app.css';
|
||||
import App from './App.svelte';
|
||||
// After the components' styles, so themes override them
|
||||
import './themes.css';
|
||||
import './themes-generated.css';
|
||||
import { startTray } from './lib/tray.svelte.ts';
|
||||
import { startTextMenu } from './lib/text-menu.ts';
|
||||
import { startHints } from './lib/hints.ts';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+132
@@ -89,6 +89,138 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="forest"], [data-theme-preview="forest"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #0c1410;
|
||||
--bg-1: #111c16;
|
||||
--bg-2: #16231c;
|
||||
--bg-3: #1f3027;
|
||||
--bg-hover: #263a2f;
|
||||
--bg-active: #2f4739;
|
||||
--line: #243529;
|
||||
--text: #e2ebe4;
|
||||
--text-dim: #9db3a4;
|
||||
--text-faint: #67806f;
|
||||
--accent: #4fbf7b;
|
||||
--accent-hover: #66d08f;
|
||||
--accent-text: #07140c;
|
||||
--speaking: #f2c14e;
|
||||
--danger: #f0605a;
|
||||
--warn: #e8b23c;
|
||||
--link: #7fd4c1;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="ember"], [data-theme-preview="ember"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #120d0a;
|
||||
--bg-1: #1a130f;
|
||||
--bg-2: #211914;
|
||||
--bg-3: #2d221b;
|
||||
--bg-hover: #372a21;
|
||||
--bg-active: #443428;
|
||||
--line: #33271f;
|
||||
--text: #efe6dd;
|
||||
--text-dim: #b5a596;
|
||||
--text-faint: #7d6f62;
|
||||
--accent: #f08a3c;
|
||||
--accent-hover: #ff9d52;
|
||||
--accent-text: #1a0d03;
|
||||
--speaking: #5fd08a;
|
||||
--danger: #f0554e;
|
||||
--warn: #f0c04a;
|
||||
--link: #f4b27a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="ocean"], [data-theme-preview="ocean"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #071018;
|
||||
--bg-1: #0b1722;
|
||||
--bg-2: #0f1e2c;
|
||||
--bg-3: #172a3c;
|
||||
--bg-hover: #1d3348;
|
||||
--bg-active: #254058;
|
||||
--line: #1b2f42;
|
||||
--text: #e0ebf5;
|
||||
--text-dim: #93a9bd;
|
||||
--text-faint: #5f7689;
|
||||
--accent: #2fc4e0;
|
||||
--accent-hover: #56d6ee;
|
||||
--accent-text: #041018;
|
||||
--speaking: #4be0a0;
|
||||
--danger: #ff6259;
|
||||
--warn: #f0c04a;
|
||||
--link: #7cc4ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="plum"], [data-theme-preview="plum"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #120c16;
|
||||
--bg-1: #19111f;
|
||||
--bg-2: #201628;
|
||||
--bg-3: #2c1f37;
|
||||
--bg-hover: #372743;
|
||||
--bg-active: #443152;
|
||||
--line: #32243e;
|
||||
--text: #eee4f3;
|
||||
--text-dim: #b3a2bf;
|
||||
--text-faint: #7a6a86;
|
||||
--accent: #e667a8;
|
||||
--accent-hover: #f07fba;
|
||||
--accent-text: #1c0612;
|
||||
--speaking: #5fd08a;
|
||||
--danger: #ff6259;
|
||||
--warn: #f0c04a;
|
||||
--link: #c9a2ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="paper"], [data-theme-preview="paper"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #e4dccb;
|
||||
--bg-1: #efe8d9;
|
||||
--bg-2: #f8f3e7;
|
||||
--bg-3: #e6dfcf;
|
||||
--bg-hover: #ded6c3;
|
||||
--bg-active: #d2c9b3;
|
||||
--line: #d3cab6;
|
||||
--text: #2b2620;
|
||||
--text-dim: #5f574b;
|
||||
--text-faint: #8a8172;
|
||||
--accent: #a2542a;
|
||||
--accent-hover: #8a441f;
|
||||
--accent-text: #ffffff;
|
||||
--speaking: #2e7d46;
|
||||
--danger: #b93a2f;
|
||||
--warn: #96690f;
|
||||
--link: #2a5f9e;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="contrast"], [data-theme-preview="contrast"] {
|
||||
--rail: var(--bg-0);
|
||||
--bg-0: #000000;
|
||||
--bg-1: #000000;
|
||||
--bg-2: #000000;
|
||||
--bg-3: #1a1a1a;
|
||||
--bg-hover: #262626;
|
||||
--bg-active: #3a3a3a;
|
||||
--line: #8c8c8c;
|
||||
--text: #ffffff;
|
||||
--text-dim: #e0e0e0;
|
||||
--text-faint: #b0b0b0;
|
||||
--accent: #ffd400;
|
||||
--accent-hover: #ffe14d;
|
||||
--accent-text: #000000;
|
||||
--speaking: #00ff7f;
|
||||
--danger: #ff6b6b;
|
||||
--warn: #ffd400;
|
||||
--link: #7fd4ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ─── Windows 95 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
:root[data-theme="win95"], [data-theme-preview="win95"] {
|
||||
|
||||
@@ -7,11 +7,26 @@
|
||||
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
|
||||
];
|
||||
|
||||
const INTERFACES: { id: 'standard' | 'rooms'; name: string; note: string }[] = [
|
||||
{ id: 'standard', name: 'Standard', note: 'Channel tree, chat and members side by side' },
|
||||
{ id: 'rooms', name: 'Rooms', note: 'One screen at a time, made for phones. New and not complete yet' }
|
||||
];
|
||||
function choose(id: 'standard' | 'rooms') {
|
||||
store.settings.interface = id;
|
||||
store.saveSettings();
|
||||
}
|
||||
|
||||
function arrange(id: Arrangement) {
|
||||
store.settings.arrangement = id;
|
||||
store.saveSettings();
|
||||
}
|
||||
|
||||
// With this many schemes the list can be narrowed by name and by dark or light
|
||||
let find = $state('');
|
||||
let shade = $state<'all' | 'dark' | 'light'>('all');
|
||||
const shown = $derived(THEMES.filter(t => (shade === 'all' || (shade === 'light') === t.light)
|
||||
&& (!find.trim() || `${t.name} ${t.note}`.toLowerCase().includes(find.trim().toLowerCase()))));
|
||||
|
||||
function pick(id: ThemeId) {
|
||||
store.settings.theme = id;
|
||||
store.saveSettings();
|
||||
@@ -19,8 +34,16 @@
|
||||
</script>
|
||||
|
||||
<h3>Color scheme</h3>
|
||||
<div class="narrow">
|
||||
<input type="search" placeholder="Find a color scheme" aria-label="Find a color scheme" bind:value={find} />
|
||||
<div class="shades" role="group" aria-label="Show">
|
||||
{#each ['all', 'dark', 'light'] as const as k (k)}
|
||||
<button class:on={shade === k} aria-pressed={shade === k} onclick={() => (shade = k)}>{k === 'all' ? `All ${THEMES.length}` : k === 'dark' ? 'Dark' : 'Light'}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="themes" role="radiogroup" aria-label="Color scheme">
|
||||
{#each THEMES as t (t.id)}
|
||||
{#each shown as t (t.id)}
|
||||
<button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id}
|
||||
onclick={() => pick(t.id)} aria-label={t.name}>
|
||||
<!-- A miniature of the app in that theme -->
|
||||
@@ -38,7 +61,32 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !shown.length}<p class="nothing">No color scheme matches.</p>{/if}
|
||||
|
||||
<h3 class="gap">Interface</h3>
|
||||
<div class="themes" role="radiogroup" aria-label="Interface">
|
||||
{#each INTERFACES as f (f.id)}
|
||||
{@const on = (store.settings.interface ?? 'standard') === f.id}
|
||||
<button role="radio" aria-checked={on} class:active={on} onclick={() => choose(f.id)} aria-label={f.name}>
|
||||
<span class="mini face {f.id}" aria-hidden="true">
|
||||
{#if f.id === 'standard'}
|
||||
<span class="m-rail"><span></span><span></span></span>
|
||||
<span class="m-side"><i></i><i class="sel"></i><i></i></span>
|
||||
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
|
||||
{:else}
|
||||
<span class="m-col">
|
||||
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i></span>
|
||||
<span class="m-tabs"><i class="sel"></i><i></i><i></i><i></i></span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="name">{f.name}</span>
|
||||
<span class="note">{f.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if (store.settings.interface ?? 'standard') === 'standard'}
|
||||
<h3 class="gap">Layout</h3>
|
||||
<div class="themes" role="radiogroup" aria-label="Layout">
|
||||
{#each ARRANGEMENTS as a (a.id)}
|
||||
@@ -64,9 +112,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
<p class="help">Narrow windows use the phone layout, except Stacked, which stays as it is.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.gap { margin-top: 22px; }
|
||||
.narrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.narrow input { flex: 1 1 180px; width: auto; }
|
||||
.shades { display: flex; border: 1px solid var(--line); }
|
||||
.shades button { padding: 0 14px; font-size: 14px; color: var(--text-dim); }
|
||||
.shades button.on { background: var(--accent); color: var(--accent-text); }
|
||||
.nothing { margin: 10px 0 0; font-size: 14px; color: var(--text-dim); }
|
||||
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.m-col .m-chat { flex: 1; }
|
||||
@@ -93,5 +148,9 @@
|
||||
.m-chat i { height: 4px; background: var(--text-dim); width: 80%; }
|
||||
.m-chat i.a { background: var(--accent); width: 35%; }
|
||||
.m-chat i.short { width: 55%; }
|
||||
.face.rooms .m-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.m-tabs { flex: none; height: 12px; display: flex; gap: 6px; align-items: center; justify-content: center; background: var(--bg-0); border-top: 1px solid var(--line); }
|
||||
.m-tabs i { width: 10px; height: 4px; background: var(--text-faint); }
|
||||
.m-tabs i.sel { background: var(--accent); }
|
||||
.m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
|
||||
import { sounds } from '../lib/audio/sounds.svelte.ts';
|
||||
import { isTextChannel, markText, stripMark } from '../core/text-signal.ts';
|
||||
import { textChannels } from '../lib/text-channels.ts';
|
||||
import { sessions } from '../lib/session.svelte.ts';
|
||||
|
||||
type Props =
|
||||
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
|
||||
@@ -30,7 +33,11 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initial = channelId != null ? session.channel(channelId) : undefined;
|
||||
let name = $state(initial?.name ?? '');
|
||||
let description = $state(initial?.description ?? '');
|
||||
// The editor never sees the text channel marker; it is put back when saving
|
||||
let description = $state(stripMark(initial?.description ?? ''));
|
||||
let textChannel = $state(isTextChannel(initial?.description ?? ''));
|
||||
// The marker is an HTML comment and the messages are plugin data
|
||||
const textPossible = textChannels.supported(sessions.active) && session.serverInfo?.config.allowHtml !== false;
|
||||
let position = $state(initial?.position ?? 0);
|
||||
let maxUsers = $state(initial?.maxUsers ?? 0);
|
||||
let temporary = $state(false);
|
||||
@@ -43,7 +50,7 @@
|
||||
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
|
||||
$effect(() => {
|
||||
if (channelId != null && descPending) session.loadDescription(channelId);
|
||||
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; }
|
||||
if (!descLoaded && channel?.description) { description = stripMark(channel.description); textChannel = isTextChannel(channel.description); descLoaded = true; }
|
||||
});
|
||||
|
||||
// ─── ACL ────────────────────────────────────────────────────────────────────
|
||||
@@ -217,7 +224,7 @@
|
||||
error = '';
|
||||
if (mode === 'create') {
|
||||
if (!name.trim()) { error = 'The channel needs a name.'; return; }
|
||||
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers });
|
||||
client.createChannel(parentId!, name.trim(), { temporary, description: markText(description, textChannel), position, maxUsers });
|
||||
sounds.play('channelCreated');
|
||||
return onclose();
|
||||
}
|
||||
@@ -225,7 +232,9 @@
|
||||
if (!ch || channelId == null) return onclose();
|
||||
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
|
||||
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim();
|
||||
if (descLoaded && description !== ch.description) fields.description = description;
|
||||
// An untouched description may not start with the marker; that alone is no change
|
||||
const changed = description !== stripMark(ch.description) || textChannel !== isTextChannel(ch.description);
|
||||
if (descLoaded && changed) fields.description = markText(description, textChannel);
|
||||
if (position !== ch.position) fields.position = Number(position) || 0;
|
||||
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
|
||||
if (Object.keys(fields).length) client.updateChannel(channelId, fields);
|
||||
@@ -268,6 +277,12 @@
|
||||
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isRoot}
|
||||
<label class="check top" title="Read and written without joining, from any channel. Only mumh5 shows it this way; in other Mumble clients it is an ordinary channel and its messages are not visible there. The server keeps no messages: mumh5 clients keep them and pass the recent ones on to people who connect later. Everyone on the server who uses mumh5 can read it.">
|
||||
<input type="checkbox" bind:checked={textChannel} disabled={!textPossible} /> Text channel (mumh5 only)
|
||||
</label>
|
||||
{#if !textPossible}<p class="dim small">Text channels need a server from version 1.4 that allows HTML.</p>{/if}
|
||||
{/if}
|
||||
<p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
|
||||
{:else if !acl}
|
||||
<p class="dim">Loading permissions...</p>
|
||||
|
||||
+117
-28
@@ -14,6 +14,8 @@
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
import { sessions } from '../lib/session.svelte.ts';
|
||||
import type { Channel } from '../core/client.ts';
|
||||
import { stripMark } from '../core/text-signal.ts';
|
||||
import { planMove } from '../core/channel-order.ts';
|
||||
|
||||
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
|
||||
|
||||
@@ -21,12 +23,14 @@
|
||||
const children = $derived(session.childrenOf(channel.id));
|
||||
const expanded = $derived(session.isExpanded(channel.id));
|
||||
const here = $derived(session.self?.channelId === channel.id);
|
||||
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
|
||||
// Read and written without joining: a click opens its chat
|
||||
const isText = $derived(session.isText(channel.id));
|
||||
const selected = $derived((session.panel.kind === 'channel' && session.panel.id === channel.id) || (isText && session.view === `ch:${channel.id}`));
|
||||
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
|
||||
const total = $derived(session.subtreeUserCount(channel.id));
|
||||
|
||||
// Resting on a channel that has a description for 1 second opens a quick preview next to its row
|
||||
const hasDescription = $derived(!!(channel.description || channel.descriptionHash));
|
||||
const hasDescription = $derived(!!(stripMark(channel.description) || (!channel.description && channel.descriptionHash)));
|
||||
const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
|
||||
let preview = $state<{ x: number; y: number } | null>(null);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -40,6 +44,8 @@
|
||||
trackPointer(e);
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
// Not while something is dragged, and not when the row moved away in the meantime
|
||||
if (drag.item || !rowEl?.matches(':hover')) return;
|
||||
// Long descriptions are only announced by hash; the text is fetched now
|
||||
session.loadDescription(channel.id);
|
||||
// Just below and right of the pointer, kept inside the window
|
||||
@@ -52,6 +58,20 @@
|
||||
// An image opened from the preview covers it; the preview stays underneath
|
||||
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250);
|
||||
}
|
||||
// A row that moves away under the pointer (after a drop) never reports the pointer leaving,
|
||||
// so an open preview also watches where the pointer is
|
||||
let rowEl = $state<HTMLElement>();
|
||||
$effect(() => {
|
||||
if (!preview) return;
|
||||
let away: ReturnType<typeof setTimeout> | undefined;
|
||||
const inside = () => !!rowEl?.matches(':hover') || !!popEl?.matches(':hover') || !!ui.image;
|
||||
const check = () => {
|
||||
if (inside()) { clearTimeout(away); away = undefined; }
|
||||
else away ??= setTimeout(() => { away = undefined; if (!inside()) preview = null; }, 300);
|
||||
};
|
||||
window.addEventListener('mousemove', check);
|
||||
return () => { window.removeEventListener('mousemove', check); clearTimeout(away); };
|
||||
});
|
||||
// Back from the image: the preview stays while the pointer is on it, and goes when it is not
|
||||
let popEl = $state<HTMLElement>();
|
||||
let hadImage = false;
|
||||
@@ -80,44 +100,99 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Drop a person to move them here, or a channel to make it a subchannel of this one
|
||||
let over = $state(false);
|
||||
const canDrop = $derived.by(() => {
|
||||
// Drop a person to move them here. A channel dropped on the middle of a row becomes a
|
||||
// subchannel; dropped on the upper or lower third it goes in front of or behind this one.
|
||||
type Zone = 'before' | 'after' | 'into';
|
||||
let zone = $state<Zone | null>(null);
|
||||
const dragging = $derived(drag.item?.kind === 'channel' && drag.item.id === channel.id);
|
||||
const open = $derived(expanded && (children.length > 0 || users.length > 0));
|
||||
|
||||
function intoAllowed(): boolean {
|
||||
const d = drag.item;
|
||||
if (!d) return false;
|
||||
if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id;
|
||||
return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id);
|
||||
});
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
over = false;
|
||||
// Check before clearing: canDrop is derived from the dragged item
|
||||
function zoneAt(e: DragEvent, row: HTMLElement): Zone | null {
|
||||
const d = drag.item;
|
||||
const ok = canDrop;
|
||||
if (!d) return null;
|
||||
const into = intoAllowed();
|
||||
if (d.kind === 'user' || channel.id === 0) return into ? 'into' : null;
|
||||
if (d.id === channel.id || session.isInside(channel.id, d.id)) return null;
|
||||
const box = row.getBoundingClientRect();
|
||||
const y = (e.clientY - box.top) / box.height;
|
||||
if (y < 0.34) return 'before';
|
||||
if (y > 0.66) return 'after';
|
||||
if (into) return 'into';
|
||||
return y < 0.5 ? 'before' : 'after';
|
||||
}
|
||||
|
||||
// Resting on a closed channel opens it, to reach the channels inside
|
||||
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function setZone(z: Zone | null) {
|
||||
if (z === zone) return;
|
||||
zone = z;
|
||||
clearTimeout(openTimer);
|
||||
if (z === 'into' && !expanded && children.length) openTimer = setTimeout(() => { if (zone === 'into' && !expanded) session.toggleExpanded(channel.id); }, 700);
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, fixed?: Zone) {
|
||||
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
|
||||
if (z) e.preventDefault();
|
||||
setZone(z);
|
||||
}
|
||||
|
||||
// Leaving for an element inside the same target is not leaving
|
||||
function onDragLeave(e: DragEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) setZone(null);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, fixed?: Zone) {
|
||||
e.preventDefault();
|
||||
// Work out the target before clearing: it depends on the dragged item
|
||||
const d = drag.item;
|
||||
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
|
||||
setZone(null);
|
||||
drag.item = null;
|
||||
if (!d || !ok) return;
|
||||
const client = session.client;
|
||||
if (!d || !z || !client) return;
|
||||
if (d.kind === 'user') {
|
||||
if (d.id === session.self?.session) session.joinChannel(channel.id);
|
||||
else session.moveUser(d.id, channel.id);
|
||||
} else {
|
||||
session.client?.updateChannel(d.id, { parent: channel.id });
|
||||
sounds.play('channelMoved');
|
||||
return;
|
||||
}
|
||||
if (z === 'into') {
|
||||
client.updateChannel(d.id, { parent: channel.id });
|
||||
} else {
|
||||
const moved = session.channel(d.id);
|
||||
if (!moved || channel.parent == null) return;
|
||||
const rest = session.childrenOf(channel.parent).filter(c => c.id !== d.id);
|
||||
const plan = planMove(rest, moved, rest.findIndex(c => c.id === channel.id) + (z === 'after' ? 1 : 0));
|
||||
const own = plan.find(u => u.id === d.id);
|
||||
const reparent = moved.parent !== channel.parent;
|
||||
if (!reparent && !plan.length) return;
|
||||
if (reparent || own) client.updateChannel(d.id, { ...(reparent ? { parent: channel.parent } : {}), ...(own ? { position: own.position } : {}) });
|
||||
for (const u of plan) if (u.id !== d.id) client.updateChannel(u.id, { position: u.position });
|
||||
}
|
||||
sounds.play('channelMoved');
|
||||
}
|
||||
|
||||
const channelClicks = clickOrDouble(
|
||||
const openText = () => { session.selectView(`ch:${channel.id}`); onnavigate(); };
|
||||
const channelClicks = $derived(isText ? { onclick: openText } : clickOrDouble(
|
||||
() => { session.showChannel(channel.id); ui.panelOpen = true; },
|
||||
() => { session.joinChannel(channel.id); onnavigate(); }
|
||||
);
|
||||
));
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px"
|
||||
<!-- Behind an open channel is below its people and subchannels; the line is drawn there -->
|
||||
<li class:after-end={zone === 'after' && open} style:--indent="{8 + depth * 14}px">
|
||||
<div class="row channel" class:here class:selected class:dragging class:into={zone === 'into'} class:before={zone === 'before'} class:after={zone === 'after' && !open} class:target={zone === 'after' && open}
|
||||
style:padding-left="{8 + depth * 14}px"
|
||||
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
|
||||
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
|
||||
ondragleave={() => (over = false)}
|
||||
ondrop={onDrop}
|
||||
ondragover={onDragOver}
|
||||
ondragleave={onDragLeave}
|
||||
ondrop={onDrop} bind:this={rowEl}
|
||||
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
|
||||
{#if children.length || users.length}
|
||||
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
|
||||
@@ -128,11 +203,11 @@
|
||||
<span class="caret"></span>
|
||||
{/if}
|
||||
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
|
||||
<button class="name" title="Click for details, double-click to join" {...channelClicks}
|
||||
<button class="name" class:text={isText} title={isText ? 'Text channel: click to open' : 'Click for details, double-click to join'} {...channelClicks}
|
||||
draggable={channel.id !== 0}
|
||||
ondragstart={e => { clearTimeout(hoverTimer); preview = null; drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
|
||||
ondragend={() => { drag.item = null; over = false; }}>
|
||||
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
|
||||
ondragend={() => { drag.item = null; setZone(null); }}>
|
||||
{#if isText}<Icon name="hash" size={14} />{:else if !channel.canEnter}<Icon name="lock" size={14} />{/if}
|
||||
<span class="label">{channel.name || 'Root'}</span>
|
||||
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
|
||||
{#if unread}<span class="badge">{unread}</span>{/if}
|
||||
@@ -151,7 +226,9 @@
|
||||
{/if}
|
||||
|
||||
{#if users.length && expanded}
|
||||
<ul class="users">
|
||||
<!-- The people under a channel belong to its drop target, so there is more to aim at -->
|
||||
<ul class="users" class:into={zone === 'into'} role="presentation"
|
||||
ondragover={e => onDragOver(e, 'into')} ondragleave={onDragLeave} ondrop={e => onDrop(e, 'into')}>
|
||||
{#each users as u (u.session)}
|
||||
{@const isSelf = u.session === session.self?.session}
|
||||
{@const clicks = clickOrDouble(
|
||||
@@ -208,8 +285,20 @@
|
||||
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
|
||||
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.channel.selected { background: var(--bg-active); color: var(--text); }
|
||||
.channel.over { background: var(--accent); color: var(--accent-text); }
|
||||
.channel.over .label { color: var(--accent-text); }
|
||||
/* Drop targets: the whole row for "into", a line with a square end for "in front of" and "behind" */
|
||||
.channel { position: relative; }
|
||||
.channel.dragging { opacity: 0.4; }
|
||||
.channel.into { background: var(--accent); color: var(--accent-text); outline: 2px solid var(--accent-hover); outline-offset: -2px; }
|
||||
.channel.into .label { color: var(--accent-text); }
|
||||
.users.into { background: var(--bg-active); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
li.after-end { position: relative; }
|
||||
.channel.before::before, .channel.after::before, li.after-end::before { content: ''; position: absolute; z-index: 2; left: var(--indent); right: 4px; height: 4px; background: var(--accent); pointer-events: none; }
|
||||
.channel.before::after, .channel.after::after, li.after-end::after { content: ''; position: absolute; z-index: 2; left: calc(var(--indent) - 4px); width: 12px; height: 12px; background: var(--accent); pointer-events: none; }
|
||||
.channel.before::before { top: -2px; }
|
||||
.channel.before::after { top: -6px; }
|
||||
.channel.after::before, li.after-end::before { bottom: -2px; }
|
||||
.channel.after::after, li.after-end::after { bottom: -6px; }
|
||||
.channel.before, .channel.after, .channel.target { background: var(--bg-hover); color: var(--text); }
|
||||
.channel.here .label { color: var(--text); font-weight: 600; }
|
||||
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
|
||||
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@
|
||||
const logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
|
||||
|
||||
const isDm = $derived(session.view.startsWith('dm:'));
|
||||
const isText = $derived(!isDm && session.isText(Number(session.view.slice(3))));
|
||||
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
|
||||
const topic = $derived.by(() => {
|
||||
if (isDm || session.status !== 'connected') return '';
|
||||
@@ -71,7 +72,7 @@
|
||||
<div class="empty">
|
||||
{#if session.status === 'connected'}
|
||||
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
|
||||
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
|
||||
<p>{isDm ? 'Messages here are only visible to the two of you.' : isText ? 'A text channel: everyone on this server who uses mumh5 can read and write here, from any channel. Other Mumble clients do not see these messages.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
|
||||
{:else if session.status === 'connecting'}
|
||||
<h2>Connecting...</h2>
|
||||
{:else}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
const grouped = (i: number) => {
|
||||
const a = entries[i - 1], b = entries[i];
|
||||
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
|
||||
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.author === b.author && a.self === b.self && b.time - a.time < 5 * 60000;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
{/if}
|
||||
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
|
||||
<div class="actions">
|
||||
{#if session.self?.channelId === channel.id}
|
||||
{#if session.isText(channel.id)}
|
||||
<button class="btn primary" onclick={() => { session.selectView(`ch:${channel.id}`); onnavigate(); }}>Open text channel</button>
|
||||
{:else if session.self?.channelId === channel.id}
|
||||
<span class="here">You are in this channel</span>
|
||||
{:else}
|
||||
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
|
||||
|
||||
@@ -114,6 +114,8 @@
|
||||
</div>
|
||||
|
||||
<h3>Processing</h3>
|
||||
<label class="check"><input type="checkbox" checked={s.releaseMicMuted} onchange={e => voice.update({ releaseMicMuted: e.currentTarget.checked })} /> Give the microphone back while muted</label>
|
||||
<p class="help">For Bluetooth headsets: with the microphone open, a headset switches to its telephone mode and everything sounds like a phone call. Muted, you then hear in full quality. To talk in full quality too, choose the phone's own microphone above instead of the headset's. No reminder when you speak while muted.{#if s.releaseMicMuted && voice.running} <strong>{voice.micOpen ? 'The microphone is open now.' : 'The microphone is given back now.'}</strong>{/if}</p>
|
||||
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
|
||||
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
|
||||
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
// as are on, and a click enlarges one.
|
||||
// videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list
|
||||
// above already shows who is here)
|
||||
let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props();
|
||||
// fill: take all the room the parent gives, without a height of its own (the voice screen)
|
||||
let { always = false, videoOnly = false, fill = false }: { always?: boolean; videoOnly?: boolean; fill?: boolean } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
||||
@@ -119,13 +120,15 @@
|
||||
return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34))));
|
||||
});
|
||||
|
||||
let collapsed = $state(load());
|
||||
let folded = $state(load());
|
||||
const collapsed = $derived(folded && !fill);
|
||||
function load(): boolean {
|
||||
try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; }
|
||||
}
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ }
|
||||
if (fill) return;
|
||||
folded = !folded;
|
||||
try { localStorage.setItem('mumh5.stageCollapsed', folded ? '1' : '0'); } catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||
@@ -349,9 +352,9 @@
|
||||
{/if}
|
||||
</section>
|
||||
{:else if visible && self}
|
||||
<section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
|
||||
style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
|
||||
{#if !collapsed}
|
||||
<section class="stage" class:meeting={!!focused} class:collapsed class:fill aria-label="Voice channel"
|
||||
style:height={collapsed || fill ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
|
||||
{#if !collapsed && !fill}
|
||||
{#if focused}
|
||||
<ResizeHandle bind:width={meetingHeight} edge="bottom" min={240} max={maxHeight} initial={460} storageKey="mumh5.stageHeightMeeting" label="Resize the meeting view" />
|
||||
{:else}
|
||||
@@ -404,6 +407,7 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stage.fill { flex: 1; border-bottom: 0; }
|
||||
.stage { position: relative; flex: none; display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--line); background: var(--bg-0); }
|
||||
header { flex: none; display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; background: var(--bg-1); }
|
||||
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import ChatLog from '../ui/ChatLog.svelte';
|
||||
import Composer from '../ui/Composer.svelte';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { menu } from '../lib/menu.svelte.ts';
|
||||
import { channelMenu, userMenu } from '../lib/actions.ts';
|
||||
import { stripMark } from '../core/text-signal.ts';
|
||||
|
||||
// back: shown where the rooms are a screen of their own
|
||||
let { back, onback }: { back: boolean; onback: () => void } = $props();
|
||||
|
||||
const connected = $derived(session.status === 'connected');
|
||||
const isDm = $derived(session.view.startsWith('dm:'));
|
||||
const id = $derived(Number(session.view.slice(3)));
|
||||
const isText = $derived(!isDm && session.isText(id));
|
||||
const mine = $derived(!isDm && session.self?.channelId === id);
|
||||
const title = $derived(connected ? session.viewTitle() : 'Chat');
|
||||
const topic = $derived(isDm || !connected ? '' : stripMark(session.channel(id)?.description ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
|
||||
const kind = $derived(isDm ? 'Direct message' : isText ? 'Text channel' : mine ? 'Your room' : 'Room you are not in');
|
||||
|
||||
function more(e: MouseEvent) {
|
||||
if (isDm) {
|
||||
const u = session.user(id);
|
||||
if (u) menu.show(e, userMenu(u));
|
||||
} else {
|
||||
const c = session.channel(id);
|
||||
if (c) menu.show(e, channelMenu(c));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="chat2" aria-label="Chat">
|
||||
<header>
|
||||
{#if back}<button class="icon-btn" aria-label="Back to rooms" onclick={onback}><Icon name="back" /></button>{/if}
|
||||
<div class="title">
|
||||
<h1><Icon name={isDm ? 'message' : 'hash'} size={18} />{title}</h1>
|
||||
{#if connected}<span title={topic}>{kind}{topic ? `. ${topic}` : ''}</span>{/if}
|
||||
</div>
|
||||
{#if connected}<button class="icon-btn" aria-label="More for {title}" onclick={more}><Icon name="more" /></button>{/if}
|
||||
</header>
|
||||
<ChatLog key={session.view}>
|
||||
{#snippet empty()}
|
||||
<div class="empty">
|
||||
{#if connected}
|
||||
<h2>{isDm ? title : `#${title}`}</h2>
|
||||
<p>{isDm ? 'Messages here are only visible to the two of you.'
|
||||
: isText ? 'A text channel: everyone on this server who uses mumh5 can read and write here, from any room. Other Mumble clients do not see these messages.'
|
||||
: mine ? 'Messages go to everyone in this room.'
|
||||
: 'You are not in this room. Your messages reach the people in it, but you only see theirs after joining.'}</p>
|
||||
{:else}
|
||||
<h2>No chat open</h2>
|
||||
<p>Connect to a server and choose a room.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChatLog>
|
||||
{#if connected}<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chat2 { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
|
||||
header { flex: none; min-height: 60px; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
||||
.title { flex: 1; min-width: 0; padding-left: 8px; }
|
||||
h1 { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 17px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
h1 :global(svg) { flex: none; color: var(--text-dim); }
|
||||
.title span { display: block; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.icon-btn { flex: none; width: 40px; height: 40px; }
|
||||
.empty { padding: 32px 16px 8px; }
|
||||
.empty h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.01em; }
|
||||
.empty p { margin: 0; color: var(--text-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '../ui/Avatar.svelte';
|
||||
import VoiceControls from './VoiceControls.svelte';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
|
||||
// Always in reach: the room you are in, who is talking, and the two buttons that matter
|
||||
let { onopen }: { onopen: () => void } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
|
||||
const room = $derived(self ? session.channel(self.channelId)?.name || 'Root' : '');
|
||||
const speakers = $derived(self ? session.usersIn(self.channelId).filter(u => u.session !== self.session && voice.talking[u.session]) : []);
|
||||
const line = $derived.by(() => {
|
||||
if (!self) return session.status === 'connecting' ? 'Connecting...' : 'Not connected';
|
||||
if (voice.error) return 'Voice unavailable';
|
||||
if (elsewhere) return `Voice is on ${sessions.voiceSession?.server?.label}`;
|
||||
if (speakers.length) return `${speakers.slice(0, 2).map(u => store.displayName(u)).join(', ')}${speakers.length > 2 ? ` +${speakers.length - 2}` : ''} talking`;
|
||||
if (voice.settings.mode === 'ptt' && !self.selfMute) return voice.pttDown ? 'Talking' : 'Push to talk';
|
||||
const n = session.usersIn(self.channelId).length;
|
||||
return n === 1 ? 'Only you here' : `${n} people here`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dock" class:talking={voice.transmitting}>
|
||||
<button class="where" onclick={onopen} disabled={!self} title="Open the voice screen">
|
||||
{#if self}<Avatar name={self.name} size={36} speaking={voice.transmitting} />{/if}
|
||||
<span class="text">
|
||||
<strong>{self ? room : 'mumh5'}</strong>
|
||||
<span>{line}</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if elsewhere}
|
||||
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
|
||||
{:else}
|
||||
<VoiceControls />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dock { flex: none; display: flex; align-items: center; gap: 8px; min-height: 60px; padding: 8px 12px; background: var(--bg-0); border-top: 1px solid var(--line); box-shadow: inset 0 2px 0 transparent; }
|
||||
.dock.talking { box-shadow: inset 0 2px 0 var(--speaking); }
|
||||
.where { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; text-align: left; }
|
||||
.where:disabled { opacity: 1; }
|
||||
.text { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.text strong, .text span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.text span { font-size: 13px; color: var(--text-dim); }
|
||||
.btn { flex: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Avatar from '../ui/Avatar.svelte';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
import { menu } from '../lib/menu.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { userMenu } from '../lib/actions.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
|
||||
// Everyone on the server, by room; yours first
|
||||
let { onchat }: { onchat: () => void } = $props();
|
||||
|
||||
let query = $state('');
|
||||
const q = $derived(query.trim().toLowerCase());
|
||||
const self = $derived(session.self);
|
||||
const groups = $derived.by(() => {
|
||||
const byRoom = new Map<number, ReturnType<typeof session.allUsers>>();
|
||||
for (const u of session.allUsers()) {
|
||||
if (q && !store.displayName(u).toLowerCase().includes(q) && !u.name.toLowerCase().includes(q)) continue;
|
||||
if (!byRoom.has(u.channelId)) byRoom.set(u.channelId, []);
|
||||
byRoom.get(u.channelId)!.push(u);
|
||||
}
|
||||
return [...byRoom].map(([id, users]) => ({ id, name: session.channel(id)?.name || 'Root', users }))
|
||||
.sort((a, b) => Number(b.id === self?.channelId) - Number(a.id === self?.channelId) || a.name.localeCompare(b.name));
|
||||
});
|
||||
const total = $derived(session.allUsers().length);
|
||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||
</script>
|
||||
|
||||
<section class="people" aria-label="People">
|
||||
<header>
|
||||
<h1>People</h1>
|
||||
<span>{session.status === 'connected' ? `${total} online` : 'Not connected'}</span>
|
||||
</header>
|
||||
{#if session.status === 'connected'}
|
||||
<label class="find">
|
||||
<Icon name="search" size={16} />
|
||||
<input type="search" placeholder="Find a person" aria-label="Find a person" bind:value={query} />
|
||||
</label>
|
||||
<div class="scroll">
|
||||
{#each groups as g (g.id)}
|
||||
<h2>{g.name}{#if g.id === self?.channelId} <em>your room</em>{/if} <span>{g.users.length}</span></h2>
|
||||
<ul>
|
||||
{#each g.users as u (u.session)}
|
||||
{@const me = u.session === self?.session}
|
||||
<li class="entry" role="presentation" oncontextmenu={e => menu.show(e, userMenu(u))}>
|
||||
<button class="main" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}>
|
||||
<Avatar name={u.name} size={40} speaking={talking(u.session)} />
|
||||
<span class="text">
|
||||
<span class="name" class:talking={talking(u.session)}>{store.displayName(u)}</span>
|
||||
<span class="sub">{me ? 'You' : u.userId != null ? 'Registered' : 'Guest'}{u.deaf || u.selfDeaf ? ', deafened' : u.mute || u.selfMute || u.suppress ? ', muted' : ''}{session.sharing[u.session] ? `, sharing ${session.sharing[u.session]}` : ''}{u.recording ? ', recording' : ''}</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if !me}
|
||||
<button class="icon-btn" aria-label="Message {store.displayName(u)}" title="Send a message" onclick={() => { session.selectView(`dm:${u.session}`); onchat(); }}><Icon name="message" size={18} /></button>
|
||||
{/if}
|
||||
<button class="icon-btn" aria-label="More for {store.displayName(u)}" onclick={e => menu.show(e, userMenu(u))}><Icon name="more" size={18} /></button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="none">Nobody matches "{query}".</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.people { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
|
||||
header { flex: none; padding: 14px 16px 8px; }
|
||||
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; }
|
||||
header span { font-size: 13px; color: var(--text-dim); }
|
||||
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.find:focus-within { border-color: var(--accent); }
|
||||
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
|
||||
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
|
||||
h2 em { font-style: normal; color: var(--speaking); margin-left: 6px; }
|
||||
h2 span { margin-left: 6px; font-weight: 400; }
|
||||
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
|
||||
.entry:hover { background: var(--bg-hover); }
|
||||
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
|
||||
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name.talking { color: var(--speaking); }
|
||||
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.icon-btn { flex: none; width: 40px; height: 40px; }
|
||||
.none { padding: 12px 8px; color: var(--text-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Avatar from '../ui/Avatar.svelte';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
import { store, type SavedServer } from '../lib/settings.svelte.ts';
|
||||
import { menu, type MenuItem } from '../lib/menu.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { channelMenu, userMenu } from '../lib/actions.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
import { desktop } from '../lib/native.ts';
|
||||
import { makeServerIcon } from '../lib/upload.ts';
|
||||
import { PERM, type Channel } from '../core/client.ts';
|
||||
import { stripMark } from '../core/text-signal.ts';
|
||||
|
||||
// The home screen: servers across the top, then every room of the server with the people
|
||||
// in it, text channels and direct messages. Rooms are a list, not a tree; where a room
|
||||
// sits is written under its name.
|
||||
let { onadd, onedit, onbrowse, onchat }:
|
||||
{ onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void; onchat: () => void } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const connected = $derived(session.status === 'connected');
|
||||
const cert = $derived(session.serverCert);
|
||||
|
||||
interface Row { channel: Channel; path: string }
|
||||
const all = $derived.by(() => {
|
||||
const out: Row[] = [];
|
||||
const walk = (parent: number | null, path: string) => {
|
||||
for (const c of session.childrenOf(parent)) {
|
||||
out.push({ channel: c, path });
|
||||
walk(c.id, c.id === 0 ? '' : path ? `${path} / ${c.name}` : c.name);
|
||||
}
|
||||
};
|
||||
walk(null, '');
|
||||
return out;
|
||||
});
|
||||
|
||||
let query = $state('');
|
||||
const q = $derived(query.trim().toLowerCase());
|
||||
const matches = (r: Row) => !q || (r.channel.name || 'Root').toLowerCase().includes(q)
|
||||
|| session.usersIn(r.channel.id).some(u => store.displayName(u).toLowerCase().includes(q));
|
||||
// Your own room leads the list; the others keep the server's order
|
||||
const voiceRooms = $derived(all.filter(r => !session.isText(r.channel.id) && matches(r))
|
||||
.sort((a, b) => Number(b.channel.id === self?.channelId) - Number(a.channel.id === self?.channelId)));
|
||||
const textRooms = $derived(all.filter(r => session.isText(r.channel.id) && matches(r)));
|
||||
const dms = $derived(Object.keys(session.logs).filter(k => k.startsWith('dm:'))
|
||||
.map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
|
||||
.filter(d => !q || (d.user?.name ?? '').toLowerCase().includes(q)));
|
||||
|
||||
// A room shows its people when opened; the one you are in is open unless you close it
|
||||
let opened = $state<Record<number, boolean>>({});
|
||||
const isOpen = (id: number) => opened[id] ?? (id === self?.channelId || !!q);
|
||||
|
||||
// Last thing said in a chat, as plain text
|
||||
function last(key: string): string {
|
||||
const e = [...(session.logs[key] ?? [])].reverse().find(x => x.kind === 'text');
|
||||
if (!e) return '';
|
||||
const text = e.html.replace(/<[^>]*>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
||||
return `${e.self ? 'You' : e.author}: ${text || (e.embeds.length ? 'sent a file' : '')}`;
|
||||
}
|
||||
const about = (c: Channel) => stripMark(c.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||
|
||||
function openChat(key: `ch:${number}` | `dm:${number}`) {
|
||||
session.selectView(key);
|
||||
onchat();
|
||||
}
|
||||
|
||||
function listMenu(e: MouseEvent) {
|
||||
if (!connected) return;
|
||||
const noMake = session.can(0, PERM.MakeChannel) === false && session.can(0, PERM.MakeTempChannel) === false;
|
||||
menu.show(e, [{ label: 'Create channel...', disabled: noMake, action: () => (ui.channelDialog = { mode: 'create', parent: 0 }) }]);
|
||||
}
|
||||
|
||||
function showCert() {
|
||||
if (!cert?.chain.length) return;
|
||||
ui.cert = {
|
||||
title: `Certificate of ${session.server?.host}`, chain: cert.chain,
|
||||
status: cert.authorized
|
||||
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
|
||||
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Servers ────────────────────────────────────────────────────────────────
|
||||
const initials = (label: string) => label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
|
||||
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
|
||||
const unreadOf = (id: string) => {
|
||||
const s = sessions.byServer(id);
|
||||
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
|
||||
};
|
||||
let iconPicker: HTMLInputElement;
|
||||
let iconFor = '';
|
||||
async function pickIcon() {
|
||||
const file = iconPicker.files?.[0];
|
||||
iconPicker.value = '';
|
||||
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
|
||||
}
|
||||
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
|
||||
const state = stateOf(s.id);
|
||||
const items: MenuItem[] = [
|
||||
{ header: true, label: s.label },
|
||||
state === 'idle' ? { label: 'Connect', action: () => sessions.open(s) } : { label: 'Disconnect', action: () => sessions.close(s.id) },
|
||||
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
|
||||
{ separator: true, label: '' },
|
||||
{ label: 'Move left', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
|
||||
{ label: 'Move right', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
|
||||
{ separator: true, label: '' },
|
||||
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
|
||||
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
|
||||
{ label: 'Edit...', action: () => onedit(s) }
|
||||
];
|
||||
menu.show(e, items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rooms" aria-label="Rooms">
|
||||
<nav class="servers" aria-label="Servers">
|
||||
{#each store.servers as s, i (s.id)}
|
||||
{@const state = stateOf(s.id)}
|
||||
{@const unread = unreadOf(s.id)}
|
||||
<button class="srv" class:active={sessions.activeId === s.id} class:live={state === 'connected'} class:connecting={state === 'connecting'}
|
||||
class:voice={sessions.voiceId === s.id && state === 'connected'}
|
||||
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
|
||||
onclick={() => sessions.open(s)} oncontextmenu={e => serverMenu(e, s, i)}>
|
||||
<span class="pic">{#if s.icon}<img src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}</span>
|
||||
<span class="cap">{s.label}</span>
|
||||
{#if unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
|
||||
<button class="srv add" title="Add a server" onclick={onadd}><span class="pic"><Icon name="plus" /></span><span class="cap">Add</span></button>
|
||||
<!-- The public list and its pings need the desktop app -->
|
||||
{#if desktop}<button class="srv add" title="Browse public servers" onclick={onbrowse}><span class="pic"><Icon name="globe" /></span><span class="cap">Browse</span></button>{/if}
|
||||
</nav>
|
||||
|
||||
<header class="top">
|
||||
<button class="place" onclick={showCert} disabled={!cert} title={cert ? (cert.authorized ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
|
||||
<h1>{session.server?.label ?? 'mumh5'}</h1>
|
||||
<span class="status">
|
||||
{#if connected}<span class="dot"></span>Connected{#if session.rtt}, {session.rtt} ms{/if}{#if cert}<Icon name={cert.authorized ? 'lock' : 'shield'} size={12} />{/if}
|
||||
{:else if session.status === 'connecting'}Connecting...
|
||||
{:else}Not connected{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if connected}<button class="icon-btn" title="Server information" aria-label="Server information" onclick={() => (ui.serverInfo = true)}><Icon name="info" size={18} /></button>{/if}
|
||||
{#if session.status !== 'idle'}
|
||||
<button class="icon-btn" title="Disconnect" aria-label="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if connected}
|
||||
<label class="find">
|
||||
<Icon name="search" size={16} />
|
||||
<input type="search" placeholder="Find a room or a person" aria-label="Find a room or a person" bind:value={query} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="scroll" role="presentation" oncontextmenu={listMenu}>
|
||||
{#if connected}
|
||||
{#if textRooms.length}
|
||||
<h2>Text channels</h2>
|
||||
<ul>
|
||||
{#each textRooms as r (r.channel.id)}
|
||||
{@const c = r.channel}
|
||||
{@const key = `ch:${c.id}` as const}
|
||||
<li class="entry" class:viewing={session.view === key}>
|
||||
<button class="main" onclick={() => openChat(key)} oncontextmenu={e => menu.show(e, channelMenu(c))}>
|
||||
<span class="glyph"><Icon name="hash" size={18} /></span>
|
||||
<span class="text">
|
||||
<span class="name">{c.name}</span>
|
||||
<span class="sub">{last(key) || r.path || 'No messages yet'}</span>
|
||||
</span>
|
||||
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
|
||||
</button>
|
||||
<button class="icon-btn" aria-label="More for {c.name}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h2>Rooms</h2>
|
||||
<ul>
|
||||
{#each voiceRooms as r (r.channel.id)}
|
||||
{@const c = r.channel}
|
||||
{@const users = session.usersIn(c.id)}
|
||||
{@const here = self?.channelId === c.id}
|
||||
{@const open = isOpen(c.id)}
|
||||
{@const key = `ch:${c.id}` as const}
|
||||
<li class="room" class:here class:open class:empty={!users.length}>
|
||||
<div class="entry" role="presentation" oncontextmenu={e => menu.show(e, channelMenu(c))}>
|
||||
<button class="main" aria-expanded={open} onclick={() => (opened[c.id] = !open)}>
|
||||
<span class="text">
|
||||
<span class="name">{#if !c.canEnter}<Icon name="lock" size={14} />{/if}{c.name || 'Root'}</span>
|
||||
{#if r.path}<span class="sub">in {r.path}</span>{/if}
|
||||
</span>
|
||||
{#if users.length && !open}
|
||||
<span class="faces" aria-label="{users.length} {users.length === 1 ? 'person' : 'people'}">
|
||||
{#each users.slice(0, 4) as u (u.session)}<Avatar name={u.name} size={26} speaking={talking(u.session)} />{/each}
|
||||
{#if users.length > 4}<span class="extra">+{users.length - 4}</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
|
||||
</button>
|
||||
{#if here}
|
||||
<span class="tag">You are here</span>
|
||||
{:else}
|
||||
<button class="join" disabled={!c.canEnter} aria-label="Join {c.name || 'Root'}" onclick={() => session.joinChannel(c.id)}>{c.canEnter ? 'Join' : 'Locked'}</button>
|
||||
{/if}
|
||||
<button class="icon-btn" aria-label="More for {c.name || 'Root'}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
|
||||
</div>
|
||||
{#if open}
|
||||
<div class="inside">
|
||||
{#if about(c)}<p class="about">{about(c)}</p>{/if}
|
||||
{#if users.length}
|
||||
<ul class="people">
|
||||
{#each users as u (u.session)}
|
||||
{@const me = u.session === self?.session}
|
||||
<li>
|
||||
<button class="person" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }} oncontextmenu={e => menu.show(e, userMenu(u))}>
|
||||
<Avatar name={u.name} size={32} speaking={talking(u.session)} />
|
||||
<span class="pname" class:talking={talking(u.session)}>{store.displayName(u)}{#if me} <em>you</em>{/if}</span>
|
||||
{#if session.sharing[u.session]}<span class="mark live" title="Sharing their {session.sharing[u.session]}"><Icon name={session.sharing[u.session]} size={14} /></span>{/if}
|
||||
{#if u.recording}<span class="rec">REC</span>{/if}
|
||||
{#if u.deaf || u.selfDeaf}<span class="mark" class:bad={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
|
||||
{:else if u.mute || u.selfMute || u.suppress}<span class="mark" class:bad={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="nobody">Nobody here.</p>
|
||||
{/if}
|
||||
<div class="acts">
|
||||
<button class="btn" onclick={() => openChat(key)}>Chat</button>
|
||||
<button class="btn" onclick={() => { session.showChannel(c.id, 'info'); ui.panelOpen = true; }}>Details</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="none">Nothing matches "{query}".</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if dms.length}
|
||||
<h2>Direct messages</h2>
|
||||
<ul>
|
||||
{#each dms as dm (dm.key)}
|
||||
<li class="entry" class:viewing={session.view === dm.key}>
|
||||
<button class="main" onclick={() => openChat(dm.key)}>
|
||||
<Avatar name={dm.user?.name ?? '?'} size={32} />
|
||||
<span class="text">
|
||||
<span class="name" class:gone={!dm.user}>{dm.user ? store.displayName(dm.user) : 'Offline user'}</span>
|
||||
<span class="sub">{last(dm.key)}</span>
|
||||
</span>
|
||||
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if session.status === 'idle' && session.disconnectInfo}
|
||||
{@const d = session.disconnectInfo}
|
||||
<div class="ended" class:bad={d.kind === 'kicked' || d.kind === 'banned'}>
|
||||
<h2>
|
||||
{#if d.kind === 'kicked'}You were kicked{d.by ? ` by ${d.by}` : ''}
|
||||
{:else if d.kind === 'banned'}You were banned{d.by ? ` by ${d.by}` : ''}
|
||||
{:else if d.kind === 'rejected'}The server refused the connection
|
||||
{:else}Connection lost{/if}
|
||||
</h2>
|
||||
{#if d.reason}<p class="reason">{d.reason}</p>{/if}
|
||||
<button class="btn primary" onclick={() => session.reconnect()}>Reconnect</button>
|
||||
</div>
|
||||
{:else if session.status === 'connecting'}
|
||||
<p class="blank">Connecting...</p>
|
||||
{:else}
|
||||
<div class="blank">
|
||||
{#if session.error}<p class="error">{session.error}</p>{/if}
|
||||
<p>Choose a server above, or add one.</p>
|
||||
<div class="acts">
|
||||
<button class="btn primary" onclick={onadd}>Add a server</button>
|
||||
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
|
||||
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rooms { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.servers { flex: none; display: flex; gap: 4px; padding: 10px 12px 6px; overflow-x: auto; scrollbar-width: none; }
|
||||
.servers::-webkit-scrollbar { display: none; }
|
||||
.srv { position: relative; flex: none; width: 64px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px 0; color: var(--text-dim); }
|
||||
.pic { position: relative; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; color: var(--text); }
|
||||
.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cap { max-width: 100%; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.srv.active .pic { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.srv.active .cap { color: var(--text); }
|
||||
.srv.live .pic::after, .srv.connecting .pic::after { content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; background: var(--speaking); border: 2px solid var(--bg-1); }
|
||||
.srv.connecting .pic::after { background: var(--text-faint); }
|
||||
.srv.voice .pic::after { box-shadow: 0 0 0 2px var(--speaking); }
|
||||
.srv.add .pic { background: transparent; border: 1px dashed var(--line); color: var(--text-dim); }
|
||||
.srv.add:hover .pic { color: var(--text); border-color: var(--text-dim); }
|
||||
.count { position: absolute; top: 0; right: 4px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; background: var(--danger); color: #fff; }
|
||||
|
||||
.top { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px 6px 0; }
|
||||
.place { flex: 1; min-width: 0; padding: 4px 16px; text-align: left; }
|
||||
.place:disabled { opacity: 1; }
|
||||
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.status { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); }
|
||||
.dot { width: 8px; height: 8px; background: var(--speaking); }
|
||||
|
||||
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.find:focus-within { border-color: var(--accent); }
|
||||
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
|
||||
|
||||
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
|
||||
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
|
||||
|
||||
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
|
||||
.entry:hover, .entry.viewing { background: var(--bg-hover); }
|
||||
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
|
||||
.glyph { flex: none; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); color: var(--text-dim); }
|
||||
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.name { display: flex; align-items: center; gap: 6px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name.gone { color: var(--text-faint); font-style: italic; }
|
||||
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.badge { flex: none; min-width: 20px; padding: 0 6px; line-height: 20px; text-align: center; font-size: 12px; font-weight: 700; background: var(--danger); color: #fff; }
|
||||
|
||||
.room { margin-bottom: 2px; border-left: 3px solid transparent; }
|
||||
.room.empty .name { color: var(--text-dim); font-weight: 500; }
|
||||
.room.here { border-left-color: var(--speaking); background: var(--bg-2); }
|
||||
.room.here .name { color: var(--text); font-weight: 700; }
|
||||
.faces { flex: none; display: flex; align-items: center; gap: 4px; }
|
||||
.extra { font-size: 12px; color: var(--text-dim); padding-left: 2px; }
|
||||
.join { flex: none; height: 36px; padding: 0 14px; font-weight: 600; font-size: 14px; border: 1px solid var(--line); color: var(--text); }
|
||||
.join:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.tag { flex: none; padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--speaking); }
|
||||
.icon-btn { flex: none; width: 40px; height: 40px; }
|
||||
|
||||
.inside { padding: 0 8px 12px 8px; }
|
||||
.about { margin: 0 8px 8px; font-size: 14px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.nobody { margin: 0 8px 8px; font-size: 14px; color: var(--text-faint); }
|
||||
.person { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 4px 8px; text-align: left; }
|
||||
.person:hover { background: var(--bg-hover); }
|
||||
.pname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pname.talking { color: var(--speaking); }
|
||||
.pname em { margin-left: 6px; font-style: normal; font-size: 12px; color: var(--text-faint); }
|
||||
.mark { flex: none; display: inline-flex; color: var(--text-faint); }
|
||||
.mark.bad { color: var(--danger); }
|
||||
.mark.live { color: var(--speaking); }
|
||||
.rec { flex: none; font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
|
||||
.acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 8px 0; }
|
||||
.none { padding: 12px 8px; color: var(--text-dim); }
|
||||
|
||||
.blank { padding: 24px 16px; color: var(--text-dim); }
|
||||
.blank p { margin: 0 0 4px; }
|
||||
.blank .acts { margin: 14px 0 0; }
|
||||
.error { color: var(--danger); }
|
||||
.ended { margin: 16px 8px; padding: 16px; background: var(--bg-2); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
|
||||
.ended.bad { border-left-color: var(--danger); }
|
||||
.ended h2 { margin: 0 0 6px; font-size: 18px; text-transform: none; letter-spacing: 0; color: var(--text); }
|
||||
.ended .reason { margin: 0 0 12px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import RightPanel from '../ui/RightPanel.svelte';
|
||||
import Rooms from './Rooms.svelte';
|
||||
import ChatScreen from './ChatScreen.svelte';
|
||||
import VoiceScreen from './VoiceScreen.svelte';
|
||||
import People from './People.svelte';
|
||||
import You from './You.svelte';
|
||||
import Dock from './Dock.svelte';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import type { SavedServer } from '../lib/settings.svelte.ts';
|
||||
|
||||
// The Rooms interface: one screen at a time with a bar to move between them, made for
|
||||
// phones first. Wide windows keep the rooms on the left next to the chosen screen. Same
|
||||
// state, dialogs and menus as the standard interface; only the arrangement differs.
|
||||
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
|
||||
|
||||
type Screen = 'rooms' | 'chat' | 'voice' | 'people' | 'you';
|
||||
let width = $state(window.innerWidth);
|
||||
const wide = $derived(width >= 900);
|
||||
let chosen = $state<Screen>('rooms');
|
||||
// With the rooms always on the left, their place in the main area goes to the chat
|
||||
const screen = $derived(wide && chosen === 'rooms' ? 'chat' : chosen);
|
||||
|
||||
const connected = $derived(session.status === 'connected');
|
||||
const unread = $derived(Object.values(session.unread).reduce((a, b) => a + b, 0));
|
||||
const inVoice = $derived(!!session.self);
|
||||
|
||||
const ITEMS: { id: Screen; name: string; icon: string }[] = [
|
||||
{ id: 'rooms', name: 'Rooms', icon: 'rooms' },
|
||||
{ id: 'chat', name: 'Chat', icon: 'message' },
|
||||
{ id: 'voice', name: 'Voice', icon: 'channel' },
|
||||
{ id: 'people', name: 'People', icon: 'users' },
|
||||
{ id: 'you', name: 'You', icon: 'user' }
|
||||
];
|
||||
const items = $derived(wide ? ITEMS.filter(i => i.id !== 'rooms') : ITEMS);
|
||||
|
||||
// A direct message or a text channel opened from a menu shows its chat. Joining a room
|
||||
// also changes the chat, but leaves you where you are.
|
||||
let lastView = session.view;
|
||||
$effect(() => {
|
||||
const view = session.view;
|
||||
if (view === lastView) return;
|
||||
lastView = view;
|
||||
if (view.startsWith('dm:') || session.isText(Number(view.slice(3)))) chosen = 'chat';
|
||||
});
|
||||
|
||||
// Profiles and channel details open over the screen
|
||||
const sheet = $derived(ui.panelOpen && connected && session.panel.kind !== 'members');
|
||||
function closeSheet() {
|
||||
ui.panelOpen = false;
|
||||
session.closePanel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={width} onkeydown={e => { if (e.key === 'Escape' && sheet) closeSheet(); }} />
|
||||
|
||||
<div class="shell" class:wide>
|
||||
<nav class="bar" aria-label="Screens">
|
||||
{#each items as item (item.id)}
|
||||
<button class="item" class:active={screen === item.id} aria-current={screen === item.id ? 'page' : undefined} onclick={() => (chosen = item.id)}>
|
||||
<span class="glyph">
|
||||
<Icon name={item.icon} size={22} />
|
||||
{#if item.id === 'chat' && unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
|
||||
{#if item.id === 'voice' && inVoice}<span class="on"></span>{/if}
|
||||
</span>
|
||||
<span class="cap">{item.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if wide}
|
||||
<div class="side">
|
||||
<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
|
||||
<Dock onopen={() => (chosen = 'voice')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main>
|
||||
<div class="screen">
|
||||
{#if screen === 'rooms'}<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
|
||||
{:else if screen === 'chat'}<ChatScreen back={!wide} onback={() => (chosen = 'rooms')} />
|
||||
{:else if screen === 'voice'}<VoiceScreen onrooms={() => (chosen = 'rooms')} onchat={() => (chosen = 'chat')} />
|
||||
{:else if screen === 'people'}<People onchat={() => (chosen = 'chat')} />
|
||||
{:else}<You {onadd} {onedit} {onbrowse} />{/if}
|
||||
</div>
|
||||
{#if !wide && screen !== 'voice'}<Dock onopen={() => (chosen = 'voice')} />{/if}
|
||||
</main>
|
||||
|
||||
{#if sheet}
|
||||
<button class="scrim" aria-label="Close" onclick={closeSheet}></button>
|
||||
<div class="sheet"><RightPanel onnavigate={() => { closeSheet(); chosen = 'chat'; }} onclose={closeSheet} /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell { position: relative; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--bg-1); }
|
||||
main { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.screen { flex: 1; min-height: 0; }
|
||||
|
||||
/* Phones: the bar is at the bottom, under the thumb */
|
||||
.bar { order: 2; flex: none; display: flex; background: var(--bg-0); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
|
||||
.item { flex: 1; min-width: 0; height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--text-faint); box-shadow: inset 0 2px 0 transparent; }
|
||||
.item:hover { color: var(--text); }
|
||||
.item.active { color: var(--text); box-shadow: inset 0 2px 0 var(--accent); }
|
||||
.glyph { position: relative; display: flex; }
|
||||
.cap { font-size: 11px; font-weight: 600; }
|
||||
.count { position: absolute; top: -6px; left: 14px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; text-align: center; background: var(--danger); color: #fff; }
|
||||
.on { position: absolute; top: -2px; right: -4px; width: 8px; height: 8px; background: var(--speaking); }
|
||||
|
||||
/* Wide windows: the bar on the left edge, the rooms next to it, the screen takes the rest */
|
||||
.shell.wide { flex-direction: row; }
|
||||
.wide .bar { order: 0; flex-direction: column; width: 76px; border-top: 0; border-right: 1px solid var(--line); padding: 8px 0; }
|
||||
.wide .item { flex: none; height: 64px; box-shadow: inset 2px 0 0 transparent; }
|
||||
.wide .item.active { box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.side { flex: none; width: 360px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
|
||||
.side > :global(.rooms) { flex: 1; }
|
||||
|
||||
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.55); }
|
||||
.sheet { position: absolute; z-index: 20; left: 0; right: 0; bottom: 0; height: min(86%, 720px); display: flex; box-shadow: 0 -8px 32px rgb(0 0 0 / 0.4); border-top: 1px solid var(--line); }
|
||||
.sheet :global(.panel) { width: 100% !important; border-left: 0; }
|
||||
.sheet :global(.panel > .resize) { display: none; }
|
||||
.wide .sheet { left: auto; top: 0; height: auto; width: 400px; border-top: 0; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { share } from '../lib/share.svelte.ts';
|
||||
|
||||
// Mute, deafen and share. large: the voice screen's row of big buttons with labels.
|
||||
let { large = false }: { large?: boolean } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
// Without a connection the buttons show the remembered choice, applied at the next connect
|
||||
const muted = $derived(self ? self.selfMute : sessions.mute);
|
||||
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
|
||||
const sharing = $derived(!!share.stream);
|
||||
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
|
||||
|
||||
function quick(e: MouseEvent, kind: 'input' | 'output') {
|
||||
e.preventDefault();
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="controls" class:large>
|
||||
<button class:on={muted} aria-label={muted ? 'Unmute' : 'Mute'} title={muted ? 'Unmute (hold or right-click for microphone options)' : 'Mute (hold or right-click for microphone options)'}
|
||||
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => quick(e, 'input')}>
|
||||
<Icon name={muted ? 'mic-off' : 'mic'} size={large ? 24 : 20} />
|
||||
{#if large}<span>{muted ? 'Muted' : 'Mute'}</span>{/if}
|
||||
</button>
|
||||
<button class:on={deafened} aria-label={deafened ? 'Undeafen' : 'Deafen'} title={deafened ? 'Undeafen (hold or right-click for output options)' : 'Deafen (hold or right-click for output options)'}
|
||||
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => quick(e, 'output')}>
|
||||
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={large ? 24 : 20} />
|
||||
{#if large}<span>{deafened ? 'Deafened' : 'Deafen'}</span>{/if}
|
||||
</button>
|
||||
{#if large}
|
||||
<button class:live={sharing} disabled={!sharing && !canShare} aria-label={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera'}
|
||||
title={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera with your channel'}
|
||||
onclick={() => (sharing ? share.stop() : share.start(sessions.active))}>
|
||||
<Icon name={sharing ? share.kind : 'screen'} size={24} />
|
||||
<span>{sharing ? 'Stop sharing' : 'Share'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls { display: flex; gap: 4px; }
|
||||
button { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; color: var(--text); background: var(--bg-3); }
|
||||
button:hover:not(:disabled) { background: var(--bg-active); }
|
||||
button.on { background: var(--danger); color: #fff; }
|
||||
button.live { background: var(--speaking); color: var(--bg-0); }
|
||||
.large { gap: 10px; justify-content: center; }
|
||||
.large button { width: 96px; height: 72px; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import VoiceStage from '../ui/VoiceStage.svelte';
|
||||
import VoiceControls from './VoiceControls.svelte';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
|
||||
// The room you are in as a call: everyone as tiles, streams large, the controls underneath
|
||||
let { onrooms, onchat }: { onrooms: () => void; onchat: () => void } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const room = $derived(self ? session.channel(self.channelId) : undefined);
|
||||
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
|
||||
const transport = $derived(session.voiceTransport);
|
||||
</script>
|
||||
|
||||
<section class="voice" aria-label="Voice">
|
||||
{#if self}
|
||||
<header>
|
||||
<div class="title">
|
||||
<h1>{room?.name || 'Root'}</h1>
|
||||
<span>{session.usersIn(self.channelId).length} here, voice over {transport.udp ? 'UDP' : 'TCP'}{#if voice.error}, voice unavailable{/if}</span>
|
||||
</div>
|
||||
<button class="btn" onclick={() => { session.selectView(`ch:${self.channelId}`); onchat(); }}>Chat</button>
|
||||
</header>
|
||||
{#if voice.error}<p class="note bad">{voice.error}</p>{/if}
|
||||
{#if elsewhere}
|
||||
<p class="note">Voice is on {sessions.voiceSession?.server?.label}; you are deafened here.
|
||||
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button></p>
|
||||
{/if}
|
||||
<div class="stage"><VoiceStage always fill /></div>
|
||||
<footer><VoiceControls large /></footer>
|
||||
{:else}
|
||||
<div class="blank">
|
||||
<h1>Voice</h1>
|
||||
<p>Connect to a server and join a room to talk.</p>
|
||||
<button class="btn primary" onclick={onrooms}>Go to rooms</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.voice { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-2); }
|
||||
header { flex: none; display: flex; align-items: center; gap: 12px; padding: 14px 16px 10px; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.title span { font-size: 13px; color: var(--text-dim); }
|
||||
.note { flex: none; display: flex; align-items: center; gap: 12px; margin: 0 16px 8px; padding: 8px 12px; font-size: 14px; color: var(--text-dim); background: var(--bg-0); border: 1px solid var(--line); }
|
||||
.note.bad { color: var(--danger); }
|
||||
.stage { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
footer { flex: none; padding: 12px 16px 16px; border-top: 1px solid var(--line); background: var(--bg-1); }
|
||||
.blank { padding: 32px 16px; color: var(--text-dim); }
|
||||
.blank h1 { color: var(--text); }
|
||||
.blank p { margin: 6px 0 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Avatar from '../ui/Avatar.svelte';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
import { store, type SavedServer } from '../lib/settings.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { changeNamePrompt } from '../lib/actions.ts';
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
import { desktop } from '../lib/native.ts';
|
||||
|
||||
// Yourself, your servers and the settings, in one place
|
||||
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
|
||||
|
||||
const self = $derived(session.self);
|
||||
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
|
||||
type Tab = typeof ui.settingsTab;
|
||||
const SETTINGS: { tab: Tab; name: string; note: string }[] = [
|
||||
{ tab: 'voice', name: 'Voice', note: 'Microphone, speakers, how you transmit' },
|
||||
{ tab: 'sounds', name: 'Sounds', note: 'Notification sounds' },
|
||||
{ tab: 'appearance', name: 'Appearance', note: 'Colors and interface' },
|
||||
{ tab: 'accessibility', name: 'Accessibility', note: '' },
|
||||
{ tab: 'files', name: 'Chat and files', note: 'Uploads, previews, embeds' },
|
||||
{ tab: 'identities', name: 'Identities', note: 'Your certificates' }
|
||||
];
|
||||
const openSettings = (tab: Tab) => { ui.settingsTab = tab; ui.settingsOpen = true; };
|
||||
function profile(edit: boolean) {
|
||||
if (!self) return;
|
||||
session.showUser(self.session);
|
||||
if (edit) ui.editDescription = true;
|
||||
ui.panelOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="you" aria-label="You">
|
||||
<div class="scroll">
|
||||
<div class="card">
|
||||
{#if self}
|
||||
<Avatar name={self.name} size={64} speaking={voice.transmitting} />
|
||||
<div class="who">
|
||||
<h1>{self.name}</h1>
|
||||
<span>{self.userId != null ? 'Registered' : 'Guest'} on {session.server?.label}, in {session.channel(self.channelId)?.name || 'Root'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="who">
|
||||
<h1>You</h1>
|
||||
<span>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if self}
|
||||
<div class="acts">
|
||||
<button class="btn" onclick={() => profile(false)}>Your profile</button>
|
||||
<button class="btn" onclick={() => profile(true)}>Change your description</button>
|
||||
<button class="btn" onclick={() => changeNamePrompt(self)}>Change name</button>
|
||||
{#if self.userId == null}<button class="btn" onclick={() => session.register(self.session)}>Register on this server</button>{/if}
|
||||
<button class="btn" onclick={() => (ui.userInfo = self.session)}>Connection information</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2>Servers</h2>
|
||||
<ul>
|
||||
{#each store.servers as s (s.id)}
|
||||
{@const state = stateOf(s.id)}
|
||||
<li class="entry">
|
||||
<button class="main" onclick={() => sessions.open(s)}>
|
||||
<span class="text">
|
||||
<span class="name">{s.label}{#if state !== 'idle'}<em>{state === 'connecting' ? 'connecting' : sessions.voiceId === s.id ? 'connected, voice' : 'connected'}</em>{/if}</span>
|
||||
<span class="sub">{s.host}:{s.port} as {s.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if state !== 'idle'}<button class="btn small" onclick={() => sessions.close(s.id)}>Disconnect</button>{/if}
|
||||
<button class="btn small" onclick={() => onedit(s)}>Edit</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="acts">
|
||||
<button class="btn" onclick={onadd}>Add a server</button>
|
||||
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
|
||||
{#if session.status === 'connected'}<button class="btn" onclick={() => (ui.serverInfo = true)}>Server information</button>{/if}
|
||||
</div>
|
||||
|
||||
<h2>Settings</h2>
|
||||
<ul>
|
||||
{#each SETTINGS as item (item.tab)}
|
||||
<li class="entry">
|
||||
<button class="main" onclick={() => openSettings(item.tab)}>
|
||||
<span class="text">
|
||||
<span class="name">{item.name}</span>
|
||||
{#if item.note}<span class="sub">{item.note}</span>{/if}
|
||||
</span>
|
||||
<Icon name="arrow-right" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.you { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
|
||||
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 24px; }
|
||||
.card { display: flex; align-items: center; gap: 16px; padding: 20px 8px 12px; }
|
||||
.who { min-width: 0; }
|
||||
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
||||
.who span { font-size: 13px; color: var(--text-dim); }
|
||||
.acts { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 8px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
h2 { margin: 24px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
|
||||
.entry { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-right: 8px; }
|
||||
.entry:hover { background: var(--bg-hover); }
|
||||
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; color: var(--text-dim); }
|
||||
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.name em { margin-left: 8px; font-style: normal; font-size: 12px; font-weight: 600; color: var(--speaking); }
|
||||
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.small { flex: none; padding: 6px 12px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planMove, byOrder, type Ordered } from '../src/core/channel-order.ts';
|
||||
|
||||
const ch = (id: number, name: string, position = 0): Ordered => ({ id, name, position });
|
||||
// The order everyone sees once the plan is applied
|
||||
function after(siblings: Ordered[], moved: Ordered, index: number): string[] {
|
||||
const plan = planMove(siblings, moved, index);
|
||||
const all = [...siblings.filter(c => c.id !== moved.id), moved].map(c => ({ ...c, position: plan.find(u => u.id === c.id)?.position ?? c.position }));
|
||||
return all.sort(byOrder).map(c => c.name);
|
||||
}
|
||||
|
||||
test('a spot that the name already gives needs no change', () => {
|
||||
const list = [ch(1, 'Alpha'), ch(3, 'Gamma')];
|
||||
assert.deepEqual(planMove(list, ch(2, 'Beta'), 1), []);
|
||||
});
|
||||
|
||||
test('one change when there is room between the neighbours', () => {
|
||||
const list = [ch(1, 'Alpha', 0), ch(2, 'Beta', 5), ch(3, 'Gamma', 9)];
|
||||
const plan = planMove(list, ch(3, 'Gamma', 9), 1);
|
||||
assert.equal(plan.length, 1);
|
||||
assert.deepEqual(after(list, ch(3, 'Gamma', 9), 1), ['Alpha', 'Gamma', 'Beta']);
|
||||
});
|
||||
|
||||
test('to the front and to the end', () => {
|
||||
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma')];
|
||||
assert.deepEqual(planMove(list, list[2], 0), [{ id: 3, position: -1 }]);
|
||||
assert.deepEqual(planMove(list, list[0], 2), [{ id: 1, position: 1 }]);
|
||||
});
|
||||
|
||||
test('channels that all share a position are numbered again when the name does not fit', () => {
|
||||
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma'), ch(4, 'Delta')];
|
||||
// Sorted: Alpha, Beta, Delta, Gamma. Gamma between Alpha and Beta.
|
||||
assert.deepEqual(after(list, list[2], 1), ['Alpha', 'Gamma', 'Beta', 'Delta']);
|
||||
assert.ok(planMove(list, list[2], 1).length > 1);
|
||||
});
|
||||
|
||||
test('every spot works, for a channel of the row and for one coming from elsewhere', () => {
|
||||
const list = [ch(1, 'b', 0), ch(2, 'a', 0), ch(3, 'c', 1), ch(4, 'd', 1), ch(5, 'e', 7)];
|
||||
const sorted = [...list].sort(byOrder);
|
||||
for (const moved of [...list, ch(9, 'new', 1)]) {
|
||||
const rest = sorted.filter(c => c.id !== moved.id).map(c => c.name);
|
||||
for (let i = 0; i <= rest.length; i++) {
|
||||
assert.deepEqual(after(list, moved, i), [...rest.slice(0, i), moved.name, ...rest.slice(i)], `${moved.name} to ${i}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
+252
-1
@@ -14,6 +14,8 @@ import { MumbleClient } from '../../src/core/client.ts';
|
||||
import type { Transport } from '../../src/core/transport.ts';
|
||||
import { openTls } from '../../electron/tls-transport.ts';
|
||||
import { generateIdentity } from '../../electron/identity.ts';
|
||||
import { byOrder } from '../../src/core/channel-order.ts';
|
||||
import { TEXT, TEXT_DATA_ID, TEXT_MARK, TextAssembler, encodeText, parseMsg, parseHistory } from '../../src/core/text-signal.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
if (!target) {
|
||||
@@ -70,6 +72,8 @@ const app = await electron.launch({
|
||||
const bob = await headless('bob');
|
||||
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server
|
||||
const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null;
|
||||
// The text channel made further down; checked again after the app reconnects
|
||||
let notesId: number | null = null;
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
|
||||
@@ -355,7 +359,28 @@ try {
|
||||
await page.getByRole('radio', { name: 'Windows 95' }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
|
||||
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
|
||||
await page.getByRole('radio', { name: 'Dark' }).click();
|
||||
for (const [name, id, bg] of [['Forest', 'forest', 'rgb(22, 35, 28)'], ['Ember', 'ember', 'rgb(33, 25, 20)'], ['Ocean', 'ocean', 'rgb(15, 30, 44)'],
|
||||
['Plum', 'plum', 'rgb(32, 22, 40)'], ['Paper', 'paper', 'rgb(248, 243, 231)'], ['High contrast', 'contrast', 'rgb(0, 0, 0)']]) {
|
||||
await page.getByRole('radio', { name }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), id);
|
||||
assert.equal(await page.locator('.chat').evaluate(el => getComputedStyle(el).backgroundColor), bg, `${name} colors the chat`);
|
||||
if (shots) await page.screenshot({ path: path.join(shots, `theme-pick-${id}.png`) });
|
||||
}
|
||||
// Generated schemes, and narrowing the list
|
||||
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 100);
|
||||
await page.getByRole('button', { name: 'Light', exact: true }).click();
|
||||
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 28);
|
||||
await page.getByRole('radio', { name: 'Lavender' }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'lavender');
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'theme-pick-lavender.png') });
|
||||
await page.getByRole('button', { name: 'All 100' }).click();
|
||||
await page.getByLabel('Find a color scheme').fill('synth');
|
||||
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 1);
|
||||
await page.getByRole('radio', { name: 'Synthwave' }).click();
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim()), 'dark');
|
||||
if (shots) { await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.screenshot({ path: path.join(shots, 'theme-synthwave.png') }); await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Appearance' }).click(); }
|
||||
await page.getByLabel('Find a color scheme').fill('');
|
||||
await page.getByRole('radio', { name: 'Dark', exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
|
||||
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
|
||||
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
|
||||
@@ -839,6 +864,28 @@ try {
|
||||
bob.joinChannel(0);
|
||||
console.log('ok: drag and drop moves channels and people');
|
||||
|
||||
// Dropped on the lower or upper third of a row, a channel goes behind or in front of it.
|
||||
// A line shows where it will land.
|
||||
const topOrder = () => [...bob.channels.values()].filter(c => c.parent === 0).sort(byOrder).map(c => c.name);
|
||||
assert.deepEqual(topOrder(), ['Games', 'Lobby']);
|
||||
await page.locator('.channel .name', { hasText: 'Games' }).hover();
|
||||
await page.mouse.down();
|
||||
const lobbyBox = (await page.locator('.channel', { hasText: 'Lobby' }).first().boundingBox())!;
|
||||
await page.mouse.move(lobbyBox.x + 60, lobbyBox.y + lobbyBox.height - 4, { steps: 8 });
|
||||
// Lobby is open (people inside), so the line sits below them
|
||||
await page.locator('li.after-end', { hasText: 'Lobby' }).waitFor();
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'drag-line.png') });
|
||||
assert.equal(await page.locator('.channel.dragging', { hasText: 'Games' }).count(), 1, 'the dragged channel is dimmed');
|
||||
await page.mouse.up();
|
||||
await bobSees('Games behind Lobby', () => topOrder().join() === 'Lobby,Games');
|
||||
assert.equal(await page.locator('li.after-end, .channel.after, .channel.before, .channel.into').count(), 0);
|
||||
await page.locator('.channel .name', { hasText: 'Games' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first(), { targetPosition: { x: 60, y: 3 } });
|
||||
await bobSees('Games in front of Lobby again', () => topOrder().join() === 'Games,Lobby');
|
||||
// The pointer rests on a row with a description; move away so no preview covers the list
|
||||
await page.mouse.move(700, 400);
|
||||
await page.locator('.desc-pop').waitFor({ state: 'detached' });
|
||||
console.log('ok: drag and drop puts a channel in front of or behind another, with a line where it lands');
|
||||
|
||||
// Delete (Team Room is now inside Games, which stays collapsed while empty)
|
||||
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
|
||||
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
|
||||
@@ -856,6 +903,84 @@ try {
|
||||
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
|
||||
await page.locator('.sidebar .row.user.self').waitFor();
|
||||
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
|
||||
|
||||
// ─── Text channels: read and written without joining, between mumh5 clients ───
|
||||
// Bob plays a second mumh5 client by speaking the plugin data protocol
|
||||
const assembler = new TextAssembler();
|
||||
const signals: { type: number; text?: string }[] = [];
|
||||
const waiting: { want: (s: { type: number; text?: string }) => boolean; res: (s: { type: number; text?: string }) => void }[] = [];
|
||||
const bobSend = async (to: number[], type: Parameters<typeof encodeText>[0], body?: unknown) => {
|
||||
for (const p of await encodeText(type, body)) { bob.sendPluginData(to, TEXT_DATA_ID, p); await new Promise(r => setTimeout(r, 120)); }
|
||||
};
|
||||
bob.on('pluginData', async (sender, dataId, data) => {
|
||||
if (dataId !== TEXT_DATA_ID) return;
|
||||
const signal = await assembler.push(sender, data);
|
||||
if (!signal) return;
|
||||
// Like a mumh5 client that was here before: answers a newcomer and hands over an old message
|
||||
if (signal.type === TEXT.hello) bobSend([sender], TEXT.here);
|
||||
if (signal.type === TEXT.want && notesId != null) {
|
||||
bobSend([sender], TEXT.history, { c: notesId, m: [{ i: 'from-before', t: Date.now() - 3600_000, n: 'dave', k: '', h: 'said while you were away' }] });
|
||||
}
|
||||
signals.push(signal);
|
||||
const at = waiting.findIndex(w => w.want(signal));
|
||||
if (at >= 0) waiting.splice(at, 1)[0].res(signal);
|
||||
});
|
||||
const bobGets = (label: string, want: (s: { type: number; text?: string }) => boolean) =>
|
||||
within(new Promise<{ type: number; text?: string }>(res => waiting.push({ want, res })), label);
|
||||
|
||||
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
|
||||
await page.getByLabel('Name').fill('notes');
|
||||
await page.getByLabel('Text channel (mumh5 only)').check();
|
||||
await page.getByRole('button', { name: 'Create channel' }).click();
|
||||
await bobSees('text channel', () => !!chByName('notes')?.description.startsWith(TEXT_MARK));
|
||||
notesId = chByName('notes')!.id;
|
||||
const notesRow = page.locator('.channel', { hasText: 'notes' });
|
||||
// No description marker for a channel whose description is only the text channel mark
|
||||
assert.equal(await notesRow.locator('.desc').count(), 0);
|
||||
const voiceChannel = await page.locator('.me .who span').textContent();
|
||||
await notesRow.locator('.name').click();
|
||||
await page.locator('.chat h1', { hasText: 'notes' }).waitFor();
|
||||
assert.equal(await page.locator('.me .who span').textContent(), voiceChannel, 'opening a text channel does not join it');
|
||||
const sent = bobGets('text channel message', m => m.type === TEXT.msg);
|
||||
await page.locator('.chat .composer [contenteditable]').fill('text hello');
|
||||
await page.locator('.chat .composer [contenteditable]').press('Enter');
|
||||
const got = parseMsg((await sent).text);
|
||||
assert.equal(got?.c, notesId);
|
||||
assert.equal(got?.h, 'text hello');
|
||||
await page.locator('.chat .msg', { hasText: 'text hello' }).waitFor();
|
||||
console.log('ok: text channel created, opened without joining, message reaches others');
|
||||
|
||||
// A message from someone in another channel arrives; unread while another chat is open
|
||||
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-1', c: notesId, h: 'hello from <b>bob</b>' });
|
||||
await page.locator('.chat .msg', { hasText: 'hello from bob' }).locator('strong', { hasText: 'bob' }).first().waitFor();
|
||||
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
|
||||
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
|
||||
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
|
||||
await notesRow.locator('.badge', { hasText: '1' }).waitFor();
|
||||
// The same message again is not shown twice
|
||||
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
|
||||
await page.waitForTimeout(500);
|
||||
await notesRow.locator('.name').click();
|
||||
await page.locator('.chat .msg', { hasText: 'second from bob' }).waitFor();
|
||||
assert.equal(await page.locator('.chat .html', { hasText: 'second from bob' }).count(), 1);
|
||||
assert.equal(await notesRow.locator('.badge').count(), 0);
|
||||
// Not into ordinary channels
|
||||
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-3', c: gamesId, h: 'sneaked in' });
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('.channel', { hasText: 'Games' }).first().locator('.badge').count(), 0);
|
||||
console.log('ok: text channel messages arrive, count as unread, no duplicates, only in text channels');
|
||||
|
||||
// The app hands its messages to a client that asks
|
||||
const handed = bobGets('history', m => m.type === TEXT.history && parseHistory(m.text).length > 0);
|
||||
await bobSend([aliceSession()], TEXT.want);
|
||||
const history = parseHistory((await handed).text);
|
||||
assert.deepEqual(history.map(m => m.h), ['text hello', 'hello from <b>bob</b>', 'second from bob']);
|
||||
assert.deepEqual(history.map(m => m.n), ['alice', 'bob', 'bob']);
|
||||
assert.equal(history[0].c, notesId);
|
||||
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
|
||||
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
|
||||
console.log('ok: text channel messages handed to a client that asks');
|
||||
}
|
||||
|
||||
// Without an upload host, images are sent inline within Mumble's image limit
|
||||
@@ -983,6 +1108,18 @@ try {
|
||||
}), 'bob sees the new name');
|
||||
console.log('ok: name changed by reconnecting');
|
||||
|
||||
// Text channel after reconnecting: what this computer kept, plus what another client hands over
|
||||
if (notesId != null) {
|
||||
await page.locator('.channel .name', { hasText: 'notes' }).click();
|
||||
await page.locator('.chat .msg', { hasText: 'said while you were away' }).locator('strong', { hasText: 'dave' }).waitFor();
|
||||
const texts = await page.locator('.chat .msg .html').allTextContents();
|
||||
assert.deepEqual(texts.map(t => t.trim()).filter(t => !t.startsWith('You joined')), ['said while you were away', 'text hello', 'hello from bob', 'second from bob']);
|
||||
assert.equal(await page.locator('.channel', { hasText: 'notes' }).locator('.badge').count(), 0, 'old messages are not unread');
|
||||
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
|
||||
console.log('ok: text channel keeps its messages and catches up from another client');
|
||||
}
|
||||
|
||||
// Self-registration from the right-click menu on yourself; the profile shows no badge either way
|
||||
const meRow = page.locator('.sidebar .user.self');
|
||||
await meRow.click({ button: 'right' });
|
||||
@@ -993,6 +1130,120 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
console.log('ok: registered on the server');
|
||||
|
||||
// Giving the microphone back while muted (for Bluetooth headsets), and taking it again
|
||||
{
|
||||
const muteBtn = (name: 'Mute' | 'Unmute') => page.locator('.me').getByRole('button', { name, exact: true });
|
||||
if (!userState(newName)!.selfMute) {
|
||||
await muteBtn('Mute').click();
|
||||
await until2('muted', () => !!userState(newName)!.selfMute);
|
||||
}
|
||||
await page.getByTitle('Settings').click();
|
||||
assert.equal(await page.getByLabel('Give the microphone back while muted').isChecked(), false, 'off by default on a desktop');
|
||||
await page.getByLabel('Give the microphone back while muted').check();
|
||||
await page.getByText('The microphone is given back now.').waitFor();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
// Unmuting takes it again
|
||||
const heard = new Promise<void>(res => { const off = bob.on('voice', raw => { if (decodeVoice(raw)?.session === userState(newName)!.session) { off(); res(); } }); });
|
||||
await muteBtn('Unmute').click();
|
||||
await until2('unmuted', () => !userState(newName)!.selfMute);
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByText('The microphone is open now.').waitFor();
|
||||
await page.getByLabel('Give the microphone back while muted').uncheck();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
// Push to talk is the mode at this point: hold the key until bob hears the fake microphone
|
||||
await page.locator('.chat header h1').click();
|
||||
await page.keyboard.down('Backquote');
|
||||
await within(heard, 'voice after taking the microphone again', 15000);
|
||||
await page.keyboard.up('Backquote');
|
||||
await muteBtn('Mute').click();
|
||||
await until2('muted again', () => !!userState(newName)!.selfMute);
|
||||
console.log('ok: microphone given back while muted and taken again on unmute');
|
||||
}
|
||||
|
||||
// ─── The Rooms interface: the same app arranged for phones ─────────────────
|
||||
{
|
||||
const before = page.viewportSize()!;
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||
await page.getByRole('radio', { name: 'Rooms' }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
const nav = page.getByRole('navigation', { name: 'Screens' });
|
||||
const rooms = page.getByRole('region', { name: 'Rooms' });
|
||||
await rooms.getByRole('heading', { name: 'Test Server' }).waitFor();
|
||||
assert.equal(await page.locator('.sidebar').count(), 0, 'the standard interface is gone');
|
||||
// People show in their room without opening it; the dock names the room you are in
|
||||
await rooms.locator('.room.here .person', { hasText: 'bob' }).waitFor();
|
||||
await page.locator('.dock', { hasText: 'Root' }).waitFor();
|
||||
if (admin) {
|
||||
await rooms.getByRole('button', { name: 'Join Games' }).click();
|
||||
await until2('alice in Games', () => bob.channels.get(userState(newName)!.channelId)?.name === 'Games');
|
||||
await page.locator('.dock', { hasText: 'Games' }).waitFor();
|
||||
await rooms.getByRole('button', { name: 'Join Root' }).click();
|
||||
await until2('alice back in Root', () => userState(newName)!.channelId === 0);
|
||||
await page.locator('.dock', { hasText: 'Root' }).waitFor();
|
||||
}
|
||||
console.log('ok: rooms interface lists rooms with their people and joins from the list');
|
||||
|
||||
// Mute from the dock
|
||||
const wasMuted = !!userState(newName)!.selfMute;
|
||||
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Unmute' : 'Mute', exact: true }).click();
|
||||
await until2('mute toggled from the dock', () => !!userState(newName)!.selfMute === !wasMuted);
|
||||
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Mute' : 'Unmute', exact: true }).click();
|
||||
await until2('mute restored', () => !!userState(newName)!.selfMute === wasMuted);
|
||||
|
||||
// Chat is a screen of its own
|
||||
await nav.getByRole('button', { name: 'Chat' }).click();
|
||||
const roomsMsg = new Promise<void>(res => bob.on('text', m => { if (m.html === 'hello from rooms') res(); }));
|
||||
await page.locator('.chat2 .composer [contenteditable]').fill('hello from rooms');
|
||||
await page.locator('.chat2 .composer [contenteditable]').press('Enter');
|
||||
await within(roomsMsg, 'message from the rooms interface');
|
||||
await page.locator('.chat2 .msg', { hasText: 'hello from rooms' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Back to rooms' }).click();
|
||||
await rooms.waitFor();
|
||||
|
||||
// The voice screen shows the room as tiles with the large controls
|
||||
await nav.getByRole('button', { name: 'Voice' }).click();
|
||||
const voiceScreen = page.getByRole('region', { name: 'Voice', exact: true });
|
||||
await voiceScreen.locator('.tile', { hasText: 'bob' }).waitFor();
|
||||
await voiceScreen.getByRole('button', { name: /^Share your screen or camera$/ }).waitFor();
|
||||
assert.equal(await page.locator('.dock').count(), 0, 'no dock on the voice screen');
|
||||
|
||||
// People: a profile opens over the screen, a message opens the chat
|
||||
await nav.getByRole('button', { name: 'People' }).click();
|
||||
const people = page.getByRole('region', { name: 'People' });
|
||||
await people.locator('.entry', { hasText: 'bob' }).locator('.main').click();
|
||||
await page.locator('.sheet h3', { hasText: 'bob' }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('.sheet').waitFor({ state: 'detached' });
|
||||
await people.getByRole('button', { name: 'Message bob' }).click();
|
||||
await page.locator('.chat2 h1', { hasText: 'bob' }).waitFor();
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-phone.png') });
|
||||
|
||||
// Wide windows keep the rooms next to the screen
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await rooms.waitFor();
|
||||
await page.locator('.chat2').waitFor();
|
||||
assert.equal(await nav.getByRole('button', { name: 'Rooms' }).count(), 0);
|
||||
if (notesId != null) {
|
||||
await rooms.locator('.entry', { hasText: 'notes' }).locator('.main').click();
|
||||
await page.locator('.chat2 .msg', { hasText: 'text hello' }).waitFor();
|
||||
}
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-wide.png') });
|
||||
console.log('ok: rooms interface: dock, chat, voice screen, people, profile sheet, wide layout');
|
||||
|
||||
// Back to the standard interface from the You screen
|
||||
await nav.getByRole('button', { name: 'You' }).click();
|
||||
await page.getByRole('region', { name: 'You' }).getByRole('button', { name: /^Appearance/ }).click();
|
||||
await page.getByRole('radio', { name: 'Standard' }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.locator('.sidebar').waitFor();
|
||||
await page.setViewportSize(before);
|
||||
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
|
||||
console.log('ok: back to the standard interface from the You screen');
|
||||
}
|
||||
|
||||
// Kicked: the reason is shown with a Reconnect button
|
||||
if (admin) {
|
||||
const me = [...admin.users.values()].find(u => u.name === newName)!;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TEXT, TEXT_MARK, HAND_OVER, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, isTextChannel, markText, stripMark, newId, type TextMsg } from '../src/core/text-signal.ts';
|
||||
|
||||
const msg = (i: string, t: number, h = 'hi'): TextMsg => ({ i, c: 3, t, n: 'ann', k: 'abc', h });
|
||||
|
||||
test('the marker turns a description into a text channel and comes off again', () => {
|
||||
assert.equal(isTextChannel('<b>Rules</b>'), false);
|
||||
const marked = markText('<b>Rules</b>', true);
|
||||
assert.equal(marked, TEXT_MARK + '<b>Rules</b>');
|
||||
assert.equal(isTextChannel(marked), true);
|
||||
// Marking twice leaves one marker
|
||||
assert.equal(markText(marked, true), marked);
|
||||
assert.equal(markText(marked, false), '<b>Rules</b>');
|
||||
assert.equal(stripMark(marked), '<b>Rules</b>');
|
||||
});
|
||||
|
||||
test('a message survives the trip, in packets Murmur accepts', async () => {
|
||||
const body = { i: newId(), c: 7, h: '<b>hello</b> ' + 'x'.repeat(3000) };
|
||||
const packets = await encodeText(TEXT.msg, body);
|
||||
assert.ok(packets.every(p => p.length <= 1000));
|
||||
const assembler = new TextAssembler();
|
||||
let signal = null;
|
||||
for (const p of packets) signal = await assembler.push(1, p);
|
||||
assert.equal(signal?.type, TEXT.msg);
|
||||
assert.deepEqual(parseMsg(signal?.text), body);
|
||||
});
|
||||
|
||||
test('a short message is one packet', async () => {
|
||||
assert.equal((await encodeText(TEXT.msg, { i: newId(), c: 1, h: 'How is everyone doing today?' })).length, 1);
|
||||
});
|
||||
|
||||
test('malformed messages are refused', () => {
|
||||
assert.equal(parseMsg('nope'), null);
|
||||
assert.equal(parseMsg('[]'), null);
|
||||
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: -1, h: 'x' })), null);
|
||||
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: '' })), null);
|
||||
assert.equal(parseMsg(JSON.stringify({ i: '', c: 1, h: 'x' })), null);
|
||||
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: 'x'.repeat(MAX_HTML + 1) })), null);
|
||||
});
|
||||
|
||||
test('history keeps valid entries, limits their number and pulls future times back', () => {
|
||||
const now = 1_000_000;
|
||||
const m = [
|
||||
{ i: 'a', t: 500, n: 'ann', k: 'h1', h: 'one' },
|
||||
{ i: 'b', t: now + 99999, n: 'bob', k: '', h: 'two' },
|
||||
{ i: 'c', t: 'soon', n: 'eve', k: '', h: 'bad time' },
|
||||
{ i: 'd', t: 600, n: 'eve', k: '', h: '' },
|
||||
null
|
||||
];
|
||||
assert.deepEqual(parseHistory(JSON.stringify({ c: 4, m }), now), [
|
||||
{ i: 'a', c: 4, t: 500, n: 'ann', k: 'h1', h: 'one' },
|
||||
{ i: 'b', c: 4, t: now, n: 'bob', k: '', h: 'two' }
|
||||
]);
|
||||
const many = Array.from({ length: HAND_OVER + 30 }, (_, i) => ({ i: `m${i}`, t: i, n: 'ann', k: '', h: 'x' }));
|
||||
assert.equal(parseHistory(JSON.stringify({ c: 4, m: many }), now).length, HAND_OVER);
|
||||
assert.deepEqual(parseHistory('{"c":4}', now), []);
|
||||
});
|
||||
|
||||
test('merging adds only unknown messages, in order of time, and keeps the newest', () => {
|
||||
const have = [msg('a', 10), msg('c', 30)];
|
||||
const { all, added } = mergeMsgs(have, [msg('b', 20), msg('a', 10), msg('d', 40)]);
|
||||
assert.deepEqual(all.map(m => m.i), ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual(added.map(m => m.i), ['b', 'd']);
|
||||
assert.equal(mergeMsgs(have, [msg('a', 10)]).added.length, 0);
|
||||
// Over the limit the oldest go, and an old message that does not make it is not reported as added
|
||||
const kept = mergeMsgs([msg('x', 50), msg('y', 60)], [msg('old', 1), msg('z', 70)], 2);
|
||||
assert.deepEqual(kept.all.map(m => m.i), ['y', 'z']);
|
||||
assert.deepEqual(kept.added.map(m => m.i), ['z']);
|
||||
});
|
||||
|
||||
test('history that is too large for one signal is cut down to the newest messages', async () => {
|
||||
// Incompressible text, so 50 messages do not fit
|
||||
const noise = (seed: number) => Array.from({ length: 1500 }, (_, i) => ((i + 1) * (seed + 7) * 2654435761 >>> 0).toString(36)).join('');
|
||||
const msgs = Array.from({ length: 50 }, (_, i) => msg(`m${i}`, i, noise(i)));
|
||||
const packets = await encodeHistory(3, msgs);
|
||||
assert.ok(packets.length > 0 && packets.length <= 32);
|
||||
const assembler = new TextAssembler();
|
||||
let signal = null;
|
||||
for (const p of packets) signal = await assembler.push(1, p);
|
||||
const back = parseHistory(signal?.text);
|
||||
assert.ok(back.length > 0 && back.length < 50);
|
||||
assert.equal(back[back.length - 1].i, 'm49');
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Every color scheme must stay readable: text on the surfaces it sits on, and the label on an accent button
|
||||
const css = ['themes.css', 'themes-generated.css'].map(f => readFileSync(path.resolve(import.meta.dirname, '../src', f), 'utf8')).join('\n');
|
||||
const settings = ['settings.svelte.ts', 'themes-generated.ts'].map(f => readFileSync(path.resolve(import.meta.dirname, '../src/lib', f), 'utf8')).join('\n');
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map(v => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
const contrast = (a: string, b: string) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
const themes = [...css.matchAll(/\[data-theme-preview="([\w-]+)"\] \{([^}]*)\}/g)].map(m => ({
|
||||
id: m[1],
|
||||
vars: Object.fromEntries([...m[2].matchAll(/(--[\w-]+):\s*(#[0-9a-f]{6});/gi)].map(v => [v[1], v[2]]))
|
||||
}));
|
||||
|
||||
test('every scheme in the settings list has its colors, and the other way round', () => {
|
||||
const listed = [...settings.matchAll(/\{ id: '([\w-]+)', name:/g)].map(m => m[1]).sort();
|
||||
assert.deepEqual(themes.map(t => t.id).sort(), listed);
|
||||
assert.equal(listed.length, 100);
|
||||
});
|
||||
|
||||
for (const t of themes) {
|
||||
test(`${t.id}: text is readable`, () => {
|
||||
const v = t.vars;
|
||||
for (const bg of ['--bg-0', '--bg-1', '--bg-2']) {
|
||||
assert.ok(contrast(v['--text'], v[bg]) >= 7, `text on ${bg}: ${contrast(v['--text'], v[bg]).toFixed(1)}`);
|
||||
assert.ok(contrast(v['--text-dim'], v[bg]) >= 4.5, `dim text on ${bg}: ${contrast(v['--text-dim'], v[bg]).toFixed(1)}`);
|
||||
}
|
||||
assert.ok(contrast(v['--accent-text'], v['--accent']) >= 3.5, `accent label: ${contrast(v['--accent-text'], v['--accent']).toFixed(1)}`);
|
||||
assert.ok(contrast(v['--link'], v['--bg-2']) >= 4, `link: ${contrast(v['--link'], v['--bg-2']).toFixed(1)}`);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user