108 Commits
Author SHA1 Message Date
selimaj-dev 4befd73f99 Fix again
Tauri Release Build / Build (ubuntu-latest) (release) Failing after 38s
Tauri Release Build / Build (macos-latest) (release) Canceled after 0s
Tauri Release Build / Build (windows-latest) (release) Canceled after 0s
2026-08-31 20:11:32 +02:00
selimaj-dev 89f65c6162 Fix 2026-08-31 20:08:37 +02:00
selimaj-dev dc12fb8184 Updated version 2026-08-31 14:02:46 -04:00
selimaj-dev 365a3c6151 tauri release 2026-08-31 14:01:11 -04:00
selimaj-dev 8c58cb8687 Updated icons 2026-08-31 19:57:53 +02:00
selimaj-dev 8628047a07 Merge pull request #12 from recurse-chat/bug-fixes
BUG Fix: Muting and deafen, and sidebar UI
2026-08-29 11:53:56 -04:00
selimaj-dev 78c1f07a35 Fix muting also deaf 2026-08-29 11:36:59 -04:00
selimaj-dev b950bb4816 A little more gap 2026-08-29 11:34:13 -04:00
selimaj-dev 0104c151ce A little more gap 2026-08-29 11:33:46 -04:00
selimaj-dev 15aafeda58 Fixed mute and deafen 2026-08-29 11:31:46 -04:00
selimaj-dev 11d271c5b7 Fixed mute and deafen 2026-08-29 11:27:15 -04:00
selimaj-dev e3230d49d3 Fixed sidebar 2026-08-29 11:19:15 -04:00
selimaj-dev afcf4725db Merge pull request #11 from recurse-chat/feature/home-main-page-and-news-sidebar
Home main page, README, and cybersecurity news sidebar
2026-08-29 09:08:03 -04:00
selimaj-dev 7612156884 News from tauri 2026-08-29 15:02:56 +02:00
selimaj-dev c352862b46 Working news 2026-08-29 14:53:47 +02:00
selimaj-dev 6032d5660c Testing news 2026-08-29 14:49:41 +02:00
selimaj-dev 7da0f97e60 SIdebar 2026-08-29 14:37:17 +02:00
selimaj-dev b131d91686 readme 2026-08-29 14:22:02 +02:00
selimaj-dev d97872f374 Built by ORUS 2026-08-29 14:13:14 +02:00
selimaj-dev 1728ba0ec4 Main page 2026-08-29 14:08:54 +02:00
selimaj-dev 11517b49b7 Main page 2026-08-29 14:00:23 +02:00
selimaj-dev a94d099e65 removed unnecessary logs 2026-08-29 03:11:04 +02:00
selimaj-dev 4fc14d0a5a Merge pull request #10 from recurse-chat/ui-improvements
UI improvements
2026-08-28 19:02:40 -04:00
selimaj-dev 6028020854 message author 2026-08-29 00:57:18 +02:00
selimaj-dev 03de380fab Message sfx 2026-08-29 00:51:08 +02:00
selimaj-dev 73520e3432 Join and leave sfx 2026-08-29 00:34:56 +02:00
selimaj-dev 75b018837a Muting and Deafening 2026-08-28 23:56:41 +02:00
selimaj-dev c4409683e5 Ordered server list 2026-08-28 23:16:41 +02:00
selimaj-dev ff8160dc48 Merge pull request #9 from recurse-chat/fix-bug
Fix bug
2026-08-28 14:56:31 -04:00
selimaj-dev 9883a5f2a5 Bug fix 2026-08-28 14:55:35 -04:00
selimaj-dev cc05a78364 Revert "I think i fixed a bug"
This reverts commit 42f50880b3.
2026-08-28 14:51:33 -04:00
selimaj-dev be6d7aeb87 Revert "ok"
This reverts commit 161fb4e0f3.
2026-08-28 14:51:33 -04:00
selimaj-dev 161fb4e0f3 ok 2026-08-28 14:44:39 -04:00
selimaj-dev 42f50880b3 I think i fixed a bug 2026-08-28 14:41:17 -04:00
selimaj-dev 5d8936e2e9 Merge pull request #8 from recurse-chat/fix-linux-audio-and-crypto
BUG: Fix linux audio and crypto
2026-08-28 14:28:02 -04:00
selimaj-dev acf76cd82f Fixed crypto 2026-08-28 14:27:35 -04:00
selimaj-dev 12fb17c9c3 Fixed linux audio 2026-08-28 14:24:20 -04:00
selimaj-dev 0fd06766e3 Added pipewire and pulseaudio 2026-08-28 11:31:24 -04:00
selimaj-dev 1735b8c674 Merge pull request #7 from recurse-chat/better-vc-ui-and-crypto
Better vc UI and added encryption
2026-08-28 10:36:56 -04:00
selimaj-dev 4f128915d0 Better voice chat UI and fix bug 2026-08-28 16:32:18 +02:00
selimaj-dev e280614829 TLS recommendation 2026-08-28 15:27:31 +02:00
selimaj-dev d5dc9d82d7 Crypto in vc 2026-08-28 15:20:38 +02:00
selimaj-dev cd564bec99 Updated stuff 2026-08-28 14:16:41 +02:00
selimaj-dev 07506aa2eb Crypto handshakce and encryption/decryption 2026-08-28 14:13:45 +02:00
selimaj-dev a19de564fe Improved sidebar 2026-08-28 12:17:53 +02:00
selimaj-dev 853f7bc84b More users 2026-08-28 02:57:30 +02:00
selimaj-dev 5b964392b9 Join on click and speaking on server channel list 2026-08-28 02:51:38 +02:00
selimaj-dev de9f3ad2fb VC user list 2026-08-28 02:40:07 +02:00
selimaj-dev 58609bd7ec Refactored status card 2026-08-28 02:25:06 +02:00
selimaj-dev 3a83226fd6 Improved resampler 2026-08-28 01:35:35 +02:00
selimaj-dev cbe0019d7c Device switching 2026-08-28 01:32:59 +02:00
selimaj-dev de236a247f Merge pull request #6 from recurse-chat/voice-chat
Voice chat
2026-08-27 14:46:31 -04:00
selimaj-dev 7436e62a49 Self healing 2026-08-27 20:38:35 +02:00
selimaj-dev 6fb889840e Proper vc leaving 2026-08-27 20:32:16 +02:00
selimaj-dev ee71a99f5d Voice chat UI 2026-08-27 17:53:40 +02:00
selimaj-dev d7f9b32a99 VAD 2026-08-27 17:18:00 +02:00
selimaj-dev 23e6a2199f Idk 2026-08-27 14:42:35 +02:00
selimaj-dev f28c9c0bdf Working good quality audio 2026-08-27 01:03:14 +02:00
selimaj-dev d450e5e66e Updated packet cusion 2026-08-27 01:00:12 +02:00
selimaj-dev 570fae9cfd Fixing choppy audio (again) 2026-08-27 00:55:09 +02:00
selimaj-dev a674b4c53a idk 2026-08-27 00:47:28 +02:00
selimaj-dev 2935d53ea8 More performance stuff 2026-08-27 00:23:40 +02:00
selimaj-dev 52e9583cfd Jitter buffer 2026-08-27 00:10:04 +02:00
selimaj-dev 1bbe820853 Using ring buffer 2026-08-26 23:51:56 +02:00
selimaj-dev 521e2d9bdc Simplified by removing a lot of bs 2026-08-26 23:03:04 +02:00
selimaj-dev b88e17109a Better audio stuff 2026-08-26 20:17:02 +02:00
selimaj-dev 16223c31b1 Optimisations 2026-08-26 20:08:48 +02:00
selimaj-dev 198eb10bcf Working playback 2026-08-26 19:59:45 +02:00
selimaj-dev 5e8d5b2877 Working audio (not really) 2026-08-26 19:49:08 +02:00
selimaj-dev 7058517e8c More resampling 2026-08-26 18:44:37 +02:00
selimaj-dev 77add3dea8 Testing resampling 2026-08-26 18:29:50 +02:00
selimaj-dev a7ae604190 PCM resampling 2026-08-26 17:50:42 +02:00
selimaj-dev c91a91643e logging 2026-08-26 14:56:11 +02:00
selimaj-dev f6cfea1621 Testing voice chat UDP 2026-08-26 14:31:26 +02:00
selimaj-dev d95ed3215e Connect and disconnect to vc 2026-08-26 12:21:12 +02:00
selimaj-dev 0c6f812143 Voice chat pre UI 2026-08-26 01:45:22 +02:00
selimaj-dev a702834034 Audio config 2026-08-26 01:33:42 +02:00
selimaj-dev 3099c91558 Config 2026-08-26 01:04:08 +02:00
selimaj-dev 4a28cd25df Settings UI 2026-08-26 00:09:39 +02:00
selimaj-dev 29baa1f486 Testing audio 2026-08-25 23:43:52 +02:00
selimaj-dev 1ac43684bf Added cpal 2026-08-25 20:55:24 +02:00
selimaj-dev 760c42d5e0 Merge pull request #5 from recurse-chat/finalized-messages-mvp
Finalized messages mvp
2026-08-22 07:34:03 -04:00
selimaj-dev bb8f80457a Disable scrollbar 2026-08-22 13:15:44 +02:00
selimaj-dev 5245e3ba71 components 2026-08-22 13:07:39 +02:00
selimaj-dev f7b7b5b637 Msg edit 2026-08-22 13:03:25 +02:00
selimaj-dev e5ffc51189 Msg delete 2026-08-22 12:54:38 +02:00
selimaj-dev 0dc30233e9 Msg delete and edit protocol and events 2026-08-22 12:39:23 +02:00
selimaj-dev b4f9210486 Author 2026-08-22 12:19:06 +02:00
selimaj-dev 546670b9d2 Get users 2026-08-22 12:14:11 +02:00
selimaj-dev 78f5544952 Improved serverlist 2026-08-22 11:14:51 +02:00
selimaj-dev a9cf2d4c70 Fix current account 2026-08-22 11:06:10 +02:00
selimaj-dev 21261098dc Message verification 2026-08-22 10:58:34 +02:00
selimaj-dev 8b80ecb8a8 Auto scroll 2026-08-22 10:35:34 +02:00
selimaj-dev ff36a84a23 idk[ 2026-08-21 15:06:20 +02:00
selimaj-dev dc7f4d2f61 Current user author 2026-08-21 13:14:52 +02:00
selimaj-dev 1d4c65ca7e Chunksc 2026-08-21 12:49:41 +02:00
selimaj-dev 6a191c802c Merge pull request #4 from recurse-chat/working-messages
Working messages
2026-08-20 13:20:57 -04:00
selimaj-dev 0aa1b8285b fix 2026-08-20 19:20:24 +02:00
selimaj-dev 7934f20985 Messages 2026-08-20 18:21:28 +02:00
selimaj-dev 83c7926728 Text Channel UI 2026-08-20 18:12:47 +02:00
selimaj-dev 8cf69d5b02 Messages 2026-08-20 15:10:25 +02:00
selimaj-dev cce9756a1b Working send and updated account structure 2026-08-20 11:24:59 +02:00
selimaj-dev 9c448049bc Meta and refactor 2026-08-20 11:06:35 +02:00
selimaj-dev 8bdae4ceb8 Message sending 2026-08-20 10:53:52 +02:00
selimaj-dev fc1c550a2e Fix recursion 2026-08-20 10:24:13 +02:00
selimaj-dev 32271289f3 Channel selector 2026-08-20 10:17:42 +02:00
selimaj-dev c319266c51 Channel view 2026-08-20 10:04:19 +02:00
selimaj-dev 755ae24cde Merge pull request #3 from recurse-chat/fix-bugs
Fix bugs
2026-08-19 14:48:37 -04:00
107 changed files with 5433 additions and 351 deletions
+94
View File
@@ -0,0 +1,94 @@
name: Tauri Release Build
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to upload to (e.g. 0.1.0)"
required: false
type: string
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: dtolnay/rust-toolchain@stable
- name: Install dependencies (Linux only)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt update
sudo apt install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libpipewire-0.3-dev \
libasound2-dev
- run: npm install
- run: npm run tauri build
# --- macOS fix ---
- name: Prepare macOS app
if: matrix.os == 'macos-latest'
run: |
APP_PATH=$(find src-tauri/target/release/bundle/macos -name "*.app")
APP_NAME=$(basename "$APP_PATH" .app)
xattr -cr "$APP_PATH"
codesign --deep --force --sign - "$APP_PATH"
ditto -c -k --keepParent "$APP_PATH" "$APP_NAME-macos.zip"
# --- Get release Tag ---
- name: Get release tag
id: get_tag
shell: bash
run: |
TAG="${{ inputs.tag }}"
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -z "$TAG" ]; then
TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
# --- Upload to release ---
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_tag.outputs.tag }}
files: |
src-tauri/target/release/bundle/**/*.AppImage
src-tauri/target/release/bundle/**/*.msi
src-tauri/target/release/bundle/**/*.exe
src-tauri/target/release/bundle/**/*.deb
src-tauri/target/release/bundle/**/*.rpm
*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+131 -4
View File
@@ -1,7 +1,134 @@
# Tauri + React + Typescript # Enclave
This template should help get you started developing with Tauri, React and Typescript in Vite. Privacy-focused messenger and voice client built by [ORUS](https://github.com/orus-dev).
## Recommended IDE Setup Enclave is a desktop app for private, self-hosted community chat and voice. It
connects to servers that you or your community control — there is no central
company operating the network, and no third party that can reset your identity
or read your messages. Your identity is a cryptographic keypair generated
locally, and everything you send is signed so it can be verified to genuinely
come from you.
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) Select a server from the left rail and join a channel to start chatting.
## About this project
Enclave is one of several privacy-focused, self-hosted applications built by
ORUS using encrypted, decentralized architecture.
- **Self-hosted servers** — Enclave connects to servers you or your community
control. There is no central company operating the network; your messages
live where you choose. Servers are [trust-on-first-use pinned](#keypins) so
their identity can't quietly change.
- **Keypair identity** — Your identity is an [Ed25519](https://github.com/paulmillr/noble-ed25519)
keypair generated locally on first launch. There is no email or password, so
no third party can reset your account or impersonate you.
- **Signed, verifiable messages** — Every message carries an Ed25519 signature
over its content and timestamp. Anything you receive can be independently
verified to come from its author.
- **Encrypted realtime transport** — Client and server exchange a shared
secret over an encrypted WebSocket, and voice traffic is encrypted in transit.
## How Enclave works
Enclave is a [Tauri 2](https://v2.tauri.app/) desktop application with a React
front end and a Rust backend.
| Layer | Tech |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| UI | React 19, TypeScript, Tailwind CSS 4, shadcn/ui, Base UI |
| App shell | Tauri 2 |
| Cryptography | [noble](https://github.com/paulmillr/noble-ed25519) (Ed25519, hashes, ciphers), [@scure/base](https://github.com/paulmillr/scure-base) |
| Realtime | WebSocket via `EnclaveServer` / `EnclaveWebSocket` |
| Audio & voice | Rust with `cpal`, `ringbuf`, `rubato`, `chacha20poly1305` |
### Architecture
The front end is a thin display of state owned by the `Enclave` class
(`src/app/app.ts`). It owns every active connection — one `EnclaveServer` per
connected server, keyed by server id — and is where all UI-facing logic lives
(connecting to a server, sending a message, joining voice). The UI never talks
directly to a server's WebSocket; it only reads and mutates what `Enclave`
exposes.
- `src/app/` — top-level application state, server connection logic, and the
protocol between client and server.
- `src/lib/` — persistence (`accounts`, `serverList`, `config`) and shared
types.
- `src/components/` — UI: server list, sidebar, channels, settings, and dialogs.
- `src-tauri/` — the Rust backend: window setup, storage, and audio/voice
handling.
### A privacy-focused protocol
Enclave's protocol (`src/app/protocol.ts`) is built around public-key
cryptography rather than accounts:
- **Local keypairs** — On first launch you generate an Ed25519 keypair. Your
public key is your handle; nothing is stored with a company, and there is no
password to leak or reset.
- **Trust on first use** — When you connect to a server, its public key is
pinned. If that server later presents a different key, Enclave refuses to
connect, so you always know you're talking to the server you chose.
- **Signed messages** — Message contents are signed with your key before being
sent (`src/app/app.ts`), and the server stores and relays the signatures so
recipients can verify origin and integrity.
- **End-to-end confidentiality** — Traffic flows over encrypted channels and,
when TLS is enabled on the server, an `https://` transport, so data in
transit stays protected.
## Privacy & security
- **Own your data** — your identity is a keypair you hold. No central service
stores your messages or your credentials.
- **Verify before you trust** — signatures and keypins mean you can confirm
every message is authentic and every server is the one you picked.
- **Minimal attack surface** — built on Tauri's small native shell and
dependency-light, audited noble cryptography primitives.
- **Self-hosting** — running your own server means you decide where data is
stored and who can access it.
Your private key never leaves your device. Guard it like you would any secret —
back it up and keep it safe, and it is the only thing that proves who you are.
## Contributing
### Prerequisites
- [Node.js](https://nodejs.org/) + npm
- [Rust](https://www.rust-lang.org/) toolchain
- Tauri's platform prerequisites (see the
[Tauri docs](https://v2.tauri.app/start/prerequisites/))
### Development
```bash
npm install
npm run tauri dev
```
### Build
```bash
npm run tauri build
```
### Scripts
| Command | Description |
| --------------------- | ---------------------------------- |
| `npm run dev` | Run the Vite dev server |
| `npm run typecheck` | Type-check the TypeScript codebase |
| `npm run build` | Type-check and build the front end |
| `npm run tauri dev` | Run the desktop app in development |
| `npm run tauri build` | Build a release bundle |
## Repo layout
```
src/
app/ # Enclave state, server logic, client/server protocol
components/ # UI: server list, sidebar, channels, settings
lib/ # persistence helpers and shared types
src-tauri/ # Rust backend (window, storage, audio/voice)
```
+32 -3
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/ed25519": "^3.1.0", "@noble/ed25519": "^3.1.0",
"@noble/hashes": "^2.3.0", "@noble/hashes": "^2.3.0",
"@scure/base": "^2.3.0", "@scure/base": "^2.3.0",
@@ -1306,6 +1308,33 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/ed25519": { "node_modules/@noble/ed25519": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
@@ -1316,9 +1345,9 @@
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
+4 -1
View File
@@ -1,10 +1,11 @@
{ {
"name": "enclave", "name": "enclave",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0-beta.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
@@ -12,6 +13,8 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/ed25519": "^3.1.0", "@noble/ed25519": "^3.1.0",
"@noble/hashes": "^2.3.0", "@noble/hashes": "^2.3.0",
"@scure/base": "^2.3.0", "@scure/base": "^2.3.0",
+1148 -41
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "enclave" name = "enclave"
version = "0.1.0" version = "0.1.0-beta.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -22,4 +22,11 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
cpal = { version = "0.18.2", features = ["pipewire", "pulseaudio"] }
ringbuf = "0.5.1"
rubato = "5.0.0"
chacha20poly1305 = "0.11.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "charset", "http2"] }
tokio = { version = "1", features = ["time", "macros"] }
quick-xml = "0.37"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+3 -2
View File
@@ -2,11 +2,12 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::types::ClientMeta;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Account { pub struct Account {
pub display_name: String, pub meta: ClientMeta,
pub avatar: Option<String>,
pub private_key: String, pub private_key: String,
} }
+790
View File
@@ -0,0 +1,790 @@
use std::{
collections::BTreeMap,
net::UdpSocket,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, Instant},
};
use chacha20poly1305::{aead::KeyInit, ChaCha20Poly1305, Key};
use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait},
FromSample, SampleFormat, SizedSample,
};
use ringbuf::{
storage::Heap,
traits::{Consumer, Observer, Producer, Split},
CachingCons, CachingProd, HeapRb, SharedRb,
};
use tauri::State;
use crate::{
commands::config::{BackendConfig, ConfigState},
crypto::SessionCipher,
};
const TARGET_SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: usize = 960;
const MAX_PACKET_SIZE: usize = 4096;
const INITIAL_PACKET_CUSHION: usize = 3;
const VAD_THRESHOLD: f32 = 0.01;
const VAD_HANGOVER_FRAMES: usize = 10;
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
#[derive(Clone, Default)]
pub struct VoiceState {
pub inner: Arc<VoiceStateInner>,
}
#[derive(Default)]
pub struct VoiceStateInner {
pub session: Mutex<Option<VoiceSession>>,
pub backend_config: Arc<Mutex<BackendConfig>>,
}
pub struct VoiceSession {
pub input_stream: Mutex<Option<cpal::Stream>>,
pub output_stream: Arc<Mutex<Option<cpal::Stream>>>,
pub socket: Arc<UdpSocket>,
pub pin: u64,
pub shutdown: Arc<AtomicBool>,
pub current_input_device: Option<String>,
pub current_output_device: Option<String>,
pub producer_in: AudioProducer,
pub consumer_out: AudioConsumer,
pub backend_config: Arc<Mutex<BackendConfig>>,
}
fn resolve_input_device(device_name: &Option<String>) -> Result<cpal::Device, String> {
let host = cpal::default_host();
match device_name {
Some(name) => host
.input_devices()
.map_err(|e| format!("Failed to enumerate input devices: {e}"))?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Input device not found: {name}")),
None => host
.default_input_device()
.ok_or_else(|| format!("No default input device")),
}
}
fn resolve_output_device(device_name: &Option<String>) -> Result<cpal::Device, String> {
let host = cpal::default_host();
match device_name {
Some(name) => host
.output_devices()
.map_err(|e| format!("Failed to enumerate output devices: {e}"))?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Output device not found: {name}")),
None => host
.default_output_device()
.ok_or_else(|| format!("No default output device")),
}
}
// Simple Linear Resampler for real-time audio conversion
struct LinearResampler {
phase: f64,
}
impl LinearResampler {
fn new() -> Self {
Self { phase: 0.0 }
}
/// Resamples dynamic buffers from `src_rate` to `dst_rate`
fn process(&mut self, input: &[f32], src_rate: u32, dst_rate: u32, output: &mut Vec<f32>) {
if src_rate == dst_rate {
output.extend_from_slice(input);
return;
}
let ratio = src_rate as f64 / dst_rate as f64;
while self.phase < input.len() as f64 {
let idx = self.phase as usize;
let frac = (self.phase - idx as f64) as f32;
let next_idx = (idx + 1).min(input.len() - 1);
let sample = input[idx] * (1.0 - frac) + input[next_idx] * frac;
output.push(sample);
self.phase += ratio;
}
self.phase -= input.len() as f64;
}
}
impl VoiceSession {
// Single code path for (re)initializing the input stream. Used both for the
// initial connect and for changing/recovering the device.
pub fn setup_input(
&mut self,
device_name: Option<String>,
state_inner: Arc<VoiceStateInner>,
) -> Result<(), String> {
let device = resolve_input_device(&device_name)?;
let input_config = device
.default_input_config()
.map_err(|e| format!("Failed to get input config: {e}"))?
.config();
eprintln!(
"[vc] Input device {:?} config: {} Hz, {} ch",
device.description().map(|d| d.name().to_string()),
input_config.sample_rate,
input_config.channels
);
let producer = Arc::clone(&self.producer_in);
let new_stream = build_input_stream(&device, input_config, producer, state_inner)?;
new_stream
.play()
.map_err(|e| format!("Failed to play input stream: {e}"))?;
if let Ok(mut slot) = self.input_stream.lock() {
*slot = Some(new_stream);
}
self.current_input_device = device_name;
Ok(())
}
// Single code path for (re)initializing the output stream. Used both for the
// initial connect and for changing/recovering the device.
pub fn setup_output(
&mut self,
device_name: Option<String>,
state_inner: Arc<VoiceStateInner>,
) -> Result<(), String> {
let device = resolve_output_device(&device_name)?;
let output_config = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?
.config();
eprintln!(
"[vc] Output device {:?} config: {} Hz, {} ch",
device.description().map(|d| d.name().to_string()),
output_config.sample_rate,
output_config.channels
);
let consumer = Arc::clone(&self.consumer_out);
let new_stream = build_output_stream(&device, output_config, consumer, state_inner)?;
new_stream
.play()
.map_err(|e| format!("Failed to play output stream: {e}"))?;
if let Ok(mut slot) = self.output_stream.lock() {
*slot = Some(new_stream);
}
self.current_output_device = device_name;
Ok(())
}
}
// Helper to build normalized Input Stream (Resampled & Downmixed to 48kHz Mono)
fn build_input_stream(
device: &cpal::Device,
config: cpal::StreamConfig,
producer: AudioProducer,
state_inner: Arc<VoiceStateInner>,
) -> Result<cpal::Stream, String> {
let sample_format = device
.default_input_config()
.map_err(|e| format!("Failed to get input config: {e}"))?
.sample_format();
match sample_format {
SampleFormat::F32 => build_input_stream_with::<f32>(device, config, producer, state_inner),
SampleFormat::I16 => build_input_stream_with::<i16>(device, config, producer, state_inner),
SampleFormat::I32 => build_input_stream_with::<i32>(device, config, producer, state_inner),
SampleFormat::I64 => build_input_stream_with::<i64>(device, config, producer, state_inner),
SampleFormat::U8 => build_input_stream_with::<u8>(device, config, producer, state_inner),
SampleFormat::U16 => build_input_stream_with::<u16>(device, config, producer, state_inner),
SampleFormat::U32 => build_input_stream_with::<u32>(device, config, producer, state_inner),
SampleFormat::U64 => build_input_stream_with::<u64>(device, config, producer, state_inner),
format => Err(format!("Unsupported input sample format: {format}")),
}
}
fn build_input_stream_with<T>(
device: &cpal::Device,
config: cpal::StreamConfig,
producer: AudioProducer,
state_inner: Arc<VoiceStateInner>,
) -> Result<cpal::Stream, String>
where
T: SizedSample + Send + 'static,
f32: FromSample<T>,
{
let native_sample_rate = config.sample_rate;
let channels = config.channels as usize;
let mut resampler = LinearResampler::new();
let mut mono_buffer = Vec::with_capacity(2048);
let mut resampled_buffer = Vec::with_capacity(2048);
let inner_input_err = Arc::clone(&state_inner);
device
.build_input_stream(
config,
move |data: &[T], _| {
mono_buffer.clear();
resampled_buffer.clear();
// Downmix channels to mono, converting to f32
for chunk in data.chunks_exact(channels) {
let sum: f32 = chunk.iter().map(|&s| s.to_sample::<f32>()).sum();
mono_buffer.push(sum / channels as f32);
}
// Resample to 48kHz standard target
resampler.process(
&mono_buffer,
native_sample_rate,
TARGET_SAMPLE_RATE,
&mut resampled_buffer,
);
if let Ok(mut prod) = producer.lock() {
let _ = prod.push_slice(&resampled_buffer);
}
},
move |err| {
eprintln!("[vc] Input error: {err}. Attempting recovery...");
let rec = Arc::clone(&inner_input_err);
thread::spawn(move || {
// Give the errored stream a moment to unwind before rebuilding.
thread::sleep(Duration::from_millis(10));
let Ok(mut lock) = rec.session.lock() else {
return;
};
if let Some(session) = lock.as_mut() {
if session.shutdown.load(Ordering::SeqCst) {
return;
}
let target_device = session.current_input_device.clone();
let _ = session.setup_input(target_device, Arc::clone(&rec));
}
});
},
None,
)
.map_err(|e| e.to_string())
}
// Helper to build normalized Output Stream (48kHz Mono -> Device Native Channels & Rate)
fn build_output_stream(
device: &cpal::Device,
config: cpal::StreamConfig,
consumer: AudioConsumer,
state_inner: Arc<VoiceStateInner>,
) -> Result<cpal::Stream, String> {
let sample_format = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?
.sample_format();
match sample_format {
SampleFormat::F32 => build_output_stream_with::<f32>(device, config, consumer, state_inner),
SampleFormat::I16 => build_output_stream_with::<i16>(device, config, consumer, state_inner),
SampleFormat::I32 => build_output_stream_with::<i32>(device, config, consumer, state_inner),
SampleFormat::I64 => build_output_stream_with::<i64>(device, config, consumer, state_inner),
SampleFormat::U8 => build_output_stream_with::<u8>(device, config, consumer, state_inner),
SampleFormat::U16 => build_output_stream_with::<u16>(device, config, consumer, state_inner),
SampleFormat::U32 => build_output_stream_with::<u32>(device, config, consumer, state_inner),
SampleFormat::U64 => build_output_stream_with::<u64>(device, config, consumer, state_inner),
format => Err(format!("Unsupported output sample format: {format}")),
}
}
fn build_output_stream_with<T>(
device: &cpal::Device,
config: cpal::StreamConfig,
consumer: AudioConsumer,
state_inner: Arc<VoiceStateInner>,
) -> Result<cpal::Stream, String>
where
T: SizedSample + Send + 'static,
T: FromSample<f32>,
{
let native_sample_rate = config.sample_rate;
let channels = config.channels as usize;
let mut resampler = LinearResampler::new();
let mut raw_mono_samples = Vec::with_capacity(2048);
let mut resampled_mono = Vec::with_capacity(2048);
let mut last_sample = 0.0f32;
let inner_output_err = Arc::clone(&state_inner);
device
.build_output_stream(
config,
move |data: &mut [T], _| {
let required_mono_samples = (data.len() / channels) * TARGET_SAMPLE_RATE as usize
/ native_sample_rate as usize;
raw_mono_samples.clear();
resampled_mono.clear();
if let Ok(mut cons) = consumer.lock() {
for _ in 0..required_mono_samples {
if let Some(s) = cons.try_pop() {
last_sample = s;
raw_mono_samples.push(s);
} else {
// Exponential decay to prevent clicking when underflowing
last_sample *= 0.92;
raw_mono_samples.push(last_sample);
}
}
}
// Resample from 48kHz mono to target native output rate
resampler.process(
&raw_mono_samples,
TARGET_SAMPLE_RATE,
native_sample_rate,
&mut resampled_mono,
);
// Interleave mono into hardware channels
let mut res_idx = 0;
let mut out_idx = 0;
while out_idx < data.len() && res_idx < resampled_mono.len() {
let mono_val = resampled_mono[res_idx];
for ch in 0..channels {
if out_idx + ch < data.len() {
data[out_idx + ch] = T::from_sample(mono_val);
}
}
out_idx += channels;
res_idx += 1;
}
},
move |err| {
eprintln!("[vc] Output error: {err}. Attempting recovery...");
let rec = Arc::clone(&inner_output_err);
thread::spawn(move || {
thread::sleep(Duration::from_millis(10));
let Ok(mut lock) = rec.session.lock() else {
return;
};
if let Some(session) = lock.as_mut() {
if session.shutdown.load(Ordering::SeqCst) {
return;
}
let target_device = session.current_output_device.clone();
let _ = session.setup_output(target_device, Arc::clone(&rec));
}
});
},
None,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_input_devices() -> Result<Vec<String>, String> {
let host = cpal::default_host();
Ok(host
.input_devices()
.map_err(|e| e.to_string())?
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
.collect())
}
#[tauri::command]
pub fn list_output_devices() -> Result<Vec<String>, String> {
let host = cpal::default_host();
Ok(host
.output_devices()
.map_err(|e| e.to_string())?
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
.collect())
}
#[tauri::command]
pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> {
let session = {
let mut lock = voice_state
.inner
.session
.lock()
.map_err(|e| e.to_string())?;
lock.take()
};
if let Some(session) = session {
session.shutdown.store(true, Ordering::SeqCst);
if let Ok(input) = session.input_stream.lock() {
if let Some(stream) = input.as_ref() {
let _ = stream.pause();
}
}
if let Ok(output) = session.output_stream.lock() {
if let Some(stream) = output.as_ref() {
let _ = stream.pause();
}
}
eprintln!("[vc] disconnected and paused streams");
}
Ok(())
}
#[tauri::command]
pub fn connect_to_vc(
hostname: String,
pin: u64,
shared_secret: Vec<u8>, // output of js `x25519.getSharedSecret`
config_state: State<'_, ConfigState>,
voice_state: State<'_, VoiceState>,
) -> Result<(), String> {
eprintln!("[vc] connect_to_vc called hostname={hostname} pin={pin}");
if let Err(e) = disconnect_from_vc(voice_state.clone()) {
eprintln!("[vc] disconnect_from_vc failed: {e}");
}
let key = match Key::try_from(shared_secret.as_slice()) {
Ok(k) => k,
Err(v) => {
let msg = v.to_string();
eprintln!("[vc] invalid key length: {msg}");
return Err(msg);
}
};
let cipher = Arc::new(Mutex::new(SessionCipher::new(ChaCha20Poly1305::new(&key))));
let config = config_state.0.lock().unwrap().clone();
let state_inner = Arc::clone(&voice_state.inner);
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(e) => {
let msg = format!("Failed to bind UDP socket: {e}");
eprintln!("[vc] {msg}");
return Err(msg);
}
};
let socket = Arc::new(socket);
if let Err(e) = socket.connect(&hostname) {
let msg = format!("Failed to connect UDP socket: {e}");
eprintln!("[vc] {msg}");
return Err(msg);
}
if let Err(e) = socket.set_read_timeout(Some(Duration::from_millis(5))) {
let msg = e.to_string();
eprintln!("[vc] set_read_timeout failed: {msg}");
return Err(msg);
}
if let Err(e) = socket.send(&pin.to_be_bytes()) {
let msg = format!("Failed to send pin: {e}");
eprintln!("[vc] {msg}");
return Err(msg);
}
let shutdown = Arc::new(AtomicBool::new(false));
// Ring Buffers (device-agnostic; shared between initial setup and any rebuild)
let rb_out = HeapRb::<f32>::new(19200);
let (mut producer_out, consumer_out) = rb_out.split();
let shared_consumer_out = Arc::new(Mutex::new(consumer_out));
let rb_in = HeapRb::<f32>::new(19200);
let (producer_in, mut consumer_in) = rb_in.split();
let shared_producer_in = Arc::new(Mutex::new(producer_in));
// Build the session with empty stream slots, then (re)initialize the actual
// device streams through the same setup path used by device changes.
let mut session = VoiceSession {
input_stream: Mutex::new(None),
output_stream: Arc::new(Mutex::new(None)),
socket: socket.clone(),
pin,
shutdown: shutdown.clone(),
current_input_device: config.input_device_name.clone(),
current_output_device: config.output_device_name.clone(),
producer_in: Arc::clone(&shared_producer_in),
consumer_out: Arc::clone(&shared_consumer_out),
backend_config: Arc::clone(&voice_state.inner.backend_config),
};
session.setup_input(config.input_device_name.clone(), Arc::clone(&state_inner))?;
session.setup_output(config.output_device_name.clone(), Arc::clone(&state_inner))?;
// Sender Thread
{
let input_socket = socket.clone();
let shutdown = shutdown.clone();
let cipher = cipher.clone();
let backend_config = session.backend_config.clone();
thread::spawn(move || {
let mut sequence = 0u32;
let mut frame_buf = vec![0.0f32; PACKET_SAMPLES];
let mut hangover_counter = 0;
let mut loop_count: u64 = 0;
// Adaptive VAD: track a slow-moving noise floor so quiet mics still trigger.
let mut noise_floor: f32 = 0.0;
while !shutdown.load(Ordering::Relaxed) {
let (is_muted, is_deaf) = {
let cfg = backend_config.lock().unwrap();
(cfg.is_muted, cfg.is_deaf)
};
if is_muted || is_deaf {
// Clear audio recorded while muted to avoid stale transmission on unmute
let available = consumer_in.occupied_len();
if available > 0 {
consumer_in.skip(available);
}
thread::sleep(Duration::from_millis(50));
continue;
}
if consumer_in.occupied_len() >= PACKET_SAMPLES {
let _ = consumer_in.pop_slice(&mut frame_buf);
let sum_squares: f32 = frame_buf.iter().map(|&s| s * s).sum();
let rms = (sum_squares / PACKET_SAMPLES as f32).sqrt();
// Update noise floor (attack fast, release slow).
if noise_floor == 0.0 {
noise_floor = rms;
} else if rms < noise_floor {
noise_floor = noise_floor * 0.9 + rms * 0.1;
} else {
noise_floor = noise_floor * 0.999;
}
let threshold = (noise_floor * 4.0).max(VAD_THRESHOLD);
let is_speaking = if rms >= threshold {
hangover_counter = VAD_HANGOVER_FRAMES;
true
} else if hangover_counter > 0 {
hangover_counter -= 1;
true
} else {
false
};
loop_count += 1;
if is_speaking {
// sequence goes INSIDE the plaintext now, prefixed before the PCM
let mut plaintext = Vec::with_capacity(4 + PACKET_SAMPLES * 2);
plaintext.extend_from_slice(&sequence.to_be_bytes());
for sample in frame_buf.iter() {
let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
plaintext.extend_from_slice(&pcm.to_be_bytes());
}
let Ok(net_packet) = cipher.lock().unwrap().encrypt(&plaintext) else {
eprintln!("[vc] failed to encrypt outgoing packet");
sequence = sequence.wrapping_add(1);
continue;
};
let sent = input_socket.send(&net_packet);
if let Err(e) = sent {
eprintln!("[vc] sender: send failed: {e}");
} else if loop_count % 200 == 0 {
eprintln!(
"[vc] sender: sent packet seq {sequence} ({} bytes)",
net_packet.len()
);
}
sequence = sequence.wrapping_add(1);
}
} else {
thread::sleep(Duration::from_millis(2));
}
}
});
}
// Receiver Thread
{
let socket = socket.clone();
let shutdown = shutdown.clone();
let cipher = cipher.clone();
let backend_config = session.backend_config.clone();
let consumer_out = session.consumer_out.clone();
thread::spawn(move || {
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
let mut expected: Option<u32> = None;
let mut is_prebuffering = true;
let mut last_good_frame = vec![0.0f32; PACKET_SAMPLES];
let mut udp_buffer = [0u8; MAX_PACKET_SIZE];
let mut next_frame_time = Instant::now();
while !shutdown.load(Ordering::Relaxed) {
let is_deaf = {
let cfg = backend_config.lock().unwrap();
cfg.is_deaf
};
if is_deaf {
// Keep draining the UDP socket while deafened/muted so stale
// packets don't accumulate in the OS buffer and get replayed
// when we undeafen/unmute. Decrypting and discarding here
// fully consumes the socket backlog.
if let Ok(len) = socket.recv(&mut udp_buffer) {
let _ = cipher.lock().unwrap().decrypt(&udp_buffer[..len]);
}
// Drop any audio already buffered for playback so that on
// unmute/undeafen we don't replay audio from the past.
if let Ok(mut cons) = consumer_out.lock() {
cons.clear();
}
packets.clear();
is_prebuffering = true;
thread::sleep(Duration::from_millis(1));
continue;
}
if let Ok(len) = socket.recv(&mut udp_buffer) {
match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) {
Ok(plaintext) => {
if plaintext.len() < 4 {
eprintln!("[vc] dropped packet: too short after decrypt");
} else {
let seq = u32::from_be_bytes(plaintext[..4].try_into().unwrap());
let pcm = &plaintext[4..];
let samples: Vec<f32> = pcm
.chunks_exact(2)
.map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
packets.insert(seq, samples);
}
}
Err(_) => {
eprintln!("[vc] dropped packet: decryption failed");
}
}
}
if is_prebuffering {
if packets.len() >= INITIAL_PACKET_CUSHION {
expected = packets.keys().next().copied();
is_prebuffering = false;
next_frame_time = Instant::now();
} else {
thread::sleep(Duration::from_millis(1));
continue;
}
}
if Instant::now() >= next_frame_time {
if let Some(seq) = expected {
if let Some(samples) = packets.remove(&seq) {
if samples.len() == PACKET_SAMPLES {
last_good_frame.copy_from_slice(&samples);
} else {
last_good_frame.clear();
last_good_frame
.extend(samples.iter().take(PACKET_SAMPLES).copied());
if last_good_frame.len() < PACKET_SAMPLES {
last_good_frame.resize(PACKET_SAMPLES, 0.0);
}
}
let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1));
} else if packets.keys().any(|&x| x > seq) {
for sample in last_good_frame.iter_mut() {
*sample *= 0.65;
}
let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1));
} else if packets.is_empty() {
is_prebuffering = true;
}
}
next_frame_time += Duration::from_millis(20);
}
thread::sleep(Duration::from_millis(1));
}
});
}
*state_inner.session.lock().unwrap() = Some(session);
// Workaround for Linux devices that don't start capturing/playing until the
// stream is (re)initialized. Rebuild both directions a moment after connect.
{
let state_for_rebuild = Arc::clone(&state_inner);
thread::spawn(move || {
thread::sleep(Duration::from_millis(1000));
let Ok(mut lock) = state_for_rebuild.session.lock() else {
return;
};
if let Some(session) = lock.as_mut() {
if session.shutdown.load(Ordering::SeqCst) {
return;
}
let out = session.current_output_device.clone();
let _ = session.setup_output(out, Arc::clone(&state_for_rebuild));
let inp = session.current_input_device.clone();
let _ = session.setup_input(inp, Arc::clone(&state_for_rebuild));
eprintln!("[vc] Reinitialized input/output streams after connect");
}
});
}
Ok(())
}
impl Drop for VoiceSession {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Ok(input) = self.input_stream.lock() {
if let Some(stream) = input.as_ref() {
let _ = stream.pause();
}
}
if let Ok(output) = self.output_stream.lock() {
if let Some(stream) = output.as_ref() {
let _ = stream.pause();
}
}
eprintln!("[vc] VoiceSession dropped and audio streams paused.");
}
}
+121
View File
@@ -0,0 +1,121 @@
use cpal::traits::{DeviceTrait, HostTrait};
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
use crate::commands::audio::VoiceState;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub input_device_name: Option<String>,
pub output_device_name: Option<String>,
pub input_volume: u8,
pub output_volume: u8,
}
impl Default for Config {
fn default() -> Self {
let host = cpal::default_host();
Self {
input_device_name: host
.default_input_device()
.and_then(|d| Some(d.description().ok()?.name().to_string())),
output_device_name: host
.default_output_device()
.and_then(|d| Some(d.description().ok()?.name().to_string())),
input_volume: 0,
output_volume: 0,
}
}
}
pub struct ConfigState(pub Mutex<Config>);
fn config_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create app data dir: {e}"))?;
Ok(dir.join("config.json"))
}
#[tauri::command]
pub fn update_config(
state: State<ConfigState>,
voice_state: State<'_, VoiceState>,
mut config: Config,
) -> Result<(), String> {
let mut state_lock = state.0.lock().unwrap();
std::mem::swap(&mut *state_lock, &mut config);
let Some(sesh) = &mut *voice_state.inner.session.lock().unwrap() else {
return Ok(());
};
if state_lock.input_device_name != config.input_device_name {
sesh.setup_input(
state_lock.input_device_name.clone(),
voice_state.inner.clone(),
)?;
}
if state_lock.output_device_name != config.output_device_name {
sesh.setup_output(
state_lock.output_device_name.clone(),
voice_state.inner.clone(),
)?;
}
Ok(())
}
#[tauri::command]
pub fn save_config(app: AppHandle, state: State<ConfigState>) -> Result<(), String> {
let path = config_file_path(&app)?;
let config = state.0.lock().unwrap();
let json = serde_json::to_string_pretty(&*config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
fs::write(&path, json).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn get_config(app: AppHandle, state: State<ConfigState>) -> Result<Config, String> {
let path = config_file_path(&app)?;
let config = if path.exists() {
let json = fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Failed to parse config: {e}"))?
} else {
Config::default()
};
*state.0.lock().unwrap() = config.clone();
Ok(config)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendConfig {
pub is_muted: bool,
pub is_deaf: bool,
}
#[tauri::command]
pub fn update_backend_config(
voice_state: State<'_, VoiceState>,
config: BackendConfig,
) -> Result<(), String> {
*voice_state.inner.backend_config.lock().unwrap() = config;
Ok(())
}
+3
View File
@@ -1,2 +1,5 @@
pub mod accounts; pub mod accounts;
pub mod audio;
pub mod config;
pub mod news;
pub mod server_list; pub mod server_list;
+218
View File
@@ -0,0 +1,218 @@
use quick_xml::events::Event;
use quick_xml::Reader;
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NewsItem {
pub title: String,
pub link: String,
pub source: String,
pub published_at: i64,
}
struct Feed {
url: &'static str,
source: &'static str,
}
const FEEDS: &[Feed] = &[
Feed {
url: "https://feeds.feedburner.com/TheHackersNews",
source: "The Hacker News",
},
Feed {
url: "https://www.cisa.gov/cybersecurity-advisories/all.xml",
source: "CISA",
},
Feed {
url: "https://krebsonsecurity.com/feed/",
source: "Krebs on Security",
},
Feed {
url: "https://www.bleepingcomputer.com/feed/",
source: "BleepingComputer",
},
];
const MAX_ITEMS: usize = 15;
#[derive(Default, Clone)]
struct RssItem {
title: String,
link: String,
pub_date: String,
}
fn field_map() -> &'static [(&'static [u8], &'static str)] {
&[
(b"title", "title"),
(b"link", "link"),
(b"pubDate", "pubDate"),
]
}
fn parse_feed(xml: &str) -> Vec<RssItem> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(false);
let mut items = Vec::new();
let mut current: Option<RssItem> = None;
// Stack of open element names (lowercased) while inside an <item>.
let mut stack: Vec<Vec<u8>> = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = e.local_name().as_ref().to_ascii_lowercase();
if name == b"item" {
current = Some(RssItem::default());
}
stack.push(name);
}
Ok(Event::End(e)) => {
let name = e.local_name().as_ref().to_ascii_lowercase();
if name == b"item" {
if let Some(item) = current.take() {
if !item.title.is_empty() && !item.link.is_empty() {
items.push(item);
}
}
}
stack.pop();
}
Ok(Event::Text(e)) => {
if let Some(item) = current.as_mut() {
if let Ok(text) = std::str::from_utf8(e.as_ref()) {
for (tag, field) in field_map() {
if stack.last().map(|s| s.as_slice()) == Some(*tag) {
match *field {
"title" => item.title.push_str(text),
"link" => item.link.push_str(text),
"pubDate" => item.pub_date.push_str(text),
_ => {}
}
}
}
}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
for item in items.iter_mut() {
item.title = item.title.trim().to_string();
item.link = item.link.trim().to_string();
item.pub_date = item.pub_date.trim().to_string();
}
items
}
/// Best-effort RFC 822 RSS date parsing; returns 0 on any failure.
fn parse_date(value: &str) -> i64 {
if value.is_empty() {
return 0;
}
let value = value.split_once(',').map(|(_, rest)| rest).unwrap_or(value);
let parts: Vec<&str> = value.split_whitespace().collect();
if parts.len() < 5 {
return 0;
}
let day: i64 = parts[0].parse().unwrap_or(0);
let month = match parts[1] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => 0,
};
let year: i64 = parts[2].parse().unwrap_or(0);
let hm: Vec<&str> = parts[3].split(':').collect();
if hm.len() < 3 || year == 0 || month == 0 || day == 0 {
return 0;
}
let hour: i64 = hm[0].parse().unwrap_or(0);
let minute: i64 = hm[1].parse().unwrap_or(0);
let second: i64 = hm[2].parse().unwrap_or(0);
let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let yday = days_before_month[(month - 1) as usize] + day + if leap && month > 2 { 1 } else { 0 };
let days = (year - 1970) * 365 + (year - 1969) / 4 + yday - 1;
days * 86_400 + hour * 3_600 + minute * 60 + second
}
#[tauri::command]
pub async fn get_cyber_news() -> Result<Vec<NewsItem>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("failed to build http client: {e}"))?;
let mut all_items: Vec<NewsItem> = Vec::new();
for feed in FEEDS {
let resp = client
.get(feed.url)
.header("User-Agent", "Mozilla/5.0")
.header(
"Accept",
"application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7",
)
.send()
.await;
let Ok(resp) = resp else { continue };
if !resp.status().is_success() {
continue;
}
let Ok(body) = resp.text().await else {
continue;
};
all_items.extend(
parse_feed(&body)
.into_iter()
.map(|item| NewsItem {
title: item.title,
link: item.link,
source: feed.source.to_string(),
published_at: parse_date(&item.pub_date),
}),
);
}
if all_items.is_empty() {
return Err("No cyber news available right now.".to_string());
}
all_items.sort_by(|a, b| b.published_at.cmp(&a.published_at));
let mut seen = std::collections::HashSet::new();
let mut deduped = Vec::new();
for item in all_items {
if seen.insert(item.link.clone()) {
deduped.push(item);
if deduped.len() >= MAX_ITEMS {
break;
}
}
}
Ok(deduped)
}
+5 -4
View File
@@ -1,18 +1,19 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs}; use std::fs;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::protocol::ServerMeta; use crate::types::ServerMeta;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KnownServer { pub struct KnownServer {
pub meta: ServerMeta, pub hostname: String,
pub public_key: String, pub public_key: String,
pub is_secure: bool, pub is_secure: bool,
pub meta: ServerMeta,
} }
type ServerList = HashMap<String, KnownServer>; type ServerList = Vec<KnownServer>;
fn servers_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> { fn servers_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app let dir = app
+57
View File
@@ -0,0 +1,57 @@
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
pub struct SessionCipher {
cipher: ChaCha20Poly1305,
send_counter: u64,
}
impl SessionCipher {
pub fn new(cipher: ChaCha20Poly1305) -> Self {
Self {
cipher,
send_counter: 0,
}
}
fn next_send_nonce(&mut self) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[..8].copy_from_slice(&self.send_counter.to_be_bytes());
// top bit distinguishes "send" direction from "recv" direction,
// so client-send and server-send counters never collide even if
// both happened to reach the same numeric value
nonce[11] |= 0b1000_0000;
self.send_counter += 1;
nonce
}
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
let nonce_bytes = self.next_send_nonce();
let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?;
let ciphertext = self
.cipher
.encrypt(&nonce, plaintext)
.map_err(|v| v.to_string())?;
// prepend the nonce so the other side can reconstruct it on decrypt
let mut out = nonce_bytes.to_vec();
out.extend(ciphertext);
Ok(out)
}
pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, String> {
if data.len() < 12 {
return Err("message too short to contain a nonce".to_string());
}
let (nonce_bytes, ciphertext) = data.split_at(12);
// Use the nonce that travelled with this packet rather than a locally
// tracked counter. This makes decryption immune to UDP packet loss or
// reordering, because we never desync from the sender's counter.
let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?;
self.cipher
.decrypt(&nonce, ciphertext)
.map_err(|v| v.to_string())
}
}
+17 -1
View File
@@ -1,15 +1,31 @@
use std::sync::Mutex;
use crate::commands::config::ConfigState;
pub mod commands; pub mod commands;
pub mod protocol; pub mod crypto;
pub mod types;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(ConfigState(Mutex::new(commands::config::Config::default())))
.manage(commands::audio::VoiceState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::server_list::save_server_list, commands::server_list::save_server_list,
commands::server_list::get_server_list, commands::server_list::get_server_list,
commands::accounts::save_accounts, commands::accounts::save_accounts,
commands::accounts::get_accounts, commands::accounts::get_accounts,
commands::audio::list_input_devices,
commands::audio::list_output_devices,
commands::audio::connect_to_vc,
commands::audio::disconnect_from_vc,
commands::config::update_config,
commands::config::save_config,
commands::config::get_config,
commands::config::update_backend_config,
commands::news::get_cyber_news,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
-46
View File
@@ -1,46 +0,0 @@
use std::borrow::Cow;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMeta {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerMeta {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum ClientMethod {
Initialized {
public_key: String,
signature: String,
timestamp: u64,
hostname: String,
},
Error {
error: Cow<'static, str>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum ServerMethod {
Initialize {
public_key: String,
signature: String,
timestamp: u64,
hostname: String,
},
Meta(ClientMeta),
Error {
error: String,
},
}
+15
View File
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientMeta {
pub display_name: String,
pub avatar: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerMeta {
pub name: String,
pub description: String,
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "enclave", "productName": "enclave",
"version": "0.1.0", "version": "0.1.0-0",
"identifier": "org.enclave.enclave", "identifier": "org.enclave.enclave",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+14 -4
View File
@@ -7,8 +7,11 @@ import {
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "./components/ui/resizable"; } from "./components/ui/resizable";
import Sidebar from "./components/view/Sidebar";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import Page from "./components/page/PageView";
import { SettingsDialog } from "./components/settings/SettingsDialog";
import { TooltipProvider } from "./components/ui/tooltip";
import Sidebar from "./components/sidebar/Sidebar";
export default function App() { export default function App() {
const appRef = useRef<Enclave | null>(null); const appRef = useRef<Enclave | null>(null);
@@ -29,20 +32,24 @@ export default function App() {
return ( return (
<ThemeProvider attribute="class" defaultTheme="system"> <ThemeProvider attribute="class" defaultTheme="system">
<TooltipProvider>
<main className="size-screen flex select-none cursor-default"> <main className="size-screen flex select-none cursor-default">
{appRef.current?.accounts && {appRef.current?.accounts &&
appRef.current.accounts.accounts.length === 0 ? ( appRef.current.accounts.accounts.length === 0 ? (
<NewProfilePage appRef={appRef} /> <NewProfilePage appRef={appRef} />
) : ( ) : (
<> <>
<ResizablePanelGroup orientation="horizontal" className="h-screen"> <ResizablePanelGroup
orientation="horizontal"
className="h-screen"
>
<ResizablePanel <ResizablePanel
defaultSize="600px" defaultSize="600px"
maxSize="400px" maxSize="400px"
minSize="4rem" minSize="4rem"
groupResizeBehavior="preserve-pixel-size" groupResizeBehavior="preserve-pixel-size"
> >
<div className="flex"> <div className="h-screen flex overflow-y-hidden">
<ServerList appRef={appRef} /> <ServerList appRef={appRef} />
<Sidebar appRef={appRef} /> <Sidebar appRef={appRef} />
</div> </div>
@@ -50,12 +57,15 @@ export default function App() {
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel defaultSize="100%"> <ResizablePanel defaultSize="100%">
<div className="h-screen"></div> <Page appRef={appRef} />
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
<SettingsDialog appRef={appRef} />
</> </>
)} )}
</main> </main>
</TooltipProvider>
</ThemeProvider> </ThemeProvider>
); );
} }
+271 -13
View File
@@ -6,6 +6,12 @@ import { base58 } from "@scure/base";
import axios from "axios"; import axios from "axios";
import { Account, AccountsFile, getAccounts } from "@/lib/accounts"; import { Account, AccountsFile, getAccounts } from "@/lib/accounts";
import { Page } from "@/components/page/PageView";
import { Channel } from "@/lib/types";
import { invoke } from "@tauri-apps/api/core";
import { ClientMethod } from "./protocol";
import { BackendConfig, Config, getConfig } from "@/lib/config";
import * as sfx from "@/lib/soundEffects";
ed.hashes.sha512 = sha512; ed.hashes.sha512 = sha512;
@@ -22,27 +28,88 @@ ed.hashes.sha512 = sha512;
* should stay a pure display of what `Enclave` exposes — it should not * should stay a pure display of what `Enclave` exposes — it should not
* reach into `EnclaveServer` or `EnclaveWebSocket` directly. * reach into `EnclaveServer` or `EnclaveWebSocket` directly.
*/ */
export default class Enclave { export default class Enclave<P = Page> {
public accounts?: AccountsFile; public accounts?: AccountsFile;
public publicKey?: string;
public server?: EnclaveServer; public server?: EnclaveServer;
public serverList: ServerList; public serverList: ServerList;
public isSettingsOpen: boolean;
public forceRender: () => void; public forceRender: () => void;
public page?: P;
public sidebarPageKind: "server" | "main";
public config?: Config;
public backendConfig: BackendConfig;
public constructor() { public constructor() {
this.serverList = {}; this.serverList = [];
this.forceRender = () => {}; this.forceRender = () => {};
this.isSettingsOpen = false;
this.backendConfig = {
isMuted: false,
isDeaf: false,
};
this.sidebarPageKind = "main";
} }
public async init() { public async init() {
this.serverList = await getServerList(); this.serverList = await getServerList();
this.accounts = await getAccounts(); this.accounts = await getAccounts();
this.config = await getConfig();
} }
public getAccount(): Account | null { public getAccount(): Account | null {
return this.accounts?.accounts[this.accounts.activeAccount] || null; const account = this.accounts?.accounts[this.accounts.activeAccount];
if (account) {
this.publicKey = base58.encode(
ed.getPublicKey(base58.decode(account.privateKey)),
);
}
return account || null;
}
public getClientSecretKey() {
if (!this.accounts) return null;
const account = this.getAccount();
return account ? base58.decode(account.privateKey) : null;
}
public sendMessage(content: string, channelId: string) {
const clientSecretKey = this.getClientSecretKey();
if (!clientSecretKey) {
console.error("Acccount not found");
return;
}
const timestamp = Date.now();
if (!this.server?.serverPublicKey) {
console.error("Server pubkey not initialized");
return;
}
const signature = new TextEncoder().encode(
`${timestamp}@${base58.encode(this.server?.serverPublicKey)}@${content}`,
);
this.server?.websocket?.send({
method: "SendMessage",
channel_id: channelId,
data: {
content,
timestamp,
signature: base58.encode(ed.sign(signature, clientSecretKey)),
},
});
} }
public async connectToServer(hostname: string, isSecure: boolean) { public async connectToServer(hostname: string, isSecure: boolean) {
this.sidebarPageKind = "server";
if (this.server) { if (this.server) {
this.server.disconnect(); this.server.disconnect();
} }
@@ -65,38 +132,229 @@ export default class Enclave {
return; return;
} }
const serveridx = this.serverList.findIndex(
(server) => server.hostname === hostname,
);
if ( if (
this.serverList[hostname] && serveridx >= 0 &&
this.serverList[hostname].publicKey !== this.serverList[serveridx].publicKey !==
base58.encode(this.server.serverPublicKey) base58.encode(this.server.serverPublicKey)
) { ) {
console.error("Server's new public key doesn't match old one", { console.error("Server's new public key doesn't match old one", {
old: this.serverList[hostname].publicKey, old: this.serverList[serveridx].publicKey,
new: base58.encode(this.server.serverPublicKey), new: base58.encode(this.server.serverPublicKey),
}); });
return; return;
} }
const account = this.getAccount();
if (!account) {
console.error("Failed to get account");
return;
}
if (this.server.websocket) {
this.server.websocket.send({ method: "Meta", ...account.meta });
this.server.websocket.websocket.onmessage = async (msg) => {
let buffer: ArrayBuffer;
if (msg.data instanceof Blob) {
buffer = await msg.data.arrayBuffer();
} else {
buffer = msg.data as ArrayBuffer;
}
const encrypted = new Uint8Array(buffer);
const plaintext = this.server?.websocket?.decrypt(encrypted);
const data = JSON.parse(new TextDecoder().decode(plaintext));
this.onMessage(data);
};
}
const metaResponse = await axios.get( const metaResponse = await axios.get(
getHTTPUrl(hostname, isSecure, "/meta"), getHTTPUrl(hostname, isSecure, "/meta"),
); );
this.server.meta = metaResponse.data as any; this.server.meta = metaResponse.data as any;
this.serverList[hostname] = { const serverItem = {
meta: metaResponse.data as any, hostname,
isSecure,
publicKey: base58.encode(this.server.serverPublicKey), publicKey: base58.encode(this.server.serverPublicKey),
isSecure,
meta: metaResponse.data as any,
}; };
if (serveridx >= 0) {
this.serverList[serveridx] = serverItem;
} else {
this.serverList.push(serverItem);
}
this.forceRender(); this.forceRender();
} }
public getClientSecretKey() { public joinVoice(channel: Channel) {
if (!this.accounts) return null; const server = this.server;
const account = this.getAccount(); if (!server || channel.kind !== "voice") return;
return account ? base58.decode(account.privateKey) : null; if (server.voiceChannelId) this.leaveVoice();
server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", {
hostname: server.hostname,
pin,
sharedSecret: server.websocket?.sharedSecret,
})
.then(() => {
server.voiceChannelId = channelId;
this.forceRender();
})
.catch(console.error);
};
server.voiceChannelId = channel.id;
server.websocket?.send({ method: "JoinVoice", channel_id: channel.id });
this.forceRender();
}
public leaveVoice() {
const server = this.server;
if (!server || !server.voiceChannelId) return;
server.websocket?.send({ method: "LeaveVoice" });
invoke("disconnect_from_vc").catch(console.error);
server.voiceChannelId = undefined;
server.voiceChatSpeakers = {};
this.forceRender();
sfx.playLeave();
}
public async onMessage(msg: ClientMethod) {
const server = this.server;
if (!server) {
console.error("Unable to get server for message, MSG:", msg);
return;
}
switch (msg.method) {
case "Initialized":
console.error("Already initialized");
return;
case "Error":
console.error("Server error:", msg.error);
return;
case "Messages":
const currentTimestamp = Date.now();
let shouldPlaySfx = false;
const authors = Object.entries(msg.messages).flatMap(
([channelId, messages]) => {
if (!server.messages[channelId]) {
server.messages[channelId] = {};
}
const page = this.page as Page | undefined;
const isInvisibleChannel =
page?.kind === "channel" && page?.channel.id !== channelId;
return messages.map((message) => {
if (
Math.abs(currentTimestamp - message.timestamp) < 1000 &&
(isInvisibleChannel || document.visibilityState === "hidden") &&
message.author !== this.publicKey
) {
shouldPlaySfx = true;
}
server.messages[channelId][message.id] = message;
return message.author;
});
},
);
const dedupedAuthors = Array.from(new Set(authors));
this.server?.getUsers(dedupedAuthors);
this.forceRender();
if (shouldPlaySfx) sfx.playMessage();
return;
case "Users":
Object.entries(msg.users).forEach(([pubKey, user]) => {
server.users[pubKey] = user;
});
this.forceRender();
return;
case "MessageDeleted":
delete server.messages[msg.channel_id][msg.message_id];
this.forceRender();
return;
case "MessageEdited":
server.messages[msg.channel_id][msg.message.id] = msg.message;
this.forceRender();
return;
case "JoinVoice":
server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id);
return;
case "UserJoinedVoice":
if (!server.voiceChatUsers[msg.channel_id])
server.voiceChatUsers[msg.channel_id] = [];
server.voiceChatUsers[msg.channel_id].push(msg.pubkey);
this.server?.getUsers([msg.pubkey]);
if (msg.channel_id === this.server?.voiceChannelId) {
sfx.playJoin();
}
return;
case "UserLeftVoice":
if (!server.voiceChatUsers[msg.channel_id]) return;
server.voiceChatUsers[msg.channel_id] = server.voiceChatUsers[
msg.channel_id
].filter((v) => v !== msg.pubkey);
this.forceRender();
if (msg.channel_id === this.server?.voiceChannelId) {
sfx.playLeave();
}
return;
case "Speaking":
clearTimeout(server.voiceChatSpeakers[msg.pubkey]);
server.voiceChatSpeakers[msg.pubkey] = setTimeout(() => {
delete server.voiceChatSpeakers[msg.pubkey];
this.forceRender();
}, 800);
this.forceRender();
return;
}
} }
} }
+74 -15
View File
@@ -1,10 +1,4 @@
export interface ClientMeta {} import { ClientMeta, MessageData, StoredMessage } from "@/lib/types";
export interface ServerMeta {
name: string;
description: string;
channels: Channel[];
}
export type ClientMethod = export type ClientMethod =
| { | {
@@ -17,6 +11,43 @@ export type ClientMethod =
| { | {
method: "Error"; method: "Error";
error: string; error: string;
}
| {
method: "Messages";
messages: Record<string, StoredMessage[]>;
}
| {
method: "Users";
users: Record<string, ClientMeta>;
}
| {
method: "MessageEdited";
channel_id: string;
message: StoredMessage;
}
| {
method: "MessageDeleted";
channel_id: string;
message_id: string;
}
| {
method: "JoinVoice";
channel_id: string;
pin: bigint;
}
| {
method: "UserJoinedVoice";
channel_id: string;
pubkey: string;
}
| {
method: "UserLeftVoice";
channel_id: string;
pubkey: string;
}
| {
method: "Speaking";
pubkey: string;
}; };
export type ServerMethod = export type ServerMethod =
@@ -27,15 +58,43 @@ export type ServerMethod =
timestamp: number; timestamp: number;
hostname: string; hostname: string;
} }
| {
method: "Meta";
}
| { | {
method: "Error"; method: "Error";
error: string; error: string;
}
| ({
method: "Meta";
} & ClientMeta)
| {
method: "SendMessage";
channel_id: string;
data: MessageData;
}
| {
method: "GetMessages";
channel_id: string;
chunk: number;
}
| {
method: "GetUsers";
pubkeys: string[];
}
| {
method: "EditMessage";
message_id: string;
channel_id: string;
content: string;
signature: string;
}
| {
method: "DeleteMessage";
channel_id: string;
message_id: string;
}
| {
method: "JoinVoice";
channel_id: string;
}
| {
method: "LeaveVoice";
}; };
export type Channel = { id: string; name: string } & ChannelKind;
export type ChannelKind =
{ kind: "text" } | { kind: "category"; channels: Channel[] };
+25 -1
View File
@@ -3,7 +3,8 @@ import * as ed from "@noble/ed25519";
import EnclaveWebSocket from "./ws"; import EnclaveWebSocket from "./ws";
import { sha512 } from "@noble/hashes/sha2.js"; import { sha512 } from "@noble/hashes/sha2.js";
import { getWSUrl } from "@/lib/serverList"; import { getWSUrl } from "@/lib/serverList";
import { ServerMeta } from "./protocol"; import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
import { ed25519 } from "@noble/curves/ed25519.js";
ed.hashes.sha512 = sha512; ed.hashes.sha512 = sha512;
@@ -30,9 +31,21 @@ export default class EnclaveServer {
public websocket?: EnclaveWebSocket; public websocket?: EnclaveWebSocket;
public meta?: ServerMeta; public meta?: ServerMeta;
public messages: Record<string, Record<string, StoredMessage>>;
public users: Record<string, ClientMeta | null>;
public voiceJoin?: (pin: bigint, channelId: string) => void;
public voiceChatUsers: Record<string, string[]>;
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
public voiceChannelId?: string;
public constructor(hostname: string, isSecure: boolean) { public constructor(hostname: string, isSecure: boolean) {
this.hostname = hostname; this.hostname = hostname;
this.isSecure = isSecure; this.isSecure = isSecure;
this.messages = {};
this.users = {};
this.voiceChatUsers = {};
this.voiceChatSpeakers = {};
} }
public disconnect() { public disconnect() {
@@ -46,6 +59,7 @@ export default class EnclaveServer {
) { ) {
this.websocket = new EnclaveWebSocket( this.websocket = new EnclaveWebSocket(
getWSUrl(this.hostname, this.isSecure), getWSUrl(this.hostname, this.isSecure),
ed25519.utils.toMontgomerySecret(clientSecretKey),
); );
const publicKeyString = base58.encode(clientPublicKey); const publicKeyString = base58.encode(clientPublicKey);
@@ -92,4 +106,14 @@ export default class EnclaveServer {
throw Error("Invalid signature"); throw Error("Invalid signature");
} }
} }
public getUsers(pubkeys: string[]) {
const filteredPubkeys = pubkeys.filter((pubkey) => {
const exists = this.users[pubkey] !== undefined;
if (!exists) this.users[pubkey] = null;
return !exists;
});
this.websocket?.send({ method: "GetUsers", pubkeys: filteredPubkeys });
}
} }
+101 -7
View File
@@ -1,42 +1,136 @@
import { invoke } from "@tauri-apps/api/core";
import { x25519 } from "@noble/curves/ed25519.js";
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
import { ClientMethod, ServerMethod } from "./protocol"; import { ClientMethod, ServerMethod } from "./protocol";
/** /**
* A protocol-aware wrapper around the browser `WebSocket`. * A protocol-aware wrapper around the browser `WebSocket`.
* *
* Exposes typed protocol methods (`ServerMethod` / `ClientMethod`) rather * Handles the x25519 key exchange handshake and ChaCha20-Poly1305
* than raw WebSocket messages. Has no concept of channels, messages, or * encryption/decryption of every message after it. Exposes typed protocol
* app state — `EnclaveServer` builds on top of this to add those. * methods (`ServerMethod` / `ClientMethod`) rather than raw WebSocket
* messages. Has no concept of channels, messages, or app state —
* `EnclaveServer` builds on top of this to add those.
*/ */
export default class EnclaveWebSocket { export default class EnclaveWebSocket {
public websocket: WebSocket; public websocket: WebSocket;
onOpenQueue: Array<() => void>; onOpenQueue: Array<() => void>;
public constructor(hostname: string) { private sendCounter = 0n;
public sharedSecret: Uint8Array | null = null;
private readonly handshakeReady: Promise<void>;
private resolveHandshake!: () => void;
public constructor(
hostname: string,
private readonly myX25519PrivateKey: Uint8Array,
) {
this.onOpenQueue = new Array(); this.onOpenQueue = new Array();
this.handshakeReady = new Promise((resolve) => {
this.resolveHandshake = resolve;
});
this.websocket = new WebSocket(hostname); this.websocket = new WebSocket(hostname);
this.websocket.binaryType = "arraybuffer";
this.websocket.onclose = () => {
invoke("disconnect_from_vc");
};
this.websocket.onopen = () => { this.websocket.onopen = () => {
this.onOpenQueue.forEach((fun) => fun()); this.onOpenQueue.forEach((fun) => fun());
}; };
// First binary message is the server's raw x25519 pubkey — this
// one-time listener handles the handshake, then hands off to
// the normal encrypted read loop.
this.websocket.onmessage = (msg) => this.handleHandshakeMessage(msg);
}
private handleHandshakeMessage(msg: MessageEvent) {
const serverPubkey = new Uint8Array(msg.data as ArrayBuffer);
this.sharedSecret = x25519.getSharedSecret(
this.myX25519PrivateKey,
serverPubkey,
);
// Respond with our own x25519 pubkey, in plaintext — this is the
// one message on either side that can't be encrypted yet, since
// the shared secret doesn't exist until both pubkeys are known.
const myPublicKey = x25519.getPublicKey(this.myX25519PrivateKey);
this.websocket.send(myPublicKey);
// From here on, every message is encrypted.
this.websocket.onmessage = null;
this.resolveHandshake();
}
private nextSendNonce(): Uint8Array {
const nonce = new Uint8Array(12);
const view = new DataView(nonce.buffer);
view.setBigUint64(0, this.sendCounter, false); // big-endian, first 8 bytes
nonce[11] |= 0b1000_0000; // distinguishes client-send from server-send direction
this.sendCounter += 1n;
return nonce;
}
private encrypt(plaintext: Uint8Array): Uint8Array {
if (!this.sharedSecret) throw new Error("Handshake not complete");
const nonce = this.nextSendNonce();
const cipher = chacha20poly1305(this.sharedSecret, nonce);
const ciphertext = cipher.encrypt(plaintext);
const out = new Uint8Array(nonce.length + ciphertext.length);
out.set(nonce, 0);
out.set(ciphertext, nonce.length);
return out;
}
public decrypt(data: Uint8Array): Uint8Array {
if (!this.sharedSecret) throw new Error("Handshake not complete");
if (data.length < 12)
throw new Error("Message too short to contain a nonce");
const nonce = data.slice(0, 12);
const ciphertext = data.slice(12);
const cipher = chacha20poly1305(this.sharedSecret, nonce);
return cipher.decrypt(ciphertext);
} }
public async send(method: ServerMethod) { public async send(method: ServerMethod) {
await this.handshakeReady;
const send = () => {
const plaintext = new TextEncoder().encode(JSON.stringify(method));
const encrypted = this.encrypt(plaintext);
this.websocket.send(encrypted);
};
if (this.websocket.readyState !== WebSocket.OPEN) { if (this.websocket.readyState !== WebSocket.OPEN) {
return new Promise<void>((ok) => { return new Promise<void>((ok) => {
this.onOpenQueue.push(() => { this.onOpenQueue.push(() => {
this.websocket.send(JSON.stringify(method)); send();
ok(); ok();
}); });
}); });
} }
this.websocket.send(JSON.stringify(method)); send();
} }
public async read(): Promise<ClientMethod> { public async read(): Promise<ClientMethod> {
await this.handshakeReady;
return new Promise((ok) => { return new Promise((ok) => {
this.websocket.onmessage = (msg) => { this.websocket.onmessage = (msg) => {
const data = JSON.parse(msg.data); const encrypted = new Uint8Array(msg.data as ArrayBuffer);
const plaintext = this.decrypt(encrypted);
const data = JSON.parse(new TextDecoder().decode(plaintext));
ok(data); ok(data);
this.websocket.onmessage = null; this.websocket.onmessage = null;
}; };
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21 -5
View File
@@ -12,7 +12,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react"; import { Info, PlusIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
// hostname[:port][/path] — explicitly no protocol/scheme allowed // hostname[:port][/path] — explicitly no protocol/scheme allowed
const HOSTNAME_PATTERN = const HOSTNAME_PATTERN =
@@ -38,7 +39,7 @@ type AddServerDialogProps = {
export function AddServerDialog({ onAdd }: AddServerDialogProps) { export function AddServerDialog({ onAdd }: AddServerDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [hostname, setHostname] = useState(""); const [hostname, setHostname] = useState("");
const [isSecure, setIsSecure] = useState(true); const [isSecure, setIsSecure] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -76,7 +77,7 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
render={() => ( render={() => (
<Button <Button
variant="secondary" variant="secondary"
className="aspect-square w-full h-auto rounded-full" className="aspect-square w-full h-auto rounded-lg"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<PlusIcon /> <PlusIcon />
@@ -106,9 +107,24 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="secure-toggle" className="flex flex-col gap-1"> <Tooltip>
Secure connection <TooltipTrigger>
<Label
htmlFor="secure-toggle"
className="flex items-center gap-1 cursor-help"
>
TLS
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</Label> </Label>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
TLS is not recommended because there is a encryption layer
already built in to Enclave
</p>
</TooltipContent>
</Tooltip>
<Switch <Switch
id="secure-toggle" id="secure-toggle"
checked={isSecure} checked={isSecure}
+255
View File
@@ -0,0 +1,255 @@
import Enclave from "@/app/app";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ArrowUpRight,
BookOpen,
Fingerprint,
KeyRound,
LockKeyhole,
Network,
Server,
ShieldCheck,
} from "lucide-react";
import { openUrl } from "@tauri-apps/plugin-opener";
type Resource = {
title: string;
description: string;
url: string;
icon: React.ElementType;
};
const APP_RESOURCES: Resource[] = [
{
title: "Self-hosted servers",
description:
"Enclave connects to servers you or your community control. There is no central company operating the network — your messages live where you choose.",
url: "https://github.com/tauri-apps/tauri",
icon: Server,
},
{
title: "Keypair identity",
description:
"Your identity is an Ed25519 keypair generated locally. There is no email or password — no third party can reset your account or impersonate you.",
url: "https://github.com/paulmillr/noble-ed25519",
icon: Fingerprint,
},
{
title: "Signed, verifiable messages",
description:
"Every message carries an Ed25519 signature over its content and timestamp. Anything you receive can be verified to genuinely come from its author.",
url: "https://github.com/paulmillr/noble-ed25519",
icon: KeyRound,
},
{
title: "Built by ORUS",
description:
"One of many privacy-focused, self-hosted applications developed by ORUS using encrypted, decentralized architecture.",
url: "https://github.com/orus-dev",
icon: LockKeyhole,
},
];
const SECURITY_RESOURCES: Resource[] = [
{
title: "End-to-end encryption",
description:
"Encrypt data before it leaves your device so only intended recipients can read it. Learn how the model works and where it applies.",
url: "https://ssd.eff.org/module/why-should-i-use-encryption",
icon: ShieldCheck,
},
{
title: "Public-key cryptography",
description:
"Understand how keypairs sign and encrypt, and why your private key should never leave your device or be shared.",
url: "https://ssd.eff.org/module/deep-dive-end-end-encryption-how-do-public-key-encryption-systems-work",
icon: KeyRound,
},
{
title: "Self-hosting & data ownership",
description:
"Running your own services means you decide where data is stored and who can access it. Explore guides to getting started.",
url: "https://www.privacyguides.org/en/self-hosting/",
icon: Server,
},
{
title: "Transport security",
description:
"TLS and WebSocket handshakes protect data in transit. Learn how certificates and secure connections keep traffic safe.",
url: "https://www.cloudflare.com/learning/ssl/why-is-http-not-secure/",
icon: Network,
},
{
title: "Threat modeling",
description:
"Think about who you are protecting against and what you are protecting. Privacy starts with knowing your threat model.",
url: "https://www.privacyguides.org/en/basics/threat-modeling/",
icon: BookOpen,
},
{
title: "Password hygiene & key management",
description:
"Good habits for protecting accounts and credentials — and why your cryptographic keys deserve even more care.",
url: "https://ssd.eff.org/en/module/creating-strong-passwords",
icon: LockKeyhole,
},
{
title: "Tauri security",
description:
"Enclave is built on Tauri, a native desktop shell designed with a minimal attack surface. Review its security guidance.",
url: "https://v2.tauri.app/security/",
icon: ShieldCheck,
},
{
title: "Encryption libraries",
description:
"Enclave uses audited, dependency-light cryptographic primitives from the noble libraries. Read their documentation.",
url: "https://github.com/paulmillr/noble-hashes",
icon: KeyRound,
},
];
const LEARNING_RESOURCES: Resource[] = [
{
title: "Surveillance Self-Defense",
description:
"The Electronic Frontier Foundation's free how-to guides for safer online communication.",
url: "https://ssd.eff.org",
icon: BookOpen,
},
{
title: "Privacy Guides",
description:
"A community hub with practical recommendations for privacy tools and self-hosting.",
url: "https://www.privacyguides.org",
icon: ShieldCheck,
},
{
title: "Let's Encrypt",
description:
"Free, automated TLS certificates so anyone can secure their own server with HTTPS.",
url: "https://letsencrypt.org",
icon: Network,
},
{
title: "The Tor Project",
description:
"Research and tools for anonymous, censorship-resistant communication.",
url: "https://www.torproject.org",
icon: KeyRound,
},
];
function ResourceCard({ resource }: { resource: Resource }) {
const Icon = resource.icon;
return (
<Card className="flex flex-col">
<CardHeader className="flex-1">
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4.5 w-4.5" />
</div>
<CardTitle className="flex items-center gap-1.5">
{resource.title}
</CardTitle>
<CardDescription>{resource.description}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => openUrl(resource.url)}
>
Learn more
<ArrowUpRight data-icon="inline-end" />
</Button>
</CardContent>
</Card>
);
}
function ResourceSection({
title,
subtitle,
resources,
}: {
title: string;
subtitle: string;
resources: Resource[];
}) {
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{resources.map((resource) => (
<ResourceCard key={resource.title} resource={resource} />
))}
</div>
</section>
);
}
export default function MainPage(_props: {
appRef: React.RefObject<Enclave | null>;
}) {
return (
<div className="h-screen w-full overflow-y-auto">
<div className="mx-auto w-full max-w-6xl px-6 py-10">
<header className="mb-10 flex flex-col items-start gap-4">
<h1 className="text-3xl font-semibold leading-tight">
Welcome to Enclave
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Enclave is a privacy-focused messenger and voice client built by{" "}
<a
className="font-medium text-foreground underline"
href="https://github.com/orus-dev"
target="_blank"
>
ORUS
</a>{" "}
using self-hosted servers, local keypair identities, and
cryptographic signatures. Nothing here requires an account with a
central company — you own your identity and your data.
</p>
<p className="max-w-2xl text-sm text-muted-foreground">
Select a server on the left and choose a channel to start chatting.
Below you'll find resources about how Enclave works, plus guides on
protecting your privacy and security.
</p>
</header>
<div className="space-y-10">
<ResourceSection
title="About this app"
subtitle="How Enclave keeps your conversations private."
resources={APP_RESOURCES}
/>
<ResourceSection
title="Privacy & security fundamentals"
subtitle="The concepts and guides behind trustworthy communication."
resources={SECURITY_RESOURCES}
/>
<ResourceSection
title="Learn more"
subtitle="Organizations and projects worth following."
resources={LEARNING_RESOURCES}
/>
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Enclave from "@/app/app";
import ChannelPage from "./channel/ChannelPage";
import { Channel } from "@/lib/types";
import MainPage from "./MainPage";
export type ChannelPageProps = {
kind: "channel";
channel: Channel;
};
export type Page = ChannelPageProps;
export default function Page({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
if (!appRef.current?.page?.kind) return <MainPage appRef={appRef} />;
switch (appRef.current?.page?.kind) {
case "channel":
return (
<ChannelPage
appRef={appRef as React.RefObject<Enclave<ChannelPageProps> | null>}
/>
);
}
}
@@ -0,0 +1,19 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "@/components/page/PageView";
import TextChannel from "./TextChannel";
import VoiceChannel from "./VoiceChannel";
export default function ChannelPage({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
if (!appRef.current?.page?.channel.kind) return null;
switch (appRef.current?.page?.channel.kind) {
case "text":
return <TextChannel appRef={appRef} />;
case "voice":
return <VoiceChannel appRef={appRef} />;
}
}
+302
View File
@@ -0,0 +1,302 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "@/components/page/PageView";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Channel, StoredMessage } from "@/lib/types";
import { base58 } from "@scure/base";
import { useEffect, useRef, useState } from "react";
import * as ed from "@noble/ed25519";
import { CircleXIcon, PencilIcon, Trash2Icon } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Textarea } from "@/components/ui/textarea";
export default function TextChannel({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
const [currentMessage, setCurrentMessage] = useState("");
const channel = appRef.current?.page?.channel;
const intersectionRef = useRef<HTMLDivElement | null>(null);
const chunkRef = useRef(0);
const account = appRef.current?.getAccount();
const secretKey = (account && base58.decode(account.privateKey)) || null;
const userPubKey = secretKey && base58.encode(ed.getPublicKey(secretKey));
useEffect(() => {
if (!intersectionRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
console.log("Element is in view!");
if (!channel) return;
appRef.current?.server?.websocket?.send({
method: "GetMessages",
channel_id: channel.id,
chunk: chunkRef.current,
});
appRef.current?.forceRender();
chunkRef.current += 1;
}
},
{
threshold: 0.5,
},
);
observer.observe(intersectionRef.current);
return () => observer.disconnect();
}, []);
if (!channel) return null;
const messages = appRef.current?.server?.messages[channel.id];
const sendMessage = () => {
appRef.current?.sendMessage(currentMessage, channel.id);
setCurrentMessage("");
};
return (
<div className="flex flex-col h-screen gap-2">
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border">
<h2>{channel.name}</h2>
</header>
<div className="h-full flex flex-col-reverse gap-2.5 px-3 pt-4 overflow-y-scroll scrollbar-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{messages &&
Object.entries(messages)
.sort((a, b) => b[1].timestamp - a[1].timestamp)
.map(([_, message]) => (
<TextMessage
key={message.id}
appRef={appRef}
message={message}
channel={channel}
userPubKey={userPubKey}
secretKey={secretKey}
/>
))}
<div ref={intersectionRef} />
</div>
<div className="pb-6 px-4 flex flex-row gap-3">
<Input
className="h-10"
placeholder={`Message ${channel.name}`}
value={currentMessage}
onChange={(e) => setCurrentMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
/>
{currentMessage.trim() && <Button onClick={sendMessage}>Send</Button>}
</div>
</div>
);
}
export function TextMessage({
appRef,
message,
channel,
userPubKey,
secretKey,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
message: StoredMessage;
channel: Channel;
userPubKey: string | null;
secretKey: Uint8Array | null;
}) {
const time = new Date(message.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const author = appRef.current?.server?.users[message.author];
const [verified, setVerified] = useState(true);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.content);
useEffect(() => {
const serverPubKey =
appRef.current?.server?.serverPublicKey &&
base58.encode(appRef.current.server.serverPublicKey);
if (!serverPubKey) return;
const authorPubKey = base58.decode(message.author);
setVerified(
ed.verify(
base58.decode(message.signature),
new TextEncoder().encode(
`${message.timestamp}@${serverPubKey}@${message.content}`,
),
authorPubKey,
),
);
}, []);
const isOwnMessage = message.author === userPubKey;
function handleDelete() {
appRef.current?.server?.websocket?.send({
method: "DeleteMessage",
message_id: message.id,
channel_id: channel.id,
});
}
function startEditing() {
setDraft(message.content);
setEditing(true);
}
function cancelEditing() {
setDraft(message.content);
setEditing(false);
}
function handleSave() {
const content = draft.trim();
if (
!content ||
content === message.content ||
!secretKey ||
!appRef.current?.server?.serverPublicKey
) {
cancelEditing();
return;
}
const signature = new TextEncoder().encode(
`${message.timestamp}@${base58.encode(appRef.current.server?.serverPublicKey)}@${content}`,
);
appRef.current?.server?.websocket?.send({
method: "EditMessage",
message_id: message.id,
channel_id: channel.id,
signature: base58.encode(ed.sign(signature, secretKey)),
content,
});
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
}
}
const content = (
<div className="rounded-lg flex gap-3 px-3 py-3 hover:bg-muted/40">
<Avatar className="h-10 w-10">
<AvatarImage src={author?.avatar} />
<AvatarFallback>
{author?.displayName.slice(0, 1).toUpperCase() ||
message.author.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium flex items-center gap-1">
{!verified && (
<CircleXIcon className="h-3.5 w-3.5 text-destructive" />
)}
{author?.displayName || message.author.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground">{time}</span>
{message.is_edited && !editing && (
<span className="text-xs text-muted-foreground">(edited)</span>
)}
</div>
{editing ? (
<div className="mt-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="min-h-20 resize-none"
/>
<div className="mt-2 flex items-center gap-2">
<Button size="sm" onClick={handleSave}>
Save
</Button>
<Button size="sm" variant="ghost" onClick={cancelEditing}>
Cancel
</Button>
<span className="text-xs text-muted-foreground">
Enter to save · Shift + Enter for new line · Esc to cancel
</span>
</div>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-sm leading-snug">
{message.content}
</p>
)}
</div>
</div>
);
// Don't allow the context menu while editing
if (!isOwnMessage || editing) {
return content;
}
return (
<ContextMenu>
<ContextMenuTrigger>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={startEditing}>
<PencilIcon className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={handleDelete} variant="destructive">
<Trash2Icon className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,141 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "../PageView";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Phone, Volume2 } from "lucide-react";
export default function VoiceChannel({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
const channel = appRef.current?.page?.channel;
if (!channel) return null;
const server = appRef.current?.server;
const isConnected = server?.voiceChannelId === channel.id;
const speakers =
server?.voiceChatSpeakers && Object.keys(server.voiceChatSpeakers);
const users = server?.voiceChatUsers[channel.id];
const usersLength = users?.length || 0;
// Dynamic grid column layout based on user count
const getGridCols = () => {
if (usersLength <= 3) return "grid-cols-1";
if (usersLength <= 6) return "grid-cols-3";
if (usersLength <= 9) return "grid-cols-3";
return "grid-cols-5";
};
// Avatar sizing scales up for fewer participants
const getAvatarSize = () => {
if (usersLength <= 2) return "h-24 w-24 text-3xl";
if (usersLength <= 4) return "h-20 w-20 text-2xl";
return "h-14 w-14 text-lg";
};
const handleJoinVoice = () => {
appRef.current?.joinVoice(channel);
};
return (
<div className="relative flex flex-col h-screen gap-2 pb-3 overflow-hidden">
{/* Header */}
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center z-10">
<h2>{channel.name}</h2>
<span className="text-xs">
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
</span>
</header>
{/* Main Grid Container */}
<div className="relative flex-1 w-full min-h-0 overflow-hidden rounded-xl">
{/* Active Grid - Fully readable at all times */}
<div
className={cn(
"grid p-4 w-full h-full gap-4 auto-rows-fr bg-background/40 rounded-xl overflow-y-auto transition-opacity duration-300",
getGridCols(),
)}
>
{users?.map((pubkey) => {
const user = server?.users[pubkey];
const isSpeaking = speakers?.includes(pubkey);
const displayName = user?.displayName || "Unknown User";
const fallbackLetter = displayName.charAt(0).toUpperCase();
return (
<div
key={pubkey}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/90 transition-all duration-150 overflow-hidden group h-full",
isSpeaking
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
: "ring-1 ring-border/60 hover:ring-border",
)}
>
{/* Avatar Container */}
<div className="relative flex items-center justify-center">
<Avatar
className={cn(
"transition-all duration-200",
getAvatarSize(),
)}
>
<AvatarImage
src={user?.avatar}
alt={displayName}
className="object-cover"
/>
<AvatarFallback className="bg-muted font-bold text-muted-foreground">
{fallbackLetter}
</AvatarFallback>
</Avatar>
</div>
{/* Name Plate */}
<div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm">
{displayName}
</div>
</div>
);
})}
</div>
{/* Floating Join CTA Overlay */}
{!isConnected && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/35 backdrop-brightness-75 pointer-events-auto transition-all animate-in fade-in duration-200">
<div className="flex flex-col items-center gap-4 p-6 sm:p-8 rounded-2xl text-center max-w-xs sm:max-w-sm mx-4 transform scale-100 transition-all">
<div className="p-3.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/20">
<Volume2 className="h-7 w-7 animate-pulse" />
</div>
<div className="space-y-1">
<h3 className="text-base sm:text-lg font-semibold text-foreground">
Ready to join {channel.name}?
</h3>
<p className="text-xs text-muted-foreground">
{usersLength > 0
? `${usersLength} ${usersLength === 1 ? "participant is" : "participants are"} currently connected.`
: "Channel is currently empty."}
</p>
</div>
<Button
onClick={handleJoinVoice}
size="lg"
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-lg shadow-emerald-600/20 transition-all"
>
<Phone />
Join Voice
</Button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { Palette } from "lucide-react";
export function AppearanceSettings() {
return (
<div className="space-y-5 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Palette /> Appearance
</h2>
<p className="text-sm text-muted-foreground">
Theme and display settings go here.
</p>
</div>
);
}
@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { Volume2, Mic, Speaker } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getConfig, updateConfig, saveConfig, Config } from "@/lib/config";
export function AudioVideoSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [inputDevices, setInputDevices] = useState<string[]>([]);
const [outputDevices, setOutputDevices] = useState<string[]>([]);
useEffect(() => {
getConfig().then(setConfig);
invoke<string[]>("list_input_devices").then(setInputDevices);
invoke<string[]>("list_output_devices").then(setOutputDevices);
}, []);
async function handleChange(patch: Partial<Config>) {
if (!config) return;
const updated = { ...config, ...patch };
setConfig(updated);
await updateConfig(updated);
await saveConfig();
}
if (!config) return null;
return (
<div className="space-y-8 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Volume2 /> Audio & Video
</h2>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Mic className="h-4 w-4" />
Input
</div>
<div className="space-y-2">
<Label>Input device</Label>
<Select
value={config.inputDeviceName ?? undefined}
onValueChange={(value) => handleChange({ inputDeviceName: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a microphone" />
</SelectTrigger>
<SelectContent>
{inputDevices.map((device) => (
<SelectItem key={device} value={device}>
{device}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Input volume</Label>
<span className="text-xs text-muted-foreground">
{config.inputVolume}%
</span>
</div>
<Slider
value={[config.inputVolume]}
onValueChange={(value) =>
handleChange({ inputVolume: value as any })
}
max={100}
step={1}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Speaker className="h-4 w-4" />
Output
</div>
<div className="space-y-2">
<Label>Output device</Label>
<Select
value={config.outputDeviceName ?? undefined}
onValueChange={(value) => handleChange({ outputDeviceName: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a speaker" />
</SelectTrigger>
<SelectContent>
{outputDevices.map((device) => (
<SelectItem key={device} value={device}>
{device}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Output volume</Label>
<span className="text-xs text-muted-foreground">
{config.outputVolume}%
</span>
</div>
<Slider
value={[config.outputVolume]}
onValueChange={(value) =>
handleChange({ outputVolume: value as any })
}
max={100}
step={1}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { User } from "lucide-react";
export function ProfileSettings() {
return (
<div className="space-y-5 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User /> Profile
</h2>
<p className="text-sm text-muted-foreground">
Display name and avatar settings go here.
</p>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { User, Palette, Volume2 } from "lucide-react";
import { ProfileSettings } from "./ProfileSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { AudioVideoSettings } from "./AudioVideoSettings";
import Enclave from "@/app/app";
type SettingsCategory = "profile" | "appearance" | "audio-video";
const CATEGORIES: {
id: SettingsCategory;
label: string;
icon: React.ElementType;
}[] = [
{ id: "profile", label: "Profile", icon: User },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "audio-video", label: "Audio & Video", icon: Volume2 },
];
export function SettingsDialog({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const [category, setCategory] = useState<SettingsCategory>("profile");
return (
<Dialog
open={appRef.current?.isSettingsOpen}
onOpenChange={(open) => {
if (!appRef.current) return;
appRef.current.isSettingsOpen = open;
appRef.current.forceRender();
}}
>
<DialogContent
className="flex h-[85vh] w-[90vw] max-w-5xl gap-0 overflow-hidden p-0 sm:max-w-5xl"
showCloseButton
>
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/30 p-3">
<span className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Settings
</span>
<nav className="flex flex-col gap-0.5">
{CATEGORIES.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setCategory(id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
category === id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</div>
<div className="flex-1 overflow-y-auto p-6">
{category === "profile" && <ProfileSettings />}
{category === "appearance" && <AppearanceSettings />}
{category === "audio-video" && <AudioVideoSettings />}
</div>
</DialogContent>
</Dialog>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useState } from "react";
import Enclave from "@/app/app";
import { getCyberNews, NewsItem } from "@/lib/cyberNews";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Button } from "@/components/ui/button";
import { RefreshCw, Rss } from "lucide-react";
import { cn } from "@/lib/utils";
import StatusCard from "../view/StatusCard";
function formatTime(publishedAt: number): string {
if (!publishedAt) return "";
const diff = Date.now() - publishedAt;
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function StaticFallback() {
const staticLinks = [
{
title: "Surveillance Self-Defense",
source: "EFF",
href: "https://ssd.eff.org",
},
{
title: "Privacy Guides",
source: "Community",
href: "https://www.privacyguides.org",
},
{
title: "The Hacker News",
source: "News",
href: "https://thehackernews.com",
},
{
title: "Krebs on Security",
source: "News",
href: "https://krebsonsecurity.com",
},
{
title: "CISA Alerts",
source: "Gov",
href: "https://www.cisa.gov/news-events/cybersecurity-advisories",
},
];
return (
<div className="flex flex-col gap-2.5 px-3 py-4">
<p className="text-xs text-muted-foreground">
Live news couldn't be loaded right now. Here are security resources you
can open manually:
</p>
{staticLinks.map((link) => (
<button
key={link.href}
className="group w-full rounded-md px-2 py-1.5 text-left hover:bg-accent"
onClick={() => openUrl(link.href)}
>
<span className="block truncate text-sm text-foreground whitespace-normal leading-tight group-hover:text-foreground">
{link.title}
</span>
<span className="text-xs text-muted-foreground">{link.source}</span>
</button>
))}
</div>
);
}
export default function MainPageSidebar({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const [items, setItems] = useState<NewsItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(() => {
setLoading(true);
setError(false);
getCyberNews()
.then((news) => setItems(news))
.catch(() => {
setItems(null);
setError(true);
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
return (
<div className="h-full relative w-full @container">
<div className="flex h-screen w-full flex-col">
<header className="flex items-center justify-between px-3 py-2.5 border-b border-b-border">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Rss className="h-4 w-4 text-primary" />
Security News
</h1>
<Button
variant="ghost"
size="icon-sm"
disabled={loading}
onClick={load}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</header>
<section className="flex-1 overflow-y-scroll scrollbar-none[&::-webkit-scrollbar]:hidden px-2 py-2 mb-28">
{loading && items === null && (
<p className="px-2 py-4 text-sm text-muted-foreground">
Loading news…
</p>
)}
{error && !loading && <StaticFallback />}
{items && (
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<button
key={item.link}
className="group flex w-full flex-col gap-1 rounded-md px-2 py-1.5 text-left hover:bg-accent"
onClick={() => openUrl(item.link)}
>
<span className="text-sm text-foreground leading-tight">
{item.title}
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{item.source}</span>
{item.publishedAt > 0 && (
<>
<span aria-hidden>·</span>
<span className="shrink-0">
{formatTime(item.publishedAt)}
</span>
</>
)}
</span>
</button>
))}
</div>
)}
</section>
</div>
<StatusCard appRef={appRef} />
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import Enclave from "@/app/app";
import { Channel, ChannelKind } from "@/lib/types";
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
import { useState } from "react";
import StatusCard from "../view/StatusCard";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
switch (kind) {
case "category":
return null;
case "text":
return <HashIcon className="p-0.5" />;
case "voice":
return <Volume2Icon className="p-0.5" />;
}
}
export function RenderCategory({
appRef,
channel,
}: {
appRef: React.RefObject<Enclave | null>;
channel: Channel;
}) {
if (channel.kind !== "category") return null;
const [open, setOpen] = useState(true);
return (
<div>
<div
className="w-full px-2 py-1 rounded-md cursor-default text-sm text-foreground/70 flex items-center"
onClick={() => setOpen((v) => !v)}
>
{channel.name}
{open ? (
<ChevronDown className="p-1 shrink" />
) : (
<ChevronUp className="p-1 shrink" />
)}
</div>
{open && (
<div className="pt-0.5 w-full flex flex-col gap-1">
<RenderChannels appRef={appRef} channels={channel.channels} />
</div>
)}
</div>
);
}
export function RenderFeatures({
appRef,
channel,
}: {
appRef: React.RefObject<Enclave | null>;
channel: Channel;
}) {
switch (channel.kind) {
case "voice":
const users = appRef.current?.server?.voiceChatUsers[channel.id];
const isCurrentVc = channel.id === appRef.current?.server?.voiceChannelId;
return (
users && (
<div className="flex flex-col gap-2 px-4 pt-2">
{(isCurrentVc ? users : users.slice(0, 15)).map((pubkey) => {
const user = appRef.current?.server?.users[pubkey];
if (!user) return null;
const isSpeaking =
appRef.current?.server?.voiceChatSpeakers[pubkey];
return (
<div key={pubkey} className="flex flex-row items-center gap-2">
<Avatar
className={cn(
"h-7 w-7",
isSpeaking
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
: "ring-1 ring-border/60 hover:ring-border",
)}
>
<AvatarImage src={user.avatar} />
<AvatarFallback>
{user?.displayName.slice(0, 1).toUpperCase() ||
pubkey.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{user.displayName}
</span>
</div>
);
})}
{!isCurrentVc && users.length > 15 && (
<span className="text-xs text-muted-foreground pl-1">
and {users.length - 15} others
</span>
)}
</div>
)
);
default:
return null;
}
}
export function RenderChannels({
appRef,
channels,
}: {
appRef: React.RefObject<Enclave | null>;
channels: Channel[];
}) {
const currentChannel =
appRef.current?.page?.kind === "channel"
? appRef.current?.page?.channel
: undefined;
return channels.map((channel) =>
channel.kind === "category" ? (
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
) : (
<div className="flex flex-col" key={channel.id}>
<div
className={cn(
"w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground",
channel.id === currentChannel?.id,
)}
onClick={() => {
if (!appRef.current) {
console.error("AppRef is not initialized yet");
return;
}
appRef.current.page = { kind: "channel", channel };
appRef.current.forceRender();
}}
>
<ChannelIcon kind={channel.kind} />
{channel.name}
</div>
<RenderFeatures appRef={appRef} channel={channel} />
</div>
),
);
}
export default function ServerSidebar({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
return (
<div className="h-full relative w-full @container">
{appRef.current?.server?.meta && (
<div className="h-full">
<header className="px-3 pt-2.5 pb-2.5 text-lg font-semibold border-b border-b-border">
<h1>{appRef.current.server.meta.name}</h1>
</header>
<section className="px-1.5 pt-3.5 w-full h-full flex flex-col gap-1 overflow-y-scroll scrollbar-none [&::-webkit-scrollbar]:hidden mb-28">
<RenderChannels
appRef={appRef}
channels={appRef.current.server.meta.channels}
/>
</section>
</div>
)}
<StatusCard appRef={appRef} />
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Enclave from "@/app/app";
import ServerSidebar from "./ServerSidebar";
import MainPageSidebar from "./MainPageSidebar";
export default function Sidebar({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
switch (appRef.current?.sidebarPageKind) {
case "main":
return <MainPageSidebar appRef={appRef} />;
case "server":
return <ServerSidebar appRef={appRef} />;
}
return <MainPageSidebar appRef={appRef} />;
}
+269
View File
@@ -0,0 +1,269 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+25 -26
View File
@@ -1,24 +1,24 @@
import * as React from "react" import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) { function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: DialogPrimitive.Close.Props) { function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -29,12 +29,12 @@ function DialogOverlay({
<DialogPrimitive.Backdrop <DialogPrimitive.Backdrop
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 backdrop-blur-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -43,7 +43,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: DialogPrimitive.Popup.Props & { }: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -52,7 +52,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
@@ -68,14 +68,13 @@ function DialogContent({
/> />
} }
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Popup> </DialogPrimitive.Popup>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@@ -94,14 +93,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
@@ -112,7 +111,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
@@ -121,11 +120,11 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn(
"font-heading text-base leading-none font-medium", "font-heading text-base leading-none font-medium",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -137,11 +136,11 @@ function DialogDescription({
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -155,4 +154,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+52
View File
@@ -0,0 +1,52 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max]
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
)
}
export { Slider }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-51
View File
@@ -1,51 +0,0 @@
import { Card } from "../ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { CopyIcon, Settings2Icon } from "lucide-react";
import { Button } from "../ui/button";
import Enclave from "@/app/app";
import { base58 } from "@scure/base";
import * as ed from "@noble/ed25519";
export default function AccountCard({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const account = appRef.current?.getAccount();
if (!account) return null;
return (
<div className="absolute -left-16 right-16 bottom-0 h-20 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
<Card className="px-3 py-3 h-full w-full flex flex-row">
<div className="flex gap-2.5 items-center w-full">
<Avatar className="h-full w-auto aspect-square">
<AvatarImage src={account.avatar} />
<AvatarFallback>{account.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col w-full">
<span
className="flex items-center gap-1.5 cursor-pointer w-full"
onClick={() => {
navigator.clipboard.writeText(
base58.encode(
ed.getPublicKey(base58.decode(account.privateKey)),
),
);
}}
>
<p>{account.displayName}</p>
<CopyIcon className="text-muted-foreground size-3" />
</span>
<span className="text-muted-foreground">Online</span>
</div>
</div>
<div className="flex items-center text-muted-foreground">
<Button variant="ghost" className="size-10">
<Settings2Icon className="size-5.5" />
</Button>
</div>
</Card>
</div>
);
}
+7 -7
View File
@@ -68,16 +68,16 @@ export function NewProfilePage({
const { secretKey } = ed.keygen(); const { secretKey } = ed.keygen();
const newAccount = {
displayName: name,
privateKey: base58.encode(secretKey),
avatar: avatar ?? undefined,
};
appRef.current.accounts.activeAccount = appRef.current.accounts.activeAccount =
appRef.current.accounts.accounts.length; appRef.current.accounts.accounts.length;
appRef.current.accounts.accounts.push(newAccount); appRef.current.accounts.accounts.push({
meta: {
displayName: name,
avatar: avatar ?? undefined,
},
privateKey: base58.encode(secretKey),
});
saveAccounts(appRef.current.accounts); saveAccounts(appRef.current.accounts);
} catch (e) { } catch (e) {
+25 -10
View File
@@ -1,6 +1,9 @@
import Enclave from "@/app/app"; import Enclave from "@/app/app";
import { getHTTPUrl, saveServerList } from "@/lib/serverList"; import { getHTTPUrl, saveServerList } from "@/lib/serverList";
import { AddServerDialog } from "../dialog/AddServerDialog"; import { AddServerDialog } from "../dialog/AddServerDialog";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { CommandIcon } from "lucide-react";
export default function ServerList({ export default function ServerList({
appRef, appRef,
@@ -11,16 +14,29 @@ export default function ServerList({
return ( return (
<aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0"> <aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0">
{Object.entries(appRef.current.serverList).map(([hostname, server]) => { <Button
variant="secondary"
className="aspect-square w-full h-auto rounded-full"
onClick={() => {
if (appRef.current) {
appRef.current.sidebarPageKind = "main";
appRef.current.page = undefined;
appRef.current.forceRender();
}
}}
>
<CommandIcon />
</Button>
{appRef.current.serverList.map((server) => {
return ( return (
<img <img
src={getHTTPUrl(hostname, server.isSecure, "/icon")} src={getHTTPUrl(server.hostname, server.isSecure, "/icon")}
key={hostname} key={server.hostname}
className={ className={cn(
hostname === appRef.current?.server?.hostname "rounded-lg",
? "rounded-lg" server.hostname === appRef.current?.server?.hostname &&
: "rounded-full" "border-primary border border-2",
} )}
onClick={async () => { onClick={async () => {
if (!appRef.current) { if (!appRef.current) {
@@ -28,12 +44,11 @@ export default function ServerList({
return; return;
} }
appRef.current.connectToServer(hostname, server.isSecure); appRef.current.connectToServer(server.hostname, server.isSecure);
}} }}
/> />
); );
})} })}
<AddServerDialog <AddServerDialog
onAdd={(hostname, isSecure) => { onAdd={(hostname, isSecure) => {
if (!appRef.current) { if (!appRef.current) {
-78
View File
@@ -1,78 +0,0 @@
import Enclave from "@/app/app";
import { Channel, ChannelKind } from "@/app/protocol";
import { ChevronDown, ChevronUp, HashIcon } from "lucide-react";
import { useState } from "react";
import AccountCard from "./AccountCard";
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
switch (kind) {
case "text":
return <HashIcon className="p-0.5" />;
default:
return null;
}
}
export function RenderCategory({ channel }: { channel: Channel }) {
if (channel.kind !== "category") return null;
const [open, setOpen] = useState(true);
return (
<div>
<div
className="w-full px-2 py-1 rounded-md cursor-default text-sm text-foreground/70 flex items-center"
onClick={() => setOpen((v) => !v)}
>
{channel.name}
{open ? (
<ChevronDown className="p-1 shrink" />
) : (
<ChevronUp className="p-1 shrink" />
)}
</div>
{open && (
<div className="pt-0.5 w-full flex flex-col gap-1">
<RenderChannels channels={channel.channels} />
</div>
)}
</div>
);
}
export function RenderChannels({ channels }: { channels: Channel[] }) {
return channels.map((channel) =>
channel.kind === "category" ? (
<RenderCategory key={channel.id} channel={channel} />
) : (
<div key={channel.id} className="w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground">
<ChannelIcon kind={channel.kind} />
{channel.name}
</div>
),
);
}
export default function Sidebar({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
return (
<div className="h-screen relative w-full @container">
{appRef.current?.server?.meta && (
<>
<header className="px-3 pt-2.5 pb-2.5 text-lg font-semibold border-b border-b-border">
<h1>{appRef.current.server.meta.name}</h1>
</header>
<section className="px-1.5 pt-3.5 w-full flex flex-col gap-1">
<RenderChannels channels={appRef.current.server.meta.channels} />
</section>
</>
)}
<AccountCard appRef={appRef} />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More