Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4befd73f99 | ||
|
|
89f65c6162 | ||
|
|
dc12fb8184 | ||
|
|
365a3c6151 | ||
|
|
8c58cb8687 | ||
|
|
8628047a07 | ||
|
|
78c1f07a35 | ||
|
|
b950bb4816 | ||
|
|
0104c151ce | ||
|
|
15aafeda58 | ||
|
|
11d271c5b7 | ||
|
|
e3230d49d3 | ||
|
|
afcf4725db | ||
|
|
7612156884 | ||
|
|
c352862b46 | ||
|
|
6032d5660c | ||
|
|
7da0f97e60 | ||
|
|
b131d91686 | ||
|
|
d97872f374 | ||
|
|
1728ba0ec4 | ||
|
|
11517b49b7 | ||
|
|
a94d099e65 | ||
|
|
4fc14d0a5a | ||
|
|
6028020854 | ||
|
|
03de380fab | ||
|
|
73520e3432 | ||
|
|
75b018837a | ||
|
|
c4409683e5 | ||
|
|
ff8160dc48 | ||
|
|
9883a5f2a5 | ||
|
|
cc05a78364 | ||
|
|
be6d7aeb87 | ||
|
|
161fb4e0f3 | ||
|
|
42f50880b3 | ||
|
|
5d8936e2e9 | ||
|
|
acf76cd82f | ||
|
|
12fb17c9c3 | ||
|
|
0fd06766e3 | ||
|
|
1735b8c674 | ||
|
|
4f128915d0 | ||
|
|
e280614829 | ||
|
|
d5dc9d82d7 | ||
|
|
cd564bec99 | ||
|
|
07506aa2eb | ||
|
|
a19de564fe | ||
|
|
853f7bc84b | ||
|
|
5b964392b9 | ||
|
|
de9f3ad2fb | ||
|
|
58609bd7ec | ||
|
|
3a83226fd6 | ||
|
|
cbe0019d7c | ||
|
|
de236a247f | ||
|
|
7436e62a49 | ||
|
|
6fb889840e | ||
|
|
ee71a99f5d | ||
|
|
d7f9b32a99 | ||
|
|
23e6a2199f | ||
|
|
f28c9c0bdf | ||
|
|
d450e5e66e | ||
|
|
570fae9cfd | ||
|
|
a674b4c53a | ||
|
|
2935d53ea8 | ||
|
|
52e9583cfd | ||
|
|
1bbe820853 | ||
|
|
521e2d9bdc | ||
|
|
b88e17109a | ||
|
|
16223c31b1 | ||
|
|
198eb10bcf | ||
|
|
5e8d5b2877 | ||
|
|
7058517e8c | ||
|
|
77add3dea8 | ||
|
|
a7ae604190 | ||
|
|
c91a91643e | ||
|
|
f6cfea1621 | ||
|
|
d95ed3215e | ||
|
|
0c6f812143 | ||
|
|
a702834034 | ||
|
|
3099c91558 | ||
|
|
4a28cd25df | ||
|
|
29baa1f486 | ||
|
|
1ac43684bf | ||
|
|
760c42d5e0 | ||
|
|
bb8f80457a | ||
|
|
5245e3ba71 | ||
|
|
f7b7b5b637 | ||
|
|
e5ffc51189 | ||
|
|
0dc30233e9 | ||
|
|
b4f9210486 | ||
|
|
546670b9d2 | ||
|
|
78f5544952 | ||
|
|
a9cf2d4c70 | ||
|
|
21261098dc | ||
|
|
8b80ecb8a8 | ||
|
|
ff36a84a23 | ||
|
|
dc7f4d2f61 | ||
|
|
1d4c65ca7e | ||
|
|
6a191c802c | ||
|
|
0aa1b8285b | ||
|
|
7934f20985 | ||
|
|
83c7926728 | ||
|
|
8cf69d5b02 | ||
|
|
cce9756a1b | ||
|
|
9c448049bc | ||
|
|
8bdae4ceb8 | ||
|
|
fc1c550a2e | ||
|
|
32271289f3 | ||
|
|
c319266c51 | ||
|
|
755ae24cde |
@@ -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 }}
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/ed25519": "^3.1.0",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@scure/base": "^2.3.0",
|
||||
@@ -1306,6 +1308,33 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
||||
@@ -1316,9 +1345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
||||
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "enclave",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
@@ -12,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/ed25519": "^3.1.0",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@scure/base": "^2.3.0",
|
||||
@@ -41,4 +44,4 @@
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "enclave"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-beta.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -22,4 +22,11 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 8.2 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -2,11 +2,12 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::types::ClientMeta;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Account {
|
||||
pub display_name: String,
|
||||
pub avatar: Option<String>,
|
||||
pub meta: ClientMeta,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod accounts;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
pub mod news;
|
||||
pub mod server_list;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, fs};
|
||||
use std::fs;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::protocol::ServerMeta;
|
||||
use crate::types::ServerMeta;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnownServer {
|
||||
pub meta: ServerMeta,
|
||||
pub hostname: String,
|
||||
pub public_key: String,
|
||||
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> {
|
||||
let dir = app
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::commands::config::ConfigState;
|
||||
|
||||
pub mod commands;
|
||||
pub mod protocol;
|
||||
pub mod crypto;
|
||||
pub mod types;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(ConfigState(Mutex::new(commands::config::Config::default())))
|
||||
.manage(commands::audio::VoiceState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::server_list::save_server_list,
|
||||
commands::server_list::get_server_list,
|
||||
commands::accounts::save_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!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "enclave",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0-0",
|
||||
"identifier": "org.enclave.enclave",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -32,4 +32,4 @@
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "./components/ui/resizable";
|
||||
import Sidebar from "./components/view/Sidebar";
|
||||
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() {
|
||||
const appRef = useRef<Enclave | null>(null);
|
||||
@@ -29,33 +32,40 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system">
|
||||
<main className="size-screen flex select-none cursor-default">
|
||||
{appRef.current?.accounts &&
|
||||
appRef.current.accounts.accounts.length === 0 ? (
|
||||
<NewProfilePage appRef={appRef} />
|
||||
) : (
|
||||
<>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-screen">
|
||||
<ResizablePanel
|
||||
defaultSize="600px"
|
||||
maxSize="400px"
|
||||
minSize="4rem"
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
<TooltipProvider>
|
||||
<main className="size-screen flex select-none cursor-default">
|
||||
{appRef.current?.accounts &&
|
||||
appRef.current.accounts.accounts.length === 0 ? (
|
||||
<NewProfilePage appRef={appRef} />
|
||||
) : (
|
||||
<>
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="h-screen"
|
||||
>
|
||||
<div className="flex">
|
||||
<ServerList appRef={appRef} />
|
||||
<Sidebar appRef={appRef} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
defaultSize="600px"
|
||||
maxSize="400px"
|
||||
minSize="4rem"
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
<div className="h-screen flex overflow-y-hidden">
|
||||
<ServerList appRef={appRef} />
|
||||
<Sidebar appRef={appRef} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize="100%">
|
||||
<div className="h-screen"></div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<ResizablePanel defaultSize="100%">
|
||||
<Page appRef={appRef} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<SettingsDialog appRef={appRef} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ import { base58 } from "@scure/base";
|
||||
|
||||
import axios from "axios";
|
||||
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;
|
||||
|
||||
@@ -22,27 +28,88 @@ ed.hashes.sha512 = sha512;
|
||||
* should stay a pure display of what `Enclave` exposes — it should not
|
||||
* reach into `EnclaveServer` or `EnclaveWebSocket` directly.
|
||||
*/
|
||||
export default class Enclave {
|
||||
export default class Enclave<P = Page> {
|
||||
public accounts?: AccountsFile;
|
||||
public publicKey?: string;
|
||||
public server?: EnclaveServer;
|
||||
public serverList: ServerList;
|
||||
public isSettingsOpen: boolean;
|
||||
public forceRender: () => void;
|
||||
public page?: P;
|
||||
public sidebarPageKind: "server" | "main";
|
||||
public config?: Config;
|
||||
public backendConfig: BackendConfig;
|
||||
|
||||
public constructor() {
|
||||
this.serverList = {};
|
||||
this.serverList = [];
|
||||
this.forceRender = () => {};
|
||||
this.isSettingsOpen = false;
|
||||
this.backendConfig = {
|
||||
isMuted: false,
|
||||
isDeaf: false,
|
||||
};
|
||||
this.sidebarPageKind = "main";
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.serverList = await getServerList();
|
||||
this.accounts = await getAccounts();
|
||||
this.config = await getConfig();
|
||||
}
|
||||
|
||||
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) {
|
||||
this.sidebarPageKind = "server";
|
||||
|
||||
if (this.server) {
|
||||
this.server.disconnect();
|
||||
}
|
||||
@@ -65,38 +132,229 @@ export default class Enclave {
|
||||
return;
|
||||
}
|
||||
|
||||
const serveridx = this.serverList.findIndex(
|
||||
(server) => server.hostname === hostname,
|
||||
);
|
||||
|
||||
if (
|
||||
this.serverList[hostname] &&
|
||||
this.serverList[hostname].publicKey !==
|
||||
serveridx >= 0 &&
|
||||
this.serverList[serveridx].publicKey !==
|
||||
base58.encode(this.server.serverPublicKey)
|
||||
) {
|
||||
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),
|
||||
});
|
||||
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(
|
||||
getHTTPUrl(hostname, isSecure, "/meta"),
|
||||
);
|
||||
|
||||
this.server.meta = metaResponse.data as any;
|
||||
|
||||
this.serverList[hostname] = {
|
||||
meta: metaResponse.data as any,
|
||||
isSecure,
|
||||
const serverItem = {
|
||||
hostname,
|
||||
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();
|
||||
}
|
||||
|
||||
public getClientSecretKey() {
|
||||
if (!this.accounts) return null;
|
||||
public joinVoice(channel: Channel) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export interface ClientMeta {}
|
||||
|
||||
export interface ServerMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
channels: Channel[];
|
||||
}
|
||||
import { ClientMeta, MessageData, StoredMessage } from "@/lib/types";
|
||||
|
||||
export type ClientMethod =
|
||||
| {
|
||||
@@ -17,6 +11,43 @@ export type ClientMethod =
|
||||
| {
|
||||
method: "Error";
|
||||
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 =
|
||||
@@ -27,15 +58,43 @@ export type ServerMethod =
|
||||
timestamp: number;
|
||||
hostname: string;
|
||||
}
|
||||
| {
|
||||
method: "Meta";
|
||||
}
|
||||
| {
|
||||
method: "Error";
|
||||
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[] };
|
||||
|
||||
@@ -3,7 +3,8 @@ import * as ed from "@noble/ed25519";
|
||||
import EnclaveWebSocket from "./ws";
|
||||
import { sha512 } from "@noble/hashes/sha2.js";
|
||||
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;
|
||||
|
||||
@@ -30,9 +31,21 @@ export default class EnclaveServer {
|
||||
public websocket?: EnclaveWebSocket;
|
||||
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) {
|
||||
this.hostname = hostname;
|
||||
this.isSecure = isSecure;
|
||||
this.messages = {};
|
||||
this.users = {};
|
||||
this.voiceChatUsers = {};
|
||||
this.voiceChatSpeakers = {};
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
@@ -46,6 +59,7 @@ export default class EnclaveServer {
|
||||
) {
|
||||
this.websocket = new EnclaveWebSocket(
|
||||
getWSUrl(this.hostname, this.isSecure),
|
||||
ed25519.utils.toMontgomerySecret(clientSecretKey),
|
||||
);
|
||||
|
||||
const publicKeyString = base58.encode(clientPublicKey);
|
||||
@@ -92,4 +106,14 @@ export default class EnclaveServer {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* A protocol-aware wrapper around the browser `WebSocket`.
|
||||
*
|
||||
* Exposes typed protocol 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.
|
||||
* Handles the x25519 key exchange handshake and ChaCha20-Poly1305
|
||||
* encryption/decryption of every message after it. Exposes typed protocol
|
||||
* 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 {
|
||||
public websocket: WebSocket;
|
||||
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.handshakeReady = new Promise((resolve) => {
|
||||
this.resolveHandshake = resolve;
|
||||
});
|
||||
|
||||
this.websocket = new WebSocket(hostname);
|
||||
this.websocket.binaryType = "arraybuffer";
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
invoke("disconnect_from_vc");
|
||||
};
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
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) {
|
||||
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) {
|
||||
return new Promise<void>((ok) => {
|
||||
this.onOpenQueue.push(() => {
|
||||
this.websocket.send(JSON.stringify(method));
|
||||
send();
|
||||
ok();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.websocket.send(JSON.stringify(method));
|
||||
send();
|
||||
}
|
||||
|
||||
public async read(): Promise<ClientMethod> {
|
||||
await this.handshakeReady;
|
||||
|
||||
return new Promise((ok) => {
|
||||
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);
|
||||
this.websocket.onmessage = null;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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
|
||||
const HOSTNAME_PATTERN =
|
||||
@@ -38,7 +39,7 @@ type AddServerDialogProps = {
|
||||
export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hostname, setHostname] = useState("");
|
||||
const [isSecure, setIsSecure] = useState(true);
|
||||
const [isSecure, setIsSecure] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -76,7 +77,7 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
||||
render={() => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="aspect-square w-full h-auto rounded-full"
|
||||
className="aspect-square w-full h-auto rounded-lg"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
@@ -106,9 +107,24 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="secure-toggle" className="flex flex-col gap-1">
|
||||
Secure connection
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<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>
|
||||
</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
|
||||
id="secure-toggle"
|
||||
checked={isSecure}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
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) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...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) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -29,12 +29,12 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
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",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -43,7 +43,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -52,7 +52,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -68,14 +68,13 @@ function DialogContent({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -94,14 +93,14 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -112,7 +111,7 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
@@ -121,11 +120,11 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -137,11 +136,11 @@ function DialogDescription({
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -155,4 +154,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -68,16 +68,16 @@ export function NewProfilePage({
|
||||
|
||||
const { secretKey } = ed.keygen();
|
||||
|
||||
const newAccount = {
|
||||
displayName: name,
|
||||
privateKey: base58.encode(secretKey),
|
||||
avatar: avatar ?? undefined,
|
||||
};
|
||||
|
||||
appRef.current.accounts.activeAccount =
|
||||
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);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import Enclave from "@/app/app";
|
||||
import { getHTTPUrl, saveServerList } from "@/lib/serverList";
|
||||
import { AddServerDialog } from "../dialog/AddServerDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { CommandIcon } from "lucide-react";
|
||||
|
||||
export default function ServerList({
|
||||
appRef,
|
||||
@@ -11,16 +14,29 @@ export default function ServerList({
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<img
|
||||
src={getHTTPUrl(hostname, server.isSecure, "/icon")}
|
||||
key={hostname}
|
||||
className={
|
||||
hostname === appRef.current?.server?.hostname
|
||||
? "rounded-lg"
|
||||
: "rounded-full"
|
||||
}
|
||||
src={getHTTPUrl(server.hostname, server.isSecure, "/icon")}
|
||||
key={server.hostname}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
server.hostname === appRef.current?.server?.hostname &&
|
||||
"border-primary border border-2",
|
||||
)}
|
||||
|
||||
onClick={async () => {
|
||||
if (!appRef.current) {
|
||||
@@ -28,12 +44,11 @@ export default function ServerList({
|
||||
return;
|
||||
}
|
||||
|
||||
appRef.current.connectToServer(hostname, server.isSecure);
|
||||
appRef.current.connectToServer(server.hostname, server.isSecure);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<AddServerDialog
|
||||
onAdd={(hostname, isSecure) => {
|
||||
if (!appRef.current) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||