Link previews voice tiles #3
@@ -0,0 +1,40 @@
|
|||||||
|
# Builds the Linux packages and the Windows installer when a version tag (v1.2.3) is pushed,
|
||||||
|
# and attaches them to a Gitea release of that tag. macOS builds need a Mac and are not made here.
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Has Wine, so the Windows installer builds on Linux
|
||||||
|
container: electronuserland/builder:wine
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx electron-builder --linux --win --publish never
|
||||||
|
- name: Upload to the release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
release=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" || true)
|
||||||
|
if [ -z "$release" ]; then
|
||||||
|
release=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"mumh5 $TAG\"}" "$API/releases")
|
||||||
|
fi
|
||||||
|
id=$(printf '%s' "$release" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).id))")
|
||||||
|
for f in release/*.AppImage release/*.deb release/*.tar.gz release/*.exe; do
|
||||||
|
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" >/dev/null
|
||||||
|
echo "uploaded $(basename "$f")"
|
||||||
|
done
|
||||||
@@ -27,7 +27,7 @@ mumh5 keeps the foundation and replaces the experience.
|
|||||||
|
|
||||||
| | Mumble desktop | mumh5 |
|
| | Mumble desktop | mumh5 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Interface | Qt widgets, dense dialogs | Modern layout: server rail, channel tree, chat, side panels |
|
| 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 |
|
| 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 |
|
| 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 | Click-to-play player (privacy mode), optional thumbnails |
|
| YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails |
|
||||||
@@ -111,7 +111,18 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
There are no release builds yet. For now, build it yourself; it takes a few minutes.
|
Download a build from the [releases page](https://git.lat/kibi/mumh5/releases):
|
||||||
|
|
||||||
|
| System | File |
|
||||||
|
|---|---|
|
||||||
|
| Windows | `mumh5-<version>-win-x64.exe` (installer; not signed yet, so Windows SmartScreen warns once) |
|
||||||
|
| Linux, any distribution | `mumh5-<version>-linux-x86_64.AppImage` (make it executable, then run it) |
|
||||||
|
| Debian, Ubuntu | `mumh5-<version>-linux-amd64.deb` |
|
||||||
|
| Linux, portable | `mumh5-<version>-linux-x64.tar.gz` |
|
||||||
|
|
||||||
|
There is no macOS build yet; build it yourself on a Mac (below).
|
||||||
|
|
||||||
|
### Running from source
|
||||||
|
|
||||||
You need [Node.js](https://nodejs.org/) 22 or newer and git.
|
You need [Node.js](https://nodejs.org/) 22 or newer and git.
|
||||||
|
|
||||||
@@ -132,7 +143,7 @@ npm run dist:win # NSIS installer
|
|||||||
npm run dist:mac # .dmg
|
npm run dist:mac # .dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
Installers are written to `release/`. Build each platform on that platform for the best results.
|
Installers are written to `release/`. The Windows installer also builds on Linux with Wine installed; the macOS one needs a Mac. Pushing a version tag (`git tag v0.1.0 && git push origin v0.1.0`) makes the Gitea workflow in `.gitea/workflows/release.yml` build the Linux and Windows files and attach them to a release.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||||
|
<!-- mumh5 app icon: the accent square with the headphones glyph, square caps like the UI icons -->
|
||||||
|
<rect width="1024" height="1024" fill="#7c6cff"/>
|
||||||
|
<rect x="0" y="880" width="1024" height="144" fill="#6a5ae8"/>
|
||||||
|
<g transform="translate(176 150) scale(28)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
|
||||||
|
<path d="M3 14v-2a9 9 0 0 1 18 0v2"/>
|
||||||
|
<path d="M21 14v7h-4v-7z" fill="#ffffff"/>
|
||||||
|
<path d="M3 14v7h4v-7z" fill="#ffffff"/>
|
||||||
|
</g>
|
||||||
|
<rect x="424" y="700" width="176" height="36" fill="#3bd16f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 665 B |
@@ -136,6 +136,8 @@ function createWindow() {
|
|||||||
minHeight: 480,
|
minHeight: 480,
|
||||||
backgroundColor: '#111214',
|
backgroundColor: '#111214',
|
||||||
title: 'mumh5',
|
title: 'mumh5',
|
||||||
|
// Window and taskbar icon on Linux (Windows and macOS take it from the package)
|
||||||
|
icon: path.join(__dirname, '../dist/icon.png'),
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.cjs'),
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
|||||||
+41
-7
@@ -2,7 +2,7 @@
|
|||||||
"name": "mumh5",
|
"name": "mumh5",
|
||||||
"productName": "mumh5",
|
"productName": "mumh5",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Modern Mumble client with rich chat, file sharing and video",
|
"description": "A modern Mumble client: Discord-like interface, file sharing, voice and more, for any Mumble server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-electron/main.cjs",
|
"main": "dist-electron/main.cjs",
|
||||||
@@ -26,24 +26,37 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"dist-electron/**",
|
"dist-electron/**",
|
||||||
|
"!dist-electron/*.map",
|
||||||
"package.json",
|
"package.json",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"THIRD_PARTY_NOTICES.md"
|
"THIRD_PARTY_NOTICES.md"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release",
|
||||||
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"deb"
|
"deb",
|
||||||
|
"tar.gz"
|
||||||
],
|
],
|
||||||
"category": "Network"
|
"category": "Network",
|
||||||
|
"executableName": "mumh5",
|
||||||
|
"synopsis": "Modern Mumble client",
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"StartupWMClass": "mumh5",
|
||||||
|
"Keywords": "mumble;voice;chat;voip;"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncDesktopName": true
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
]
|
],
|
||||||
|
"icon": "build/icon.png"
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -54,6 +67,22 @@
|
|||||||
"NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.",
|
"NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.",
|
||||||
"NSCameraUsageDescription": "mumh5 uses the camera for video chat."
|
"NSCameraUsageDescription": "mumh5 uses the camera for video chat."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||||
|
"deb": {
|
||||||
|
"depends": [
|
||||||
|
"libnotify4",
|
||||||
|
"libxtst6",
|
||||||
|
"libnss3",
|
||||||
|
"libasound2 | libasound2t64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"perMachine": false,
|
||||||
|
"shortcutName": "mumh5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -81,9 +110,14 @@
|
|||||||
"esbuild@0.28.2": true,
|
"esbuild@0.28.2": true,
|
||||||
"electron-winstaller": false
|
"electron-winstaller": false
|
||||||
},
|
},
|
||||||
"author": "Kibi Kelburton",
|
"author": {
|
||||||
|
"name": "Kibi Kelburton",
|
||||||
|
"email": "git@f0ck.it"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "gitea@git.lat:kibi/mumh5.git"
|
"url": "gitea@git.lat:kibi/mumh5.git"
|
||||||
}
|
},
|
||||||
|
"homepage": "https://git.lat/kibi/mumh5",
|
||||||
|
"desktopName": "mumh5.desktop"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
+45
-11
@@ -14,6 +14,7 @@
|
|||||||
import UserInfoDialog from './ui/UserInfoDialog.svelte';
|
import UserInfoDialog from './ui/UserInfoDialog.svelte';
|
||||||
import ChannelDialog from './ui/ChannelDialog.svelte';
|
import ChannelDialog from './ui/ChannelDialog.svelte';
|
||||||
import PublicServers from './ui/PublicServers.svelte';
|
import PublicServers from './ui/PublicServers.svelte';
|
||||||
|
import ResizeHandle from './ui/ResizeHandle.svelte';
|
||||||
import { identities } from './lib/identities.svelte.ts';
|
import { identities } from './lib/identities.svelte.ts';
|
||||||
import { session } from './lib/session.svelte.ts';
|
import { session } from './lib/session.svelte.ts';
|
||||||
import { ui } from './lib/ui.svelte.ts';
|
import { ui } from './lib/ui.svelte.ts';
|
||||||
@@ -23,6 +24,13 @@
|
|||||||
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
|
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
|
||||||
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
|
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
|
||||||
|
|
||||||
|
// Arrangement (Appearance setting): side is the default, classic puts the channel list right
|
||||||
|
// of the chat, stacked above it. Phones always use drawers.
|
||||||
|
const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
|
||||||
|
// The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays
|
||||||
|
const panelInline = $derived(layout === 'wide' && arrangement === 'side');
|
||||||
|
let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280));
|
||||||
|
|
||||||
let navOpen = $state(false);
|
let navOpen = $state(false);
|
||||||
let editing = $state<{ server: SavedServer | null } | null>(null);
|
let editing = $state<{ server: SavedServer | null } | null>(null);
|
||||||
|
|
||||||
@@ -35,12 +43,13 @@
|
|||||||
else delete document.documentElement.dataset.theme;
|
else delete document.documentElement.dataset.theme;
|
||||||
});
|
});
|
||||||
|
|
||||||
// The panel starts open on wide layouts only
|
// The panel starts open only where it sits inline
|
||||||
let lastLayout = '';
|
let lastLayout = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (layout === lastLayout) return;
|
const key = `${layout}/${arrangement}`;
|
||||||
ui.panelOpen = layout === 'wide';
|
if (key === lastLayout) return;
|
||||||
lastLayout = layout;
|
ui.panelOpen = panelInline;
|
||||||
|
lastLayout = key;
|
||||||
});
|
});
|
||||||
// On phones one drawer at a time
|
// On phones one drawer at a time
|
||||||
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
|
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
|
||||||
@@ -53,26 +62,48 @@
|
|||||||
|
|
||||||
const closeOverlays = () => {
|
const closeOverlays = () => {
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
if (layout !== 'wide') ui.panelOpen = false;
|
if (!panelInline) ui.panelOpen = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth={width} />
|
<svelte:window bind:innerWidth={width} />
|
||||||
|
|
||||||
<div class="app {layout}">
|
{#snippet rail()}
|
||||||
{#if layout !== 'narrow' || navOpen}
|
|
||||||
<div class="nav" class:drawer={layout === 'narrow'}>
|
|
||||||
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
|
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
|
||||||
onsettings={() => (ui.settingsOpen = true)} />
|
onsettings={() => (ui.settingsOpen = true)} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet chat()}
|
||||||
|
<Chat showMenu={layout === 'narrow'} showStage={!panelInline && arrangement === 'side'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="app {layout} {arrangement}">
|
||||||
|
{#if arrangement === 'classic'}
|
||||||
|
{@render rail()}
|
||||||
|
{@render chat()}
|
||||||
|
<Sidebar handleEdge="left" onnavigate={() => {}} />
|
||||||
|
{:else if arrangement === 'stacked'}
|
||||||
|
{@render rail()}
|
||||||
|
<div class="stack">
|
||||||
|
<div class="tree" style:height="{treeHeight}px">
|
||||||
|
<Sidebar fill onnavigate={() => {}} />
|
||||||
|
<ResizeHandle bind:width={treeHeight} edge="bottom" min={120} max={() => window.innerHeight - 220} initial={280}
|
||||||
|
storageKey="mumh5.treeHeight" label="Resize channel list" />
|
||||||
|
</div>
|
||||||
|
{@render chat()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if layout !== 'narrow' || navOpen}
|
||||||
|
<div class="nav" class:drawer={layout === 'narrow'}>
|
||||||
|
{@render rail()}
|
||||||
<Sidebar resizable={layout !== 'narrow'} onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
|
<Sidebar resizable={layout !== 'narrow'} onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
|
||||||
</div>
|
</div>
|
||||||
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
|
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render chat()}
|
||||||
<Chat showMenu={layout === 'narrow'} showStage={layout !== 'wide'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
{/if}
|
||||||
|
|
||||||
{#if showPanel}
|
{#if showPanel}
|
||||||
{#if layout === 'wide'}
|
{#if panelInline}
|
||||||
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
|
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
|
||||||
{:else}
|
{:else}
|
||||||
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
|
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
|
||||||
@@ -104,6 +135,9 @@
|
|||||||
.nav { display: flex; flex: none; min-height: 0; }
|
.nav { display: flex; flex: none; min-height: 0; }
|
||||||
.nav.drawer { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; max-width: calc(100% - 48px); box-shadow: 8px 0 32px rgb(0 0 0 / 0.4); }
|
.nav.drawer { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; max-width: calc(100% - 48px); box-shadow: 8px 0 32px rgb(0 0 0 / 0.4); }
|
||||||
.nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
|
.nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
|
||||||
|
.stack { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.stack > :global(.chat) { flex: 1; min-height: 0; }
|
||||||
|
.tree { position: relative; z-index: 1; flex: none; display: flex; min-height: 0; border-bottom: 1px solid var(--line); }
|
||||||
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); }
|
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); }
|
||||||
.panel-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; max-width: calc(100% - 48px); display: flex; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
|
.panel-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; max-width: calc(100% - 48px); display: flex; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ export interface Settings {
|
|||||||
linkPreviews: 'host' | 'direct' | 'off';
|
linkPreviews: 'host' | 'direct' | 'off';
|
||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
publicListUrl: string; // where the public server list comes from
|
publicListUrl: string; // where the public server list comes from
|
||||||
|
arrangement: Arrangement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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' | 'win95';
|
||||||
|
|
||||||
export const THEMES: { id: ThemeId; name: string; note: string }[] = [
|
export const THEMES: { id: ThemeId; name: string; note: string }[] = [
|
||||||
@@ -45,7 +49,8 @@ const defaults: Settings = {
|
|||||||
youtube: 'click',
|
youtube: 'click',
|
||||||
linkPreviews: 'host',
|
linkPreviews: 'host',
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0'
|
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
|
||||||
|
arrangement: 'side'
|
||||||
};
|
};
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { store, THEMES, type ThemeId } from '../lib/settings.svelte.ts';
|
import { store, THEMES, type ThemeId, type Arrangement } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
const ARRANGEMENTS: { id: Arrangement; name: string; note: string }[] = [
|
||||||
|
{ id: 'side', name: 'Modern', note: 'Channels left, members right' },
|
||||||
|
{ id: 'classic', name: 'Classic', note: 'Chat left, channels right, like Mumble' },
|
||||||
|
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function arrange(id: Arrangement) {
|
||||||
|
store.settings.arrangement = id;
|
||||||
|
store.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
function pick(id: ThemeId) {
|
function pick(id: ThemeId) {
|
||||||
store.settings.theme = id;
|
store.settings.theme = id;
|
||||||
@@ -28,7 +39,41 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="gap">Layout</h3>
|
||||||
|
<div class="themes" role="radiogroup" aria-label="Layout">
|
||||||
|
{#each ARRANGEMENTS as a (a.id)}
|
||||||
|
{@const on = (store.settings.arrangement ?? 'side') === a.id}
|
||||||
|
<button role="radio" aria-checked={on} class:active={on} onclick={() => arrange(a.id)} aria-label={a.name}>
|
||||||
|
<span class="mini lay {a.id}" aria-hidden="true">
|
||||||
|
<span class="m-rail"><span></span><span></span></span>
|
||||||
|
{#if a.id === 'side'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
|
||||||
|
{#if a.id === 'stacked'}
|
||||||
|
<span class="m-col">
|
||||||
|
<span class="m-side"><i></i><i class="sel"></i></span>
|
||||||
|
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i><span class="m-box"></span></span>
|
||||||
|
{/if}
|
||||||
|
{#if a.id === 'classic'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
|
||||||
|
{#if a.id === 'side'}<span class="m-members"><i></i><i></i></span>{/if}
|
||||||
|
</span>
|
||||||
|
<span class="name">{a.name}</span>
|
||||||
|
<span class="note">{a.note}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="help">Narrow windows always use the phone layout.</p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.gap { margin-top: 22px; }
|
||||||
|
.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; }
|
||||||
|
.m-col .m-side { width: auto; height: 34%; flex: none; padding: 5px; gap: 4px; border-bottom: 1px solid var(--line); }
|
||||||
|
.m-members { width: 18%; background: var(--bg-1); display: flex; flex-direction: column; gap: 5px; padding: 8px 4px; border-left: 1px solid var(--line); }
|
||||||
|
.m-members i { height: 4px; background: var(--text-faint); width: 80%; }
|
||||||
|
.lay.classic .m-side { border-left: 1px solid var(--line); }
|
||||||
h3 { margin: 8px 0 0; font-size: 14px; }
|
h3 { margin: 8px 0 0; font-size: 14px; }
|
||||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
|
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
|
||||||
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||||
|
|||||||
+8
-3
@@ -58,13 +58,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h2>mumh5</h2>
|
<h2>mumh5</h2>
|
||||||
<p>Choose a server to get started, or find one:</p>
|
<p>Choose a server to get started, or find one:</p>
|
||||||
<button class="btn browse" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
|
<div class="welcome-actions">
|
||||||
|
<button class="btn" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
|
||||||
|
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChatLog>
|
</ChatLog>
|
||||||
|
|
||||||
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
|
{#if session.status === 'connected'}
|
||||||
|
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -79,7 +84,7 @@
|
|||||||
.empty { padding: 32px 16px 8px; }
|
.empty { padding: 32px 16px 8px; }
|
||||||
.empty h2 { margin: 0 0 4px; font-size: 24px; }
|
.empty h2 { margin: 0 0 4px; font-size: 24px; }
|
||||||
.empty p { margin: 0; color: var(--text-dim); }
|
.empty p { margin: 0; color: var(--text-dim); }
|
||||||
.browse { margin-top: 12px; }
|
.welcome-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
.ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
|
.ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
|
||||||
.ended.bad { border-left-color: var(--danger); }
|
.ended.bad { border-left-color: var(--danger); }
|
||||||
.ended h2 { margin: 0; font-size: 20px; }
|
.ended h2 { margin: 0; font-size: 20px; }
|
||||||
|
|||||||
@@ -26,17 +26,23 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// A new list starts a new ping round; answers from the previous round are dropped
|
// A new list starts a new ping round; answers from the previous round are dropped
|
||||||
generation++;
|
// and a list that arrives after the source changed is ignored
|
||||||
|
const round = ++generation;
|
||||||
queue = [];
|
queue = [];
|
||||||
running = 0;
|
running = 0;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
let list: PublicServer[] = [];
|
||||||
|
let failed = '';
|
||||||
try {
|
try {
|
||||||
servers = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
|
list = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
|
||||||
if (!native) error = 'The public list needs the desktop app.';
|
if (!native) failed = 'The public list needs the desktop app.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
failed = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||||
}
|
}
|
||||||
|
if (round !== generation || !alive) return;
|
||||||
|
servers = list;
|
||||||
|
error = failed;
|
||||||
loading = false;
|
loading = false;
|
||||||
pingVisible();
|
pingVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
|
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
|
||||||
// the right of the window is resized from its left edge and vice versa. Width is remembered.
|
// the right of the window is resized from its left edge and vice versa; a panel at the
|
||||||
|
// bottom from its top edge and one at the top from its bottom edge (then `width` is its
|
||||||
|
// height). The size is remembered.
|
||||||
let { width = $bindable(), edge, min, max, initial, storageKey, label }:
|
let { width = $bindable(), edge, min, max, initial, storageKey, label }:
|
||||||
{ width: number; edge: 'left' | 'right'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
|
{ width: number; edge: 'left' | 'right' | 'top' | 'bottom'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
resizing = true;
|
resizing = true;
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
|
const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
|
||||||
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : ev.clientX - box.left);
|
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : edge === 'top' ? box.bottom - ev.clientY : edge === 'bottom' ? ev.clientY - box.top : ev.clientX - box.left);
|
||||||
const up = () => {
|
const up = () => {
|
||||||
resizing = false;
|
resizing = false;
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
@@ -32,8 +34,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(e: KeyboardEvent) {
|
function onkeydown(e: KeyboardEvent) {
|
||||||
const grow = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
|
const grow = edge === 'left' ? 'ArrowLeft' : edge === 'top' ? 'ArrowUp' : edge === 'bottom' ? 'ArrowDown' : 'ArrowRight';
|
||||||
const shrink = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
|
const shrink = edge === 'left' ? 'ArrowRight' : edge === 'top' ? 'ArrowDown' : edge === 'bottom' ? 'ArrowUp' : 'ArrowLeft';
|
||||||
if (e.key === grow) setWidth(width + 16);
|
if (e.key === grow) setWidth(width + 16);
|
||||||
else if (e.key === shrink) setWidth(width - 16);
|
else if (e.key === shrink) setWidth(width - 16);
|
||||||
else return;
|
else return;
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
|
|
||||||
<!-- Focusable separator is the ARIA window-splitter pattern -->
|
<!-- Focusable separator is the ARIA window-splitter pattern -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
||||||
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation="vertical" aria-label={label}
|
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation={edge === 'top' || edge === 'bottom' ? 'horizontal' : 'vertical'} aria-label={label}
|
||||||
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
|
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
|
||||||
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
|
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
|
||||||
|
|
||||||
@@ -53,6 +55,13 @@
|
|||||||
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
|
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
|
||||||
.resize.left { left: -4px; }
|
.resize.left { left: -4px; }
|
||||||
.resize.right { right: -4px; }
|
.resize.right { right: -4px; }
|
||||||
|
.resize.top, .resize.bottom { left: 0; right: 0; width: auto; height: 8px; cursor: row-resize; }
|
||||||
|
.resize.top { top: -4px; bottom: auto; }
|
||||||
|
.resize.bottom { bottom: -4px; top: auto; }
|
||||||
|
.resize.top:hover, .resize.top:focus-visible, .resize.top.active,
|
||||||
|
.resize.bottom:hover, .resize.bottom:focus-visible, .resize.bottom.active {
|
||||||
|
background: linear-gradient(to bottom, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
|
||||||
|
}
|
||||||
.resize:hover, .resize:focus-visible, .resize.active {
|
.resize:hover, .resize:focus-visible, .resize.active {
|
||||||
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
|
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
|
|
||||||
// resizable: false in the phone drawer, which keeps a fixed width
|
// resizable: false in the phone drawer, which keeps a fixed width
|
||||||
let { onnavigate, resizable = true }: { onnavigate: () => void; resizable?: boolean } = $props();
|
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
|
||||||
|
// fill: take the whole area (stacked layout, where the parent sizes it)
|
||||||
|
let { onnavigate, resizable = true, handleEdge = 'right', fill = false }:
|
||||||
|
{ onnavigate: () => void; resizable?: boolean; handleEdge?: 'left' | 'right'; fill?: boolean } = $props();
|
||||||
|
|
||||||
const MIN_W = 200, DEFAULT_W = 260;
|
const MIN_W = 200, DEFAULT_W = 260;
|
||||||
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
|
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
|
||||||
@@ -58,8 +61,8 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar" style:width={resizable ? `${width}px` : undefined}>
|
<aside class="sidebar" class:fill style:width={resizable && !fill ? `${width}px` : undefined}>
|
||||||
{#if resizable}<ResizeHandle bind:width edge="right" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
|
{#if resizable && !fill}<ResizeHandle bind:width edge={handleEdge} min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
|
||||||
<header class:trusted>
|
<header class:trusted>
|
||||||
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
|
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
|
||||||
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
|
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
|
||||||
@@ -134,6 +137,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
|
.sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
|
||||||
|
.sidebar.fill { width: 100%; height: 100%; }
|
||||||
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
|
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
|
||||||
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
|
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
|
||||||
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
|
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ try {
|
|||||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
||||||
console.log('ok: identity wizard creates an identity');
|
console.log('ok: identity wizard creates an identity');
|
||||||
|
|
||||||
|
// Welcome page: no message box while offline, identities one click away
|
||||||
|
assert.equal(await page.locator('.chat .composer').count(), 0);
|
||||||
|
await page.getByRole('button', { name: 'Manage identities' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Identities', selected: true }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
console.log('ok: welcome page without composer, manage identities');
|
||||||
|
|
||||||
// Settings: upload host + key
|
// Settings: upload host + key
|
||||||
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
|
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
|
||||||
await page.getByTitle('Settings').click();
|
await page.getByTitle('Settings').click();
|
||||||
@@ -307,6 +314,36 @@ try {
|
|||||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
console.log('ok: color schemes');
|
console.log('ok: color schemes');
|
||||||
|
|
||||||
|
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
|
||||||
|
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||||
|
await page.getByRole('radio', { name: 'Classic' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
await page.locator('.app.classic').waitFor();
|
||||||
|
assert.ok((await box('.sidebar')).x > (await box('.chat')).x, 'classic: channel list right of chat');
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||||
|
await page.getByRole('radio', { name: 'Stacked' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
await page.locator('.app.stacked').waitFor();
|
||||||
|
const chatBox = await box('.chat'), treeBox = await box('.sidebar');
|
||||||
|
assert.ok(chatBox.y >= treeBox.y + treeBox.height - 1, 'stacked: chat below channel list');
|
||||||
|
// The split between chat and channel list drags
|
||||||
|
const handle = page.getByRole('separator', { name: 'Resize channel list' });
|
||||||
|
const hb = (await handle.boundingBox())!;
|
||||||
|
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(hb.x + hb.width / 2, hb.y + 60, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
assert.ok((await box('.sidebar')).height > treeBox.height + 40, 'stacked: split resizes');
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||||
|
await page.getByRole('radio', { name: 'Modern' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
await page.locator('.app.side').waitFor();
|
||||||
|
console.log('ok: classic and stacked layouts');
|
||||||
|
|
||||||
// Public server list, from a local list in the official XML format
|
// Public server list, from a local list in the official XML format
|
||||||
const http = await import('node:http');
|
const http = await import('node:http');
|
||||||
const listServer = http.createServer((_req, res) => {
|
const listServer = http.createServer((_req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user