feat: implement full client UI from mockup — 10 phases, 331 tests

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)
This commit is contained in:
jevb
2026-03-15 11:42:25 +01:00
parent 7ee190fc3f
commit c1c25ed26c
110 changed files with 19180 additions and 742 deletions
+19 -8
View File
@@ -29,12 +29,18 @@ type GitHubConfig struct {
Token string `koanf:"token"`
}
// VoiceConfig holds STUN/TURN server settings for WebRTC signaling.
// VoiceConfig holds STUN/TURN server settings and SFU configuration.
type VoiceConfig struct {
TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty
STUNPort int `koanf:"stun_port"` // default 3478
TURNPort int `koanf:"turn_port"` // default 3478
TURNEnabled bool `koanf:"turn_enabled"` // default true
TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty
STUNPort int `koanf:"stun_port"` // default 3478
TURNPort int `koanf:"turn_port"` // default 3478
TURNEnabled bool `koanf:"turn_enabled"` // default true
Quality string `koanf:"quality"` // low | medium | high
MixingThreshold int `koanf:"mixing_threshold"` // selective forwarding threshold
TopSpeakers int `koanf:"top_speakers"` // top-N speakers in selective mode
ExternalIP string `koanf:"external_ip"` // set if behind NAT
MediaPortMin int `koanf:"media_port_min"` // UDP port range start for WebRTC media
MediaPortMax int `koanf:"media_port_max"` // UDP port range end for WebRTC media
}
// ServerConfig holds HTTP server settings.
@@ -90,9 +96,14 @@ func defaults() Config {
StorageDir: "data/uploads",
},
Voice: VoiceConfig{
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: true,
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: true,
Quality: "medium",
MixingThreshold: 10,
TopSpeakers: 3,
MediaPortMin: 10000,
MediaPortMax: 10100,
},
GitHub: GitHubConfig{},
}
+76
View File
@@ -226,6 +226,82 @@ tls:
}
}
func TestLoadVoiceConfigDefaults(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
tests := []struct {
name string
got interface{}
want interface{}
}{
{"Voice.Quality", cfg.Voice.Quality, "medium"},
{"Voice.MixingThreshold", cfg.Voice.MixingThreshold, 10},
{"Voice.TopSpeakers", cfg.Voice.TopSpeakers, 3},
{"Voice.ExternalIP", cfg.Voice.ExternalIP, ""},
{"Voice.MediaPortMin", cfg.Voice.MediaPortMin, 10000},
{"Voice.MediaPortMax", cfg.Voice.MediaPortMax, 10100},
{"Voice.STUNPort", cfg.Voice.STUNPort, 3478},
{"Voice.TURNPort", cfg.Voice.TURNPort, 3478},
{"Voice.TURNEnabled", cfg.Voice.TURNEnabled, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.got != tc.want {
t.Errorf("got %v, want %v", tc.got, tc.want)
}
})
}
}
func TestLoadVoiceConfigFromYAML(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
yaml := `
voice:
quality: high
mixing_threshold: 5
top_speakers: 4
external_ip: "1.2.3.4"
media_port_min: 20000
media_port_max: 20500
`
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.Voice.Quality != "high" {
t.Errorf("Voice.Quality = %q, want 'high'", cfg.Voice.Quality)
}
if cfg.Voice.MixingThreshold != 5 {
t.Errorf("Voice.MixingThreshold = %d, want 5", cfg.Voice.MixingThreshold)
}
if cfg.Voice.TopSpeakers != 4 {
t.Errorf("Voice.TopSpeakers = %d, want 4", cfg.Voice.TopSpeakers)
}
if cfg.Voice.ExternalIP != "1.2.3.4" {
t.Errorf("Voice.ExternalIP = %q, want '1.2.3.4'", cfg.Voice.ExternalIP)
}
if cfg.Voice.MediaPortMin != 20000 {
t.Errorf("Voice.MediaPortMin = %d, want 20000", cfg.Voice.MediaPortMin)
}
if cfg.Voice.MediaPortMax != 20500 {
t.Errorf("Voice.MediaPortMax = %d, want 20500", cfg.Voice.MediaPortMax)
}
}
func TestLoadUploadBoundaryValues(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")