Client UI: - Design system: Colors, Typography, Controls resource dictionaries - Message actions: reply compose bar, hover edit/delete/reply buttons - Rich content: code blocks, attachments, system messages, content parser - Server strip: 72px sidebar with server icons, home button, add server - Status picker: popup for changing online/idle/dnd/invisible status - ConnectPage: server health check dots with auto-refresh - User popup: profile card with banner, avatar, roles, member since - Emoji picker: 6 categories, search, grid of Unicode emojis - Settings overlay: full-screen with sidebar navigation - Friends/DM view: sidebar + friends list with tabs (online/all/pending) - Toast notifications: auto-dismiss after 3s with fade animation Models & services: - Attachment model added to Message, ApiMessage, ChatMessagePayload - EditMessageAsync, DeleteMessageAsync, SendStatusChangeAsync APIs - MessageContentParser (code blocks, inline code, bold, italic) - EmojiData, ToastService, HealthStatusToBrushConverter Server (from prior session): - Voice room management, SFU, speaker detection - ACME/TLS support, config improvements - Protocol and schema updates Tests: 331 passing (61 converter + 24 voice service + 34 voice VM + 41 parser + 9 edit/delete + existing)
9.5 KiB
WebSocket Protocol Spec
All client-server communication (except file uploads and
admin panel) happens over a single WebSocket connection.
Messages are JSON with a type and payload.
Message Format
{
"type": "message_type",
"id": "unique-request-id",
"payload": { }
}
type— string, required. Determines how payload is interpreted.id— string, optional. Client-generated UUID for request/response correlation.payload— object, required. Contents vary by type.
Server responses to client requests include the same id for correlation.
Authentication
Client → Server
{ "type": "auth", "payload": { "token": "session-token-here" } }
Server → Client (success)
{
"type": "auth_ok",
"payload": {
"user": {
"id": 1, "username": "alex",
"avatar": "uuid.png", "role": "admin"
},
"server_name": "My Server",
"motd": "Welcome!"
}
}
Server → Client (failure)
{ "type": "auth_error", "payload": { "message": "Invalid or expired token" } }
Connection is closed by server after auth_error.
Chat Messages
Send Message (Client → Server)
{
"type": "chat_send",
"id": "req-uuid",
"payload": {
"channel_id": 5,
"content": "Hello everyone!",
"reply_to": null,
"attachments": ["upload-uuid-1"]
}
}
Message Broadcast (Server → Client)
{
"type": "chat_message",
"payload": {
"id": 1042, "channel_id": 5,
"user": {
"id": 1, "username": "alex",
"avatar": "uuid.png"
},
"content": "Hello everyone!",
"reply_to": null,
"attachments": [{
"id": "upload-uuid-1",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/files/upload-uuid-1"
}],
"timestamp": "2026-03-14T10:30:00Z"
}
}
Send Ack (Server → Client)
{
"type": "chat_send_ok",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"timestamp": "2026-03-14T10:30:00Z"
}
}
Edit Message (Client → Server)
{
"type": "chat_edit",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"content": "Hello everyone! (edited)"
}
}
Edit Broadcast (Server → Client)
{
"type": "chat_edited",
"payload": {
"message_id": 1042,
"channel_id": 5,
"content": "Hello everyone! (edited)",
"edited_at": "2026-03-14T10:31:00Z"
}
}
Delete Message (Client → Server)
{ "type": "chat_delete", "id": "req-uuid", "payload": { "message_id": 1042 } }
Delete Broadcast (Server → Client)
{ "type": "chat_deleted", "payload": { "message_id": 1042, "channel_id": 5 } }
Reaction Add/Remove (Client → Server)
{ "type": "reaction_add", "payload": { "message_id": 1042, "emoji": "👍" } }
{ "type": "reaction_remove", "payload": { "message_id": 1042, "emoji": "👍" } }
Reaction Broadcast (Server → Client)
{
"type": "reaction_update",
"payload": {
"message_id": 1042,
"channel_id": 5,
"emoji": "👍",
"user_id": 1,
"action": "add"
}
}
Typing Indicators
Client → Server (throttle to 1 per 3 seconds)
{ "type": "typing_start", "payload": { "channel_id": 5 } }
Server → Client (broadcast to channel members)
{
"type": "typing",
"payload": {
"channel_id": 5,
"user_id": 1,
"username": "alex"
}
}
Client-side: show indicator for 5 seconds, reset on new typing event from same user.
Presence
Presence Client → Server
{ "type": "presence_update", "payload": { "status": "online" } }
Status values: online, idle, dnd, offline
Presence Server → Client (broadcast)
{ "type": "presence", "payload": { "user_id": 1, "status": "online" } }
Server auto-sets idle after 10 minutes of no WebSocket activity.
Channel Updates
Server → Client (on channel created/edited/deleted/reordered)
{
"type": "channel_create",
"payload": {
"id": 8, "name": "gaming",
"type": "text",
"category": "Hangout", "position": 3
}
}
{
"type": "channel_update",
"payload": {
"id": 8, "name": "gaming-talk",
"position": 4
}
}
{ "type": "channel_delete", "payload": { "id": 8 } }
Channel types: text, voice, announcement
Voice Signaling
Join Voice Channel (Client → Server)
{ "type": "voice_join", "payload": { "channel_id": 10 } }
Server → Client (voice state updates, broadcast to channel)
{
"type": "voice_state",
"payload": {
"channel_id": 10, "user_id": 1,
"username": "alex",
"muted": false, "deafened": false,
"speaking": false,
"camera": false, "screenshare": false
}
}
Voice User Left (Server → Client)
{ "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } }
Voice Config (Server → Client, sent after voice_join acceptance)
{
"type": "voice_config",
"payload": {
"channel_id": 10, "quality": "medium", "bitrate": 64000,
"threshold_mode": "forwarding", "mixing_threshold": 10,
"top_speakers": 3, "max_users": 50
}
}
Client uses bitrate to configure the Opus encoder. Other fields are
informational for UI.
WebRTC Signaling (Client ↔ Server SFU)
Note: As of the SFU migration, voice_offer/voice_answer/voice_ice
are exchanged between each client and the server (not relayed between
clients). The server is the WebRTC peer.
Clients must include RFC 6464 ssrc-audio-level RTP header extension in SDP offers.
{ "type": "voice_offer", "payload": { "channel_id": 10, "sdp": "..." } }
{ "type": "voice_answer", "payload": { "channel_id": 10, "sdp": "..." } }
{ "type": "voice_ice", "payload": { "channel_id": 10, "candidate": "..." } }
Voice Control (Client → Server)
{ "type": "voice_mute", "payload": { "muted": true } }
{ "type": "voice_deafen", "payload": { "deafened": true } }
Voice Camera / Screenshare (Client → Server)
{ "type": "voice_camera", "payload": { "enabled": true } }
{ "type": "voice_screenshare", "payload": { "enabled": true } }
Requires USE_VIDEO (bit 11) or SHARE_SCREEN (bit 12) permission.
Rate limit: 2/sec per user.
Active Speakers (Server → Client)
{
"type": "voice_speakers",
"payload": {
"channel_id": 10, "speakers": [1, 5, 12],
"threshold_mode": "forwarding"
}
}
speakers: Active speaker user IDs (up to top-N)threshold_mode:"forwarding"or"selective"- Sent on speaker list changes or mode transitions
- Rate: at most once per 200ms per channel
Soundboard (Client → Server)
{ "type": "soundboard_play", "payload": { "sound_id": "uuid" } }
Member Updates
Server → Client
{
"type": "member_join",
"payload": {
"user": {
"id": 5, "username": "newuser",
"avatar": null, "role": "member"
}
}
}
{ "type": "member_leave", "payload": { "user_id": 5 } }
{ "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } }
{ "type": "member_ban", "payload": { "user_id": 5 } }
Server Restart
Restart Server → Client
{
"type": "server_restart",
"payload": {
"reason": "update",
"delay_seconds": 5
}
}
reason(string): Why the server is restarting. Currently only"update".delay_seconds(integer): How many seconds until the server shuts down.
Client behavior: Display a banner ("Server restarting..."), then auto-reconnect after the delay expires.
Initial State (sent after auth_ok)
Ready Server → Client
{
"type": "ready",
"payload": {
"channels": [
{
"id": 1, "name": "general",
"type": "text", "category": "Main",
"position": 0, "unread_count": 3,
"last_message_id": 1040
},
{
"id": 10, "name": "voice-chat",
"type": "voice", "category": "Main",
"position": 1
}
],
"members": [
{
"id": 1, "username": "alex",
"avatar": "uuid.png",
"role": "admin", "status": "online"
},
{
"id": 2, "username": "jordan",
"avatar": null,
"role": "member", "status": "idle"
}
],
"voice_states": [
{ "channel_id": 10, "user_id": 2, "muted": false, "deafened": false }
],
"roles": [
{
"id": 1, "name": "Owner",
"color": "#E74C3C",
"permissions": 2147483647
},
{
"id": 2, "name": "Admin",
"color": "#F39C12",
"permissions": 1073741823
},
{ "id": 3, "name": "Member", "color": null, "permissions": 1049601 }
]
}
}
Message History (REST, not WebSocket)
Fetched via REST API, not WebSocket, to keep the WS connection lean.
GET /api/channels/{id}/messages?before={msg_id}&limit=50
Error Format
Any request that fails returns:
{
"type": "error",
"id": "original-req-uuid",
"payload": {
"code": "FORBIDDEN",
"message": "No permission to post here"
}
}
Error codes: FORBIDDEN, NOT_FOUND, RATE_LIMITED, INVALID_INPUT,
SERVER_ERROR, CHANNEL_FULL, INVALID_SDP, VOICE_ERROR, VIDEO_LIMIT
Rate Limits
- Chat messages: 10/sec per user
- Typing events: 1/3sec per user per channel
- Presence updates: 1/10sec per user
- Reactions: 5/sec per user
- Voice signaling: 20/sec per user
- Voice camera/screenshare: 2/sec per user
- Soundboard: 1/3sec per user
Server sends rate_limited error with retry_after in seconds.