docs: rewrite README with feature list, tech stack, status table; fix Slint padding warnings; update encryption status
This commit is contained in:
parent
49e1f38793
commit
44d284a568
12 changed files with 135 additions and 49 deletions
145
README.md
145
README.md
|
|
@ -1,74 +1,131 @@
|
||||||
# VNOX
|
# VNOX — Client
|
||||||
|
|
||||||
Self-hosted realtime voice and chat. Decentralized. Lightweight. Moddable.
|
> Native desktop client for VNOX. Voice, text, no cloud.
|
||||||
Built on LNEx, a custom protocol for low-latency federated communication.
|
|
||||||
|
|
||||||
Not Discord. Not TeamSpeak. Not cloud.
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](docs/00-status.md)
|
||||||
|
|
||||||
```
|
---
|
||||||
vnox://server/channel
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick links
|
## What is this?
|
||||||
|
|
||||||
- [Architecture](docs/01-architecture.md)
|
The official desktop client for [VNOX](https://github.com/loki5512344/Vnox) — a self-hosted voice and text communication platform. Connects to a VNOX server over the LNEx protocol.
|
||||||
- [Protocol](docs/02-protocol/README.md)
|
|
||||||
- [Current status and limitations](docs/00-status.md)
|
- Connects to gateway via **TCP** (text, auth, channels, guilds, DMs)
|
||||||
- [Server setup](docs/03-server/deployment.md)
|
- Connects to voice node via **UDP** (encrypted Opus voice packets)
|
||||||
- [Local dev config](dev/README.md)
|
- Opus audio encoding/decoding with adaptive jitter buffer
|
||||||
- [Contributing](docs/community/contributing.md)
|
- Native UI built with **Slint** — no Electron, no web views
|
||||||
- [Changelog](CHANGELOG.md)
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- **Auth:** Ed25519 keypair generation, challenge-response login, reconnect with exponential backoff
|
||||||
|
- **Encryption:** ChaCha20-Poly1305 AEAD + X25519 ECDH key exchange
|
||||||
|
- **Text chat:** Persistent history, reactions, replies, edit, delete, typing indicators, read receipts
|
||||||
|
- **Channel management:** Join, leave, create, delete; text and voice channels
|
||||||
|
- **Guilds:** Create, list, leave, member list with roles, kick, audit log viewer
|
||||||
|
- **Roles:** Role list, assign/unassign with permission checks
|
||||||
|
- **Direct Messages:** 1:1 DMs with persistent history, unread badges, search
|
||||||
|
- **Friends:** Requests, accept/decline, Online/All/Pending/Blocked tabs
|
||||||
|
- **Presence:** Online/Idle/DND/Invisible, custom status text, activity display
|
||||||
|
- **Voice:** PTT / VAD / always-on modes, configurable bitrate, per-user volume
|
||||||
|
- **Jitter buffer:** Adaptive mode, configurable target latency
|
||||||
|
- **Noise suppression:** RNNoise (feature-gated, off by default)
|
||||||
|
- **Identity vault:** Optional Argon2id + ChaCha20-Poly1305 encrypted keyfile
|
||||||
|
- **Keyfile export/import:** Encrypted or plain JSON with passphrase
|
||||||
|
- **Bookmarks:** Save/remove server nodes in connect screen
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- In-game overlay (Phase 2)
|
||||||
|
- Federation support (Phase 3)
|
||||||
|
- Mobile client (Phase 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phase 1: implemented, not production ready.
|
Phase 1 is implemented. Not production ready.
|
||||||
|
|
||||||
See [docs/00-status.md](docs/00-status.md) for an honest list of what works,
|
| Feature | Status |
|
||||||
what is only specified on paper, and known gaps.
|
|----------------------|----------------------------|
|
||||||
|
| Connect to server | ✅ Working |
|
||||||
|
| Text chat | ✅ Working |
|
||||||
|
| Channel management | ✅ Working |
|
||||||
|
| Guilds & roles | ✅ Working |
|
||||||
|
| Direct Messages | ✅ Working |
|
||||||
|
| Friends & presence | ✅ Working |
|
||||||
|
| Voice (send/receive) | 🔧 Partial (audio pipeline) |
|
||||||
|
| Encryption | ✅ ChaCha20-Poly1305 + X25519 |
|
||||||
|
| In-game overlay | 🔲 Phase 2 |
|
||||||
|
| Mobile client | 🔲 Phase 3 |
|
||||||
|
|
||||||
| Component | Status |
|
See [docs/00-status.md](docs/00-status.md) for a full breakdown.
|
||||||
|----------------|--------|
|
|
||||||
| Gateway | TCP listener, LNEx handshake, channels, chat, SQLite |
|
|
||||||
| Voice node | UDP relay, voice packet routing |
|
|
||||||
| Desktop client | egui UI, net layer, audio pipeline (partial) |
|
|
||||||
| LNEx protocol | Specified and implemented (JSON in Phase 1) |
|
|
||||||
| Federation | Planned (Phase 3) |
|
|
||||||
| Mobile client | Planned (Phase 3) |
|
|
||||||
|
|
||||||
Traffic in v0.1.x is **unencrypted plaintext**. Do not use in production.
|
---
|
||||||
|
|
||||||
## Running locally
|
## Quick start
|
||||||
|
|
||||||
Requires Rust 1.85+.
|
**Requirements:** Rust 1.85+, a running [VNOX Server](https://github.com/loki5512344/Vnox)
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
# terminal 1 - gateway
|
# Terminal 1 — gateway
|
||||||
cargo run -p vnox-gateway -- --config dev/config.toml
|
cargo run -p vnox-gateway -- --config dev/config.toml
|
||||||
|
|
||||||
# terminal 2 - voice node
|
# Terminal 2 — voice node
|
||||||
cargo run -p vnox-voice-node -- --config dev/config.toml
|
cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||||
|
|
||||||
# terminal 3 - client
|
# Terminal 3 — client
|
||||||
cargo run -p vnox-client
|
cargo run -p vnox-client
|
||||||
```
|
```
|
||||||
|
|
||||||
The client connects to `127.0.0.1:7600` by default (editable in the UI).
|
The client connects to `127.0.0.1:7600` by default. You can change the address in the UI on the connect screen.
|
||||||
Config details: [dev/README.md](dev/README.md).
|
|
||||||
|
|
||||||
### Opus on Windows
|
### Opus on Windows
|
||||||
|
|
||||||
`audiopus_sys` builds libopus from source via CMake.
|
`audiopus_sys` builds libopus from source via CMake. CMake 4.x policy flag is already set in `.cargo/config.toml` — no manual steps needed.
|
||||||
CMake 4.x requires a policy flag, already set in `.cargo/config.toml`:
|
|
||||||
|
|
||||||
```toml
|
---
|
||||||
[env]
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------------|--------------------------------|
|
||||||
|
| UI | Slint |
|
||||||
|
| Networking | Tokio (async TCP + UDP) |
|
||||||
|
| Audio | cpal + rodio + opus + rnnoise |
|
||||||
|
| Identity | ed25519-dalek + x25519-dalek |
|
||||||
|
| Crypto | ChaCha20-Poly1305 + Argon2id |
|
||||||
|
| Protocol | LNEx v1 (JSON, Phase 1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Entry point
|
||||||
|
├── ui/ # Slint UI (slint files + Rust glue)
|
||||||
|
├── net/ # LNEx TCP + UDP networking
|
||||||
|
│ ├── crypto/ # Session encryption
|
||||||
|
│ ├── framing/ # Packet read/write
|
||||||
|
│ ├── voice/ # UDP voice send/recv
|
||||||
|
│ └── session/ # Connection lifecycle, reconnection
|
||||||
|
├── audio/ # Audio pipeline
|
||||||
|
│ ├── capture.rs # Mic → Opus encode
|
||||||
|
│ ├── playback.rs # Opus decode → speaker
|
||||||
|
│ ├── config.rs # Bitrate, VAD, jitter settings
|
||||||
|
│ └── processing/ # VAD, noise suppression
|
||||||
|
├── identity_vault.rs # Argon2id-encrypted keyfile
|
||||||
|
├── app.rs # UI state and event dispatch
|
||||||
|
└── jitter/ # Jitter buffer
|
||||||
```
|
```
|
||||||
|
|
||||||
No manual steps needed.
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-3.0. See [docs/LICENSE.md](docs/LICENSE.md).
|
Client code: **GPL-3.0** — see [LICENSE](LICENSE)
|
||||||
|
LNEx protocol specification: **CC0** (public domain)
|
||||||
The LNEx protocol specification is CC0 (public domain).
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Re
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
voice_endpoint: p.voice_endpoint,
|
voice_endpoint: p.voice_endpoint,
|
||||||
|
guild_id: p.guild_id,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +36,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Re
|
||||||
channel_id: p.channel_id,
|
channel_id: p.channel_id,
|
||||||
channel_name: p.channel_name,
|
channel_name: p.channel_name,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
|
guild_id: p.guild_id,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +59,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Re
|
||||||
channel_id: c.channel_id,
|
channel_id: c.channel_id,
|
||||||
channel_name: c.channel_name,
|
channel_name: c.channel_name,
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
|
guild_id: c.guild_id,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ pub struct ChannelStatePayload {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub members: Vec<WireMember>,
|
pub members: Vec<WireMember>,
|
||||||
pub voice_endpoint: String,
|
pub voice_endpoint: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
|
@ -81,6 +83,8 @@ pub struct ChannelListItem {
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
pub channel_name: String,
|
pub channel_name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,12 @@ pub async fn session_loop(
|
||||||
framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq,
|
framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq,
|
||||||
&serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?;
|
&serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?;
|
||||||
}
|
}
|
||||||
Some(NetCommand::ChannelCreate { channel_id, channel_name, kind }) => {
|
Some(NetCommand::ChannelCreate { channel_id, channel_name, kind, guild_id }) => {
|
||||||
let payload = serde_json::to_vec(&ChannelCreatePayload {
|
let payload = serde_json::to_vec(&ChannelCreatePayload {
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_name,
|
channel_name,
|
||||||
kind,
|
kind,
|
||||||
guild_id: None,
|
guild_id,
|
||||||
})?;
|
})?;
|
||||||
framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?;
|
framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub enum NetCommand {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
channel_name: String,
|
channel_name: String,
|
||||||
kind: String,
|
kind: String,
|
||||||
|
guild_id: Option<String>,
|
||||||
},
|
},
|
||||||
ChannelDelete {
|
ChannelDelete {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ pub enum NetEvent {
|
||||||
kind: String,
|
kind: String,
|
||||||
members: Vec<MemberInfo>,
|
members: Vec<MemberInfo>,
|
||||||
voice_endpoint: String,
|
voice_endpoint: String,
|
||||||
|
guild_id: Option<String>,
|
||||||
},
|
},
|
||||||
ChannelCreated {
|
ChannelCreated {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
channel_name: String,
|
channel_name: String,
|
||||||
kind: String,
|
kind: String,
|
||||||
|
guild_id: Option<String>,
|
||||||
},
|
},
|
||||||
ChannelDeleted {
|
ChannelDeleted {
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub struct ChannelListItem {
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
pub channel_name: String,
|
pub channel_name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
pub guild_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ fn sync_sidebar(window: &MainWindow, state: &UiState, own_nickname: &str) {
|
||||||
let channels: Vec<ChannelItem> = state
|
let channels: Vec<ChannelItem> = state
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|c| match guild_id {
|
||||||
|
None => true,
|
||||||
|
Some(gid) => c.guild_id.as_deref() == Some(gid) || c.guild_id.is_none(),
|
||||||
|
})
|
||||||
.map(|c| ChannelItem {
|
.map(|c| ChannelItem {
|
||||||
channel_id: SharedString::from(c.id.as_str()),
|
channel_id: SharedString::from(c.id.as_str()),
|
||||||
name: SharedString::from(c.name.as_str()),
|
name: SharedString::from(c.name.as_str()),
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,21 @@ export component SettingsWindow {
|
||||||
background: active-tab == "audio" ? Theme.accent : Theme.bg-interactive;
|
background: active-tab == "audio" ? Theme.accent : Theme.bg-interactive;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
TouchArea { clicked => { active-tab = "audio"; } }
|
TouchArea { clicked => { active-tab = "audio"; } }
|
||||||
Text { text: "Audio"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
Text { text: "Audio"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: active-tab == "network" ? Theme.accent : Theme.bg-interactive;
|
background: active-tab == "network" ? Theme.accent : Theme.bg-interactive;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
TouchArea { clicked => { active-tab = "network"; } }
|
TouchArea { clicked => { active-tab = "network"; } }
|
||||||
Text { text: "Network"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
Text { text: "Network"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: active-tab == "identity" ? Theme.accent : Theme.bg-interactive;
|
background: active-tab == "identity" ? Theme.accent : Theme.bg-interactive;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
TouchArea { clicked => { active-tab = "identity"; } }
|
TouchArea { clicked => { active-tab = "identity"; } }
|
||||||
Text { text: "Identity"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
Text { text: "Identity"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
}
|
}
|
||||||
HorizontalLayout { horizontal-stretch: 1; }
|
HorizontalLayout { horizontal-stretch: 1; }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
|
|
||||||
|
|
@ -44,30 +44,35 @@ impl UiState {
|
||||||
id: "general".into(),
|
id: "general".into(),
|
||||||
name: "general".into(),
|
name: "general".into(),
|
||||||
kind: "text".into(),
|
kind: "text".into(),
|
||||||
|
guild_id: None,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: "dev-talk".into(),
|
id: "dev-talk".into(),
|
||||||
name: "dev-talk".into(),
|
name: "dev-talk".into(),
|
||||||
kind: "text".into(),
|
kind: "text".into(),
|
||||||
|
guild_id: None,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: "plugins".into(),
|
id: "plugins".into(),
|
||||||
name: "plugins".into(),
|
name: "plugins".into(),
|
||||||
kind: "text".into(),
|
kind: "text".into(),
|
||||||
|
guild_id: None,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: "lobby".into(),
|
id: "lobby".into(),
|
||||||
name: "lobby".into(),
|
name: "lobby".into(),
|
||||||
kind: "voice".into(),
|
kind: "voice".into(),
|
||||||
|
guild_id: None,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: "gaming".into(),
|
id: "gaming".into(),
|
||||||
name: "gaming".into(),
|
name: "gaming".into(),
|
||||||
kind: "voice".into(),
|
kind: "voice".into(),
|
||||||
|
guild_id: None,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub struct Channel {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
pub guild_id: Option<String>,
|
||||||
pub members: Vec<String>,
|
pub members: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||||
channel_name,
|
channel_name,
|
||||||
kind,
|
kind,
|
||||||
members,
|
members,
|
||||||
|
guild_id,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mems: Vec<String> = members
|
let mems: Vec<String> = members
|
||||||
|
|
@ -322,11 +323,15 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
|
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
|
||||||
ch.members = mems;
|
ch.members = mems;
|
||||||
|
if guild_id.is_some() {
|
||||||
|
ch.guild_id = guild_id.clone();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ui.channels.push(crate::ui::state::Channel {
|
ui.channels.push(crate::ui::state::Channel {
|
||||||
id: channel_id.clone(),
|
id: channel_id.clone(),
|
||||||
name: channel_name,
|
name: channel_name,
|
||||||
kind: kind.clone(),
|
kind: kind.clone(),
|
||||||
|
guild_id: guild_id.clone(),
|
||||||
members: mems,
|
members: mems,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -347,12 +352,14 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_name,
|
channel_name,
|
||||||
kind,
|
kind,
|
||||||
|
guild_id,
|
||||||
} => {
|
} => {
|
||||||
if !ui.channels.iter().any(|c| c.id == channel_id) {
|
if !ui.channels.iter().any(|c| c.id == channel_id) {
|
||||||
ui.channels.push(crate::ui::state::Channel {
|
ui.channels.push(crate::ui::state::Channel {
|
||||||
id: channel_id,
|
id: channel_id,
|
||||||
name: channel_name,
|
name: channel_name,
|
||||||
kind,
|
kind,
|
||||||
|
guild_id,
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -381,6 +388,7 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||||
id: c.channel_id,
|
id: c.channel_id,
|
||||||
name: c.channel_name,
|
name: c.channel_name,
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
|
guild_id: c.guild_id,
|
||||||
members,
|
members,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue