diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c03ac92..43c79c9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,26 +2,13 @@ ## Development Setup -### Server +See **SETUP.md** for tooling requirements and +**CLAUDE.md** for build commands. -- **Go 1.25+** +## Active Branches -```bash -go install github.com/air-verse/air@latest -go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest -``` - -### Client (Tauri v2) - -- **Node.js 20+** -- **Rust** (latest stable via rustup) -- **Visual Studio Build Tools 2022** (C++ workload) - -```bash -cd Client/tauri-client -npm install -npm run tauri dev -``` +- `main` -- stable releases +- `tauri-migration` -- active development ## Branch Naming @@ -29,12 +16,6 @@ npm run tauri dev - `fix/` -- bug fixes - `docs/` -- documentation changes -## Active Branches - -- `main` -- stable releases -- `dev` -- WPF client development (legacy) -- `tauri-migration` -- Tauri v2 client (active) - ## Commit Format Use conventional commits: @@ -52,59 +33,21 @@ ci: add lint step to GitHub Actions ## Pull Request Process -1. Branch from `tauri-migration` (for client work) or - `dev` (for server work) +1. Branch from `tauri-migration` 2. CI must pass (build + test + lint) 3. Request code review 4. Squash merge preferred -## Test Requirements +## Testing -Target **80%+ coverage**. Follow TDD workflow: write tests -first, then implement. - -### Server Tests - -```bash -cd Server -go test ./... -cover -``` - -### Client Tests (Tauri v2) - -```bash -cd Client/tauri-client -npm test # all tests -npm run test:unit # unit only -npm run test:integration # integration only -npm run test:e2e # E2E (Playwright) -npm run test:coverage # with coverage -``` - -### Rust Tests - -```bash -cd Client/tauri-client/src-tauri -cargo test -``` +Target **80%+ coverage**. Follow TDD workflow. +See **TESTING-STRATEGY.md** for full details and +**CLAUDE.md** for test commands. ## Code Style -### TypeScript (Client) - -- Strict mode enabled -- Immutable state updates (never mutate) -- No `any` types -- Path aliases: `@lib/`, `@stores/`, `@components/` - -### Go (Server) - -- `gofmt` + `golangci-lint` -- Standard library preferred -- `log/slog` for logging - -### Rust (Tauri backend) - -- `cargo fmt` + `cargo clippy` -- All FFI wrapped in `Result` -- Minimal code: only native APIs the webview can't access +- **TypeScript**: See CLIENT-ARCHITECTURE.md +- **Go**: `gofmt` + `golangci-lint`, standard + library preferred +- **Rust**: `cargo fmt` + `cargo clippy`, minimal + code (native APIs only) diff --git a/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs b/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs deleted file mode 100644 index dabfef32..00000000 --- a/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs +++ /dev/null @@ -1,529 +0,0 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Media; -using OwnCord.Client.Converters; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Tests.Converters; - -public class FirstCharConverterTests -{ - private readonly FirstCharConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Theory] - [InlineData("hello", "H")] - [InlineData("World", "W")] - [InlineData("a", "A")] - [InlineData("123", "1")] - public void Convert_ReturnsFirstCharUppercased(string input, string expected) - { - var result = _converter.Convert(input, typeof(string), null!, _culture); - Assert.Equal(expected, result); - } - - [Fact] - public void Convert_EmptyString_ReturnsQuestionMark() - { - var result = _converter.Convert("", typeof(string), null!, _culture); - Assert.Equal("?", result); - } - - [Fact] - public void Convert_Null_ReturnsQuestionMark() - { - var result = _converter.Convert(null, typeof(string), null!, _culture); - Assert.Equal("?", result); - } - - [Fact] - public void Convert_NonString_ReturnsQuestionMark() - { - var result = _converter.Convert(42, typeof(string), null!, _culture); - Assert.Equal("?", result); - } - - [Fact] - public void ConvertBack_ThrowsNotSupported() - { - Assert.Throws(() => - _converter.ConvertBack("H", typeof(string), null!, _culture)); - } -} - -public class FirstLetterConverterTests -{ - private readonly FirstLetterConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Theory] - [InlineData("alice", "A")] - [InlineData("Bob", "B")] - public void Convert_ReturnsFirstLetterUppercased(string input, string expected) - { - var result = _converter.Convert(input, typeof(string), null!, _culture); - Assert.Equal(expected, result); - } - - [Fact] - public void Convert_EmptyString_ReturnsQuestionMark() - { - var result = _converter.Convert("", typeof(string), null!, _culture); - Assert.Equal("?", result); - } - - [Fact] - public void Convert_Null_ReturnsQuestionMark() - { - var result = _converter.Convert(null, typeof(string), null!, _culture); - Assert.Equal("?", result); - } -} - -public class RelativeTimeConverterTests -{ - private readonly RelativeTimeConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_JustNow_WithinLastMinute() - { - var dt = DateTime.UtcNow.AddSeconds(-30); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - Assert.Equal("just now", result); - } - - [Fact] - public void Convert_MinutesAgo() - { - var dt = DateTime.UtcNow.AddMinutes(-15); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - Assert.Equal("15m ago", result); - } - - [Fact] - public void Convert_HoursAgo() - { - var dt = DateTime.UtcNow.AddHours(-3); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - Assert.Equal("3h ago", result); - } - - [Fact] - public void Convert_Yesterday() - { - var dt = DateTime.UtcNow.AddHours(-30); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - Assert.Equal("yesterday", result); - } - - [Fact] - public void Convert_DaysAgo() - { - var dt = DateTime.UtcNow.AddDays(-4); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - Assert.Equal("4d ago", result); - } - - [Fact] - public void Convert_OlderThanWeek_ReturnsFormattedDate() - { - var dt = DateTime.UtcNow.AddDays(-30); - var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); - // Should be formatted like "Feb 13" (month abbrev + day) - Assert.Matches(@"^[A-Z][a-z]{2} \d{1,2}$", result); - } - - [Fact] - public void Convert_NonDateTime_ReturnsNever() - { - var result = _converter.Convert("not a date", typeof(string), null!, _culture); - Assert.Equal("never", result); - } - - [Fact] - public void Convert_Null_ReturnsNever() - { - var result = _converter.Convert(null, typeof(string), null!, _culture); - Assert.Equal("never", result); - } - - [Fact] - public void ConvertBack_ThrowsNotSupported() - { - Assert.Throws(() => - _converter.ConvertBack("just now", typeof(DateTime?), null!, _culture)); - } -} - -public class ColorToBrushConverterTests -{ - private readonly ColorToBrushConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_ValidHex_ReturnsBrush() - { - var result = _converter.Convert("#ff0000", typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Colors.Red, brush.Color); - } - - [Fact] - public void Convert_Null_ReturnsFallbackBlurple() - { - var result = _converter.Convert(null, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); - } - - [Fact] - public void Convert_ShortString_ReturnsFallback() - { - var result = _converter.Convert("#fff", typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); - } - - [Fact] - public void Convert_InvalidHex_ReturnsFallback() - { - var result = _converter.Convert("#zzzzzz", typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); - } - - [Fact] - public void ConvertBack_ThrowsNotSupported() - { - Assert.Throws(() => - _converter.ConvertBack(new SolidColorBrush(), typeof(string), null!, _culture)); - } -} - -public class HexColorToBrushConverterTests -{ - private readonly HexColorToBrushConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_ValidHex_ReturnsBrush() - { - var result = _converter.Convert("#00ff00", typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Color.FromRgb(0, 255, 0), brush.Color); - } - - [Fact] - public void Convert_NoHashPrefix_ReturnsFallback() - { - var result = _converter.Convert("ff0000", typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - // Fallback is #949ba4 - Assert.Equal(Color.FromRgb(0x94, 0x9B, 0xA4), brush.Color); - } - - [Fact] - public void Convert_Null_ReturnsFallback() - { - var result = _converter.Convert(null, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Color.FromRgb(0x94, 0x9B, 0xA4), brush.Color); - } -} - -public class HostPortConverterTests -{ - private readonly HostPortConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_DefaultPort_ReturnsHostOnly() - { - var result = _converter.Convert( - new object[] { "example.com", 8443 }, typeof(string), null!, _culture); - Assert.Equal("example.com", result); - } - - [Fact] - public void Convert_CustomPort_ReturnsHostColon() - { - var result = _converter.Convert( - new object[] { "example.com", 9090 }, typeof(string), null!, _culture); - Assert.Equal("example.com:9090", result); - } - - [Fact] - public void Convert_NullHost_ReturnsEmptyWithPort() - { - var result = _converter.Convert( - new object[] { null!, 9090 }, typeof(string), null!, _culture); - Assert.Equal(":9090", result); - } - - [Fact] - public void Convert_SingleValue_DefaultsPort8443() - { - var result = _converter.Convert( - new object[] { "example.com" }, typeof(string), null!, _culture); - Assert.Equal("example.com", result); - } - - [Fact] - public void ConvertBack_ThrowsNotSupported() - { - Assert.Throws(() => - _converter.ConvertBack("x", new[] { typeof(string) }, null!, _culture)); - } -} - -public class BoolToVisibilityConverterTests -{ - private readonly BoolToVisibilityConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsVisible() - { - var result = _converter.Convert(true, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Visible, result); - } - - [Fact] - public void Convert_False_ReturnsCollapsed() - { - var result = _converter.Convert(false, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } - - [Fact] - public void ConvertBack_Visible_ReturnsTrue() - { - var result = _converter.ConvertBack(Visibility.Visible, typeof(bool), null!, _culture); - Assert.Equal(true, result); - } - - [Fact] - public void ConvertBack_Collapsed_ReturnsFalse() - { - var result = _converter.ConvertBack(Visibility.Collapsed, typeof(bool), null!, _culture); - Assert.Equal(false, result); - } -} - -public class InverseBoolToVisibilityConverterTests -{ - private readonly InverseBoolToVisibilityConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsCollapsed() - { - var result = _converter.Convert(true, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } - - [Fact] - public void Convert_False_ReturnsVisible() - { - var result = _converter.Convert(false, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Visible, result); - } - - [Fact] - public void ConvertBack_Collapsed_ReturnsTrue() - { - var result = _converter.ConvertBack(Visibility.Collapsed, typeof(bool), null!, _culture); - Assert.Equal(true, result); - } -} - -public class IntToVisibilityConverterTests -{ - private readonly IntToVisibilityConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_PositiveInt_ReturnsVisible() - { - var result = _converter.Convert(5, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Visible, result); - } - - [Fact] - public void Convert_Zero_ReturnsCollapsed() - { - var result = _converter.Convert(0, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } - - [Fact] - public void Convert_NegativeInt_ReturnsCollapsed() - { - var result = _converter.Convert(-1, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } - - [Fact] - public void Convert_NonInt_ReturnsCollapsed() - { - var result = _converter.Convert("not an int", typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } -} - -public class NullToVisibilityConverterTests -{ - private readonly NullToVisibilityConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_NonNull_ReturnsVisible() - { - var result = _converter.Convert("something", typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Visible, result); - } - - [Fact] - public void Convert_Null_ReturnsCollapsed() - { - var result = _converter.Convert(null, typeof(Visibility), null!, _culture); - Assert.Equal(Visibility.Collapsed, result); - } -} - -public class InverseBoolConverterTests -{ - private readonly InverseBoolConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsFalse() - { - var result = _converter.Convert(true, typeof(bool), null!, _culture); - Assert.Equal(false, result); - } - - [Fact] - public void Convert_False_ReturnsTrue() - { - var result = _converter.Convert(false, typeof(bool), null!, _culture); - Assert.Equal(true, result); - } - - [Fact] - public void ConvertBack_True_ReturnsFalse() - { - var result = _converter.ConvertBack(true, typeof(bool), null!, _culture); - Assert.Equal(false, result); - } -} - -public class StatusToBrushConverterTests -{ - private readonly StatusToBrushConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_Online_ReturnsGreen() - { - var result = _converter.Convert(UserStatus.Online, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#23a55a"), brush.Color); - } - - [Fact] - public void Convert_Idle_ReturnsYellow() - { - var result = _converter.Convert(UserStatus.Idle, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#f0b232"), brush.Color); - } - - [Fact] - public void Convert_Dnd_ReturnsRed() - { - var result = _converter.Convert(UserStatus.Dnd, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#f23f43"), brush.Color); - } - - [Fact] - public void Convert_Offline_ReturnsGray() - { - var result = _converter.Convert(UserStatus.Offline, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#6d6f78"), brush.Color); - } -} - -public class BoolToRedBrushConverterTests -{ - private readonly BoolToRedBrushConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsRed() - { - var result = _converter.Convert(true, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#f23f43"), brush.Color); - } - - [Fact] - public void Convert_False_ReturnsNormal() - { - var result = _converter.Convert(false, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#b5bac1"), brush.Color); - } -} - -public class SpeakingToStrokeBrushConverterTests -{ - private readonly SpeakingToStrokeBrushConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsGreen() - { - var result = _converter.Convert(true, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal((Color)ColorConverter.ConvertFromString("#23a55a"), brush.Color); - } - - [Fact] - public void Convert_False_ReturnsTransparent() - { - var result = _converter.Convert(false, typeof(SolidColorBrush), null!, _culture); - var brush = Assert.IsType(result); - Assert.Equal(Colors.Transparent, brush.Color); - } -} - -public class BoolToArrowConverterTests -{ - private readonly BoolToArrowConverter _converter = new(); - private readonly CultureInfo _culture = CultureInfo.InvariantCulture; - - [Fact] - public void Convert_True_ReturnsDownArrow() - { - var result = _converter.Convert(true, typeof(string), null!, _culture); - Assert.Equal("\u25BE", result); // ▾ - } - - [Fact] - public void Convert_False_ReturnsRightArrow() - { - var result = _converter.Convert(false, typeof(string), null!, _culture); - Assert.Equal("\u25B8", result); // ▸ - } - - [Fact] - public void ConvertBack_ThrowsNotSupported() - { - Assert.Throws(() => - _converter.ConvertBack("▾", typeof(bool), null!, _culture)); - } -} diff --git a/Client/OwnCord.Client.Tests/Models/ApiResponseTests.cs b/Client/OwnCord.Client.Tests/Models/ApiResponseTests.cs deleted file mode 100644 index 5d2bcd03..00000000 --- a/Client/OwnCord.Client.Tests/Models/ApiResponseTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Text.Json; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Tests.Models; - -public class ApiResponseTests -{ - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNameCaseInsensitive = true - }; - - [Fact] - public void Deserialize_AuthResponse() - { - var json = """ - { - "token": "abc123", - "user": { - "id": 1, - "username": "alice", - "avatar": "", - "status": "online", - "role_id": 1, - "created_at": "2026-01-01T00:00:00Z" - } - } - """; - - var result = JsonSerializer.Deserialize(json, JsonOpts)!; - Assert.Equal("abc123", result.Token); - Assert.Equal("alice", result.User.Username); - Assert.Equal(1, result.User.RoleId); - } - - [Fact] - public void Deserialize_MessagesResponse() - { - var json = """ - { - "messages": [ - { - "id": 10, - "channel_id": 1, - "user_id": 1, - "content": "hello", - "reply_to": null, - "edited_at": null, - "deleted": false, - "pinned": false, - "timestamp": "2026-01-01T00:00:00Z", - "username": "alice", - "avatar": null - } - ], - "has_more": true - } - """; - - var result = JsonSerializer.Deserialize(json, JsonOpts)!; - Assert.Single(result.Messages); - Assert.Equal("hello", result.Messages[0].Content); - Assert.Equal("alice", result.Messages[0].Username); - Assert.True(result.HasMore); - } - - [Fact] - public void Deserialize_ChannelArray() - { - var json = """ - [ - { "id": 1, "name": "general", "type": "text", "category": "Chat", "topic": "Welcome", "position": 0, "slow_mode": 0, "archived": false, "created_at": "2026-01-01T00:00:00Z" }, - { "id": 2, "name": "voice-lobby", "type": "voice", "category": "Voice", "topic": "", "position": 1, "slow_mode": 0, "archived": false, "created_at": "2026-01-01T00:00:00Z" } - ] - """; - - var channels = JsonSerializer.Deserialize>(json, JsonOpts)!; - Assert.Equal(2, channels.Count); - Assert.Equal("general", channels[0].Name); - Assert.Equal("text", channels[0].Type); - Assert.Equal("voice", channels[1].Type); - } - - [Fact] - public void Deserialize_HealthResponse() - { - var json = """{ "status": "ok", "version": "1.0.0" }"""; - - var result = JsonSerializer.Deserialize(json, JsonOpts)!; - Assert.Equal("ok", result.Status); - Assert.Equal("1.0.0", result.Version); - } - - [Fact] - public void Deserialize_ApiError() - { - var json = """{ "error": "UNAUTHORIZED", "message": "invalid credentials" }"""; - - var result = JsonSerializer.Deserialize(json, JsonOpts)!; - Assert.Equal("UNAUTHORIZED", result.Error); - Assert.Equal("invalid credentials", result.Message); - } -} diff --git a/Client/OwnCord.Client.Tests/Models/ModelTests.cs b/Client/OwnCord.Client.Tests/Models/ModelTests.cs deleted file mode 100644 index 752b2707..00000000 --- a/Client/OwnCord.Client.Tests/Models/ModelTests.cs +++ /dev/null @@ -1,524 +0,0 @@ -using OwnCord.Client.Models; - -namespace OwnCord.Client.Tests.Models; - -// ── ServerProfile Tests ───────────────────────────────────────────────────── - -public class ServerProfileTests -{ - [Fact] - public void Create_GeneratesUniqueId() - { - var p = ServerProfile.Create("Home", "localhost"); - Assert.False(string.IsNullOrEmpty(p.Id)); - } - - [Fact] - public void Create_TwoCallsProduceDifferentIds() - { - var a = ServerProfile.Create("A", "a.local"); - var b = ServerProfile.Create("B", "b.local"); - Assert.NotEqual(a.Id, b.Id); - } - - [Fact] - public void Create_StoresNameHostUsername() - { - var p = ServerProfile.Create("Home", "192.168.1.1", "alice"); - Assert.Equal("Home", p.Name); - Assert.Equal("192.168.1.1", p.Host); - Assert.Equal("alice", p.LastUsername); - } - - [Fact] - public void Create_DefaultsPortTo8443() - { - var p = ServerProfile.Create("Home", "localhost"); - Assert.Equal(8443, p.Port); - } - - [Fact] - public void Create_DefaultsColorToAccent() - { - var p = ServerProfile.Create("Home", "localhost"); - Assert.Equal("#5865f2", p.Color); - } - - [Fact] - public void Create_CustomPortAndColor() - { - var p = ServerProfile.Create("Home", "localhost", port: 9443, color: "#ff0000"); - Assert.Equal(9443, p.Port); - Assert.Equal("#ff0000", p.Color); - } - - [Fact] - public void Create_AutoConnectDefaultsFalse() - { - var p = ServerProfile.Create("Home", "localhost"); - Assert.False(p.AutoConnect); - } - - [Fact] - public void Create_LastConnectedIsNull() - { - var p = ServerProfile.Create("Home", "localhost"); - Assert.Null(p.LastConnected); - } - - [Fact] - public void HostDisplay_OmitsDefaultPort() - { - var p = ServerProfile.Create("Home", "192.168.1.1", port: 8443); - Assert.Equal("192.168.1.1", p.HostDisplay); - } - - [Fact] - public void HostDisplay_IncludesNonDefaultPort() - { - var p = ServerProfile.Create("Home", "192.168.1.1", port: 9443); - Assert.Equal("192.168.1.1:9443", p.HostDisplay); - } - - [Fact] - public void WithExpression_CreatesNewInstance() - { - var original = ServerProfile.Create("Old", "old.local"); - var updated = original with { Name = "New" }; - Assert.Equal("New", updated.Name); - Assert.Equal("Old", original.Name); - Assert.Equal(original.Id, updated.Id); - } -} - -// ── MessageDisplayItem Tests ──────────────────────────────────────────────── - -public class MessageDisplayItemTests -{ - private static User Alice => new(1, "alice", null, 1, UserStatus.Online); - private static User Bob => new(2, "bob", null, 1, UserStatus.Online); - - private static Message Msg(long id, User author, DateTime ts, long? replyTo = null) - => new(id, 1, author, "hello", ts, replyTo, null, false, [], []); - - [Fact] - public void FirstMessage_NotGrouped() - { - var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); - Assert.False(item.IsGrouped); - } - - [Fact] - public void FirstMessage_ShowsDayDivider() - { - var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); - Assert.True(item.ShowDayDivider); - } - - [Fact] - public void SameAuthor_Within7Min_IsGrouped() - { - var ts = DateTime.Today.AddHours(10); - var prev = Msg(1, Alice, ts); - var curr = Msg(2, Alice, ts.AddMinutes(3)); - var item = new MessageDisplayItem(curr, prev); - Assert.True(item.IsGrouped); - } - - [Fact] - public void SameAuthor_Over7Min_NotGrouped() - { - var ts = DateTime.Today.AddHours(10); - var prev = Msg(1, Alice, ts); - var curr = Msg(2, Alice, ts.AddMinutes(8)); - var item = new MessageDisplayItem(curr, prev); - Assert.False(item.IsGrouped); - } - - [Fact] - public void DifferentAuthor_NotGrouped() - { - var ts = DateTime.Today.AddHours(10); - var prev = Msg(1, Alice, ts); - var curr = Msg(2, Bob, ts.AddMinutes(1)); - var item = new MessageDisplayItem(curr, prev); - Assert.False(item.IsGrouped); - } - - [Fact] - public void Reply_BreaksGrouping() - { - var ts = DateTime.Today.AddHours(10); - var prev = Msg(1, Alice, ts); - var curr = Msg(2, Alice, ts.AddMinutes(1), replyTo: 99); - var item = new MessageDisplayItem(curr, prev); - Assert.False(item.IsGrouped); - } - - [Fact] - public void DifferentDay_ShowsDayDivider() - { - var prev = Msg(1, Alice, DateTime.Today.AddDays(-1).AddHours(23)); - var curr = Msg(2, Alice, DateTime.Today.AddHours(0)); - var item = new MessageDisplayItem(curr, prev); - Assert.True(item.ShowDayDivider); - Assert.False(item.IsGrouped); - } - - [Fact] - public void Today_DayDividerText_SaysToday() - { - var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); - Assert.Equal("Today", item.DayDividerText); - } - - [Fact] - public void Yesterday_DayDividerText_SaysYesterday() - { - var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddDays(-1).AddHours(10)), null); - Assert.Equal("Yesterday", item.DayDividerText); - } - - [Fact] - public void OlderDate_DayDividerText_FormatsDate() - { - var date = new DateTime(2026, 1, 15, 10, 0, 0); - var item = new MessageDisplayItem(Msg(1, Alice, date), null); - Assert.Contains("January", item.DayDividerText); - Assert.Contains("15", item.DayDividerText); - Assert.Contains("2026", item.DayDividerText); - } - - [Fact] - public void SameDay_NoDayDivider() - { - var ts = DateTime.Today.AddHours(10); - var prev = Msg(1, Alice, ts); - var curr = Msg(2, Bob, ts.AddHours(1)); - var item = new MessageDisplayItem(curr, prev); - Assert.False(item.ShowDayDivider); - Assert.Null(item.DayDividerText); - } - - [Fact] - public void PassThroughProperties_MatchMessage() - { - var msg = new Message(42, 1, Alice, "test content", DateTime.UtcNow, 10, "2026-01-01", false, [new Reaction("\ud83d\udc4d", 3, true)], []); - var item = new MessageDisplayItem(msg, null); - Assert.Equal(42, item.Id); - Assert.Equal(Alice, item.Author); - Assert.Equal("test content", item.Content); - Assert.Equal(10, item.ReplyToId); - Assert.Equal("2026-01-01", item.EditedAt); - Assert.True(item.IsEdited); - Assert.True(item.HasReactions); - Assert.Single(item.Reactions); - } - - [Fact] - public void IsReply_TrueWhenBothIdAndMessageSet() - { - var reply = Msg(2, Alice, DateTime.Today.AddHours(10), replyTo: 1); - var replyTarget = Msg(1, Bob, DateTime.Today.AddHours(9)); - var item = new MessageDisplayItem(reply, null) { ReplyToMessage = replyTarget }; - Assert.True(item.IsReply); - } - - [Fact] - public void IsReply_FalseWhenNoReplyTo() - { - var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); - Assert.False(item.IsReply); - } - - [Fact] - public void IsReply_FalseWhenReplyToIdButNoMessage() - { - var reply = Msg(2, Alice, DateTime.Today.AddHours(10), replyTo: 1); - var item = new MessageDisplayItem(reply, null); - Assert.False(item.IsReply); - } -} - -// ── ChannelGroup Tests ────────────────────────────────────────────────────── - -public class ChannelGroupTests -{ - [Fact] - public void HasCategory_TrueWhenSet() - { - var group = new ChannelGroup { CategoryName = "Text Channels" }; - Assert.True(group.HasCategory); - } - - [Fact] - public void HasCategory_FalseWhenNull() - { - var group = new ChannelGroup { CategoryName = null }; - Assert.False(group.HasCategory); - } - - [Fact] - public void DisplayName_UppercaseCategory() - { - var group = new ChannelGroup { CategoryName = "Text Channels" }; - Assert.Equal("TEXT CHANNELS", group.DisplayName); - } - - [Fact] - public void DisplayName_EmptyWhenNoCategory() - { - var group = new ChannelGroup { CategoryName = null }; - Assert.Equal(string.Empty, group.DisplayName); - } - - [Fact] - public void IsExpanded_DefaultsTrue() - { - var group = new ChannelGroup(); - Assert.True(group.IsExpanded); - } - - [Fact] - public void IsExpanded_RaisesPropertyChanged() - { - var group = new ChannelGroup(); - string? changed = null; - group.PropertyChanged += (_, e) => changed = e.PropertyName; - group.IsExpanded = false; - Assert.Equal("IsExpanded", changed); - } - - [Fact] - public void IsExpanded_SameValue_NoEvent() - { - var group = new ChannelGroup(); - string? changed = null; - group.PropertyChanged += (_, e) => changed = e.PropertyName; - group.IsExpanded = true; // same as default - Assert.Null(changed); - } - - [Fact] - public void Items_InitializedEmpty() - { - var group = new ChannelGroup(); - Assert.Empty(group.Items); - } -} - -// ── ChannelItem Tests ─────────────────────────────────────────────────────── - -public class ChannelItemTests -{ - private static Channel TextChannel => new(1, "general", ChannelType.Text, "Chat", 0, 3, null, "Welcome"); - private static Channel VoiceChannel => new(2, "Lounge", ChannelType.Voice, "Voice", 1, 0, null); - - [Fact] - public void PassThrough_Id() => Assert.Equal(1, new ChannelItem { Channel = TextChannel }.Id); - - [Fact] - public void PassThrough_Name() => Assert.Equal("general", new ChannelItem { Channel = TextChannel }.Name); - - [Fact] - public void PassThrough_Type() => Assert.Equal(ChannelType.Text, new ChannelItem { Channel = TextChannel }.Type); - - [Fact] - public void PassThrough_UnreadCount() => Assert.Equal(3, new ChannelItem { Channel = TextChannel }.UnreadCount); - - [Fact] - public void PassThrough_Topic() => Assert.Equal("Welcome", new ChannelItem { Channel = TextChannel }.Topic); - - [Fact] - public void VoiceUsers_InitializedEmpty() - { - var item = new ChannelItem { Channel = VoiceChannel }; - Assert.Empty(item.VoiceUsers); - } - - [Fact] - public void VoiceUsers_CanAddState() - { - var item = new ChannelItem { Channel = VoiceChannel }; - item.VoiceUsers.Add(new VoiceStateInfo { UserId = 1, ChannelId = 2, Username = "alice" }); - Assert.Single(item.VoiceUsers); - } -} - -// ── MemberGroup Tests ─────────────────────────────────────────────────────── - -public class MemberGroupTests -{ - [Fact] - public void Members_InitializedEmpty() - { - var mg = new MemberGroup(); - Assert.Empty(mg.Members); - } - - [Fact] - public void MemberCount_ReflectsCollection() - { - var mg = new MemberGroup { RoleName = "Admin" }; - mg.Members.Add(new User(1, "alice", null, 1, UserStatus.Online)); - mg.Members.Add(new User(2, "bob", null, 1, UserStatus.Online)); - Assert.Equal(2, mg.MemberCount); - } - - [Fact] - public void Properties_StoreValues() - { - var mg = new MemberGroup { RoleName = "Owner", RoleColor = "#e74c3c", Position = 100 }; - Assert.Equal("Owner", mg.RoleName); - Assert.Equal("#e74c3c", mg.RoleColor); - Assert.Equal(100, mg.Position); - } -} - -// ── VoiceStateInfo Tests ──────────────────────────────────────────────────── - -public class VoiceStateInfoTests -{ - [Fact] - public void Muted_RaisesPropertyChanged() - { - var vs = new VoiceStateInfo { UserId = 1 }; - string? changed = null; - vs.PropertyChanged += (_, e) => changed = e.PropertyName; - vs.Muted = true; - Assert.Equal("Muted", changed); - } - - [Fact] - public void Deafened_RaisesPropertyChanged() - { - var vs = new VoiceStateInfo { UserId = 1 }; - string? changed = null; - vs.PropertyChanged += (_, e) => changed = e.PropertyName; - vs.Deafened = true; - Assert.Equal("Deafened", changed); - } - - [Fact] - public void Speaking_RaisesPropertyChanged() - { - var vs = new VoiceStateInfo { UserId = 1 }; - string? changed = null; - vs.PropertyChanged += (_, e) => changed = e.PropertyName; - vs.Speaking = true; - Assert.Equal("Speaking", changed); - } - - [Fact] - public void SameValue_NoEvent() - { - var vs = new VoiceStateInfo { UserId = 1 }; - string? changed = null; - vs.PropertyChanged += (_, e) => changed = e.PropertyName; - vs.Muted = false; // default is false - Assert.Null(changed); - } - - [Fact] - public void ChannelId_IsMutable() - { - var vs = new VoiceStateInfo { UserId = 1, ChannelId = 10 }; - vs.ChannelId = 20; - Assert.Equal(20, vs.ChannelId); - } -} - -// ── Channel Record Tests ──────────────────────────────────────────────────── - -public class ChannelRecordTests -{ - [Fact] - public void WithExpression_CreatesNewInstance() - { - var original = new Channel(1, "general", ChannelType.Text, "Chat", 0, 0, null); - var updated = original with { UnreadCount = 5 }; - Assert.Equal(5, updated.UnreadCount); - Assert.Equal(0, original.UnreadCount); - } - - [Fact] - public void Topic_DefaultsToNull() - { - var ch = new Channel(1, "general", ChannelType.Text, "Chat", 0, 0, null); - Assert.Null(ch.Topic); - } - - [Fact] - public void ChannelType_EnumValues() - { - Assert.Equal(ChannelType.Text, new Channel(1, "g", ChannelType.Text, null, 0, 0, null).Type); - Assert.Equal(ChannelType.Voice, new Channel(2, "v", ChannelType.Voice, null, 0, 0, null).Type); - Assert.Equal(ChannelType.Announcement, new Channel(3, "a", ChannelType.Announcement, null, 0, 0, null).Type); - } -} - -// ── User Record Tests ─────────────────────────────────────────────────────── - -public class UserRecordTests -{ - [Fact] - public void WithExpression_CreatesNewInstance() - { - var original = new User(1, "alice", null, 1, UserStatus.Online); - var updated = original with { Status = UserStatus.Dnd }; - Assert.Equal(UserStatus.Dnd, updated.Status); - Assert.Equal(UserStatus.Online, original.Status); - } - - [Fact] - public void UserStatus_AllValues() - { - Assert.Equal(4, Enum.GetValues().Length); - } -} - -// ── Message Record Tests ──────────────────────────────────────────────────── - -public class MessageRecordTests -{ - [Fact] - public void WithExpression_EditedAt() - { - var msg = new Message(1, 1, new User(1, "alice", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); - var edited = msg with { Content = "edited", EditedAt = "2026-01-01T00:00:00Z" }; - Assert.Equal("edited", edited.Content); - Assert.NotNull(edited.EditedAt); - Assert.Null(msg.EditedAt); - } - - [Fact] - public void WithExpression_Deleted() - { - var msg = new Message(1, 1, new User(1, "alice", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); - var deleted = msg with { Deleted = true, Content = "[deleted]" }; - Assert.True(deleted.Deleted); - Assert.Equal("[deleted]", deleted.Content); - Assert.False(msg.Deleted); - } - - [Fact] - public void Reactions_EmptyByDefault() - { - var msg = new Message(1, 1, new User(1, "a", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); - Assert.Empty(msg.Reactions); - } -} - -// ── Reaction Record Tests ─────────────────────────────────────────────────── - -public class ReactionRecordTests -{ - [Fact] - public void Stores_Values() - { - var r = new Reaction("👍", 3, true); - Assert.Equal("👍", r.Emoji); - Assert.Equal(3, r.Count); - Assert.True(r.Me); - } -} diff --git a/Client/OwnCord.Client.Tests/Models/WsEnvelopeTests.cs b/Client/OwnCord.Client.Tests/Models/WsEnvelopeTests.cs deleted file mode 100644 index 62eaa8a3..00000000 --- a/Client/OwnCord.Client.Tests/Models/WsEnvelopeTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Text.Json; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Tests.Models; - -public class WsEnvelopeTests -{ - [Fact] - public void Deserialize_AuthOk() - { - var json = """ - { - "type": "auth_ok", - "payload": { - "user": { "id": 1, "username": "alice", "avatar": null, "status": "online" }, - "server_name": "My Server", - "motd": "Welcome!" - } - } - """; - - var env = JsonSerializer.Deserialize(json)!; - Assert.Equal("auth_ok", env.Type); - Assert.Null(env.Id); - - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal("alice", payload.User.Username); - Assert.Equal(1, payload.User.Id); - Assert.Equal("My Server", payload.ServerName); - Assert.Equal("Welcome!", payload.Motd); - } - - [Fact] - public void Deserialize_Ready() - { - var json = """ - { - "type": "ready", - "payload": { - "channels": [ - { "id": 1, "name": "general", "type": "text", "category": "Chat", "topic": "", "position": 0, "slow_mode": 0, "archived": false, "created_at": "2026-01-01T00:00:00Z" } - ], - "members": [{ "id": 1, "username": "alice", "avatar": null, "status": "online", "role_id": 1 }], - "voice_states": [], - "roles": [ - { "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647, "position": 100, "is_default": false } - ] - } - } - """; - - var env = JsonSerializer.Deserialize(json)!; - Assert.Equal("ready", env.Type); - - var payload = env.Payload!.Value.Deserialize()!; - Assert.Single(payload.Channels); - Assert.Equal("general", payload.Channels[0].Name); - Assert.Equal("text", payload.Channels[0].Type); - Assert.Single(payload.Members); - Assert.Equal("alice", payload.Members[0].Username); - Assert.Empty(payload.VoiceStates); - Assert.Single(payload.Roles); - Assert.Equal("Owner", payload.Roles[0].Name); - } - - [Fact] - public void Deserialize_ChatMessage() - { - var json = """ - { - "type": "chat_message", - "payload": { - "id": 42, - "channel_id": 1, - "user": { "id": 1, "username": "alice", "avatar": null }, - "content": "Hello world!", - "reply_to": null, - "timestamp": "2026-03-14T22:30:00Z" - } - } - """; - - var env = JsonSerializer.Deserialize(json)!; - Assert.Equal("chat_message", env.Type); - - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(42, payload.Id); - Assert.Equal(1, payload.ChannelId); - Assert.Equal("alice", payload.User.Username); - Assert.Equal("Hello world!", payload.Content); - Assert.Null(payload.ReplyTo); - } - - [Fact] - public void Deserialize_ChatSendOk() - { - var json = """ - { - "type": "chat_send_ok", - "id": "req-123", - "payload": { "message_id": 42, "timestamp": "2026-03-14T22:30:00Z" } - } - """; - - var env = JsonSerializer.Deserialize(json)!; - Assert.Equal("chat_send_ok", env.Type); - Assert.Equal("req-123", env.Id); - - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(42, payload.MessageId); - } - - [Fact] - public void Deserialize_Typing() - { - var json = """ - { "type": "typing", "payload": { "channel_id": 1, "user_id": 2, "username": "bob" } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(1, payload.ChannelId); - Assert.Equal("bob", payload.Username); - } - - [Fact] - public void Deserialize_Presence() - { - var json = """ - { "type": "presence", "payload": { "user_id": 3, "status": "idle" } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(3, payload.UserId); - Assert.Equal("idle", payload.Status); - } - - [Fact] - public void Deserialize_ChatEdited() - { - var json = """ - { "type": "chat_edited", "payload": { "message_id": 10, "channel_id": 1, "content": "edited content", "edited_at": "2026-03-14T23:00:00Z" } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(10, payload.MessageId); - Assert.Equal("edited content", payload.Content); - } - - [Fact] - public void Deserialize_ChatDeleted() - { - var json = """ - { "type": "chat_deleted", "payload": { "message_id": 10, "channel_id": 1 } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(10, payload.MessageId); - Assert.Equal(1, payload.ChannelId); - } - - [Fact] - public void Deserialize_ServerRestart() - { - var json = """ - { "type": "server_restart", "payload": { "reason": "Update applied", "delay_seconds": 5 } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal("Update applied", payload.Reason); - Assert.Equal(5, payload.DelaySeconds); - } - - [Fact] - public void Deserialize_Error() - { - var json = """ - { "type": "error", "id": "req-456", "payload": { "code": "RATE_LIMITED", "message": "slow down" } } - """; - - var env = JsonSerializer.Deserialize(json)!; - Assert.Equal("error", env.Type); - Assert.Equal("req-456", env.Id); - - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal("RATE_LIMITED", payload.Code); - } - - [Fact] - public void Deserialize_ReactionUpdate() - { - var json = """ - { "type": "reaction_update", "payload": { "message_id": 5, "channel_id": 1, "emoji": "👍", "user_id": 2, "action": "add" } } - """; - - var env = JsonSerializer.Deserialize(json)!; - var payload = env.Payload!.Value.Deserialize()!; - Assert.Equal(5, payload.MessageId); - Assert.Equal("👍", payload.Emoji); - Assert.Equal("add", payload.Action); - } -} diff --git a/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj b/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj deleted file mode 100644 index d1545866..00000000 --- a/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - net8.0-windows - enable - enable - - false - true - true - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs b/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs deleted file mode 100644 index 46897147..00000000 --- a/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text.Json; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.Tests.Services; - -public class ApiClientTests -{ - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }; - - private static ApiClient CreateClient(HttpMessageHandler handler) - { - var http = new HttpClient(handler); - return new ApiClient(http); - } - - private static FakeHandler Ok(object body) => - new(HttpStatusCode.OK, JsonSerializer.Serialize(body, JsonOpts)); - - private static FakeHandler Created(object body) => - new(HttpStatusCode.Created, JsonSerializer.Serialize(body, JsonOpts)); - - private static FakeHandler Error(HttpStatusCode code, string errorCode, string message) => - new(code, JsonSerializer.Serialize(new { error = errorCode, message }, JsonOpts)); - - // ── Login ──────────────────────────────────────────────────────────────── - - [Fact] - public async Task LoginAsync_Success_ReturnsTokenAndUser() - { - var handler = Ok(new - { - token = "abc123", - user = new { id = 1, username = "alice", avatar = (string?)null, status = "online", role_id = 1, created_at = "2026-01-01T00:00:00Z" } - }); - var client = CreateClient(handler); - - var result = await client.LoginAsync("localhost:8443", "alice", "password"); - - Assert.Equal("abc123", result.Token); - Assert.Equal("alice", result.User.Username); - Assert.Equal(1, result.User.Id); - Assert.Equal(1, result.User.RoleId); - Assert.Contains("/api/v1/auth/login", handler.LastRequestUri!); - Assert.Equal(HttpMethod.Post, handler.LastMethod); - } - - [Fact] - public async Task LoginAsync_InvalidCredentials_ThrowsApiException() - { - var handler = Error(HttpStatusCode.Unauthorized, "UNAUTHORIZED", "invalid credentials"); - var client = CreateClient(handler); - - var ex = await Assert.ThrowsAsync( - () => client.LoginAsync("localhost:8443", "alice", "wrong")); - - Assert.Equal("UNAUTHORIZED", ex.ErrorCode); - Assert.Equal(401, ex.StatusCode); - } - - // ── Register ───────────────────────────────────────────────────────────── - - [Fact] - public async Task RegisterAsync_Success_ReturnsTokenAndUser() - { - var handler = Created(new - { - token = "newtoken", - user = new { id = 2, username = "bob", avatar = (string?)null, status = "online", role_id = 4, created_at = "2026-01-01T00:00:00Z" } - }); - var client = CreateClient(handler); - - var result = await client.RegisterAsync("localhost:8443", "bob", "password", "invite123"); - - Assert.Equal("newtoken", result.Token); - Assert.Equal("bob", result.User.Username); - Assert.Contains("/api/v1/auth/register", handler.LastRequestUri!); - } - - [Fact] - public async Task RegisterAsync_BadInvite_ThrowsApiException() - { - var handler = Error(HttpStatusCode.BadRequest, "INVALID_CREDENTIALS", "invalid invite or credentials"); - var client = CreateClient(handler); - - var ex = await Assert.ThrowsAsync( - () => client.RegisterAsync("localhost:8443", "bob", "pass", "badinvite")); - - Assert.Equal("INVALID_CREDENTIALS", ex.ErrorCode); - Assert.Equal(400, ex.StatusCode); - } - - // ── GetChannels ────────────────────────────────────────────────────────── - - [Fact] - public async Task GetChannelsAsync_ReturnsChannelList() - { - var handler = Ok(new[] - { - new { id = 1, name = "general", type = "text", category = "Chat", topic = "", position = 0, slow_mode = 0, archived = false, created_at = "2026-01-01T00:00:00Z" }, - new { id = 2, name = "voice", type = "voice", category = "Voice", topic = "", position = 1, slow_mode = 0, archived = false, created_at = "2026-01-01T00:00:00Z" } - }); - var client = CreateClient(handler); - - var channels = await client.GetChannelsAsync("localhost:8443", "token123"); - - Assert.Equal(2, channels.Count); - Assert.Equal("general", channels[0].Name); - Assert.Equal("voice", channels[1].Name); - Assert.Contains("Bearer token123", handler.LastAuthHeader!); - } - - // ── GetMessages ────────────────────────────────────────────────────────── - - [Fact] - public async Task GetMessagesAsync_ReturnsMessagesWithHasMore() - { - var handler = Ok(new - { - messages = new[] - { - new { id = 10, channel_id = 1, user_id = 1, content = "hello", reply_to = (long?)null, edited_at = (string?)null, deleted = false, pinned = false, timestamp = "2026-01-01T00:00:00Z", username = "alice", avatar = (string?)null } - }, - has_more = true - }); - var client = CreateClient(handler); - - var result = await client.GetMessagesAsync("localhost:8443", "token", 1); - - Assert.Single(result.Messages); - Assert.Equal("hello", result.Messages[0].Content); - Assert.True(result.HasMore); - } - - [Fact] - public async Task GetMessagesAsync_WithBeforeParam_IncludesInUrl() - { - var handler = Ok(new { messages = Array.Empty(), has_more = false }); - var client = CreateClient(handler); - - await client.GetMessagesAsync("localhost:8443", "token", 1, limit: 25, before: 100); - - Assert.Contains("before=100", handler.LastRequestUri!); - Assert.Contains("limit=25", handler.LastRequestUri!); - } - - // ── Health ──────────────────────────────────────────────────────────────── - - [Fact] - public async Task HealthCheckAsync_ReturnsStatusAndVersion() - { - var handler = Ok(new { status = "ok", version = "1.0.0" }); - var client = CreateClient(handler); - - var result = await client.HealthCheckAsync("localhost:8443"); - - Assert.Equal("ok", result.Status); - Assert.Equal("1.0.0", result.Version); - } - - // ── Network error ──────────────────────────────────────────────────────── - - [Fact] - public async Task LoginAsync_NetworkError_ThrowsHttpRequestException() - { - var handler = new FakeHandler(new HttpRequestException("Connection refused")); - var client = CreateClient(handler); - - await Assert.ThrowsAsync( - () => client.LoginAsync("unreachable:8443", "alice", "pass")); - } - - // ── Fake handler ───────────────────────────────────────────────────────── - - private sealed class FakeHandler : HttpMessageHandler - { - private readonly HttpStatusCode _code; - private readonly string? _body; - private readonly Exception? _exception; - - public string? LastRequestUri { get; private set; } - public string? LastAuthHeader { get; private set; } - public HttpMethod? LastMethod { get; private set; } - public string? LastRequestBody { get; private set; } - - public FakeHandler(HttpStatusCode code, string body) - { - _code = code; - _body = body; - } - - public FakeHandler(Exception exception) - { - _exception = exception; - _code = default; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - if (_exception is not null) throw _exception; - - LastRequestUri = request.RequestUri?.ToString(); - LastMethod = request.Method; - LastAuthHeader = request.Headers.Authorization?.ToString(); - if (request.Content is not null) - LastRequestBody = await request.Content.ReadAsStringAsync(ct); - - return new HttpResponseMessage(_code) - { - Content = new StringContent(_body!, System.Text.Encoding.UTF8, "application/json") - }; - } - } -} diff --git a/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs b/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs deleted file mode 100644 index 3f9c7488..00000000 --- a/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.IO; -using OwnCord.Client.Services; - -namespace OwnCord.Client.Tests.Services; - -/// -/// Tests for CertificateTrustService — Trust-On-First-Use (TOFU) certificate pinning. -/// Each test uses an isolated temp directory so there is no shared state between tests. -/// -public sealed class CertificateTrustServiceTests : IDisposable -{ - private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - - // Factory so each assertion that needs a "new instance" can create one pointing at the same dir. - private CertificateTrustService NewSvc() => new(_tempDir); - - // ── IsTrusted ───────────────────────────────────────────────────────────── - - [Fact] - public void IsTrusted_FirstUse_AutoTrustsAndReturnsTrue() - { - // Arrange: no stored fingerprint for this host - var svc = NewSvc(); - - // Act: first connection — TOFU should auto-trust - var result = svc.IsTrusted("server1.local:8443", "AABBCC112233"); - - // Assert - Assert.True(result, "First-use should auto-trust the certificate and return true."); - } - - [Fact] - public void IsTrusted_SameFingerprint_ReturnsTrue() - { - // Arrange: trust fingerprint on first use - var svc = NewSvc(); - svc.IsTrusted("server2.local:8443", "FINGERPRINT_A"); - - // Act: same fingerprint presented again - var result = svc.IsTrusted("server2.local:8443", "FINGERPRINT_A"); - - Assert.True(result, "A previously trusted fingerprint must continue to be accepted."); - } - - [Fact] - public void IsTrusted_DifferentFingerprint_ReturnsFalse() - { - // Arrange: trust an initial fingerprint - var svc = NewSvc(); - svc.IsTrusted("server3.local:8443", "FINGERPRINT_ORIGINAL"); - - // Act: different fingerprint — cert was swapped - var result = svc.IsTrusted("server3.local:8443", "FINGERPRINT_ATTACKER"); - - Assert.False(result, "A changed fingerprint must be rejected to prevent MITM."); - } - - [Fact] - public void IsTrusted_NullCertificate_ReturnsFalse() - { - // Arrange: host has an existing trusted fingerprint - var svc = NewSvc(); - svc.TrustFingerprint("server4.local:8443", "FINGERPRINT_OK"); - - // Act: null/empty fingerprint (cert was null) - var resultNull = svc.IsTrusted("server4.local:8443", null!); - var resultEmpty = svc.IsTrusted("server4.local:8443", ""); - - Assert.False(resultNull, "Null fingerprint must be rejected."); - Assert.False(resultEmpty, "Empty fingerprint must be rejected."); - } - - [Fact] - public void IsTrusted_NullOrEmptyHost_ReturnsFalse() - { - var svc = NewSvc(); - - Assert.False(svc.IsTrusted(null!, "FINGERPRINT"), "Null host must return false."); - Assert.False(svc.IsTrusted("", "FINGERPRINT"), "Empty host must return false."); - } - - [Fact] - public void IsTrusted_DifferentHostsSameFingerprint_TrackedIndependently() - { - // Two different hosts can have the same fingerprint — each is independent - var svc = NewSvc(); - svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT"); - svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"); - - // Changing one host's cert must not affect the other - Assert.True(svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT")); - Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT")); - Assert.False(svc.IsTrusted("host-a:8443", "NEW_FINGERPRINT")); - Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"), "host-b trust must be unaffected."); - } - - // ── TrustFingerprint ────────────────────────────────────────────────────── - - [Fact] - public void TrustFingerprint_StoresFingerprint_CanBeRetrieved() - { - var svc = NewSvc(); - svc.TrustFingerprint("server5.local:8443", "STORED_FP"); - - Assert.Equal("STORED_FP", svc.GetTrustedFingerprint("server5.local:8443")); - } - - [Fact] - public void TrustFingerprint_OverwritesExisting() - { - // Explicitly overwriting — e.g. user manually updated cert trust - var svc = NewSvc(); - svc.TrustFingerprint("server6.local:8443", "OLD_FP"); - svc.TrustFingerprint("server6.local:8443", "NEW_FP"); - - Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server6.local:8443")); - } - - // ── RemoveTrust ─────────────────────────────────────────────────────────── - - [Fact] - public void RemoveTrust_RemovesStoredFingerprint() - { - var svc = NewSvc(); - svc.TrustFingerprint("server7.local:8443", "FP"); - svc.RemoveTrust("server7.local:8443"); - - Assert.Null(svc.GetTrustedFingerprint("server7.local:8443")); - } - - [Fact] - public void RemoveTrust_AfterRemoval_NextConnectionAutoTrustsAgain() - { - // After trust is cleared, the next connection acts as first-use again - var svc = NewSvc(); - svc.TrustFingerprint("server8.local:8443", "OLD_FP"); - svc.RemoveTrust("server8.local:8443"); - - var result = svc.IsTrusted("server8.local:8443", "NEW_FP"); - - Assert.True(result, "After removing trust, the next fingerprint should be auto-trusted."); - Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server8.local:8443")); - } - - [Fact] - public void RemoveTrust_NonExistentHost_DoesNotThrow() - { - var svc = NewSvc(); - var ex = Record.Exception(() => svc.RemoveTrust("never-seen.local:8443")); - Assert.Null(ex); - } - - // ── GetTrustedFingerprint ───────────────────────────────────────────────── - - [Fact] - public void GetTrustedFingerprint_UnknownHost_ReturnsNull() - { - var svc = NewSvc(); - Assert.Null(svc.GetTrustedFingerprint("unknown.local:8443")); - } - - // ── Persistence ─────────────────────────────────────────────────────────── - - [Fact] - public void Persistence_FingerprintSurvivesNewInstanceCreation() - { - // Instance 1: store a fingerprint - NewSvc().TrustFingerprint("persist-host:8443", "PERSISTED_FP"); - - // Instance 2: different object, same directory — must read the stored fingerprint - var fp = NewSvc().GetTrustedFingerprint("persist-host:8443"); - Assert.Equal("PERSISTED_FP", fp); - } - - [Fact] - public void Persistence_IsTrustedUsesPersistedData() - { - // First process: trust on first use - NewSvc().IsTrusted("persist2-host:8443", "FIRST_FP"); - - // Second process: different instance must reject a changed fingerprint - var result = NewSvc().IsTrusted("persist2-host:8443", "CHANGED_FP"); - Assert.False(result, "Persisted fingerprint must be enforced across instances."); - } - - [Fact] - public void Persistence_RemoveTrustSurvivesNewInstance() - { - var svc1 = NewSvc(); - svc1.TrustFingerprint("persist3-host:8443", "FP"); - svc1.RemoveTrust("persist3-host:8443"); - - // New instance: trust should be gone - Assert.Null(NewSvc().GetTrustedFingerprint("persist3-host:8443")); - } - - [Fact] - public void Persistence_CreatesDirectoryIfMissing() - { - Assert.False(Directory.Exists(_tempDir)); - NewSvc().TrustFingerprint("server-dir-test:8443", "FP"); - Assert.True(Directory.Exists(_tempDir)); - } - - // ── Fingerprint case-insensitivity ──────────────────────────────────────── - - [Fact] - public void IsTrusted_FingerprintComparison_IsCaseInsensitive() - { - // SHA-256 hex strings may arrive in upper or lower case depending on the source - var svc = NewSvc(); - svc.TrustFingerprint("case-host:8443", "aabbccddeeff"); - - Assert.True(svc.IsTrusted("case-host:8443", "AABBCCDDEEFF"), - "Fingerprint comparison must be case-insensitive."); - Assert.True(svc.IsTrusted("case-host:8443", "aAbBcCdDeEfF"), - "Mixed-case fingerprint must also match."); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } -} diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs deleted file mode 100644 index 3e9b35fe..00000000 --- a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs +++ /dev/null @@ -1,420 +0,0 @@ -using System.Net.WebSockets; -using System.Text.Json; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.Tests.Services; - -// ── Fakes ──────────────────────────────────────────────────────────────────── - -public class FakeApiClient : IApiClient -{ - public AuthResponse? LoginResult { get; set; } - public AuthResponse? RegisterResult { get; set; } - public IReadOnlyList? ChannelsResult { get; set; } - public MessagesResponse? MessagesResult { get; set; } - public HealthResponse? HealthResult { get; set; } - public ApiUser? MeResult { get; set; } - - public string? LastLoginHost { get; private set; } - public string? LastLoginUsername { get; private set; } - public bool LogoutCalled { get; private set; } - public int LoginCallCount { get; private set; } - - public Task LoginAsync(string host, string username, string password, CancellationToken ct) - { - LastLoginHost = host; - LastLoginUsername = username; - LoginCallCount++; - return Task.FromResult(LoginResult ?? throw new InvalidOperationException("LoginResult not set")); - } - - public Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct) - => Task.FromResult(RegisterResult ?? throw new InvalidOperationException("RegisterResult not set")); - - public Task LogoutAsync(string host, string token, CancellationToken ct) - { - LogoutCalled = true; - return Task.CompletedTask; - } - - public Task GetMeAsync(string host, string token, CancellationToken ct) - => Task.FromResult(MeResult ?? throw new InvalidOperationException("MeResult not set")); - - public Task> GetChannelsAsync(string host, string token, CancellationToken ct) - => Task.FromResult(ChannelsResult ?? throw new InvalidOperationException("ChannelsResult not set")); - - public Task GetMessagesAsync(string host, string token, long channelId, int limit, long? before, CancellationToken ct) - => Task.FromResult(MessagesResult ?? throw new InvalidOperationException("MessagesResult not set")); - - public Task HealthCheckAsync(string host, CancellationToken ct) - => Task.FromResult(HealthResult ?? throw new InvalidOperationException("HealthResult not set")); - - public AuthResponse? VerifyTotpResult { get; set; } - - public Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct) - => Task.FromResult(VerifyTotpResult ?? throw new InvalidOperationException("VerifyTotpResult not set")); -} - -public class FakeWebSocketService : IWebSocketService -{ - public bool IsConnected { get; set; } - public WebSocketState State { get; set; } = WebSocketState.None; - - public event Action? MessageReceived; - public event Action? Disconnected; - - public string? LastConnectUri { get; private set; } - public string? LastConnectToken { get; private set; } - public bool DisconnectCalled { get; private set; } - public List SentMessages { get; } = new(); - public bool RunReceiveLoopStarted { get; private set; } - - public Task ConnectAsync(string uri, string token, CancellationToken ct) - { - LastConnectUri = uri; - LastConnectToken = token; - IsConnected = true; - State = WebSocketState.Open; - return Task.CompletedTask; - } - - public Task SendAsync(object message, CancellationToken ct) - { - SentMessages.Add(JsonSerializer.Serialize(message)); - return Task.CompletedTask; - } - - public Task RunReceiveLoopAsync(CancellationToken ct) - { - RunReceiveLoopStarted = true; - // Don't block — just record that it was called - return Task.CompletedTask; - } - - public IAsyncEnumerable ReceiveAsync(CancellationToken ct) => throw new NotImplementedException(); - - public Task DisconnectAsync() - { - DisconnectCalled = true; - IsConnected = false; - State = WebSocketState.Closed; - return Task.CompletedTask; - } - - // Test helpers to simulate server messages - public void SimulateMessage(string json) => MessageReceived?.Invoke(json); - public void SimulateDisconnect(string reason = "test disconnect") - { - IsConnected = false; - State = WebSocketState.Closed; - Disconnected?.Invoke(reason); - } -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -public class ChatServiceTests -{ - private static readonly ApiUser TestUser = new(1, "alice", null, "online", 1, "2026-01-01T00:00:00Z"); - private static readonly AuthResponse TestAuthResponse = new("tok_abc", TestUser); - - private readonly FakeApiClient _api = new(); - private readonly FakeWebSocketService _ws = new(); - - private ChatService CreateService() => new(_api, _ws); - - // ── Login ──────────────────────────────────────────────────────────── - - [Fact] - public async Task LoginAsync_CallsApiAndStoresState() - { - _api.LoginResult = TestAuthResponse; - var svc = CreateService(); - - var result = await svc.LoginAsync("localhost:8443", "alice", "password"); - - Assert.Equal("tok_abc", result.Token); - Assert.Equal("alice", result.User!.Username); - Assert.Equal("tok_abc", svc.CurrentToken); - Assert.Equal("alice", svc.CurrentUser?.Username); - Assert.Equal("localhost:8443", _api.LastLoginHost); - } - - [Fact] - public async Task LoginAsync_PropagatesApiException() - { - _api.LoginResult = null; // will throw - var svc = CreateService(); - - await Assert.ThrowsAsync( - () => svc.LoginAsync("host", "user", "pass")); - } - - // ── Logout ─────────────────────────────────────────────────────────── - - [Fact] - public async Task LogoutAsync_DisconnectsAndClearsState() - { - _api.LoginResult = TestAuthResponse; - var svc = CreateService(); - await svc.LoginAsync("localhost:8443", "alice", "pass"); - - await svc.LogoutAsync(); - - Assert.True(_api.LogoutCalled); - Assert.True(_ws.DisconnectCalled); - Assert.Null(svc.CurrentToken); - Assert.Null(svc.CurrentUser); - } - - // ── WebSocket connect ──────────────────────────────────────────────── - - [Fact] - public async Task ConnectWebSocketAsync_ConnectsWithCorrectUri() - { - var svc = CreateService(); - - await svc.ConnectWebSocketAsync("localhost:8443", "tok_abc"); - - Assert.Equal("wss://localhost:8443/api/v1/ws", _ws.LastConnectUri); - Assert.Equal("tok_abc", _ws.LastConnectToken); - Assert.True(svc.IsConnected); - } - - // ── Message dispatch (server → client events) ──────────────────────── - - [Fact] - public async Task Dispatches_AuthOk_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - AuthOkPayload? received = null; - svc.AuthOk += p => received = p; - - var json = """ - { "type": "auth_ok", "payload": { "user": { "id": 1, "username": "alice", "avatar": null, "status": "online" }, "server_name": "Test", "motd": "Hi" } } - """; - _ws.SimulateMessage(json); - - Assert.NotNull(received); - Assert.Equal("alice", received!.User.Username); - Assert.Equal("Test", received.ServerName); - } - - [Fact] - public async Task Dispatches_Ready_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - ReadyPayload? received = null; - svc.Ready += p => received = p; - - var json = """ - { "type": "ready", "payload": { "channels": [], "members": [], "voice_states": [], "roles": [] } } - """; - _ws.SimulateMessage(json); - - Assert.NotNull(received); - Assert.Empty(received!.Channels); - } - - [Fact] - public async Task Dispatches_ChatMessage_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - ChatMessagePayload? received = null; - svc.ChatMessageReceived += p => received = p; - - var json = """ - { "type": "chat_message", "payload": { "id": 42, "channel_id": 1, "user": { "id": 1, "username": "alice", "avatar": null }, "content": "Hello!", "reply_to": null, "timestamp": "2026-01-01T00:00:00Z" } } - """; - _ws.SimulateMessage(json); - - Assert.NotNull(received); - Assert.Equal(42, received!.Id); - Assert.Equal("Hello!", received.Content); - } - - [Fact] - public async Task Dispatches_Typing_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - TypingPayload? received = null; - svc.TypingReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "typing", "payload": { "channel_id": 1, "user_id": 2, "username": "bob" } }"""); - - Assert.NotNull(received); - Assert.Equal("bob", received!.Username); - } - - [Fact] - public async Task Dispatches_Presence_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - PresencePayload? received = null; - svc.PresenceChanged += p => received = p; - - _ws.SimulateMessage("""{ "type": "presence", "payload": { "user_id": 3, "status": "idle" } }"""); - - Assert.NotNull(received); - Assert.Equal("idle", received!.Status); - } - - [Fact] - public async Task Dispatches_Error_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - WsErrorPayload? received = null; - svc.ErrorReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "error", "id": "req-1", "payload": { "code": "RATE_LIMITED", "message": "slow down" } }"""); - - Assert.NotNull(received); - Assert.Equal("RATE_LIMITED", received!.Code); - } - - [Fact] - public async Task Dispatches_ChatEdited_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - ChatEditedPayload? received = null; - svc.ChatEdited += p => received = p; - - _ws.SimulateMessage("""{ "type": "chat_edited", "payload": { "message_id": 10, "channel_id": 1, "content": "edited", "edited_at": "2026-01-01T00:00:00Z" } }"""); - - Assert.NotNull(received); - Assert.Equal("edited", received!.Content); - } - - [Fact] - public async Task Dispatches_ChatDeleted_Event() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - ChatDeletedPayload? received = null; - svc.ChatDeleted += p => received = p; - - _ws.SimulateMessage("""{ "type": "chat_deleted", "payload": { "message_id": 10, "channel_id": 1 } }"""); - - Assert.NotNull(received); - Assert.Equal(10, received!.MessageId); - } - - // ── Send message ───────────────────────────────────────────────────── - - [Fact] - public async Task SendMessageAsync_SendsCorrectEnvelope() - { - _api.LoginResult = TestAuthResponse; - var svc = CreateService(); - await svc.LoginAsync("host:8443", "alice", "pass"); - await svc.ConnectWebSocketAsync("host:8443", "tok_abc"); - - await svc.SendMessageAsync(1, "Hello!", replyTo: 5); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("chat_send", sent.RootElement.GetProperty("type").GetString()); - Assert.Equal(1, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); - Assert.Equal("Hello!", sent.RootElement.GetProperty("payload").GetProperty("content").GetString()); - Assert.Equal(5, sent.RootElement.GetProperty("payload").GetProperty("reply_to").GetInt64()); - } - - [Fact] - public async Task SendTypingAsync_SendsCorrectEnvelope() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - await svc.SendTypingAsync(1); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("typing_start", sent.RootElement.GetProperty("type").GetString()); - Assert.Equal(1, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); - } - - // ── REST data fetches ──────────────────────────────────────────────── - - [Fact] - public async Task GetChannelsAsync_UsesStoredHostAndToken() - { - _api.LoginResult = TestAuthResponse; - _api.ChannelsResult = new List - { - new(1, "general", "text", "Chat", "", 0, 0, false, "2026-01-01T00:00:00Z") - }; - - var svc = CreateService(); - await svc.LoginAsync("localhost:8443", "alice", "pass"); - - var channels = await svc.GetChannelsAsync(); - - Assert.Single(channels); - Assert.Equal("general", channels[0].Name); - } - - [Fact] - public async Task GetMessagesAsync_PassesParameters() - { - _api.LoginResult = TestAuthResponse; - _api.MessagesResult = new MessagesResponse( - new List { new(1, 1, 1, "hi", null, null, false, false, "2026-01-01T00:00:00Z", "alice", null) }, - false - ); - - var svc = CreateService(); - await svc.LoginAsync("localhost:8443", "alice", "pass"); - - var result = await svc.GetMessagesAsync(1, limit: 25, before: 100); - - Assert.Single(result.Messages); - Assert.False(result.HasMore); - } - - // ── Disconnection event ────────────────────────────────────────────── - - [Fact] - public async Task ConnectionLost_FiresOnDisconnect() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - string? reason = null; - svc.ConnectionLost += r => reason = r; - - _ws.SimulateDisconnect(); - - Assert.NotNull(reason); - Assert.False(svc.IsConnected); - } - - // ── Unknown message type is silently ignored ───────────────────────── - - [Fact] - public async Task UnknownMessageType_DoesNotThrow() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - - var exception = Record.Exception(() => - _ws.SimulateMessage("""{ "type": "unknown_future_type", "payload": {} }""")); - - Assert.Null(exception); - } -} diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs deleted file mode 100644 index 26d199f4..00000000 --- a/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System.Text.Json; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.Tests.Services; - -/// Tests for ChatService voice commands and additional dispatch events. -public class ChatServiceVoiceTests -{ - private static readonly ApiUser TestUser = new(1, "alice", null, "online", 1, "2026-01-01T00:00:00Z"); - private static readonly AuthResponse TestAuthResponse = new("tok_abc", TestUser); - - private readonly FakeApiClient _api = new(); - private readonly FakeWebSocketService _ws = new(); - - private ChatService CreateService() => new(_api, _ws); - - private async Task CreateConnectedService() - { - var svc = CreateService(); - await svc.ConnectWebSocketAsync("host:8443", "tok"); - return svc; - } - - // ── Voice outbound commands ────────────────────────────────────────── - - [Fact] - public async Task JoinVoiceAsync_SendsCorrectEnvelope() - { - var svc = await CreateConnectedService(); - - await svc.JoinVoiceAsync(42); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("voice_join", sent.RootElement.GetProperty("type").GetString()); - Assert.Equal(42, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); - } - - [Fact] - public async Task LeaveVoiceAsync_SendsCorrectEnvelope() - { - var svc = await CreateConnectedService(); - - await svc.LeaveVoiceAsync(); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("voice_leave", sent.RootElement.GetProperty("type").GetString()); - } - - [Fact] - public async Task SendVoiceMuteAsync_SendsCorrectEnvelope() - { - var svc = await CreateConnectedService(); - - await svc.SendVoiceMuteAsync(true); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("voice_mute", sent.RootElement.GetProperty("type").GetString()); - Assert.True(sent.RootElement.GetProperty("payload").GetProperty("muted").GetBoolean()); - } - - [Fact] - public async Task SendVoiceDeafenAsync_SendsCorrectEnvelope() - { - var svc = await CreateConnectedService(); - - await svc.SendVoiceDeafenAsync(true); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("voice_deafen", sent.RootElement.GetProperty("type").GetString()); - Assert.True(sent.RootElement.GetProperty("payload").GetProperty("deafened").GetBoolean()); - } - - [Fact] - public async Task SendChannelFocusAsync_SendsCorrectEnvelope() - { - var svc = await CreateConnectedService(); - - await svc.SendChannelFocusAsync(7); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("channel_focus", sent.RootElement.GetProperty("type").GetString()); - Assert.Equal(7, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); - } - - // ── Voice inbound events ───────────────────────────────────────────── - - [Fact] - public async Task Dispatches_VoiceState_Event() - { - var svc = await CreateConnectedService(); - VoiceStatePayload? received = null; - svc.VoiceStateReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": true, "deafened": false } }"""); - - Assert.NotNull(received); - Assert.Equal(1, received!.UserId); - Assert.Equal(5, received.ChannelId); - Assert.True(received.Muted); - Assert.False(received.Deafened); - } - - [Fact] - public async Task Dispatches_VoiceLeave_Event() - { - var svc = await CreateConnectedService(); - VoiceLeavePayload? received = null; - svc.VoiceLeaveReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 2, "channel_id": 5 } }"""); - - Assert.NotNull(received); - Assert.Equal(2, received!.UserId); - Assert.Equal(5, received.ChannelId); - } - - [Fact] - public async Task Dispatches_VoiceConfig_Event() - { - var svc = await CreateConnectedService(); - VoiceConfigPayload? received = null; - svc.VoiceConfigReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "voice_config", "payload": { "channel_id": 5, "quality": "high", "bitrate": 128000, "mode": "sfu" } }"""); - - Assert.NotNull(received); - Assert.Equal("high", received!.Quality); - Assert.Equal(128000, received.Bitrate); - } - - [Fact] - public async Task Dispatches_VoiceSpeakers_Event() - { - var svc = await CreateConnectedService(); - VoiceSpeakersPayload? received = null; - svc.VoiceSpeakersReceived += p => received = p; - - _ws.SimulateMessage("""{ "type": "voice_speakers", "payload": { "channel_id": 5, "speakers": [1, 3], "mode": "sfu" } }"""); - - Assert.NotNull(received); - Assert.Equal(5, received!.ChannelId); - Assert.Equal(new long[] { 1, 3 }, received.Speakers); - } - - // ── Additional dispatch events ─────────────────────────────────────── - - [Fact] - public async Task Dispatches_ChatSendOk_Event() - { - var svc = await CreateConnectedService(); - ChatSendOkPayload? received = null; - svc.ChatSendOk += p => received = p; - - _ws.SimulateMessage("""{ "type": "chat_send_ok", "payload": { "message_id": 99, "timestamp": "2026-01-01T00:00:00Z" } }"""); - - Assert.NotNull(received); - Assert.Equal(99, received!.MessageId); - } - - [Fact] - public async Task Dispatches_ReactionUpdate_Event() - { - var svc = await CreateConnectedService(); - ReactionUpdatePayload? received = null; - svc.ReactionUpdated += p => received = p; - - _ws.SimulateMessage("""{ "type": "reaction_update", "payload": { "message_id": 10, "channel_id": 1, "emoji": "👍", "user_id": 2, "action": "add" } }"""); - - Assert.NotNull(received); - Assert.Equal("add", received!.Action); - Assert.Equal(10, received.MessageId); - } - - [Fact] - public async Task Dispatches_ServerRestart_Event() - { - var svc = await CreateConnectedService(); - ServerRestartPayload? received = null; - svc.ServerRestarting += p => received = p; - - _ws.SimulateMessage("""{ "type": "server_restart", "payload": { "reason": "update", "delay_seconds": 30 } }"""); - - Assert.NotNull(received); - Assert.Equal("update", received!.Reason); - Assert.Equal(30, received.DelaySeconds); - } - - [Fact] - public async Task Dispatches_MemberJoin_Event() - { - var svc = await CreateConnectedService(); - WsMember? received = null; - svc.MemberJoined += p => received = p; - - _ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 10, "username": "newuser", "avatar": null, "status": "online", "role_id": 1 } }"""); - - Assert.NotNull(received); - Assert.Equal("newuser", received!.Username); - } - - [Fact] - public async Task Dispatches_ChannelCreate_Event() - { - var svc = await CreateConnectedService(); - ChannelEventPayload? received = null; - svc.ChannelCreated += p => received = p; - - _ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 5, "name": "new-channel", "type": "text", "category": "Chat", "topic": "Hello", "position": 3 } }"""); - - Assert.NotNull(received); - Assert.Equal("new-channel", received!.Name); - } - - [Fact] - public async Task Dispatches_ChannelUpdate_Event() - { - var svc = await CreateConnectedService(); - ChannelEventPayload? received = null; - svc.ChannelUpdated += p => received = p; - - _ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 5, "name": "renamed-channel", "type": "text", "category": "Chat", "topic": null, "position": 3 } }"""); - - Assert.NotNull(received); - Assert.Equal("renamed-channel", received!.Name); - } - - [Fact] - public async Task Dispatches_ChannelDelete_Event() - { - var svc = await CreateConnectedService(); - long? received = null; - svc.ChannelDeleted += id => received = id; - - _ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 5 } }"""); - - Assert.NotNull(received); - Assert.Equal(5, received); - } - - // ── TOTP ───────────────────────────────────────────────────────────── - - [Fact] - public async Task VerifyTotpAsync_CallsApiAndStoresState() - { - _api.VerifyTotpResult = TestAuthResponse; - var svc = CreateService(); - - var result = await svc.VerifyTotpAsync("localhost:8443", "partial_tok", "123456"); - - Assert.Equal("tok_abc", result.Token); - Assert.Equal("alice", result.User!.Username); - Assert.Equal("tok_abc", svc.CurrentToken); - } - - // ── Register ───────────────────────────────────────────────────────── - - [Fact] - public async Task RegisterAsync_CallsApiAndStoresState() - { - _api.RegisterResult = TestAuthResponse; - var svc = CreateService(); - - var result = await svc.RegisterAsync("localhost:8443", "alice", "pass", "invite123"); - - Assert.Equal("tok_abc", result.Token); - Assert.Equal("alice", svc.CurrentUser?.Username); - } - - // ── Edge cases ─────────────────────────────────────────────────────── - - [Fact] - public async Task MalformedJson_DoesNotThrow() - { - var svc = await CreateConnectedService(); - - var exception = Record.Exception(() => _ws.SimulateMessage("not json at all")); - - Assert.Null(exception); - } - - [Fact] - public async Task KnownType_NullPayload_DoesNotThrow() - { - var svc = await CreateConnectedService(); - - // A known type like "chat_message" with null payload should not crash - var exception = Record.Exception(() => - _ws.SimulateMessage("""{ "type": "chat_message", "payload": null }""")); - - Assert.Null(exception); - } - - [Fact] - public async Task KnownType_MissingPayload_DoesNotThrow() - { - var svc = await CreateConnectedService(); - - // A known type with no payload key at all - var exception = Record.Exception(() => - _ws.SimulateMessage("""{ "type": "chat_message" }""")); - - Assert.Null(exception); - } - - [Fact] - public async Task DisconnectWebSocketAsync_SetsIntentionalFlag() - { - var svc = await CreateConnectedService(); - - await svc.DisconnectWebSocketAsync(); - - Assert.True(_ws.DisconnectCalled); - } - - [Fact] - public async Task VoiceMute_False_SendsCorrectPayload() - { - var svc = await CreateConnectedService(); - - await svc.SendVoiceMuteAsync(false); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.False(sent.RootElement.GetProperty("payload").GetProperty("muted").GetBoolean()); - } - - // ── Edit / Delete message outbound commands ────────────────────────── - - [Fact] - public async Task EditMessageAsync_SendsCorrectType() - { - var svc = await CreateConnectedService(); - - await svc.EditMessageAsync(77, "updated content"); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("chat_edit", sent.RootElement.GetProperty("type").GetString()); - } - - [Fact] - public async Task EditMessageAsync_SendsCorrectMessageId() - { - var svc = await CreateConnectedService(); - - await svc.EditMessageAsync(77, "updated content"); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal(77, sent.RootElement.GetProperty("payload").GetProperty("message_id").GetInt64()); - } - - [Fact] - public async Task EditMessageAsync_SendsCorrectContent() - { - var svc = await CreateConnectedService(); - - await svc.EditMessageAsync(77, "updated content"); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("updated content", sent.RootElement.GetProperty("payload").GetProperty("content").GetString()); - } - - [Fact] - public async Task EditMessageAsync_IncludesNonEmptyId() - { - var svc = await CreateConnectedService(); - - await svc.EditMessageAsync(77, "updated content"); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - var id = sent.RootElement.GetProperty("id").GetString(); - Assert.NotNull(id); - Assert.NotEmpty(id); - } - - [Fact] - public async Task DeleteMessageAsync_SendsCorrectType() - { - var svc = await CreateConnectedService(); - - await svc.DeleteMessageAsync(55); - - Assert.Single(_ws.SentMessages); - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("chat_delete", sent.RootElement.GetProperty("type").GetString()); - } - - [Fact] - public async Task DeleteMessageAsync_SendsCorrectMessageId() - { - var svc = await CreateConnectedService(); - - await svc.DeleteMessageAsync(55); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal(55, sent.RootElement.GetProperty("payload").GetProperty("message_id").GetInt64()); - } - - [Fact] - public async Task DeleteMessageAsync_IncludesNonEmptyId() - { - var svc = await CreateConnectedService(); - - await svc.DeleteMessageAsync(55); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - var id = sent.RootElement.GetProperty("id").GetString(); - Assert.NotNull(id); - Assert.NotEmpty(id); - } - - [Fact] - public async Task EditMessageAsync_EachCallProducesUniqueId() - { - var svc = await CreateConnectedService(); - - await svc.EditMessageAsync(1, "first"); - await svc.EditMessageAsync(2, "second"); - - var id1 = JsonDocument.Parse(_ws.SentMessages[0]).RootElement.GetProperty("id").GetString(); - var id2 = JsonDocument.Parse(_ws.SentMessages[1]).RootElement.GetProperty("id").GetString(); - Assert.NotEqual(id1, id2); - } - - [Fact] - public async Task DeleteMessageAsync_DoesNotIncludeContentField() - { - var svc = await CreateConnectedService(); - - await svc.DeleteMessageAsync(55); - - var sent = JsonDocument.Parse(_ws.SentMessages[0]); - var payload = sent.RootElement.GetProperty("payload"); - Assert.False(payload.TryGetProperty("content", out _)); - } -} diff --git a/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs b/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs deleted file mode 100644 index c95dd3ff..00000000 --- a/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs +++ /dev/null @@ -1,521 +0,0 @@ -using OwnCord.Client.Services; -using static OwnCord.Client.Services.MessageContentParser; - -namespace OwnCord.Client.Tests.Services; - -public class MessageContentParserTests -{ - // ── Helpers ─────────────────────────────────────────────────────────────── - - private static ContentSegment Text(string text) => - new(SegmentType.Text, text); - - private static ContentSegment Code(string text, string? lang = null) => - new(SegmentType.CodeBlock, text, lang); - - private static ContentSegment Inline(string text) => - new(SegmentType.InlineCode, text); - - private static ContentSegment Bold(string text) => - new(SegmentType.Bold, text); - - private static ContentSegment Italic(string text) => - new(SegmentType.Italic, text); - - // ── 1. Plain text → single Text segment ────────────────────────────────── - - [Fact] - public void Parse_PlainText_ReturnsSingleTextSegment() - { - var result = Parse("Hello, world!"); - - Assert.Single(result); - Assert.Equal(Text("Hello, world!"), result[0]); - } - - [Fact] - public void Parse_PlainTextWithSpaces_PreservesWhitespace() - { - var result = Parse(" spaces around "); - - Assert.Single(result); - Assert.Equal(Text(" spaces around "), result[0]); - } - - // ── 2. Empty string → empty list ───────────────────────────────────────── - - [Fact] - public void Parse_EmptyString_ReturnsEmptyList() - { - var result = Parse(string.Empty); - - Assert.Empty(result); - } - - [Fact] - public void Parse_NullString_ReturnsEmptyList() - { - var result = Parse(null!); - - Assert.Empty(result); - } - - // ── 3. Code block with language ─────────────────────────────────────────── - - [Fact] - public void Parse_CodeBlockWithLanguage_ReturnsCodeBlockSegmentWithLanguage() - { - var result = Parse("```csharp\nvar x = 1;\n```"); - - Assert.Single(result); - var seg = result[0]; - Assert.Equal(SegmentType.CodeBlock, seg.Type); - Assert.Equal("var x = 1;\n", seg.Text); - Assert.Equal("csharp", seg.Language); - } - - [Fact] - public void Parse_CodeBlockWithLanguage_CapturesMultilineCode() - { - var input = "```go\nfunc main() {\n fmt.Println(\"hello\")\n}\n```"; - - var result = Parse(input); - - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - Assert.Equal("go", result[0].Language); - Assert.Contains("func main()", result[0].Text); - } - - // ── 4. Code block without language ─────────────────────────────────────── - - [Fact] - public void Parse_CodeBlockWithoutLanguage_ReturnsNullLanguage() - { - var result = Parse("```\nsome code\n```"); - - Assert.Single(result); - var seg = result[0]; - Assert.Equal(SegmentType.CodeBlock, seg.Type); - Assert.Null(seg.Language); - Assert.Equal("some code\n", seg.Text); - } - - [Fact] - public void Parse_CodeBlockWithoutLanguageNoNewline_ReturnsNullLanguage() - { - // Regex allows optional newline after language: ```(\w*)\n? - var result = Parse("```some code```"); - - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - // "some" would be captured as language since it matches \w+ - // "some" is captured by group 1 (\w*), space breaks \w so "some" is language - Assert.Equal("some", result[0].Language); - } - - // ── 5. Inline code ──────────────────────────────────────────────────────── - - [Fact] - public void Parse_InlineCode_ReturnsInlineCodeSegment() - { - var result = Parse("`var x = 1`"); - - Assert.Single(result); - Assert.Equal(Inline("var x = 1"), result[0]); - } - - [Fact] - public void Parse_InlineCode_CapturedTextExcludesBackticks() - { - var result = Parse("`hello`"); - - Assert.Single(result); - Assert.Equal("hello", result[0].Text); - Assert.Equal(SegmentType.InlineCode, result[0].Type); - Assert.Null(result[0].Language); - } - - // ── 6. Bold text ───────────────────────────────────────────────────────── - - [Fact] - public void Parse_BoldText_ReturnsBoldSegment() - { - var result = Parse("**bold text**"); - - Assert.Single(result); - Assert.Equal(Bold("bold text"), result[0]); - } - - [Fact] - public void Parse_BoldText_CapturedTextExcludesAsterisks() - { - var result = Parse("**important**"); - - Assert.Single(result); - Assert.Equal(SegmentType.Bold, result[0].Type); - Assert.Equal("important", result[0].Text); - } - - // ── 7. Italic text ──────────────────────────────────────────────────────── - - [Fact] - public void Parse_ItalicText_ReturnsItalicSegment() - { - var result = Parse("*italic text*"); - - Assert.Single(result); - Assert.Equal(Italic("italic text"), result[0]); - } - - [Fact] - public void Parse_ItalicText_CapturedTextExcludesAsterisks() - { - var result = Parse("*emphasis*"); - - Assert.Single(result); - Assert.Equal(SegmentType.Italic, result[0].Type); - Assert.Equal("emphasis", result[0].Text); - } - - // ── 8. Mixed: "Hello `code` world" ─────────────────────────────────────── - - [Fact] - public void Parse_TextInlineCodeText_ReturnsThreeSegments() - { - var result = Parse("Hello `code` world"); - - Assert.Equal(3, result.Count); - Assert.Equal(Text("Hello "), result[0]); - Assert.Equal(Inline("code"), result[1]); - Assert.Equal(Text(" world"), result[2]); - } - - [Fact] - public void Parse_InlineCodeAtStart_ReturnsInlineCodeThenText() - { - var result = Parse("`start` and more"); - - Assert.Equal(2, result.Count); - Assert.Equal(Inline("start"), result[0]); - Assert.Equal(Text(" and more"), result[1]); - } - - [Fact] - public void Parse_InlineCodeAtEnd_ReturnsTextThenInlineCode() - { - var result = Parse("prefix `end`"); - - Assert.Equal(2, result.Count); - Assert.Equal(Text("prefix "), result[0]); - Assert.Equal(Inline("end"), result[1]); - } - - // ── 9. Code block with surrounding text ────────────────────────────────── - - [Fact] - public void Parse_TextCodeBlockText_ReturnsThreeSegments() - { - var input = "Before:\n```python\nprint(\"hi\")\n```\nAfter"; - - var result = Parse(input); - - Assert.Equal(3, result.Count); - Assert.Equal(SegmentType.Text, result[0].Type); - Assert.Equal("Before:\n", result[0].Text); - Assert.Equal(SegmentType.CodeBlock, result[1].Type); - Assert.Equal("python", result[1].Language); - Assert.Equal(SegmentType.Text, result[2].Type); - Assert.Equal("\nAfter", result[2].Text); - } - - [Fact] - public void Parse_CodeBlockAtStart_ReturnsCodeBlockThenText() - { - var input = "```js\nconsole.log(1)\n```\nDone."; - - var result = Parse(input); - - Assert.Equal(2, result.Count); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - Assert.Equal("js", result[0].Language); - Assert.Equal(SegmentType.Text, result[1].Type); - Assert.Equal("\nDone.", result[1].Text); - } - - // ── 10. Multiple inline codes in one message ────────────────────────────── - - [Fact] - public void Parse_MultipleInlineCodes_AllCaptured() - { - var result = Parse("`foo` and `bar` and `baz`"); - - Assert.Equal(5, result.Count); - Assert.Equal(Inline("foo"), result[0]); - Assert.Equal(Text(" and "), result[1]); - Assert.Equal(Inline("bar"), result[2]); - Assert.Equal(Text(" and "), result[3]); - Assert.Equal(Inline("baz"), result[4]); - } - - [Fact] - public void Parse_TwoAdjacentInlineCodes_BothCaptured() - { - var result = Parse("`a``b`"); - - // `a` matches, then `` ` `` (empty) is skipped (regex requires [^`\n]+), - // then `b` matches: result is [Inline("a"), Inline("b")] - Assert.Equal(2, result.Count); - Assert.Equal(Inline("a"), result[0]); - Assert.Equal(Inline("b"), result[1]); - } - - // ── 11. Bold and italic mixed ───────────────────────────────────────────── - - [Fact] - public void Parse_BoldAndItalic_BothSegmentsPresent() - { - var result = Parse("**bold** and *italic*"); - - Assert.Equal(3, result.Count); - Assert.Equal(Bold("bold"), result[0]); - Assert.Equal(Text(" and "), result[1]); - Assert.Equal(Italic("italic"), result[2]); - } - - [Fact] - public void Parse_ItalicThenBold_BothSegmentsPresent() - { - var result = Parse("*em* then **strong**"); - - Assert.Equal(3, result.Count); - Assert.Equal(SegmentType.Italic, result[0].Type); - Assert.Equal("em", result[0].Text); - Assert.Equal(Text(" then "), result[1]); - Assert.Equal(Bold("strong"), result[2]); - } - - // ── 12. Bold containing text (no nesting) ──────────────────────────────── - - [Fact] - public void Parse_BoldSpan_InnerTextIsPreservedVerbatim() - { - // The parser does NOT recurse into bold/italic — inner text is raw. - var result = Parse("**hello world**"); - - Assert.Single(result); - Assert.Equal(SegmentType.Bold, result[0].Type); - Assert.Equal("hello world", result[0].Text); - } - - [Fact] - public void Parse_BoldContainingAsterisk_MatchesInnerContent() - { - // Bold uses .+? so it stops at the first ** - var result = Parse("**a * b**"); - - Assert.Single(result); - Assert.Equal(SegmentType.Bold, result[0].Type); - Assert.Equal("a * b", result[0].Text); - } - - // ── 13. Unclosed backtick → plain text ─────────────────────────────────── - - [Fact] - public void Parse_UnclosedInlineBacktick_TreatedAsPlainText() - { - // InlineCode regex requires a closing backtick on the same line - var result = Parse("hello `world"); - - Assert.Single(result); - Assert.Equal(Text("hello `world"), result[0]); - } - - [Fact] - public void Parse_BacktickWithNewlineInside_TreatedAsPlainText() - { - // [^`\n]+ excludes newlines, so a backtick spanning lines cannot match - var result = Parse("`line1\nline2`"); - - Assert.Single(result); - Assert.Equal(SegmentType.Text, result[0].Type); - Assert.Equal("`line1\nline2`", result[0].Text); - } - - [Fact] - public void Parse_OnlyOpeningBacktick_TreatedAsPlainText() - { - var result = Parse("`"); - - Assert.Single(result); - Assert.Equal(Text("`"), result[0]); - } - - // ── 14. Empty code block ────────────────────────────────────────────────── - - [Fact] - public void Parse_EmptyCodeBlockNoLanguage_CodeBlockWithEmptyText() - { - // ```(\w*)\n?([\s\S]*?)``` — lazy *? can match empty string - var result = Parse("``````"); - - // ``` `` ``` — three backticks open, zero chars, three backticks close - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - Assert.Equal(string.Empty, result[0].Text); - Assert.Null(result[0].Language); - } - - [Fact] - public void Parse_CodeBlockWithOnlyNewline_CodeBlockWithNewlineText() - { - var result = Parse("```\n\n```"); - - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - // The optional \n? consumes the first newline; second \n is part of the code - Assert.Equal("\n", result[0].Text); - Assert.Null(result[0].Language); - } - - // ── 15. Code block with special characters ──────────────────────────────── - - [Fact] - public void Parse_CodeBlockWithSpecialChars_PreservesContent() - { - var code = "x < 10 && y > 5 || z == 0;\n\n"; - var input = $"```\n{code}```"; - - var result = Parse(input); - - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - Assert.Equal(code, result[0].Text); - } - - [Fact] - public void Parse_CodeBlockWithUnicode_PreservesContent() - { - var input = "```\n日本語テスト 🎉\n```"; - - var result = Parse(input); - - Assert.Single(result); - Assert.Equal(SegmentType.CodeBlock, result[0].Type); - Assert.Contains("日本語テスト", result[0].Text); - Assert.Contains("🎉", result[0].Text); - } - - [Fact] - public void Parse_CodeBlockWithSqlChars_PreservesContent() - { - var input = "```sql\nSELECT * FROM users WHERE id = '1' OR '1'='1';\n```"; - - var result = Parse(input); - - Assert.Single(result); - Assert.Equal("sql", result[0].Language); - Assert.Contains("SELECT * FROM users", result[0].Text); - } - - [Fact] - public void Parse_InlineCodeWithSpecialChars_PreservesContent() - { - var result = Parse("`x < y && z > 0`"); - - Assert.Single(result); - Assert.Equal(SegmentType.InlineCode, result[0].Type); - Assert.Equal("x < y && z > 0", result[0].Text); - } - - // ── Boundary / additional edge cases ───────────────────────────────────── - - [Fact] - public void Parse_BoldWithNoSurroundingText_NoBoundaryTextSegments() - { - var result = Parse("**only bold**"); - - Assert.Single(result); - Assert.Equal(Bold("only bold"), result[0]); - } - - [Fact] - public void Parse_ItalicWithNoSurroundingText_NoBoundaryTextSegments() - { - var result = Parse("*only italic*"); - - Assert.Single(result); - Assert.Equal(Italic("only italic"), result[0]); - } - - [Fact] - public void Parse_DoubleAsterisksAreNotItalic() - { - // ** is consumed by bold regex; the italic lookahead (? s.Type == SegmentType.CodeBlock).ToList(); - Assert.Equal(2, codeBlocks.Count); - Assert.Contains("first", codeBlocks[0].Text); - Assert.Contains("second", codeBlocks[1].Text); - } - - [Fact] - public void Parse_WhitespaceOnlyString_ReturnsSingleTextSegment() - { - // string.IsNullOrEmpty(" ") is false, so whitespace-only goes through parsing - // No formatting marks → AddTextSegment adds it as Text - var result = Parse(" "); - - Assert.Single(result); - Assert.Equal(SegmentType.Text, result[0].Type); - Assert.Equal(" ", result[0].Text); - } - - [Fact] - public void Parse_InlineCodeInsideTextWithBold_InlineCodeTakesPrecedence() - { - // Inline code is processed before bold/italic, so **...** inside inline code - // is NOT parsed as bold — it's raw code content. - var result = Parse("`**not bold**`"); - - Assert.Single(result); - Assert.Equal(SegmentType.InlineCode, result[0].Type); - Assert.Equal("**not bold**", result[0].Text); - } - - [Fact] - public void Parse_SegmentTypesAreNeverNull() - { - var inputs = new[] - { - "plain text", - "**bold**", - "*italic*", - "`code`", - "```\nblock\n```", - "mix **bold** and `code`", - }; - - foreach (var input in inputs) - { - var result = Parse(input); - Assert.All(result, seg => Assert.True( - Enum.IsDefined(typeof(SegmentType), seg.Type), - $"Invalid segment type in: {input}")); - } - } -} diff --git a/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs deleted file mode 100644 index 40bf968c..00000000 --- a/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.IO; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.Tests.Services; - -public sealed class ProfileServiceTests : IDisposable -{ - private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - private ProfileService Svc => new(_tempDir); - - [Fact] - public void LoadProfiles_ReturnsEmpty_WhenNoFile() - { - var profiles = Svc.LoadProfiles(); - Assert.Empty(profiles); - } - - [Fact] - public void SaveAndLoad_RoundTrips() - { - var svc = Svc; - var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "alice"); - svc.SaveProfiles([profile]); - var loaded = svc.LoadProfiles(); - Assert.Single(loaded); - Assert.Equal(profile.Name, loaded[0].Name); - Assert.Equal(profile.Host, loaded[0].Host); - } - - [Fact] - public void AddProfile_DoesNotMutateOriginal() - { - var svc = Svc; - IReadOnlyList original = []; - var profile = ServerProfile.Create("Home", "localhost:8443"); - var updated = svc.AddProfile(original, profile); - Assert.Empty(original); - Assert.Single(updated); - } - - [Fact] - public void RemoveProfile_RemovesById() - { - var svc = Svc; - var p1 = ServerProfile.Create("A", "a:8443"); - var p2 = ServerProfile.Create("B", "b:8443"); - var list = svc.AddProfile(svc.AddProfile([], p1), p2); - var result = svc.RemoveProfile(list, p1.Id); - Assert.Single(result); - Assert.Equal(p2.Id, result[0].Id); - } - - [Fact] - public void UpdateProfile_ReplacesMatchingId() - { - var svc = Svc; - var p = ServerProfile.Create("Old", "old:8443"); - var list = svc.AddProfile([], p); - var updated = p with { Name = "New" }; - var result = svc.UpdateProfile(list, updated); - Assert.Equal("New", result[0].Name); - } - - [Fact] - public void SaveProfiles_CreatesDirectory() - { - Assert.False(Directory.Exists(_tempDir)); - Svc.SaveProfiles([]); - Assert.True(Directory.Exists(_tempDir)); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } -} diff --git a/Client/OwnCord.Client.Tests/UnitTest1.cs b/Client/OwnCord.Client.Tests/UnitTest1.cs deleted file mode 100644 index 8832df3f..00000000 --- a/Client/OwnCord.Client.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OwnCord.Client.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs deleted file mode 100644 index f9ed1f67..00000000 --- a/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs +++ /dev/null @@ -1,293 +0,0 @@ -using OwnCord.Client.Models; -using OwnCord.Client.Services; -using OwnCord.Client.ViewModels; - -namespace OwnCord.Client.Tests.ViewModels; - -public sealed class ConnectViewModelTests -{ - private static ConnectViewModel MakeVm(IProfileService? svc = null, ICredentialService? creds = null, IApiClient? api = null) - => new(svc ?? new FakeProfileService(), creds ?? new FakeCredentialService(), api ?? new StubApiClient()); - - [Fact] - public void DefaultMode_IsLogin() - { - var vm = MakeVm(); - Assert.False(vm.IsRegisterMode); - } - - [Fact] - public void ToggleRegisterMode_FlipsFlag() - { - var vm = MakeVm(); - vm.IsRegisterMode = true; - Assert.True(vm.IsRegisterMode); - vm.IsRegisterMode = false; - Assert.False(vm.IsRegisterMode); - } - - [Fact] - public void ConnectCommand_DisabledWhenHostEmpty() - { - var vm = MakeVm(); - vm.Username = "alice"; - vm.Host = ""; - Assert.False(vm.ConnectCommand.CanExecute(null)); - } - - [Fact] - public void ConnectCommand_DisabledWhenUsernameEmpty() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = ""; - Assert.False(vm.ConnectCommand.CanExecute(null)); - } - - [Fact] - public void ConnectCommand_EnabledWhenHostAndUsernameSet() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - Assert.True(vm.ConnectCommand.CanExecute(null)); - } - - [Fact] - public void ConnectCommand_RaisesConnectRequested() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - vm.Password = "pass123"; - (string host, string user, string? invite, bool isReg) captured = default; - vm.ConnectRequested += (h, u, _, i, r) => captured = (h, u, i, r); - vm.ConnectCommand.Execute(null); - Assert.Equal("localhost:8443", captured.host); - Assert.Equal("alice", captured.user); - Assert.Null(captured.invite); - Assert.False(captured.isReg); - } - - [Fact] - public void ConnectCommand_RegisterMode_PassesInviteCode() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - vm.Password = "pass123"; - vm.IsRegisterMode = true; - vm.InviteCode = "abc123"; - string? capturedInvite = null; - vm.ConnectRequested += (_, _, _, i, _) => capturedInvite = i; - vm.ConnectCommand.Execute(null); - Assert.Equal("abc123", capturedInvite); - } - - [Fact] - public void SaveProfileCommand_DisabledWhenHostEmpty() - { - var vm = MakeVm(); - vm.Username = "alice"; - Assert.False(vm.SaveProfileCommand.CanExecute(null)); - } - - [Fact] - public void SaveProfile_AddsToCollection() - { - var svc = new FakeProfileService(); - var vm = MakeVm(svc); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - vm.SaveProfileCommand.Execute(null); - Assert.Single(vm.Profiles); - Assert.Equal("localhost:8443", vm.Profiles[0].Host); - } - - [Fact] - public void SelectProfile_PopulatesHostAndUsername() - { - var svc = new FakeProfileService(); - var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); - svc.Saved = [profile]; - var vm = MakeVm(svc); - vm.SelectedProfile = profile; - Assert.Equal("192.168.1.10:8443", vm.Host); - Assert.Equal("bob", vm.Username); - } - - [Fact] - public void DeleteProfile_RemovesFromCollection() - { - var svc = new FakeProfileService(); - var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); - svc.Saved = [profile]; - var vm = MakeVm(svc); - vm.SelectedProfile = profile; - vm.DeleteProfileCommand.Execute(null); - Assert.Empty(vm.Profiles); - } - - [Fact] - public void Password_PropertyNotifiesChange() - { - var vm = MakeVm(); - string? changed = null; - vm.PropertyChanged += (_, e) => changed = e.PropertyName; - vm.Password = "secret"; - Assert.Equal("Password", changed); - Assert.Equal("secret", vm.Password); - } - - [Fact] - public void ErrorMessage_PropertyNotifiesChange() - { - var vm = MakeVm(); - string? changed = null; - vm.PropertyChanged += (_, e) => changed = e.PropertyName; - vm.ErrorMessage = "Login failed"; - Assert.Equal("ErrorMessage", changed); - Assert.Equal("Login failed", vm.ErrorMessage); - } - - [Fact] - public void IsLoading_PropertyNotifiesChange() - { - var vm = MakeVm(); - string? changed = null; - vm.PropertyChanged += (_, e) => changed = e.PropertyName; - vm.IsLoading = true; - Assert.Equal("IsLoading", changed); - Assert.True(vm.IsLoading); - } - - [Fact] - public void ConnectCommand_IncludesPasswordInEvent() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - vm.Password = "secret123"; - string? capturedPassword = null; - vm.ConnectRequested += (_, _, password, _, _) => capturedPassword = password; - vm.ConnectCommand.Execute(null); - Assert.Equal("secret123", capturedPassword); - } - - [Fact] - public void ConnectCommand_DisabledWhenLoading() - { - var vm = MakeVm(); - vm.Host = "localhost:8443"; - vm.Username = "alice"; - vm.IsLoading = true; - Assert.False(vm.ConnectCommand.CanExecute(null)); - } - - [Fact] - public void PersistPassword_SavesWhenChecked() - { - var creds = new FakeCredentialService(); - var vm = MakeVm(creds: creds); - vm.SavePassword = true; - vm.PersistPasswordIfRequested("localhost:8443", "alice", "secret"); - Assert.Equal("secret", creds.LoadPassword("localhost:8443", "alice")); - } - - [Fact] - public void PersistPassword_DeletesWhenUnchecked() - { - var creds = new FakeCredentialService(); - creds.SavePassword("localhost:8443", "alice", "secret"); - var vm = MakeVm(creds: creds); - vm.SavePassword = false; - vm.PersistPasswordIfRequested("localhost:8443", "alice", "secret"); - Assert.Null(creds.LoadPassword("localhost:8443", "alice")); - } - - [Fact] - public void SelectProfile_LoadsSavedPassword() - { - var creds = new FakeCredentialService(); - creds.SavePassword("192.168.1.10:8443", "bob", "pass123"); - var svc = new FakeProfileService(); - var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); - svc.Saved = [profile]; - var vm = MakeVm(svc, creds); - vm.SelectedProfile = profile; - Assert.Equal("pass123", vm.Password); - Assert.True(vm.SavePassword); - } - - [Fact] - public void SelectProfile_ClearsPasswordWhenNoneSaved() - { - var creds = new FakeCredentialService(); - var svc = new FakeProfileService(); - var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); - svc.Saved = [profile]; - var vm = MakeVm(svc, creds); - vm.Password = "old"; - vm.SavePassword = true; - vm.SelectedProfile = profile; - Assert.Equal(string.Empty, vm.Password); - Assert.False(vm.SavePassword); - } -} - -internal sealed class FakeCredentialService : ICredentialService -{ - private readonly Dictionary _tokens = new(); - private readonly Dictionary _passwords = new(); - - private static string Key(string host, string username) => $"{host}:{username}"; - - public void SaveToken(string host, string username, string token) => _tokens[Key(host, username)] = token; - public string? LoadToken(string host, string username) => _tokens.GetValueOrDefault(Key(host, username)); - public void DeleteToken(string host, string username) => _tokens.Remove(Key(host, username)); - - public void SavePassword(string host, string username, string password) => _passwords[Key(host, username)] = password; - public string? LoadPassword(string host, string username) => _passwords.GetValueOrDefault(Key(host, username)); - public void DeletePassword(string host, string username) => _passwords.Remove(Key(host, username)); -} - -internal sealed class StubApiClient : IApiClient -{ - public HealthResponse? HealthResult { get; set; } = new("ok", "1.0.0"); - public bool HealthThrows { get; set; } - - public Task LoginAsync(string host, string username, string password, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task LogoutAsync(string host, string token, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task GetMeAsync(string host, string token, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task> GetChannelsAsync(string host, string token, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) - => throw new NotImplementedException(); - - public Task HealthCheckAsync(string host, CancellationToken ct = default) - { - if (HealthThrows) throw new Exception("Connection refused"); - return Task.FromResult(HealthResult ?? new HealthResponse("ok", "1.0.0")); - } -} - -internal sealed class FakeProfileService : IProfileService -{ - public List Saved = []; - - public IReadOnlyList LoadProfiles() => Saved; - public IReadOnlyList AddProfile(IReadOnlyList p, ServerProfile profile) - => [.. p, profile]; - public IReadOnlyList RemoveProfile(IReadOnlyList p, string id) - => p.Where(x => x.Id != id).ToList(); - public IReadOnlyList UpdateProfile(IReadOnlyList p, ServerProfile updated) - => p.Select(x => x.Id == updated.Id ? updated : x).ToList(); - public void SaveProfiles(IReadOnlyList profiles) => Saved = [.. profiles]; -} diff --git a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs deleted file mode 100644 index 6f51fecf..00000000 --- a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs +++ /dev/null @@ -1,216 +0,0 @@ -using OwnCord.Client.Models; -using OwnCord.Client.Services; -using OwnCord.Client.Tests.Services; -using OwnCord.Client.ViewModels; - -namespace OwnCord.Client.Tests.ViewModels; - -public sealed class MainViewModelTests -{ - private static MainViewModel MakeVm() => new(); - - private static MainViewModel MakeVmWithChat(out FakeApiClient api, out FakeWebSocketService ws) - { - api = new FakeApiClient(); - ws = new FakeWebSocketService(); - var chat = new ChatService(api, ws); - var vm = new MainViewModel(); - vm.Initialize(chat); - return vm; - } - - private static Channel MakeChannel(long id, string name, int unread = 0) - => new(id, name, ChannelType.Text, null, 0, unread, null); - - private static User MakeUser(long id, string name) - => new(id, name, null, 4, UserStatus.Online); - - private static Message MakeMessage(long id, long channelId, string content) - => new(id, channelId, MakeUser(1, "alice"), content, DateTime.UtcNow, null, null, false, [], []); - - [Fact] - public void SendCommand_DisabledWhenInputEmpty() - { - var vm = MakeVm(); - vm.SelectedChannel = MakeChannel(1, "general"); - vm.MessageInput = ""; - Assert.False(vm.SendMessageCommand.CanExecute(null)); - } - - [Fact] - public void SendCommand_DisabledWhenNoChannelSelected() - { - var vm = MakeVm(); - vm.MessageInput = "hello"; - Assert.False(vm.SendMessageCommand.CanExecute(null)); - } - - [Fact] - public void SendCommand_EnabledWhenInputAndChannelSet() - { - var vm = MakeVm(); - vm.SelectedChannel = MakeChannel(1, "general"); - vm.MessageInput = "hello"; - Assert.True(vm.SendMessageCommand.CanExecute(null)); - } - - [Fact] - public void SendCommand_SendsViaChatServiceAndClearsInput() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.SelectedChannel = MakeChannel(1, "general"); - vm.MessageInput = "hello"; - ws.IsConnected = true; - ws.State = System.Net.WebSockets.WebSocketState.Open; - vm.SendMessageCommand.Execute(null); - Assert.Equal(string.Empty, vm.MessageInput); - Assert.Contains(ws.SentMessages, m => m.Contains("chat_send")); - } - - [Fact] - public void SelectChannel_ClearsMessages() - { - var vm = MakeVm(); - vm.AddMessage(MakeMessage(1, 1, "hi")); - vm.SelectedChannel = MakeChannel(2, "random"); - Assert.Empty(vm.Messages); - } - - [Fact] - public void LoadChannels_PopulatesCollection() - { - var vm = MakeVm(); - vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); - Assert.Equal(2, vm.Channels.Count); - } - - [Fact] - public void LoadMembers_PopulatesCollection() - { - var vm = MakeVm(); - vm.LoadMembers([MakeUser(1, "alice"), MakeUser(2, "bob")]); - Assert.Equal(2, vm.Members.Count); - } - - [Fact] - public void AddMessage_AppendsToCollection() - { - var vm = MakeVm(); - vm.AddMessage(MakeMessage(1, 1, "hello")); - Assert.Single(vm.Messages); - } - - [Fact] - public void ShowTyping_SetsIsTypingAndText() - { - var vm = MakeVm(); - vm.ShowTyping("alice"); - Assert.True(vm.IsTyping); - Assert.Contains("alice", vm.TypingText); - } - - [Fact] - public void HideTyping_ClearsIsTyping() - { - var vm = MakeVm(); - vm.ShowTyping("alice"); - vm.HideTyping(); - Assert.False(vm.IsTyping); - Assert.Null(vm.TypingText); - } - - [Fact] - public void UpdateUnreadCount_UpdatesChannel() - { - var vm = MakeVm(); - vm.LoadChannels([MakeChannel(1, "general", 0)]); - vm.UpdateUnreadCount(1, 5); - Assert.Equal(5, vm.Channels[0].UnreadCount); - } - - [Fact] - public void Initialize_ReadyEvent_PopulatesChannels() - { - var vm = MakeVmWithChat(out _, out var ws); - - var json = """ - { "type": "ready", "payload": { "channels": [ - { "id": 1, "name": "general", "type": "text", "category": "Chat", "topic": "", "position": 0, "slow_mode": 0, "archived": false, "created_at": "2026-01-01T00:00:00Z" } - ], "members": [], "voice_states": [], "roles": [] } } - """; - ws.SimulateMessage(json); - - Assert.Single(vm.Channels); - Assert.Equal("general", vm.Channels[0].Name); - Assert.Equal(ChannelType.Text, vm.Channels[0].Type); - Assert.Equal(vm.Channels[0], vm.SelectedChannel); - } - - [Fact] - public void Initialize_ChatMessage_AddsToMessages() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - vm.SelectedChannel = vm.Channels[0]; - - var json = """ - { "type": "chat_message", "payload": { "id": 42, "channel_id": 1, "user": { "id": 1, "username": "alice", "avatar": null }, "content": "Hello!", "reply_to": null, "timestamp": "2026-01-01T00:00:00Z" } } - """; - ws.SimulateMessage(json); - - Assert.Single(vm.Messages); - Assert.Equal("Hello!", vm.Messages[0].Content); - Assert.Equal("alice", vm.Messages[0].Author.Username); - } - - [Fact] - public void Initialize_Typing_ShowsTypingIndicator() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - vm.SelectedChannel = vm.Channels[0]; - - ws.SimulateMessage("""{ "type": "typing", "payload": { "channel_id": 1, "user_id": 2, "username": "bob" } }"""); - - Assert.True(vm.IsTyping); - Assert.Contains("bob", vm.TypingText); - } - - [Fact] - public void Initialize_ChatEdited_UpdatesMessage() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - vm.SelectedChannel = vm.Channels[0]; - vm.AddMessage(MakeMessage(10, 1, "original")); - - ws.SimulateMessage("""{ "type": "chat_edited", "payload": { "message_id": 10, "channel_id": 1, "content": "edited", "edited_at": "2026-01-01T00:00:00Z" } }"""); - - Assert.Equal("edited", vm.Messages[0].Content); - Assert.NotNull(vm.Messages[0].EditedAt); - } - - [Fact] - public void Initialize_ChatDeleted_MarksMessageDeleted() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - vm.SelectedChannel = vm.Channels[0]; - vm.AddMessage(MakeMessage(10, 1, "to delete")); - - ws.SimulateMessage("""{ "type": "chat_deleted", "payload": { "message_id": 10, "channel_id": 1 } }"""); - - Assert.True(vm.Messages[0].Deleted); - Assert.Equal("[deleted]", vm.Messages[0].Content); - } - - [Fact] - public void Initialize_ConnectionLost_SetsStatus() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateDisconnect(); - - Assert.Contains("Disconnected", vm.ConnectionStatus); - } -} diff --git a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs deleted file mode 100644 index 7bbc35df..00000000 --- a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs +++ /dev/null @@ -1,549 +0,0 @@ -using OwnCord.Client.Models; -using OwnCord.Client.Services; -using OwnCord.Client.Tests.Services; -using OwnCord.Client.ViewModels; - -namespace OwnCord.Client.Tests.ViewModels; - -/// Tests for MainViewModel voice events, channel CRUD, member events, and grouping. -public sealed class MainViewModelVoiceTests -{ - private static readonly ApiUser TestUser = new(1, "alice", null, "online", 1, "2026-01-01T00:00:00Z"); - private static readonly AuthResponse TestAuth = new("tok_abc", TestUser); - - private static MainViewModel MakeVmWithChat(out FakeApiClient api, out FakeWebSocketService ws) - { - api = new FakeApiClient(); - ws = new FakeWebSocketService(); - var chat = new ChatService(api, ws); - var vm = new MainViewModel(); - vm.Initialize(chat); - return vm; - } - - private static async Task<(MainViewModel vm, FakeApiClient api, FakeWebSocketService ws)> MakeLoggedInVm() - { - var api = new FakeApiClient { LoginResult = TestAuth }; - var ws = new FakeWebSocketService(); - var chat = new ChatService(api, ws); - var vm = new MainViewModel(); - vm.Initialize(chat); - await chat.LoginAsync("host:8443", "alice", "pass"); - await chat.ConnectWebSocketAsync("host:8443", "tok_abc"); - return (vm, api, ws); - } - - private static Channel MakeChannel(long id, string name, ChannelType type = ChannelType.Text, string? category = null, int position = 0) - => new(id, name, type, category, position, 0, null); - - private static User MakeUser(long id, string name, long roleId = 1) - => new(id, name, null, roleId, UserStatus.Online); - - private static Message MakeMessage(long id, long channelId, string content, long authorId = 1, string authorName = "alice") - => new(id, channelId, MakeUser(authorId, authorName), content, DateTime.UtcNow, null, null, false, [], []); - - private static string ReadyJson(string channels = "[]", string members = "[]", string voiceStates = "[]", string roles = "[]") - => $@"{{ ""type"": ""ready"", ""payload"": {{ ""channels"": {channels}, ""members"": {members}, ""voice_states"": {voiceStates}, ""roles"": {roles} }} }}"; - - // ── Voice state event ──────────────────────────────────────────────── - - [Fact] - public void VoiceState_AddsNewVoiceUser() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice)]); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); - - Assert.Single(vm.VoiceStates); - Assert.Equal("bob", vm.VoiceStates[0].Username); - Assert.Equal(5, vm.VoiceStates[0].ChannelId); - } - - [Fact] - public void VoiceState_UpdatesExistingUser() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice)]); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": true, "deafened": false } }"""); - - Assert.Single(vm.VoiceStates); - Assert.True(vm.VoiceStates[0].Muted); - } - - [Fact] - public async Task VoiceState_LocalUser_SetsVoiceWidgetState() - { - var (vm, _, ws) = await MakeLoggedInVm(); - - // Fire ready to populate channels and set CurrentUser on ChatService - ws.SimulateMessage(ReadyJson( - channels: @"[{ ""id"": 5, ""name"": ""voice-room"", ""type"": ""voice"", ""category"": ""Voice"", ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", - members: @"[{ ""id"": 1, ""username"": ""alice"", ""avatar"": null, ""status"": ""online"", ""role_id"": 1 }]" - )); - - // Simulate local user (id=1) joining voice - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); - - Assert.True(vm.IsInVoice); - Assert.Equal("voice-room", vm.VoiceChannelName); - Assert.False(vm.IsMuted); - } - - // ── Voice leave event ──────────────────────────────────────────────── - - [Fact] - public void VoiceLeave_RemovesVoiceUser() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); - Assert.Single(vm.VoiceStates); - - ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 2, "channel_id": 5 } }"""); - Assert.Empty(vm.VoiceStates); - } - - [Fact] - public async Task VoiceLeave_LocalUser_ClearsVoiceWidget() - { - var (vm, _, ws) = await MakeLoggedInVm(); - - ws.SimulateMessage(ReadyJson( - channels: @"[{ ""id"": 5, ""name"": ""voice-room"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", - members: @"[{ ""id"": 1, ""username"": ""alice"", ""avatar"": null, ""status"": ""online"", ""role_id"": 1 }]" - )); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); - Assert.True(vm.IsInVoice); - - ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 1, "channel_id": 5 } }"""); - Assert.False(vm.IsInVoice); - Assert.Null(vm.VoiceChannelName); - Assert.False(vm.IsMuted); - Assert.False(vm.IsDeafened); - } - - // ── Voice speakers event ───────────────────────────────────────────── - - [Fact] - public void VoiceSpeakers_UpdatesSpeakingState() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 3, "channel_id": 5, "username": "carol", "muted": false, "deafened": false } }"""); - - ws.SimulateMessage("""{ "type": "voice_speakers", "payload": { "channel_id": 5, "speakers": [2], "mode": "sfu" } }"""); - - var bob = vm.VoiceStates.First(vs => vs.UserId == 2); - var carol = vm.VoiceStates.First(vs => vs.UserId == 3); - Assert.True(bob.Speaking); - Assert.False(carol.Speaking); - } - - // ── Channel CRUD events ────────────────────────────────────────────── - - [Fact] - public void ChannelCreated_AddsNewChannel() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - - ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 2, "name": "random", "type": "text", "category": "Chat", "topic": null, "position": 1 } }"""); - - Assert.Equal(2, vm.Channels.Count); - Assert.Contains(vm.Channels, c => c.Name == "random"); - } - - [Fact] - public void ChannelCreated_DuplicateId_DoesNotAdd() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - - ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 1, "name": "general-dup", "type": "text", "category": null, "topic": null, "position": 0 } }"""); - - Assert.Single(vm.Channels); - } - - [Fact] - public void ChannelUpdated_UpdatesExistingChannel() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - - ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 1, "name": "general-renamed", "type": "text", "category": "Chat", "topic": "New topic", "position": 0 } }"""); - - Assert.Equal("general-renamed", vm.Channels[0].Name); - Assert.Equal("New topic", vm.Channels[0].Topic); - } - - [Fact] - public void ChannelUpdated_SelectedChannel_NotifiesTopicChanged() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general")]); - vm.SelectedChannel = vm.Channels[0]; - - bool topicNotified = false; - vm.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(vm.SelectedChannelTopic)) - topicNotified = true; - }; - - ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 1, "name": "general", "type": "text", "category": null, "topic": "Updated!", "position": 0 } }"""); - - Assert.True(topicNotified); - // The channel in Channels collection has the updated topic - Assert.Equal("Updated!", vm.Channels[0].Topic); - } - - [Fact] - public void ChannelDeleted_RemovesChannel() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); - - ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 2 } }"""); - - Assert.Single(vm.Channels); - Assert.Equal("general", vm.Channels[0].Name); - } - - [Fact] - public void ChannelDeleted_SelectedChannel_SelectsAnother() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); - vm.SelectedChannel = vm.Channels.First(c => c.Id == 2); - - ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 2 } }"""); - - Assert.NotNull(vm.SelectedChannel); - Assert.Equal(1, vm.SelectedChannel!.Id); - } - - // ── Member events ──────────────────────────────────────────────────── - - [Fact] - public void MemberJoined_AddsNewMember() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadMembers([MakeUser(1, "alice")]); - - ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 10, "username": "newuser", "avatar": null, "status": "online", "role_id": 1 } }"""); - - Assert.Equal(2, vm.Members.Count); - Assert.Contains(vm.Members, m => m.Username == "newuser"); - } - - [Fact] - public void MemberJoined_DuplicateId_DoesNotAdd() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadMembers([MakeUser(1, "alice")]); - - ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 1, "username": "alice-dup", "avatar": null, "status": "online", "role_id": 1 } }"""); - - Assert.Single(vm.Members); - } - - [Fact] - public void Presence_UpdatesMemberStatus() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadMembers([MakeUser(1, "alice")]); - - ws.SimulateMessage("""{ "type": "presence", "payload": { "user_id": 1, "status": "idle" } }"""); - - Assert.Equal(UserStatus.Idle, vm.Members[0].Status); - } - - // ── Channel grouping ───────────────────────────────────────────────── - - [Fact] - public void ChannelGroups_GroupedByCategory() - { - var vm = new MainViewModel(); - vm.LoadChannels([ - MakeChannel(1, "general", ChannelType.Text, "Chat", 0), - MakeChannel(2, "random", ChannelType.Text, "Chat", 1), - MakeChannel(3, "voice", ChannelType.Voice, "Voice", 0), - ]); - - Assert.Equal(2, vm.ChannelGroups.Count); - Assert.Contains(vm.ChannelGroups, g => g.CategoryName == "Chat" && g.Items.Count == 2); - Assert.Contains(vm.ChannelGroups, g => g.CategoryName == "Voice" && g.Items.Count == 1); - } - - [Fact] - public void ChannelGroups_PreservesExpandedState() - { - var vm = new MainViewModel(); - vm.LoadChannels([ - MakeChannel(1, "general", ChannelType.Text, "Chat", 0), - MakeChannel(2, "voice", ChannelType.Voice, "Voice", 0), - ]); - - // Collapse the Chat group - var chatGroup = vm.ChannelGroups.First(g => g.CategoryName == "Chat"); - chatGroup.IsExpanded = false; - - // Reload channels — should preserve collapsed state - vm.LoadChannels([ - MakeChannel(1, "general", ChannelType.Text, "Chat", 0), - MakeChannel(2, "voice", ChannelType.Voice, "Voice", 0), - MakeChannel(3, "random", ChannelType.Text, "Chat", 1), - ]); - - var chatGroupAfter = vm.ChannelGroups.First(g => g.CategoryName == "Chat"); - Assert.False(chatGroupAfter.IsExpanded); - } - - [Fact] - public void ChannelGroups_VoiceChannelsIncludeVoiceUsers() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice, "Voice")]); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); - - var voiceGroup = vm.ChannelGroups.First(g => g.CategoryName == "Voice"); - var voiceItem = voiceGroup.Items[0]; - Assert.Single(voiceItem.VoiceUsers); - Assert.Equal("bob", voiceItem.VoiceUsers[0].Username); - } - - [Fact] - public void ChannelGroups_NullCategory_SortedFirst() - { - var vm = new MainViewModel(); - vm.LoadChannels([ - MakeChannel(1, "general", ChannelType.Text, null, 0), - MakeChannel(2, "chat", ChannelType.Text, "Chat", 0), - ]); - - Assert.Null(vm.ChannelGroups[0].CategoryName); - Assert.Equal("Chat", vm.ChannelGroups[1].CategoryName); - } - - // ── Member grouping ────────────────────────────────────────────────── - - [Fact] - public void MemberGroups_GroupedByRole() - { - var vm = new MainViewModel(); - vm.Roles.Add(new WsRole(1, "Admin", "#ff0000", 0, 0, false)); - vm.Roles.Add(new WsRole(2, "Member", null, 0, 1, true)); - vm.LoadMembers([ - MakeUser(1, "alice", 1), - MakeUser(2, "bob", 2), - MakeUser(3, "carol", 2), - ]); - - Assert.Equal(2, vm.MemberGroups.Count); - var adminGroup = vm.MemberGroups.First(g => g.RoleName == "Admin"); - Assert.Single(adminGroup.Members); - var memberGroup = vm.MemberGroups.First(g => g.RoleName == "Member"); - Assert.Equal(2, memberGroup.Members.Count); - } - - [Fact] - public void MemberGroups_UnknownRole_DefaultsToMembers() - { - var vm = new MainViewModel(); - vm.LoadMembers([MakeUser(1, "alice", 99)]); - - Assert.Single(vm.MemberGroups); - Assert.Equal("Members", vm.MemberGroups[0].RoleName); - } - - // ── Connection status ──────────────────────────────────────────────── - - [Fact] - public void ConnectionStatus_SetsHasConnectionIssue() - { - var vm = new MainViewModel(); - Assert.False(vm.HasConnectionIssue); - - vm.ConnectionStatus = "Disconnected"; - Assert.True(vm.HasConnectionIssue); - - vm.ConnectionStatus = null; - Assert.False(vm.HasConnectionIssue); - } - - // ── Ready event with voice states ──────────────────────────────────── - - [Fact] - public void Ready_LoadsVoiceStates() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage(ReadyJson( - channels: @"[{ ""id"": 5, ""name"": ""voice"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", - voiceStates: @"[{ ""user_id"": 2, ""channel_id"": 5, ""username"": ""bob"", ""muted"": true, ""deafened"": false, ""speaking"": false }]" - )); - - Assert.Single(vm.VoiceStates); - Assert.Equal("bob", vm.VoiceStates[0].Username); - Assert.True(vm.VoiceStates[0].Muted); - } - - [Fact] - public void Ready_LoadsRoles() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage(ReadyJson( - roles: @"[{ ""id"": 1, ""name"": ""Admin"", ""color"": ""#ff0000"", ""permissions"": 255, ""position"": 0, ""is_default"": false }, { ""id"": 2, ""name"": ""Member"", ""color"": null, ""permissions"": 1, ""position"": 1, ""is_default"": true }]" - )); - - Assert.Equal(2, vm.Roles.Count); - Assert.Equal("Admin", vm.Roles[0].Name); - } - - [Fact] - public void Ready_SelectsFirstTextChannel() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage(ReadyJson( - channels: @"[{ ""id"": 5, ""name"": ""voice"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }, { ""id"": 1, ""name"": ""general"", ""type"": ""text"", ""category"": null, ""topic"": """", ""position"": 1, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]" - )); - - Assert.NotNull(vm.SelectedChannel); - Assert.Equal("general", vm.SelectedChannel!.Name); - } - - // ── Chat message to non-selected channel increments unread ────────── - - [Fact] - public void ChatMessage_OtherChannel_IncrementsUnread() - { - var vm = MakeVmWithChat(out _, out var ws); - vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); - vm.SelectedChannel = vm.Channels[0]; // selected = general (id 1) - - ws.SimulateMessage("""{ "type": "chat_message", "payload": { "id": 50, "channel_id": 2, "user": { "id": 2, "username": "bob", "avatar": null }, "content": "hi", "reply_to": null, "timestamp": "2026-01-01T00:00:00Z" } }"""); - - Assert.Equal(1, vm.Channels.First(c => c.Id == 2).UnreadCount); - } - - // ── Display messages ───────────────────────────────────────────────── - - [Fact] - public void AddMessage_CreatesDisplayMessage() - { - var vm = new MainViewModel(); - vm.AddMessage(MakeMessage(1, 1, "hello")); - - Assert.Single(vm.DisplayMessages); - Assert.Equal("hello", vm.DisplayMessages[0].Content); - } - - [Fact] - public void AddMessage_SecondBySameAuthor_GroupedTogether() - { - var vm = new MainViewModel(); - vm.AddMessage(MakeMessage(1, 1, "hello", 1, "alice")); - vm.AddMessage(MakeMessage(2, 1, "world", 1, "alice")); - - Assert.Equal(2, vm.DisplayMessages.Count); - Assert.False(vm.DisplayMessages[0].IsGrouped); // First message shows header - Assert.True(vm.DisplayMessages[1].IsGrouped); // Second is grouped (no header) - } - - // ── Toggle commands ────────────────────────────────────────────────── - - [Fact] - public void ToggleMemberList_TogglesVisibility() - { - var vm = new MainViewModel(); - Assert.True(vm.IsMemberListVisible); - - vm.ToggleMemberListCommand.Execute(null); - Assert.False(vm.IsMemberListVisible); - - vm.ToggleMemberListCommand.Execute(null); - Assert.True(vm.IsMemberListVisible); - } - - [Fact] - public void ToggleCategory_TogglesExpandedState() - { - var vm = new MainViewModel(); - vm.LoadChannels([MakeChannel(1, "general", ChannelType.Text, "Chat")]); - - var group = vm.ChannelGroups[0]; - Assert.True(group.IsExpanded); - - vm.ToggleCategoryCommand.Execute(group); - Assert.False(group.IsExpanded); - - vm.ToggleCategoryCommand.Execute(group); - Assert.True(group.IsExpanded); - } - - // ── GetVoiceUsersForChannel ────────────────────────────────────────── - - [Fact] - public void GetVoiceUsersForChannel_FiltersCorrectly() - { - var vm = MakeVmWithChat(out _, out var ws); - - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); - ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 6, "username": "bob", "muted": false, "deafened": false } }"""); - - var users5 = vm.GetVoiceUsersForChannel(5).ToList(); - var users6 = vm.GetVoiceUsersForChannel(6).ToList(); - var users7 = vm.GetVoiceUsersForChannel(7).ToList(); - - Assert.Single(users5); - Assert.Single(users6); - Assert.Empty(users7); - } - - // ── CurrentUser properties ─────────────────────────────────────────── - - [Fact] - public void CurrentUsername_DefaultsToUnknown() - { - var vm = new MainViewModel(); - Assert.Equal("Unknown", vm.CurrentUsername); - } - - [Fact] - public void CurrentUserStatusEnum_DefaultsToOffline() - { - var vm = new MainViewModel(); - Assert.Equal(UserStatus.Offline, vm.CurrentUserStatusEnum); - } - - // ── SelectChannelCommand ───────────────────────────────────────────── - - [Fact] - public void SelectChannelCommand_WithChannelItem_SelectsChannel() - { - var vm = new MainViewModel(); - var ch = MakeChannel(1, "general"); - vm.LoadChannels([ch]); - - var item = vm.ChannelGroups[0].Items[0]; - vm.SelectChannelCommand.Execute(item); - - Assert.Equal(ch.Id, vm.SelectedChannel?.Id); - } - - [Fact] - public void SelectChannelCommand_WithNull_DoesNothing() - { - var vm = new MainViewModel(); - vm.SelectChannelCommand.Execute(null); - Assert.Null(vm.SelectedChannel); - } -} diff --git a/Client/OwnCord.Client.sln b/Client/OwnCord.Client.sln deleted file mode 100644 index c314d9a4..00000000 --- a/Client/OwnCord.Client.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OwnCord.Client", "OwnCord.Client\OwnCord.Client.csproj", "{A25E4856-BF72-4C5C-940C-808B3694F1A1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OwnCord.Client.Tests", "OwnCord.Client.Tests\OwnCord.Client.Tests.csproj", "{14D58A32-A395-4278-AC89-88910F1FE2F8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Release|Any CPU.Build.0 = Release|Any CPU - {14D58A32-A395-4278-AC89-88910F1FE2F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {14D58A32-A395-4278-AC89-88910F1FE2F8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {14D58A32-A395-4278-AC89-88910F1FE2F8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {14D58A32-A395-4278-AC89-88910F1FE2F8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Client/OwnCord.Client/App.xaml b/Client/OwnCord.Client/App.xaml deleted file mode 100644 index 82449b66..00000000 --- a/Client/OwnCord.Client/App.xaml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs deleted file mode 100644 index 449e6bdf..00000000 --- a/Client/OwnCord.Client/App.xaml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using System.Windows; -using OwnCord.Client.Services; -using OwnCord.Client.ViewModels; -using OwnCord.Client.Views; - -namespace OwnCord.Client; - -public partial class App : Application -{ - private void Application_Startup(object sender, StartupEventArgs e) - { - var dataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OwnCord"); - - var profileService = new ProfileService(dataDir); - var credentialService = new CredentialService(); - var trustService = new CertificateTrustService(); - var wsService = new WebSocketService(trustService); - var apiClient = ApiClient.CreateWithTofuTls(trustService); - var chatService = new ChatService(apiClient, wsService); - - var connectVm = new ConnectViewModel(profileService, credentialService, apiClient); - var mainVm = new MainViewModel(); - - var mainWindow = new MainWindow(connectVm, mainVm, chatService); - mainWindow.Show(); - - // Clean up old binary from previous update - var updateService = new UpdateService(); - updateService.CleanupOldVersion(); - - // Check for updates (non-blocking) - _ = Task.Run(async () => - { - var info = await updateService.CheckForUpdateAsync(); - if (info?.UpdateAvailable == true) - { - await Current.Dispatcher.InvokeAsync(() => - { - var vm = new UpdateViewModel(updateService, info); - var dialog = new UpdateDialog(vm); - dialog.ShowDialog(); - }); - } - }); - } -} diff --git a/Client/OwnCord.Client/AssemblyInfo.cs b/Client/OwnCord.Client/AssemblyInfo.cs deleted file mode 100644 index adfa6fc0..00000000 --- a/Client/OwnCord.Client/AssemblyInfo.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Windows; - -[assembly: InternalsVisibleTo("OwnCord.Client.Tests")] - -[assembly:ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] diff --git a/Client/OwnCord.Client/Controls/AttachmentControl.xaml b/Client/OwnCord.Client/Controls/AttachmentControl.xaml deleted file mode 100644 index 6e7da682..00000000 --- a/Client/OwnCord.Client/Controls/AttachmentControl.xaml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs b/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs deleted file mode 100644 index 7f6bd355..00000000 --- a/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media.Imaging; - -namespace OwnCord.Client.Controls; - -public partial class AttachmentControl : UserControl -{ - public static readonly DependencyProperty FilenameProperty = - DependencyProperty.Register( - nameof(Filename), - typeof(string), - typeof(AttachmentControl), - new PropertyMetadata(string.Empty, OnPropertyChanged)); - - public static readonly DependencyProperty FileSizeProperty = - DependencyProperty.Register( - nameof(FileSize), - typeof(long), - typeof(AttachmentControl), - new PropertyMetadata(0L, OnPropertyChanged)); - - public static readonly DependencyProperty MimeTypeProperty = - DependencyProperty.Register( - nameof(MimeType), - typeof(string), - typeof(AttachmentControl), - new PropertyMetadata(string.Empty, OnPropertyChanged)); - - public static readonly DependencyProperty FileUrlProperty = - DependencyProperty.Register( - nameof(FileUrl), - typeof(string), - typeof(AttachmentControl), - new PropertyMetadata(string.Empty)); - - public string Filename - { - get => (string)GetValue(FilenameProperty); - set => SetValue(FilenameProperty, value); - } - - public long FileSize - { - get => (long)GetValue(FileSizeProperty); - set => SetValue(FileSizeProperty, value); - } - - public string MimeType - { - get => (string)GetValue(MimeTypeProperty); - set => SetValue(MimeTypeProperty, value); - } - - public string FileUrl - { - get => (string)GetValue(FileUrlProperty); - set => SetValue(FileUrlProperty, value); - } - - public AttachmentControl() - { - InitializeComponent(); - } - - private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is AttachmentControl control) - { - control.UpdateDisplay(); - } - } - - private void UpdateDisplay() - { - var isImage = !string.IsNullOrEmpty(MimeType) - && MimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); - - ImagePanel.Visibility = isImage ? Visibility.Visible : Visibility.Collapsed; - FilePanel.Visibility = isImage ? Visibility.Collapsed : Visibility.Visible; - - if (isImage) - { - LoadImage(); - } - else - { - FilenameText.Text = Filename; - FileSizeText.Text = FormatFileSize(FileSize); - } - } - - private void LoadImage() - { - if (string.IsNullOrEmpty(FileUrl)) return; - - try - { - var bitmap = new BitmapImage(); - bitmap.BeginInit(); - bitmap.UriSource = new Uri(FileUrl, UriKind.RelativeOrAbsolute); - bitmap.CacheOption = BitmapCacheOption.OnLoad; - bitmap.DecodePixelWidth = 400; // Limit decode size for performance - bitmap.EndInit(); - - if (bitmap.IsDownloading) - { - bitmap.DownloadCompleted += (_, _) => - { - AttachmentImage.Source = bitmap; - ImagePlaceholder.Visibility = Visibility.Collapsed; - }; - bitmap.DownloadFailed += (_, _) => - { - // Keep placeholder visible on failure - }; - } - else - { - AttachmentImage.Source = bitmap; - ImagePlaceholder.Visibility = Visibility.Collapsed; - } - } - catch - { - // Keep placeholder visible on error - } - } - - private static string FormatFileSize(long bytes) - { - return bytes switch - { - < 1024 => $"{bytes} B", - < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", - _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB" - }; - } -} diff --git a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml deleted file mode 100644 index 5581dd8c..00000000 --- a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs deleted file mode 100644 index 307b7ab8..00000000 --- a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace OwnCord.Client.Controls; - -public partial class CodeBlockControl : UserControl -{ - public static readonly DependencyProperty CodeProperty = - DependencyProperty.Register( - nameof(Code), - typeof(string), - typeof(CodeBlockControl), - new PropertyMetadata(string.Empty, OnPropertyChanged)); - - public static readonly DependencyProperty CodeLanguageProperty = - DependencyProperty.Register( - nameof(CodeLanguage), - typeof(string), - typeof(CodeBlockControl), - new PropertyMetadata(string.Empty, OnPropertyChanged)); - - public string Code - { - get => (string)GetValue(CodeProperty); - set => SetValue(CodeProperty, value); - } - - public string CodeLanguage - { - get => (string)GetValue(CodeLanguageProperty); - set => SetValue(CodeLanguageProperty, value); - } - - public CodeBlockControl() - { - InitializeComponent(); - } - - private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is CodeBlockControl control) - { - control.UpdateDisplay(); - } - } - - private void UpdateDisplay() - { - CodeText.Text = Code; - - var hasLanguage = !string.IsNullOrWhiteSpace(CodeLanguage); - LanguageLabel.Text = hasLanguage ? CodeLanguage : string.Empty; - LanguageLabel.Visibility = hasLanguage ? Visibility.Visible : Visibility.Collapsed; - } -} diff --git a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml deleted file mode 100644 index 19548424..00000000 --- a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs deleted file mode 100644 index bda4cf67..00000000 --- a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class DmSidebarControl : UserControl -{ - public static readonly DependencyProperty DirectMessagesProperty = - DependencyProperty.Register( - nameof(DirectMessages), - typeof(IEnumerable), - typeof(DmSidebarControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty SelectedDmProperty = - DependencyProperty.Register( - nameof(SelectedDm), - typeof(object), - typeof(DmSidebarControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty SelectDmCommandProperty = - DependencyProperty.Register( - nameof(SelectDmCommand), - typeof(ICommand), - typeof(DmSidebarControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty FriendsCommandProperty = - DependencyProperty.Register( - nameof(FriendsCommand), - typeof(ICommand), - typeof(DmSidebarControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty CloseDmCommandProperty = - DependencyProperty.Register( - nameof(CloseDmCommand), - typeof(ICommand), - typeof(DmSidebarControl), - new PropertyMetadata(null)); - - public DmSidebarControl() - { - InitializeComponent(); - } - - public IEnumerable? DirectMessages - { - get => (IEnumerable?)GetValue(DirectMessagesProperty); - set => SetValue(DirectMessagesProperty, value); - } - - public object? SelectedDm - { - get => GetValue(SelectedDmProperty); - set => SetValue(SelectedDmProperty, value); - } - - public ICommand? SelectDmCommand - { - get => (ICommand?)GetValue(SelectDmCommandProperty); - set => SetValue(SelectDmCommandProperty, value); - } - - public ICommand? FriendsCommand - { - get => (ICommand?)GetValue(FriendsCommandProperty); - set => SetValue(FriendsCommandProperty, value); - } - - public ICommand? CloseDmCommand - { - get => (ICommand?)GetValue(CloseDmCommandProperty); - set => SetValue(CloseDmCommandProperty, value); - } -} diff --git a/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml b/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml deleted file mode 100644 index 782490a9..00000000 --- a/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs b/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs deleted file mode 100644 index 079c37ed..00000000 --- a/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class FriendsViewControl : UserControl -{ - public static readonly DependencyProperty SelectedTabProperty = - DependencyProperty.Register( - nameof(SelectedTab), - typeof(string), - typeof(FriendsViewControl), - new PropertyMetadata("online")); - - public static readonly DependencyProperty FriendsProperty = - DependencyProperty.Register( - nameof(Friends), - typeof(IEnumerable), - typeof(FriendsViewControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty FriendSearchTextProperty = - DependencyProperty.Register( - nameof(FriendSearchText), - typeof(string), - typeof(FriendsViewControl), - new PropertyMetadata(string.Empty)); - - public static readonly DependencyProperty SelectTabCommandProperty = - DependencyProperty.Register( - nameof(SelectTabCommand), - typeof(ICommand), - typeof(FriendsViewControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty MessageFriendCommandProperty = - DependencyProperty.Register( - nameof(MessageFriendCommand), - typeof(ICommand), - typeof(FriendsViewControl), - new PropertyMetadata(null)); - - public FriendsViewControl() - { - InitializeComponent(); - } - - public string SelectedTab - { - get => (string)GetValue(SelectedTabProperty); - set => SetValue(SelectedTabProperty, value); - } - - public IEnumerable? Friends - { - get => (IEnumerable?)GetValue(FriendsProperty); - set => SetValue(FriendsProperty, value); - } - - public string FriendSearchText - { - get => (string)GetValue(FriendSearchTextProperty); - set => SetValue(FriendSearchTextProperty, value); - } - - public ICommand? SelectTabCommand - { - get => (ICommand?)GetValue(SelectTabCommandProperty); - set => SetValue(SelectTabCommandProperty, value); - } - - public ICommand? MessageFriendCommand - { - get => (ICommand?)GetValue(MessageFriendCommandProperty); - set => SetValue(MessageFriendCommandProperty, value); - } -} diff --git a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml deleted file mode 100644 index 00cf2a50..00000000 --- a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs deleted file mode 100644 index b10fdc3b..00000000 --- a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class MessageActionsBar : UserControl -{ - public static readonly DependencyProperty ReplyCommandProperty = - DependencyProperty.Register( - nameof(ReplyCommand), - typeof(ICommand), - typeof(MessageActionsBar), - new PropertyMetadata(null)); - - public static readonly DependencyProperty EditCommandProperty = - DependencyProperty.Register( - nameof(EditCommand), - typeof(ICommand), - typeof(MessageActionsBar), - new PropertyMetadata(null)); - - public static readonly DependencyProperty DeleteCommandProperty = - DependencyProperty.Register( - nameof(DeleteCommand), - typeof(ICommand), - typeof(MessageActionsBar), - new PropertyMetadata(null)); - - public static readonly DependencyProperty IsOwnMessageProperty = - DependencyProperty.Register( - nameof(IsOwnMessage), - typeof(bool), - typeof(MessageActionsBar), - new PropertyMetadata(false)); - - public static readonly DependencyProperty CommandParameterProperty = - DependencyProperty.Register( - nameof(CommandParameter), - typeof(object), - typeof(MessageActionsBar), - new PropertyMetadata(null)); - - public ICommand? ReplyCommand - { - get => (ICommand?)GetValue(ReplyCommandProperty); - set => SetValue(ReplyCommandProperty, value); - } - - public ICommand? EditCommand - { - get => (ICommand?)GetValue(EditCommandProperty); - set => SetValue(EditCommandProperty, value); - } - - public ICommand? DeleteCommand - { - get => (ICommand?)GetValue(DeleteCommandProperty); - set => SetValue(DeleteCommandProperty, value); - } - - public bool IsOwnMessage - { - get => (bool)GetValue(IsOwnMessageProperty); - set => SetValue(IsOwnMessageProperty, value); - } - - public object? CommandParameter - { - get => GetValue(CommandParameterProperty); - set => SetValue(CommandParameterProperty, value); - } - - public MessageActionsBar() - { - InitializeComponent(); - } -} diff --git a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml deleted file mode 100644 index 358ab551..00000000 --- a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs deleted file mode 100644 index 9f51d5b1..00000000 --- a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class ReplyComposeBar : UserControl -{ - public static readonly DependencyProperty UsernameProperty = - DependencyProperty.Register( - nameof(Username), - typeof(string), - typeof(ReplyComposeBar), - new PropertyMetadata(string.Empty)); - - public static readonly DependencyProperty CancelCommandProperty = - DependencyProperty.Register( - nameof(CancelCommand), - typeof(ICommand), - typeof(ReplyComposeBar), - new PropertyMetadata(null)); - - public string Username - { - get => (string)GetValue(UsernameProperty); - set => SetValue(UsernameProperty, value); - } - - public ICommand? CancelCommand - { - get => (ICommand?)GetValue(CancelCommandProperty); - set => SetValue(CancelCommandProperty, value); - } - - public ReplyComposeBar() - { - InitializeComponent(); - } -} diff --git a/Client/OwnCord.Client/Controls/ServerStripControl.xaml b/Client/OwnCord.Client/Controls/ServerStripControl.xaml deleted file mode 100644 index ef1cc786..00000000 --- a/Client/OwnCord.Client/Controls/ServerStripControl.xaml +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs b/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs deleted file mode 100644 index 94695435..00000000 --- a/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class StatusPickerControl : UserControl -{ - public static readonly DependencyProperty SelectedStatusProperty = - DependencyProperty.Register( - nameof(SelectedStatus), - typeof(string), - typeof(StatusPickerControl), - new PropertyMetadata("online")); - - public static readonly DependencyProperty StatusChangedCommandProperty = - DependencyProperty.Register( - nameof(StatusChangedCommand), - typeof(ICommand), - typeof(StatusPickerControl), - new PropertyMetadata(null)); - - public StatusPickerControl() - { - InitializeComponent(); - } - - public string SelectedStatus - { - get => (string)GetValue(SelectedStatusProperty); - set => SetValue(SelectedStatusProperty, value); - } - - public ICommand StatusChangedCommand - { - get => (ICommand)GetValue(StatusChangedCommandProperty); - set => SetValue(StatusChangedCommandProperty, value); - } -} diff --git a/Client/OwnCord.Client/Controls/ToastControl.xaml b/Client/OwnCord.Client/Controls/ToastControl.xaml deleted file mode 100644 index 743db726..00000000 --- a/Client/OwnCord.Client/Controls/ToastControl.xaml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/ToastControl.xaml.cs b/Client/OwnCord.Client/Controls/ToastControl.xaml.cs deleted file mode 100644 index 803776de..00000000 --- a/Client/OwnCord.Client/Controls/ToastControl.xaml.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media.Animation; -using System.Windows.Threading; - -namespace OwnCord.Client.Controls; - -public partial class ToastControl : UserControl -{ - private DispatcherTimer? _autoDismissTimer; - - public static readonly DependencyProperty MessageProperty = - DependencyProperty.Register( - nameof(Message), - typeof(string), - typeof(ToastControl), - new PropertyMetadata(string.Empty)); - - public static readonly DependencyProperty IsOpenProperty = - DependencyProperty.Register( - nameof(IsOpen), - typeof(bool), - typeof(ToastControl), - new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsOpenChanged)); - - public ToastControl() - { - InitializeComponent(); - } - - public string Message - { - get => (string)GetValue(MessageProperty); - set => SetValue(MessageProperty, value); - } - - public bool IsOpen - { - get => (bool)GetValue(IsOpenProperty); - set => SetValue(IsOpenProperty, value); - } - - private static void OnIsOpenChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is ToastControl control) - control.HandleIsOpenChanged((bool)e.NewValue); - } - - private void HandleIsOpenChanged(bool isOpen) - { - _autoDismissTimer?.Stop(); - _autoDismissTimer = null; - - if (isOpen) - { - // Fade in - var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)) - { - EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } - }; - BeginAnimation(OpacityProperty, fadeIn); - - // Auto-dismiss after 3 seconds - _autoDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; - _autoDismissTimer.Tick += OnAutoDismiss; - _autoDismissTimer.Start(); - } - else - { - // Fade out - var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)) - { - EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } - }; - BeginAnimation(OpacityProperty, fadeOut); - } - } - - private void OnAutoDismiss(object? sender, EventArgs e) - { - _autoDismissTimer?.Stop(); - _autoDismissTimer = null; - IsOpen = false; - } -} diff --git a/Client/OwnCord.Client/Controls/UserBarControl.xaml b/Client/OwnCord.Client/Controls/UserBarControl.xaml deleted file mode 100644 index 2225dae3..00000000 --- a/Client/OwnCord.Client/Controls/UserBarControl.xaml +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs b/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs deleted file mode 100644 index 22fe0103..00000000 --- a/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Windows.Controls; - -namespace OwnCord.Client.Controls; - -public partial class UserBarControl : UserControl -{ - public UserBarControl() - { - InitializeComponent(); - } -} diff --git a/Client/OwnCord.Client/Controls/UserPopupControl.xaml b/Client/OwnCord.Client/Controls/UserPopupControl.xaml deleted file mode 100644 index 959582c1..00000000 --- a/Client/OwnCord.Client/Controls/UserPopupControl.xaml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs b/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs deleted file mode 100644 index 6d07934d..00000000 --- a/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace OwnCord.Client.Controls; - -public partial class UserPopupControl : UserControl -{ - public static readonly DependencyProperty UsernameProperty = - DependencyProperty.Register(nameof(Username), typeof(string), typeof(UserPopupControl), - new PropertyMetadata(string.Empty)); - - public static readonly DependencyProperty AvatarColorProperty = - DependencyProperty.Register(nameof(AvatarColor), typeof(string), typeof(UserPopupControl), - new PropertyMetadata("#5865f2")); - - public static readonly DependencyProperty RoleNameProperty = - DependencyProperty.Register(nameof(RoleName), typeof(string), typeof(UserPopupControl), - new PropertyMetadata("Member")); - - public static readonly DependencyProperty RoleColorProperty = - DependencyProperty.Register(nameof(RoleColor), typeof(string), typeof(UserPopupControl), - new PropertyMetadata("#949ba4")); - - public static readonly DependencyProperty JoinedDateProperty = - DependencyProperty.Register(nameof(JoinedDate), typeof(string), typeof(UserPopupControl), - new PropertyMetadata(string.Empty)); - - public static readonly DependencyProperty StatusTextProperty = - DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(UserPopupControl), - new PropertyMetadata("Offline")); - - public static readonly DependencyProperty MessageCommandProperty = - DependencyProperty.Register(nameof(MessageCommand), typeof(ICommand), typeof(UserPopupControl), - new PropertyMetadata(null)); - - public static readonly DependencyProperty CloseCommandProperty = - DependencyProperty.Register(nameof(CloseCommand), typeof(ICommand), typeof(UserPopupControl), - new PropertyMetadata(null)); - - public UserPopupControl() - { - InitializeComponent(); - } - - public string Username - { - get => (string)GetValue(UsernameProperty); - set => SetValue(UsernameProperty, value); - } - - public string AvatarColor - { - get => (string)GetValue(AvatarColorProperty); - set => SetValue(AvatarColorProperty, value); - } - - public string RoleName - { - get => (string)GetValue(RoleNameProperty); - set => SetValue(RoleNameProperty, value); - } - - public string RoleColor - { - get => (string)GetValue(RoleColorProperty); - set => SetValue(RoleColorProperty, value); - } - - public string JoinedDate - { - get => (string)GetValue(JoinedDateProperty); - set => SetValue(JoinedDateProperty, value); - } - - public string StatusText - { - get => (string)GetValue(StatusTextProperty); - set => SetValue(StatusTextProperty, value); - } - - public ICommand MessageCommand - { - get => (ICommand)GetValue(MessageCommandProperty); - set => SetValue(MessageCommandProperty, value); - } - - public ICommand CloseCommand - { - get => (ICommand)GetValue(CloseCommandProperty); - set => SetValue(CloseCommandProperty, value); - } -} diff --git a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml deleted file mode 100644 index eef4eb06..00000000 --- a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs deleted file mode 100644 index 4b66a50f..00000000 --- a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Windows.Controls; - -namespace OwnCord.Client.Controls; - -public partial class VoiceWidgetControl : UserControl -{ - public VoiceWidgetControl() - { - InitializeComponent(); - } -} diff --git a/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs b/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs deleted file mode 100644 index de70fe16..00000000 --- a/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -[ValueConversion(typeof(bool), typeof(Visibility))] -public sealed class BoolToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? Visibility.Visible : Visibility.Collapsed; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => value is Visibility.Visible; -} - -[ValueConversion(typeof(int), typeof(Visibility))] -public sealed class IntToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is int n && n > 0 ? Visibility.Visible : Visibility.Collapsed; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/ColorConverters.cs b/Client/OwnCord.Client/Converters/ColorConverters.cs deleted file mode 100644 index fba55c8d..00000000 --- a/Client/OwnCord.Client/Converters/ColorConverters.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace OwnCord.Client.Converters; - -/// Converts a hex color string (#rrggbb) to a SolidColorBrush. -[ValueConversion(typeof(string), typeof(SolidColorBrush))] -public sealed class HexColorToBrushConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - { - if (value is string hex && hex.StartsWith('#') && hex.Length >= 7) - { - try - { - var color = (Color)ColorConverter.ConvertFromString(hex); - var brush = new SolidColorBrush(color); - brush.Freeze(); - return brush; - } - catch - { - // Fall through to default - } - } - - // Default fallback color (muted text) - var fallback = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#949ba4")); - fallback.Freeze(); - return fallback; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// Converts a UserStatus enum to the corresponding status dot color brush. -[ValueConversion(typeof(string), typeof(SolidColorBrush))] -public sealed class StatusToBrushConverter : IValueConverter -{ - private static readonly SolidColorBrush Online = CreateFrozen("#23a55a"); - private static readonly SolidColorBrush Idle = CreateFrozen("#f0b232"); - private static readonly SolidColorBrush Dnd = CreateFrozen("#f23f43"); - private static readonly SolidColorBrush Offline = CreateFrozen("#6d6f78"); - - private static SolidColorBrush CreateFrozen(string hex) - { - var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); - brush.Freeze(); - return brush; - } - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return value switch - { - Models.UserStatus.Online => Online, - Models.UserStatus.Idle => Idle, - Models.UserStatus.Dnd => Dnd, - _ => Offline - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// Gets the first letter of a string (for avatar circle initials). -[ValueConversion(typeof(string), typeof(string))] -public sealed class FirstLetterConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - => value is string s && s.Length > 0 ? s[0].ToString().ToUpperInvariant() : "?"; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// Converts a bool to a Foreground color (red when true, muted when false). -[ValueConversion(typeof(bool), typeof(SolidColorBrush))] -public sealed class BoolToRedBrushConverter : IValueConverter -{ - private static readonly SolidColorBrush Red = CreateFrozen("#f23f43"); - private static readonly SolidColorBrush Normal = CreateFrozen("#b5bac1"); - - private static SolidColorBrush CreateFrozen(string hex) - { - var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); - brush.Freeze(); - return brush; - } - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? Red : Normal; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// Converts a bool speaking state to a green or transparent stroke brush. -[ValueConversion(typeof(bool), typeof(SolidColorBrush))] -public sealed class SpeakingToStrokeBrushConverter : IValueConverter -{ - private static readonly SolidColorBrush Speaking = CreateFrozen((Color)ColorConverter.ConvertFromString("#23a55a")); - private static readonly SolidColorBrush Silent = CreateFrozen(Colors.Transparent); - - private static SolidColorBrush CreateFrozen(Color color) - { - var brush = new SolidColorBrush(color); - brush.Freeze(); - return brush; - } - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? Speaking : Silent; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// Converts a boolean expand state to an arrow character. -[ValueConversion(typeof(bool), typeof(string))] -public sealed class BoolToArrowConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? "\u25BE" : "\u25B8"; // ▾ or ▸ - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs b/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs deleted file mode 100644 index 2c9be9fa..00000000 --- a/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace OwnCord.Client.Converters; - -/// Converts a hex color string like "#5865f2" to a SolidColorBrush. -[ValueConversion(typeof(string), typeof(SolidColorBrush))] -public sealed class ColorToBrushConverter : IValueConverter -{ - private static readonly SolidColorBrush Fallback = new(Color.FromRgb(0x58, 0x65, 0xF2)); - - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not string hex || hex.Length < 7) - return Fallback; - - try - { - var color = (Color)ColorConverter.ConvertFromString(hex); - var brush = new SolidColorBrush(color); - brush.Freeze(); - return brush; - } - catch - { - return Fallback; - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/ContentPartTemplateSelector.cs b/Client/OwnCord.Client/Converters/ContentPartTemplateSelector.cs deleted file mode 100644 index 7c9c95b3..00000000 --- a/Client/OwnCord.Client/Converters/ContentPartTemplateSelector.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Converters; - -public sealed class ContentPartTemplateSelector : DataTemplateSelector -{ - public DataTemplate? TextTemplate { get; set; } - public DataTemplate? CodeTemplate { get; set; } - - public override DataTemplate? SelectTemplate(object item, DependencyObject container) - { - if (item is ContentPart part) - return part.IsCode ? CodeTemplate : TextTemplate; - return base.SelectTemplate(item, container); - } -} diff --git a/Client/OwnCord.Client/Converters/EqualityConverter.cs b/Client/OwnCord.Client/Converters/EqualityConverter.cs deleted file mode 100644 index a3cc3405..00000000 --- a/Client/OwnCord.Client/Converters/EqualityConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -/// -/// IMultiValueConverter that returns true when the first two bound values are equal strings. -/// Used with MultiBinding to compare two dynamic properties (e.g. Tag vs SelectedSection). -/// -public sealed class EqualityConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Length < 2) return false; - var a = values[0]?.ToString(); - var b = values[1]?.ToString(); - return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/FirstCharConverter.cs b/Client/OwnCord.Client/Converters/FirstCharConverter.cs deleted file mode 100644 index 754cfe20..00000000 --- a/Client/OwnCord.Client/Converters/FirstCharConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -/// Returns the first character of a string, uppercased. -[ValueConversion(typeof(string), typeof(string))] -public sealed class FirstCharConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not string s || s.Length == 0) - return "?"; - return char.ToUpperInvariant(s[0]).ToString(); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs b/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs deleted file mode 100644 index 31232c78..00000000 --- a/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace OwnCord.Client.Converters; - -/// -/// Converts a health status string to a SolidColorBrush for the status indicator dot. -/// "online" = Green, "checking" = Yellow, "offline" = Red, "unknown"/other = Gray. -/// -public sealed class HealthStatusToBrushConverter : IValueConverter -{ - private static readonly SolidColorBrush OnlineBrush = new(Color.FromRgb(0x23, 0xa5, 0x5a)); - private static readonly SolidColorBrush CheckingBrush = new(Color.FromRgb(0xf0, 0xb2, 0x32)); - private static readonly SolidColorBrush OfflineBrush = new(Color.FromRgb(0xf2, 0x3f, 0x43)); - private static readonly SolidColorBrush UnknownBrush = new(Color.FromRgb(0x6d, 0x6f, 0x78)); - - static HealthStatusToBrushConverter() - { - OnlineBrush.Freeze(); - CheckingBrush.Freeze(); - OfflineBrush.Freeze(); - UnknownBrush.Freeze(); - } - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return (value as string) switch - { - "online" => OnlineBrush, - "checking" => CheckingBrush, - "offline" => OfflineBrush, - _ => UnknownBrush - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/HostPortConverter.cs b/Client/OwnCord.Client/Converters/HostPortConverter.cs deleted file mode 100644 index 18c1896f..00000000 --- a/Client/OwnCord.Client/Converters/HostPortConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -/// Combines Host and Port into a display string. Used as a multi-value converter. -public sealed class HostPortConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - var host = values[0] as string ?? ""; - var port = values.Length > 1 && values[1] is int p ? p : 8443; - return port == 8443 ? host : $"{host}:{port}"; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/InverseBoolToVisibilityConverter.cs b/Client/OwnCord.Client/Converters/InverseBoolToVisibilityConverter.cs deleted file mode 100644 index 7bb41877..00000000 --- a/Client/OwnCord.Client/Converters/InverseBoolToVisibilityConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -[ValueConversion(typeof(bool), typeof(Visibility))] -public sealed class InverseBoolToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? Visibility.Collapsed : Visibility.Visible; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => value is Visibility.Collapsed; -} - -[ValueConversion(typeof(object), typeof(Visibility))] -public sealed class NullToVisibilityConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - => value is not null ? Visibility.Visible : Visibility.Collapsed; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -[ValueConversion(typeof(bool), typeof(Visibility))] -public sealed class InverseBoolConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? false : true; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => value is true ? false : true; -} diff --git a/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs b/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs deleted file mode 100644 index e85e8347..00000000 --- a/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -/// Converts a DateTime? to a human-readable relative time string. -[ValueConversion(typeof(DateTime?), typeof(string))] -public sealed class RelativeTimeConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not DateTime dt) - return "never"; - - var span = DateTime.UtcNow - dt.ToUniversalTime(); - - return span.TotalSeconds switch - { - < 60 => "just now", - < 3600 => $"{(int)span.TotalMinutes}m ago", - < 86400 => $"{(int)span.TotalHours}h ago", - < 172800 => "yesterday", - < 604800 => $"{(int)span.TotalDays}d ago", - _ => dt.ToLocalTime().ToString("MMM d", culture) - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/Converters/StringEqualsConverter.cs b/Client/OwnCord.Client/Converters/StringEqualsConverter.cs deleted file mode 100644 index 31038a86..00000000 --- a/Client/OwnCord.Client/Converters/StringEqualsConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace OwnCord.Client.Converters; - -/// -/// Returns true when the bound string value equals the converter parameter (case-insensitive). -/// Useful for highlighting the active tab in a tab bar. -/// -[ValueConversion(typeof(string), typeof(bool))] -public sealed class StringEqualsConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - => value is string s && parameter is string p - && string.Equals(s, p, StringComparison.OrdinalIgnoreCase); - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} diff --git a/Client/OwnCord.Client/MainWindow.xaml b/Client/OwnCord.Client/MainWindow.xaml deleted file mode 100644 index 1d0c1cba..00000000 --- a/Client/OwnCord.Client/MainWindow.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/Client/OwnCord.Client/MainWindow.xaml.cs b/Client/OwnCord.Client/MainWindow.xaml.cs deleted file mode 100644 index 1ed4a862..00000000 --- a/Client/OwnCord.Client/MainWindow.xaml.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Windows; -using OwnCord.Client.Services; -using OwnCord.Client.ViewModels; -using OwnCord.Client.Views; - -namespace OwnCord.Client; - -public partial class MainWindow : Window -{ - private readonly IChatService _chat; - private readonly ConnectViewModel _connectVm; - private readonly MainViewModel _mainVm; - - public MainWindow( - ConnectViewModel connectVm, - MainViewModel mainVm, - IChatService chat) - { - InitializeComponent(); - _chat = chat; - _connectVm = connectVm; - _mainVm = mainVm; - - connectVm.ConnectRequested += OnConnectRequested; - connectVm.TotpVerifyRequested += OnTotpVerifyRequested; - RootFrame.Navigate(new ConnectPage(connectVm)); - } - - private async void OnConnectRequested(string host, string username, string password, string? inviteCode, bool isRegister) - { - _connectVm.ErrorMessage = null; - _connectVm.IsLoading = true; - - try - { - Models.AuthResponse result; - if (isRegister) - result = await _chat.RegisterAsync(host, username, password, inviteCode ?? ""); - else - result = await _chat.LoginAsync(host, username, password); - - if (result.Requires2FA) - { - _connectVm.Enter2FAMode(result.PartialToken ?? ""); - return; - } - - _connectVm.PersistPasswordIfRequested(host, username, password); - _connectVm.MarkProfileConnected(host); - - _mainVm.Initialize(_chat); - RootFrame.Navigate(new MainPage(_mainVm)); - - try - { - await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); - } - catch (Exception wsEx) - { - // WebSocket errors after navigation should show on MainPage, not ConnectPage - _mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}"; - } - } - catch (ApiException ex) - { - _connectVm.ErrorMessage = ex.Message; - } - catch (Exception ex) - { - _connectVm.ErrorMessage = $"Connection failed: {ex.Message}"; - } - finally - { - _connectVm.IsLoading = false; - } - } - - private async void OnTotpVerifyRequested(string host, string partialToken, string code) - { - _connectVm.ErrorMessage = null; - _connectVm.IsLoading = true; - - try - { - var result = await _chat.VerifyTotpAsync(host, partialToken, code); - - _connectVm.PersistPasswordIfRequested(host, _connectVm.Username, _connectVm.Password); - _connectVm.MarkProfileConnected(host); - _connectVm.IsTotpRequired = false; - - _mainVm.Initialize(_chat); - RootFrame.Navigate(new MainPage(_mainVm)); - - try - { - await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); - } - catch (Exception wsEx) - { - _mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}"; - } - } - catch (ApiException ex) - { - _connectVm.ErrorMessage = ex.Message; - } - catch (Exception ex) - { - _connectVm.ErrorMessage = $"Verification failed: {ex.Message}"; - } - finally - { - _connectVm.IsLoading = false; - } - } - - /// Navigate back to the connect/login page (called after logout). - public void NavigateToConnect() - { - _connectVm.ErrorMessage = null; - _connectVm.IsLoading = false; - RootFrame.Navigate(new ConnectPage(_connectVm)); - } - - protected override void OnClosing(System.ComponentModel.CancelEventArgs e) - { - base.OnClosing(e); - _ = _chat.DisconnectWebSocketAsync(); - } -} diff --git a/Client/OwnCord.Client/Models/ApiResponses.cs b/Client/OwnCord.Client/Models/ApiResponses.cs deleted file mode 100644 index 77d7471d..00000000 --- a/Client/OwnCord.Client/Models/ApiResponses.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Text.Json.Serialization; - -namespace OwnCord.Client.Models; - -/// REST API response for login and register endpoints. -public record AuthResponse( - [property: JsonPropertyName("token")] string Token, - [property: JsonPropertyName("user")] ApiUser? User, - [property: JsonPropertyName("requires_2fa")] bool Requires2FA = false, - [property: JsonPropertyName("partial_token")] string? PartialToken = null -); - -/// User shape returned by auth endpoints. -public record ApiUser( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("avatar")] string? Avatar, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("role_id")] long RoleId, - [property: JsonPropertyName("created_at")] string CreatedAt -); - -/// Single channel from GET /api/v1/channels or ready payload. -public record ApiChannel( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("category")] string? Category, - [property: JsonPropertyName("topic")] string? Topic, - [property: JsonPropertyName("position")] int Position, - [property: JsonPropertyName("slow_mode")] int SlowMode, - [property: JsonPropertyName("archived")] bool Archived, - [property: JsonPropertyName("created_at")] string CreatedAt -); - -/// Response from GET /api/v1/channels/{id}/messages. -public record MessagesResponse( - [property: JsonPropertyName("messages")] IReadOnlyList Messages, - [property: JsonPropertyName("has_more")] bool HasMore -); - -/// Single message from the REST API (includes flattened user fields). -public record ApiMessage( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("reply_to")] long? ReplyTo, - [property: JsonPropertyName("edited_at")] string? EditedAt, - [property: JsonPropertyName("deleted")] bool Deleted, - [property: JsonPropertyName("pinned")] bool Pinned, - [property: JsonPropertyName("timestamp")] string Timestamp, - [property: JsonPropertyName("username")] string? Username, - [property: JsonPropertyName("avatar")] string? Avatar, - [property: JsonPropertyName("attachments")] IReadOnlyList? Attachments = null -); - -/// Single attachment from the REST API. -public record ApiAttachment( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("filename")] string Filename, - [property: JsonPropertyName("size")] long Size, - [property: JsonPropertyName("mime")] string Mime, - [property: JsonPropertyName("url")] string Url -); - -/// Error response shape from all REST endpoints. -public record ApiError( - [property: JsonPropertyName("error")] string Error, - [property: JsonPropertyName("message")] string Message -); - -/// Response from GET /health. -public record HealthResponse( - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("version")] string Version -); diff --git a/Client/OwnCord.Client/Models/Channel.cs b/Client/OwnCord.Client/Models/Channel.cs deleted file mode 100644 index 1171d598..00000000 --- a/Client/OwnCord.Client/Models/Channel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace OwnCord.Client.Models; - -public enum ChannelType { Text, Voice, Announcement } - -public record Channel( - long Id, - string Name, - ChannelType Type, - string? Category, - int Position, - int UnreadCount, - long? LastMessageId, - string? Topic = null -); diff --git a/Client/OwnCord.Client/Models/ChannelGroup.cs b/Client/OwnCord.Client/Models/ChannelGroup.cs deleted file mode 100644 index a83c13a7..00000000 --- a/Client/OwnCord.Client/Models/ChannelGroup.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace OwnCord.Client.Models; - -/// -/// Groups channels by category for the sidebar. Supports collapse/expand. -/// -public sealed class ChannelGroup : INotifyPropertyChanged -{ - private bool _isExpanded = true; - - public string? CategoryName { get; init; } - public ObservableCollection Items { get; } = []; - - public bool IsExpanded - { - get => _isExpanded; - set { if (_isExpanded != value) { _isExpanded = value; OnPropertyChanged(); } } - } - - /// Display name: uppercase category or empty for ungrouped. - public string DisplayName => CategoryName?.ToUpperInvariant() ?? string.Empty; - - /// True if this group has a category name (shows header). - public bool HasCategory => CategoryName is not null; - - public event PropertyChangedEventHandler? PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); -} diff --git a/Client/OwnCord.Client/Models/ChannelItem.cs b/Client/OwnCord.Client/Models/ChannelItem.cs deleted file mode 100644 index eb1b2dbc..00000000 --- a/Client/OwnCord.Client/Models/ChannelItem.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.ObjectModel; - -namespace OwnCord.Client.Models; - -/// -/// Wraps a Channel with its associated voice users for display in the sidebar. -/// -public sealed class ChannelItem -{ - public Channel Channel { get; init; } = null!; - public ObservableCollection VoiceUsers { get; } = []; - - // Convenience pass-through for binding - public long Id => Channel.Id; - public string Name => Channel.Name; - public ChannelType Type => Channel.Type; - public int UnreadCount => Channel.UnreadCount; - public string? Topic => Channel.Topic; -} diff --git a/Client/OwnCord.Client/Models/ContentPart.cs b/Client/OwnCord.Client/Models/ContentPart.cs deleted file mode 100644 index 182514ff..00000000 --- a/Client/OwnCord.Client/Models/ContentPart.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace OwnCord.Client.Models; - -public sealed class ContentPart -{ - public bool IsCode { get; } - public string Text { get; } - public string? Language { get; } - - public ContentPart(string text, bool isCode = false, string? language = null) - { - Text = text; - IsCode = isCode; - Language = language; - } - - /// Parse message content into text and code block segments. - public static IReadOnlyList Parse(string content) - { - var parts = new List(); - var remaining = content; - - while (remaining.Length > 0) - { - var fenceStart = remaining.IndexOf("```", StringComparison.Ordinal); - if (fenceStart < 0) - { - if (remaining.Length > 0) - parts.Add(new ContentPart(remaining)); - break; - } - - // Add text before the code block - if (fenceStart > 0) - parts.Add(new ContentPart(remaining[..fenceStart])); - - // Find the language hint (rest of the line after ```) - var afterFence = remaining[(fenceStart + 3)..]; - var langEnd = afterFence.IndexOf('\n'); - string? language = null; - - if (langEnd >= 0) - { - var langHint = afterFence[..langEnd].Trim(); - if (langHint.Length > 0 && langHint.Length < 20) - language = langHint; - afterFence = afterFence[(langEnd + 1)..]; - } - - // Find closing ``` - var fenceEnd = afterFence.IndexOf("```", StringComparison.Ordinal); - if (fenceEnd >= 0) - { - var code = afterFence[..fenceEnd]; - // Remove trailing newline from code if present - if (code.EndsWith('\n')) - code = code[..^1]; - parts.Add(new ContentPart(code, isCode: true, language: language)); - remaining = afterFence[(fenceEnd + 3)..]; - } - else - { - // No closing fence — treat rest as code - var code = afterFence; - if (code.EndsWith('\n')) - code = code[..^1]; - parts.Add(new ContentPart(code, isCode: true, language: language)); - break; - } - } - - // If no parts were created, return the original content as a single text part - if (parts.Count == 0) - parts.Add(new ContentPart(content)); - - return parts; - } -} diff --git a/Client/OwnCord.Client/Models/MemberGroup.cs b/Client/OwnCord.Client/Models/MemberGroup.cs deleted file mode 100644 index 9202747f..00000000 --- a/Client/OwnCord.Client/Models/MemberGroup.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.ObjectModel; - -namespace OwnCord.Client.Models; - -/// -/// Groups members by their role for the member list sidebar. -/// -public sealed class MemberGroup -{ - public string RoleName { get; init; } = string.Empty; - public string? RoleColor { get; init; } - public int Position { get; init; } - public ObservableCollection Members { get; } = []; - public int MemberCount => Members.Count; -} diff --git a/Client/OwnCord.Client/Models/Message.cs b/Client/OwnCord.Client/Models/Message.cs deleted file mode 100644 index 2ef13f73..00000000 --- a/Client/OwnCord.Client/Models/Message.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace OwnCord.Client.Models; - -public record Attachment( - string Id, - string Filename, - long Size, - string Mime, - string Url -); - -public record Message( - long Id, - long ChannelId, - User Author, - string Content, - DateTime Timestamp, - long? ReplyToId, - string? EditedAt, - bool Deleted, - IReadOnlyList Reactions, - IReadOnlyList Attachments -); - -public record Reaction(string Emoji, int Count, bool Me); diff --git a/Client/OwnCord.Client/Models/MessageDisplayItem.cs b/Client/OwnCord.Client/Models/MessageDisplayItem.cs deleted file mode 100644 index 11f49923..00000000 --- a/Client/OwnCord.Client/Models/MessageDisplayItem.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Linq; - -namespace OwnCord.Client.Models; - -/// -/// Wraps a Message with computed display properties for the UI. -/// Handles message grouping (consecutive same-author) and day dividers. -/// -public sealed class MessageDisplayItem -{ - public Message Message { get; } - - /// True when this message is from the same author as the previous one - /// and within 7 minutes — avatar and author name should be hidden. - public bool IsGrouped { get; } - - /// True when this message is the first of a new calendar day. - public bool ShowDayDivider { get; } - - /// Formatted day divider text (e.g. "March 15, 2026"). - public string? DayDividerText { get; } - - /// The message this replies to (if any) — set externally by the ViewModel. - public Message? ReplyToMessage { get; init; } - - // ── Pass-through convenience properties ── - - public long Id => Message.Id; - public User Author => Message.Author; - public string Content => Message.Content; - public DateTime Timestamp => Message.Timestamp; - public long? ReplyToId => Message.ReplyToId; - public string? EditedAt => Message.EditedAt; - public bool Deleted => Message.Deleted; - public IReadOnlyList Reactions => Message.Reactions; - public IReadOnlyList Attachments => Message.Attachments; - public bool IsEdited => EditedAt is not null; - public bool HasReactions => Reactions.Count > 0; - public bool HasAttachments => Attachments.Count > 0; - public bool IsReply => ReplyToId is not null && ReplyToMessage is not null; - public bool IsSystemMessage => Message.Author.Username == "System"; - - /// Parsed content segments (text and code blocks). - public IReadOnlyList ContentParts { get; } - - /// True if the message contains at least one code block. - public bool HasCodeBlocks => ContentParts.Any(p => p.IsCode); - - /// True when the current user authored this message (for showing edit/delete actions). - public bool IsOwnMessage { get; init; } - - /// Hex color for the author's role, e.g. "#e74c3c". Null falls back to white. - public string? AuthorRoleColor { get; init; } - - public MessageDisplayItem(Message message, Message? previousMessage) - { - Message = message; - ContentParts = ContentPart.Parse(message.Content); - - // Day divider logic - if (previousMessage is null || - message.Timestamp.Date != previousMessage.Timestamp.Date) - { - ShowDayDivider = true; - DayDividerText = message.Timestamp.Date == DateTime.Today - ? "Today" - : message.Timestamp.Date == DateTime.Today.AddDays(-1) - ? "Yesterday" - : message.Timestamp.ToString("MMMM d, yyyy"); - } - - // Grouping logic: same author, within 7 minutes, no day break, not a reply - if (previousMessage is not null && - !ShowDayDivider && - message.Author.Id == previousMessage.Author.Id && - message.ReplyToId is null && - (message.Timestamp - previousMessage.Timestamp).TotalMinutes <= 7) - { - IsGrouped = true; - } - } -} diff --git a/Client/OwnCord.Client/Models/Role.cs b/Client/OwnCord.Client/Models/Role.cs deleted file mode 100644 index 89c3c92a..00000000 --- a/Client/OwnCord.Client/Models/Role.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace OwnCord.Client.Models; - -public record Role(long Id, string Name, string? Color, long Permissions); diff --git a/Client/OwnCord.Client/Models/ServerProfile.cs b/Client/OwnCord.Client/Models/ServerProfile.cs deleted file mode 100644 index 0ca0a199..00000000 --- a/Client/OwnCord.Client/Models/ServerProfile.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; - -namespace OwnCord.Client.Models; - -public record ServerProfile( - string Id, - string Name, - string Host, - string? LastUsername, - bool AutoConnect, - [property: JsonPropertyName("port")] int Port = 8443, - [property: JsonPropertyName("color")] string Color = "#5865f2", - [property: JsonPropertyName("last_connected")] DateTime? LastConnected = null -) -{ - public static ServerProfile Create( - string name, - string host, - string? lastUsername = null, - bool autoConnect = false, - int port = 8443, - string color = "#5865f2") - => new(Guid.NewGuid().ToString(), name, host, lastUsername, autoConnect, port, color, null); - - /// Returns host:port for display, omitting port if it is the default 8443. - public string HostDisplay => Port == 8443 ? Host : $"{Host}:{Port}"; -} diff --git a/Client/OwnCord.Client/Models/User.cs b/Client/OwnCord.Client/Models/User.cs deleted file mode 100644 index 2616576e..00000000 --- a/Client/OwnCord.Client/Models/User.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OwnCord.Client.Models; - -public enum UserStatus { Online, Idle, Dnd, Offline } - -public record User( - long Id, - string Username, - string? Avatar, - long RoleId, - UserStatus Status -); diff --git a/Client/OwnCord.Client/Models/VoiceStateInfo.cs b/Client/OwnCord.Client/Models/VoiceStateInfo.cs deleted file mode 100644 index 1c1ba339..00000000 --- a/Client/OwnCord.Client/Models/VoiceStateInfo.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace OwnCord.Client.Models; - -/// -/// Mutable view-model-friendly class representing a user's voice state. -/// Implements INotifyPropertyChanged so the UI can bind to Speaking, Muted, etc. -/// -public sealed class VoiceStateInfo : INotifyPropertyChanged -{ - private bool _muted; - private bool _deafened; - private bool _speaking; - private long _channelId; - - public long UserId { get; init; } - public long ChannelId - { - get => _channelId; - set { if (_channelId != value) { _channelId = value; OnPropertyChanged(); } } - } - public string Username { get; init; } = string.Empty; - - public bool Muted - { - get => _muted; - set { if (_muted != value) { _muted = value; OnPropertyChanged(); } } - } - - public bool Deafened - { - get => _deafened; - set { if (_deafened != value) { _deafened = value; OnPropertyChanged(); } } - } - - public bool Speaking - { - get => _speaking; - set { if (_speaking != value) { _speaking = value; OnPropertyChanged(); } } - } - - public event PropertyChangedEventHandler? PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); -} diff --git a/Client/OwnCord.Client/Models/WsEnvelope.cs b/Client/OwnCord.Client/Models/WsEnvelope.cs deleted file mode 100644 index 4568ccb5..00000000 --- a/Client/OwnCord.Client/Models/WsEnvelope.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace OwnCord.Client.Models; - -/// Top-level WebSocket message envelope. -public record WsEnvelope( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("id")] string? Id, - [property: JsonPropertyName("payload")] JsonElement? Payload -); - -// ── Inbound payloads (server → client) ────────────────────────────────────── - -public record AuthOkPayload( - [property: JsonPropertyName("user")] WsUser User, - [property: JsonPropertyName("server_name")] string ServerName, - [property: JsonPropertyName("motd")] string? Motd -); - -public record ReadyPayload( - [property: JsonPropertyName("channels")] IReadOnlyList Channels, - [property: JsonPropertyName("members")] IReadOnlyList Members, - [property: JsonPropertyName("voice_states")] IReadOnlyList VoiceStates, - [property: JsonPropertyName("roles")] IReadOnlyList Roles -); - -public record WsMember( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("avatar")] string? Avatar, - [property: JsonPropertyName("status")] string? Status, - [property: JsonPropertyName("role_id")] long RoleId -); - -/// User shape in WebSocket messages (subset of ApiUser). -public record WsUser( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("avatar")] string? Avatar, - [property: JsonPropertyName("status")] string? Status -); - -public record WsRole( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("color")] string? Color, - [property: JsonPropertyName("permissions")] long Permissions, - [property: JsonPropertyName("position")] int Position, - [property: JsonPropertyName("is_default")] bool IsDefault -); - -public record WsVoiceState( - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("muted")] bool Muted, - [property: JsonPropertyName("deafened")] bool Deafened, - [property: JsonPropertyName("speaking")] bool Speaking -); - -public record ChatMessagePayload( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("user")] WsUser User, - [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("reply_to")] long? ReplyTo, - [property: JsonPropertyName("timestamp")] string Timestamp, - [property: JsonPropertyName("attachments")] IReadOnlyList? Attachments = null -); - -public record ChatSendOkPayload( - [property: JsonPropertyName("message_id")] long MessageId, - [property: JsonPropertyName("timestamp")] string Timestamp -); - -public record ChatEditedPayload( - [property: JsonPropertyName("message_id")] long MessageId, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("edited_at")] string EditedAt -); - -public record ChatDeletedPayload( - [property: JsonPropertyName("message_id")] long MessageId, - [property: JsonPropertyName("channel_id")] long ChannelId -); - -public record TypingPayload( - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("username")] string Username -); - -public record PresencePayload( - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("status")] string Status -); - -public record ReactionUpdatePayload( - [property: JsonPropertyName("message_id")] long MessageId, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("emoji")] string Emoji, - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("action")] string Action -); - -public record WsErrorPayload( - [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("message")] string Message -); - -public record ServerRestartPayload( - [property: JsonPropertyName("reason")] string Reason, - [property: JsonPropertyName("delay_seconds")] int DelaySeconds -); - -public record ChannelEventPayload( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("category")] string? Category, - [property: JsonPropertyName("topic")] string? Topic, - [property: JsonPropertyName("position")] int Position -); - -// ── Voice payloads ─────────────────────────────────────────────────────────── - -public record VoiceStatePayload( - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("muted")] bool Muted, - [property: JsonPropertyName("deafened")] bool Deafened -); - -public record VoiceLeavePayload( - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("channel_id")] long ChannelId -); - -public record VoiceConfigPayload( - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("quality")] string Quality, - [property: JsonPropertyName("bitrate")] int Bitrate, - [property: JsonPropertyName("mode")] string Mode -); - -public record VoiceSpeakersPayload( - [property: JsonPropertyName("channel_id")] long ChannelId, - [property: JsonPropertyName("speakers")] IReadOnlyList Speakers, - [property: JsonPropertyName("mode")] string Mode -); diff --git a/Client/OwnCord.Client/OwnCord.Client.csproj b/Client/OwnCord.Client/OwnCord.Client.csproj deleted file mode 100644 index b0ad54eb..00000000 --- a/Client/OwnCord.Client/OwnCord.Client.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - WinExe - net8.0-windows - enable - enable - true - 0.1.0 - 0.1.0.0 - - - diff --git a/Client/OwnCord.Client/Services/ApiClient.cs b/Client/OwnCord.Client/Services/ApiClient.cs deleted file mode 100644 index c2dd21b5..00000000 --- a/Client/OwnCord.Client/Services/ApiClient.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -/// -/// HTTP REST client for the OwnCord server API. -/// -public sealed class ApiClient : IApiClient -{ - private readonly HttpClient _http; - - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true - }; - - public ApiClient(HttpClient http) - { - _http = http; - } - - /// - /// Creates an ApiClient that uses Trust-On-First-Use (TOFU) certificate pinning. - /// On first connection to a host, the server's self-signed certificate SHA-256 fingerprint - /// is stored. Subsequent connections must present the same fingerprint. - /// - public static ApiClient CreateWithTofuTls(ICertificateTrustService trustService) - { - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => - { - if (cert == null) return false; - var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256); - // Extract host:port directly from the request URI. - // Fail closed: if the URI is missing, reject the connection. - // Never fall back to cert.Subject — an attacker controls that value. - var uri = request.RequestUri; - if (uri == null) return false; - var host = uri.IsDefaultPort - ? uri.Host - : $"{uri.Host}:{uri.Port}"; - return trustService.IsTrusted(host, fingerprint); - } - }; - var http = new HttpClient(handler); - http.DefaultRequestHeaders.Add("User-Agent", "OwnCord-Client/0.1.0"); - return new ApiClient(http); - } - - public async Task LoginAsync(string host, string username, string password, CancellationToken ct = default) - { - var body = new { username, password }; - var response = await PostJsonAsync(host, "/api/v1/auth/login", body, ct); - return await ReadOrThrowAsync(response, ct); - } - - public async Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default) - { - var body = new { username, password, invite_code = inviteCode }; - var response = await PostJsonAsync(host, "/api/v1/auth/register", body, ct); - return await ReadOrThrowAsync(response, ct); - } - - public async Task LogoutAsync(string host, string token, CancellationToken ct = default) - { - var request = new HttpRequestMessage(HttpMethod.Post, BuildUrl(host, "/api/v1/auth/logout")); - request.Headers.Add("Authorization", $"Bearer {token}"); - var response = await _http.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - await ThrowApiExceptionAsync(response, ct); - } - - public async Task GetMeAsync(string host, string token, CancellationToken ct = default) - { - var response = await GetAuthenticatedAsync(host, "/api/v1/auth/me", token, ct); - return await ReadOrThrowAsync(response, ct); - } - - public async Task> GetChannelsAsync(string host, string token, CancellationToken ct = default) - { - var response = await GetAuthenticatedAsync(host, "/api/v1/channels", token, ct); - return await ReadOrThrowAsync>(response, ct); - } - - public async Task GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default) - { - var path = $"/api/v1/channels/{channelId}/messages?limit={limit}"; - if (before.HasValue) - path += $"&before={before.Value}"; - - var response = await GetAuthenticatedAsync(host, path, token, ct); - return await ReadOrThrowAsync(response, ct); - } - - public async Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) - { - var body = new { partial_token = partialToken, code }; - var response = await PostJsonAsync(host, "/api/v1/auth/verify-totp", body, ct); - return await ReadOrThrowAsync(response, ct); - } - - public async Task HealthCheckAsync(string host, CancellationToken ct = default) - { - var response = await _http.GetAsync(BuildUrl(host, "/health"), ct); - return await ReadOrThrowAsync(response, ct); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - private static string BuildUrl(string host, string path) - => $"https://{NormalizeHost(host)}{path}"; - - /// - /// Strips any scheme prefix and trailing slashes so both ApiClient and - /// ChatService can build correct URLs from the raw user input. - /// e.g. "https://example.com:8443/" → "example.com:8443" - /// "http://example.com" → "example.com" - /// "example.com:8443" → "example.com:8443" - /// - internal static string NormalizeHost(string host) - { - host = host.Trim(); - if (host.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - host = host["https://".Length..]; - else if (host.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) - host = host["http://".Length..]; - return host.TrimEnd('/'); - } - - private async Task PostJsonAsync(string host, string path, object body, CancellationToken ct) - { - var json = JsonSerializer.Serialize(body, JsonOpts); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await _http.PostAsync(BuildUrl(host, path), content, ct); - } - - private async Task GetAuthenticatedAsync(string host, string path, string token, CancellationToken ct) - { - var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(host, path)); - request.Headers.Add("Authorization", $"Bearer {token}"); - return await _http.SendAsync(request, ct); - } - - private static async Task ReadOrThrowAsync(HttpResponseMessage response, CancellationToken ct) - { - var body = await response.Content.ReadAsStringAsync(ct); - - if (!response.IsSuccessStatusCode) - { - try - { - var error = JsonSerializer.Deserialize(body, JsonOpts); - throw new ApiException( - error?.Error ?? "UNKNOWN", - error?.Message ?? response.ReasonPhrase ?? "Request failed", - (int)response.StatusCode); - } - catch (JsonException) - { - throw new ApiException("UNKNOWN", body, (int)response.StatusCode); - } - } - - return JsonSerializer.Deserialize(body, JsonOpts) - ?? throw new ApiException("PARSE_ERROR", "Failed to deserialize response", (int)response.StatusCode); - } - - private static async Task ThrowApiExceptionAsync(HttpResponseMessage response, CancellationToken ct) - { - var body = await response.Content.ReadAsStringAsync(ct); - try - { - var error = JsonSerializer.Deserialize(body, JsonOpts); - throw new ApiException( - error?.Error ?? "UNKNOWN", - error?.Message ?? "Request failed", - (int)response.StatusCode); - } - catch (JsonException) - { - throw new ApiException("UNKNOWN", body, (int)response.StatusCode); - } - } -} diff --git a/Client/OwnCord.Client/Services/ApiException.cs b/Client/OwnCord.Client/Services/ApiException.cs deleted file mode 100644 index e8e89c65..00000000 --- a/Client/OwnCord.Client/Services/ApiException.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace OwnCord.Client.Services; - -/// -/// Exception thrown when the OwnCord server returns an error response. -/// -public sealed class ApiException : Exception -{ - public string ErrorCode { get; } - public int StatusCode { get; } - - public ApiException(string errorCode, string message, int statusCode) - : base(message) - { - ErrorCode = errorCode; - StatusCode = statusCode; - } -} diff --git a/Client/OwnCord.Client/Services/CertificateTrustService.cs b/Client/OwnCord.Client/Services/CertificateTrustService.cs deleted file mode 100644 index f50ea150..00000000 --- a/Client/OwnCord.Client/Services/CertificateTrustService.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Threading; - -namespace OwnCord.Client.Services; - -/// -/// Trust-On-First-Use (TOFU) certificate pinning service. -/// Fingerprints are persisted as a JSON file in the application data directory so -/// that trust decisions survive application restarts. -/// -/// Storage format: a flat JSON object mapping host strings to SHA-256 hex fingerprints, -/// e.g. { "server.local:8443": "AABBCC..." } -/// -public sealed class CertificateTrustService : ICertificateTrustService -{ - private readonly string _dir; - private readonly string _filePath; - private readonly SemaphoreSlim _lock = new(1, 1); - - // ── Constructors ────────────────────────────────────────────────────────── - - public CertificateTrustService() - : this(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OwnCord", - "certs")) { } - - /// Internal constructor allowing an isolated directory for unit tests. - internal CertificateTrustService(string dir) - { - _dir = dir; - _filePath = Path.Combine(_dir, "trusted_certs.json"); - } - - // ── ICertificateTrustService ────────────────────────────────────────────── - - /// - public bool IsTrusted(string host, string fingerprint) - { - if (string.IsNullOrEmpty(host)) return false; - if (string.IsNullOrEmpty(fingerprint)) return false; - - _lock.Wait(); - try - { - var store = Load(); - - if (!store.TryGetValue(host, out var stored)) - { - // First use — auto-trust (TOFU) - var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase) - { - [host] = fingerprint - }; - Save(updated); - return true; - } - - return string.Equals(stored, fingerprint, StringComparison.OrdinalIgnoreCase); - } - finally - { - _lock.Release(); - } - } - - /// - public void TrustFingerprint(string host, string fingerprint) - { - if (string.IsNullOrEmpty(host)) - throw new ArgumentException("Host must not be null or empty.", nameof(host)); - if (string.IsNullOrEmpty(fingerprint)) - throw new ArgumentException("Fingerprint must not be null or empty.", nameof(fingerprint)); - - _lock.Wait(); - try - { - var store = Load(); - var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase) - { - [host] = fingerprint - }; - Save(updated); - } - finally - { - _lock.Release(); - } - } - - /// - public void RemoveTrust(string host) - { - if (string.IsNullOrEmpty(host)) return; - - _lock.Wait(); - try - { - var store = Load(); - if (!store.ContainsKey(host)) return; - - var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase); - updated.Remove(host); - Save(updated); - } - finally - { - _lock.Release(); - } - } - - /// - public string? GetTrustedFingerprint(string host) - { - if (string.IsNullOrEmpty(host)) return null; - - var store = Load(); - return store.TryGetValue(host, out var fp) ? fp : null; - } - - // ── Private helpers ─────────────────────────────────────────────────────── - - private static readonly JsonSerializerOptions JsonOpts = new() - { - WriteIndented = true - }; - - /// - /// Reads the trust store from disk. Returns an empty dictionary if the file does not exist. - /// Throws if the file exists but is corrupt/unreadable — this prevents a silent TOFU - /// downgrade where a corrupt store causes all hosts to be re-auto-trusted. - /// - private Dictionary Load() - { - if (!File.Exists(_filePath)) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - var json = File.ReadAllText(_filePath); - var raw = JsonSerializer.Deserialize>(json); - return raw is null - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - : new Dictionary(raw, StringComparer.OrdinalIgnoreCase); - } - - /// Writes the trust store to disk atomically via a temp-file swap. - private void Save(Dictionary store) - { - Directory.CreateDirectory(_dir); - - var json = JsonSerializer.Serialize(store, JsonOpts); - - // Write to a temp file first, then replace, to avoid corruption on crash - var tmp = _filePath + ".tmp"; - File.WriteAllText(tmp, json); - File.Move(tmp, _filePath, overwrite: true); - } -} diff --git a/Client/OwnCord.Client/Services/ChatService.cs b/Client/OwnCord.Client/Services/ChatService.cs deleted file mode 100644 index 50fa24e0..00000000 --- a/Client/OwnCord.Client/Services/ChatService.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System.Text.Json; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -/// -/// Orchestrates REST API calls and WebSocket lifecycle. -/// ViewModels depend on this — never on IApiClient or IWebSocketService directly. -/// -public sealed class ChatService : IChatService -{ - private readonly IApiClient _api; - private readonly IWebSocketService _ws; - - private string? _host; - private CancellationTokenSource? _reconnectCts; - private bool _intentionalDisconnect; - - public bool IsConnected => _ws.IsConnected; - public string? CurrentToken { get; private set; } - public string? CurrentHost => _host; - public ApiUser? CurrentUser { get; private set; } - - // ── Events ────────────────────────────────────────────────────────────── - - public event Action? AuthOk; - public event Action? Ready; - public event Action? ChatMessageReceived; - public event Action? ChatSendOk; - public event Action? ChatEdited; - public event Action? ChatDeleted; - public event Action? TypingReceived; - public event Action? PresenceChanged; - public event Action? ReactionUpdated; - public event Action? ErrorReceived; - public event Action? ServerRestarting; - public event Action? MemberJoined; - public event Action? ChannelCreated; - public event Action? ChannelUpdated; - public event Action? ChannelDeleted; - public event Action? ConnectionLost; - public event Action? VoiceStateReceived; - public event Action? VoiceLeaveReceived; - public event Action? VoiceConfigReceived; - public event Action? VoiceSpeakersReceived; - - public ChatService(IApiClient api, IWebSocketService ws) - { - _api = api; - _ws = ws; - - _ws.MessageReceived += OnMessageReceived; - _ws.Disconnected += reason => OnDisconnected(reason); - } - - // ── Auth ──────────────────────────────────────────────────────────────── - - public async Task LoginAsync(string host, string username, string password, CancellationToken ct = default) - { - var result = await _api.LoginAsync(host, username, password, ct); - _host = ApiClient.NormalizeHost(host); - CurrentToken = result.Token; - CurrentUser = result.User; - return result; - } - - public async Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default) - { - var result = await _api.RegisterAsync(host, username, password, inviteCode, ct); - _host = ApiClient.NormalizeHost(host); - CurrentToken = result.Token; - CurrentUser = result.User; - return result; - } - - public async Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) - { - var result = await _api.VerifyTotpAsync(host, partialToken, code, ct); - _host = ApiClient.NormalizeHost(host); - CurrentToken = result.Token; - CurrentUser = result.User; - return result; - } - - public async Task LogoutAsync(CancellationToken ct = default) - { - _intentionalDisconnect = true; - _reconnectCts?.Cancel(); - - if (_host is not null && CurrentToken is not null) - await _api.LogoutAsync(_host, CurrentToken, ct); - - await _ws.DisconnectAsync(); - CurrentToken = null; - CurrentUser = null; - _host = null; - } - - // ── WebSocket lifecycle ───────────────────────────────────────────────── - - public async Task ConnectWebSocketAsync(string host, string token, CancellationToken ct = default) - { - _intentionalDisconnect = false; - _reconnectCts?.Cancel(); - _reconnectCts = new CancellationTokenSource(); - - var wsUri = $"wss://{ApiClient.NormalizeHost(host)}/api/v1/ws"; - await _ws.ConnectAsync(wsUri, token, ct); - - // Use the reconnect CTS so the loop can be cancelled on logout/disconnect, - // not the caller's token which may be default/already disposed. - _ = RunReceiveLoopWithErrorHandlingAsync(_reconnectCts.Token); - } - - private async Task RunReceiveLoopWithErrorHandlingAsync(CancellationToken ct) - { - try - { - await _ws.RunReceiveLoopAsync(ct); - } - catch (OperationCanceledException) - { - // Normal shutdown — ignore - } - catch (Exception ex) - { - ConnectionLost?.Invoke($"Receive loop error: {ex.Message}"); - } - } - - public Task DisconnectWebSocketAsync() - { - _intentionalDisconnect = true; - _reconnectCts?.Cancel(); - return _ws.DisconnectAsync(); - } - - // ── REST data fetches ─────────────────────────────────────────────────── - - public Task> GetChannelsAsync(CancellationToken ct = default) - => _api.GetChannelsAsync(_host!, CurrentToken!, ct); - - public Task GetMessagesAsync(long channelId, int limit = 50, long? before = null, CancellationToken ct = default) - => _api.GetMessagesAsync(_host!, CurrentToken!, channelId, limit, before, ct); - - // ── Outbound actions ──────────────────────────────────────────────────── - - public Task SendMessageAsync(long channelId, string content, long? replyTo = null, CancellationToken ct = default) - { - var envelope = new - { - type = "chat_send", - id = Guid.NewGuid().ToString(), - payload = new { channel_id = channelId, content, reply_to = replyTo } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task EditMessageAsync(long messageId, string content, CancellationToken ct = default) - { - var envelope = new - { - type = "chat_edit", - id = Guid.NewGuid().ToString(), - payload = new { message_id = messageId, content } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task DeleteMessageAsync(long messageId, CancellationToken ct = default) - { - var envelope = new - { - type = "chat_delete", - id = Guid.NewGuid().ToString(), - payload = new { message_id = messageId } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task SendTypingAsync(long channelId, CancellationToken ct = default) - { - var envelope = new - { - type = "typing_start", - payload = new { channel_id = channelId } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task SendChannelFocusAsync(long channelId, CancellationToken ct = default) - { - var envelope = new - { - type = "channel_focus", - payload = new { channel_id = channelId } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task SendStatusChangeAsync(string status, CancellationToken ct = default) - { - var envelope = new - { - type = "presence_update", - payload = new { status } - }; - return _ws.SendAsync(envelope, ct); - } - - // ── Voice outbound actions ───────────────────────────────────────────── - - public Task JoinVoiceAsync(long channelId, CancellationToken ct = default) - { - var envelope = new - { - type = "voice_join", - payload = new { channel_id = channelId } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task LeaveVoiceAsync(CancellationToken ct = default) - { - var envelope = new { type = "voice_leave" }; - return _ws.SendAsync(envelope, ct); - } - - public Task SendVoiceMuteAsync(bool muted, CancellationToken ct = default) - { - var envelope = new - { - type = "voice_mute", - payload = new { muted } - }; - return _ws.SendAsync(envelope, ct); - } - - public Task SendVoiceDeafenAsync(bool deafened, CancellationToken ct = default) - { - var envelope = new - { - type = "voice_deafen", - payload = new { deafened } - }; - return _ws.SendAsync(envelope, ct); - } - - // ── Inbound message dispatch ──────────────────────────────────────────── - - private void OnMessageReceived(string json) - { - try - { - var envelope = JsonSerializer.Deserialize(json); - if (envelope is null) return; - - switch (envelope.Type) - { - case "auth_ok": - AuthOk?.Invoke(Deserialize(envelope)); - break; - case "ready": - Ready?.Invoke(Deserialize(envelope)); - break; - case "chat_message": - ChatMessageReceived?.Invoke(Deserialize(envelope)); - break; - case "chat_send_ok": - ChatSendOk?.Invoke(Deserialize(envelope)); - break; - case "chat_edited": - ChatEdited?.Invoke(Deserialize(envelope)); - break; - case "chat_deleted": - ChatDeleted?.Invoke(Deserialize(envelope)); - break; - case "typing": - TypingReceived?.Invoke(Deserialize(envelope)); - break; - case "presence": - PresenceChanged?.Invoke(Deserialize(envelope)); - break; - case "reaction_update": - ReactionUpdated?.Invoke(Deserialize(envelope)); - break; - case "error": - ErrorReceived?.Invoke(Deserialize(envelope)); - break; - case "server_restart": - ServerRestarting?.Invoke(Deserialize(envelope)); - break; - case "member_join": - MemberJoined?.Invoke(Deserialize(envelope)); - break; - case "channel_create": - ChannelCreated?.Invoke(Deserialize(envelope)); - break; - case "channel_update": - ChannelUpdated?.Invoke(Deserialize(envelope)); - break; - case "channel_delete": - var delPayload = envelope.Payload?.Deserialize(); - if (delPayload?.TryGetProperty("id", out var idEl) == true) - ChannelDeleted?.Invoke(idEl.GetInt64()); - break; - case "voice_state": - VoiceStateReceived?.Invoke(Deserialize(envelope)); - break; - case "voice_leave": - VoiceLeaveReceived?.Invoke(Deserialize(envelope)); - break; - case "voice_config": - VoiceConfigReceived?.Invoke(Deserialize(envelope)); - break; - case "voice_speakers": - VoiceSpeakersReceived?.Invoke(Deserialize(envelope)); - break; - // Unknown types silently ignored — forward compatibility - } - } - catch (JsonException) - { - // Malformed message — don't crash the receive loop - } - } - - private void OnDisconnected(string reason) - { - ConnectionLost?.Invoke(reason); - - if (!_intentionalDisconnect && _host is not null && CurrentToken is not null) - _ = ReconnectAsync(); - } - - private async Task ReconnectAsync() - { - var ct = _reconnectCts?.Token ?? default; - var delays = new[] { 1000, 2000, 4000, 8000, 15000, 30000 }; - - for (var attempt = 0; attempt < delays.Length; attempt++) - { - if (ct.IsCancellationRequested || _host is null || CurrentToken is null) - return; - - try - { - await Task.Delay(delays[attempt], ct); - await ConnectWebSocketAsync(_host, CurrentToken, ct); - return; // Success - } - catch (OperationCanceledException) - { - return; - } - catch - { - ConnectionLost?.Invoke($"Reconnection attempt {attempt + 1} failed"); - } - } - - ConnectionLost?.Invoke("Could not reconnect after multiple attempts"); - } - - private static T Deserialize(WsEnvelope envelope) - => envelope.Payload!.Value.Deserialize() - ?? throw new JsonException($"Failed to deserialize {typeof(T).Name} payload"); -} diff --git a/Client/OwnCord.Client/Services/CredentialService.cs b/Client/OwnCord.Client/Services/CredentialService.cs deleted file mode 100644 index ec7b247d..00000000 --- a/Client/OwnCord.Client/Services/CredentialService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace OwnCord.Client.Services; - -/// -/// Stores auth tokens encrypted with DPAPI (CurrentUser scope) in AppData. -/// Equivalent security to Windows Credential Manager without requiring WinRT. -/// -public sealed class CredentialService : ICredentialService -{ - private readonly string _dir; - - public CredentialService() - : this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OwnCord", "creds")) { } - - internal CredentialService(string dir) => _dir = dir; - - public void SaveToken(string host, string username, string token) - { - Directory.CreateDirectory(_dir); - var plain = Encoding.UTF8.GetBytes(token); - var encrypted = ProtectedData.Protect(plain, GetEntropy(host, username), DataProtectionScope.CurrentUser); - File.WriteAllBytes(CredPath(host, username, "tok"), encrypted); - } - - public string? LoadToken(string host, string username) - { - var path = CredPath(host, username, "tok"); - if (!File.Exists(path)) return null; - try - { - var encrypted = File.ReadAllBytes(path); - var plain = ProtectedData.Unprotect(encrypted, GetEntropy(host, username), DataProtectionScope.CurrentUser); - return Encoding.UTF8.GetString(plain); - } - catch { return null; } - } - - public void DeleteToken(string host, string username) - { - var path = CredPath(host, username, "tok"); - if (File.Exists(path)) File.Delete(path); - } - - public void SavePassword(string host, string username, string password) - { - Directory.CreateDirectory(_dir); - var plain = Encoding.UTF8.GetBytes(password); - var encrypted = ProtectedData.Protect(plain, GetEntropy(host, username), DataProtectionScope.CurrentUser); - File.WriteAllBytes(CredPath(host, username, "pwd"), encrypted); - } - - public string? LoadPassword(string host, string username) - { - var path = CredPath(host, username, "pwd"); - if (!File.Exists(path)) return null; - try - { - var encrypted = File.ReadAllBytes(path); - var plain = ProtectedData.Unprotect(encrypted, GetEntropy(host, username), DataProtectionScope.CurrentUser); - return Encoding.UTF8.GetString(plain); - } - catch { return null; } - } - - public void DeletePassword(string host, string username) - { - var path = CredPath(host, username, "pwd"); - if (File.Exists(path)) File.Delete(path); - } - - private string CredPath(string host, string username, string prefix = "tok") - { - var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{host}:{username}"))); - return Path.Combine(_dir, $"{prefix}_{key}.dat"); - } - - private static byte[] GetEntropy(string host, string username) - => Encoding.UTF8.GetBytes($"owncord:{host}:{username}"); -} diff --git a/Client/OwnCord.Client/Services/EmojiData.cs b/Client/OwnCord.Client/Services/EmojiData.cs deleted file mode 100644 index a9491b5e..00000000 --- a/Client/OwnCord.Client/Services/EmojiData.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace OwnCord.Client.Services; - -public static class EmojiData -{ - public record EmojiCategory(string Name, IReadOnlyList Emojis); - - public static IReadOnlyList Categories { get; } = new[] - { - new EmojiCategory("Smileys", new[] { "\U0001F600", "\U0001F603", "\U0001F604", "\U0001F601", "\U0001F606", "\U0001F605", "\U0001F923", "\U0001F602", "\U0001F642", "\U0001F60A", "\U0001F607", "\U0001F970", "\U0001F60D", "\U0001F929", "\U0001F618", "\U0001F617", "\U0001F61A", "\U0001F619", "\U0001F972", "\U0001F60B", "\U0001F61B", "\U0001F61C", "\U0001F92A", "\U0001F61D", "\U0001F911", "\U0001F917", "\U0001F92D", "\U0001F92B", "\U0001F914", "\U0001FAE1", "\U0001F910", "\U0001F928", "\U0001F610", "\U0001F611", "\U0001F636", "\U0001FAE5", "\U0001F60F", "\U0001F612", "\U0001F644", "\U0001F62C", "\U0001F925", "\U0001F60C", "\U0001F614", "\U0001F62A", "\U0001F924", "\U0001F634", "\U0001F637", "\U0001F912", "\U0001F915" }), - new EmojiCategory("People", new[] { "\U0001F44B", "\U0001F91A", "\U0001F590", "\u270B", "\U0001F596", "\U0001F44C", "\U0001F90C", "\U0001F90F", "\u270C", "\U0001F91E", "\U0001F91F", "\U0001F918", "\U0001F919", "\U0001F448", "\U0001F449", "\U0001F446", "\U0001F595", "\U0001F447", "\u261D", "\U0001F44D", "\U0001F44E", "\u270A", "\U0001F44A", "\U0001F91B", "\U0001F91C", "\U0001F44F", "\U0001F64C", "\U0001F450", "\U0001F932", "\U0001F91D", "\U0001F64F" }), - new EmojiCategory("Nature", new[] { "\U0001F436", "\U0001F431", "\U0001F42D", "\U0001F439", "\U0001F430", "\U0001F98A", "\U0001F43B", "\U0001F43C", "\U0001F43B\u200D\u2744", "\U0001F428", "\U0001F42F", "\U0001F981", "\U0001F42E", "\U0001F437", "\U0001F438", "\U0001F435", "\U0001F338", "\U0001F339", "\U0001F33A", "\U0001F33B", "\U0001F33C", "\U0001F337", "\U0001F331", "\U0001F332", "\U0001F333", "\U0001F334", "\U0001F340", "\U0001F341", "\U0001F342", "\U0001F343" }), - new EmojiCategory("Food", new[] { "\U0001F34E", "\U0001F350", "\U0001F34A", "\U0001F34B", "\U0001F34C", "\U0001F349", "\U0001F347", "\U0001F353", "\U0001FAD0", "\U0001F348", "\U0001F352", "\U0001F351", "\U0001F96D", "\U0001F34D", "\U0001F965", "\U0001F95D", "\U0001F345", "\U0001F346", "\U0001F951", "\U0001F966", "\U0001F96C", "\U0001F336", "\U0001F33D", "\U0001F955", "\U0001F9C4", "\U0001F9C5", "\U0001F954", "\U0001F360", "\U0001F950", "\U0001F355" }), - new EmojiCategory("Objects", new[] { "\u231A", "\U0001F4F1", "\U0001F4BB", "\u2328", "\U0001F5A5", "\U0001F5A8", "\U0001F5B1", "\U0001F4BF", "\U0001F4C0", "\U0001F3AE", "\U0001F579", "\U0001F3A7", "\U0001F3A4", "\U0001F3B5", "\U0001F3B6", "\U0001F3B8", "\U0001F3B9", "\U0001F3BA", "\U0001F3BB", "\U0001F941", "\U0001F4F7", "\U0001F4F8", "\U0001F4F9", "\U0001F3AC", "\U0001F4FA", "\U0001F4FB", "\U0001F514", "\U0001F515", "\U0001F4E3", "\U0001F4A1" }), - new EmojiCategory("Symbols", new[] { "\u2764", "\U0001F9E1", "\U0001F49B", "\U0001F49A", "\U0001F499", "\U0001F49C", "\U0001F5A4", "\U0001F90D", "\U0001F90E", "\U0001F494", "\u2763", "\U0001F495", "\U0001F49E", "\U0001F493", "\U0001F497", "\U0001F496", "\U0001F498", "\U0001F49D", "\u2B50", "\U0001F31F", "\U0001F4AB", "\u2728", "\u26A1", "\U0001F525", "\U0001F4A5", "\U0001F389", "\U0001F38A", "\u2705", "\u274C", "\u26A0" }) - }; -} diff --git a/Client/OwnCord.Client/Services/IApiClient.cs b/Client/OwnCord.Client/Services/IApiClient.cs deleted file mode 100644 index b5abc63c..00000000 --- a/Client/OwnCord.Client/Services/IApiClient.cs +++ /dev/null @@ -1,16 +0,0 @@ -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -/// REST API client for the OwnCord server. -public interface IApiClient -{ - Task LoginAsync(string host, string username, string password, CancellationToken ct = default); - Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default); - Task LogoutAsync(string host, string token, CancellationToken ct = default); - Task GetMeAsync(string host, string token, CancellationToken ct = default); - Task> GetChannelsAsync(string host, string token, CancellationToken ct = default); - Task GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default); - Task HealthCheckAsync(string host, CancellationToken ct = default); - Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default); -} diff --git a/Client/OwnCord.Client/Services/ICertificateTrustService.cs b/Client/OwnCord.Client/Services/ICertificateTrustService.cs deleted file mode 100644 index f510221d..00000000 --- a/Client/OwnCord.Client/Services/ICertificateTrustService.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace OwnCord.Client.Services; - -/// -/// Trust-On-First-Use (TOFU) certificate pinning service. -/// On the first connection to a host, the certificate fingerprint is automatically -/// trusted and stored. On subsequent connections, the stored fingerprint must match. -/// -public interface ICertificateTrustService -{ - /// - /// Returns true if the given fingerprint is trusted for the host. - /// On first use (no stored fingerprint), automatically trusts and stores the fingerprint. - /// Returns false if a different fingerprint was previously stored for this host. - /// - bool IsTrusted(string host, string fingerprint); - - /// Explicitly stores a fingerprint as trusted for the given host. - void TrustFingerprint(string host, string fingerprint); - - /// Removes any stored trust record for the given host. - void RemoveTrust(string host); - - /// Returns the stored fingerprint for the host, or null if none is stored. - string? GetTrustedFingerprint(string host); -} diff --git a/Client/OwnCord.Client/Services/IChatService.cs b/Client/OwnCord.Client/Services/IChatService.cs deleted file mode 100644 index 1a71a1c2..00000000 --- a/Client/OwnCord.Client/Services/IChatService.cs +++ /dev/null @@ -1,76 +0,0 @@ -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -/// -/// High-level orchestrator: login/logout, WebSocket lifecycle, message dispatch. -/// ViewModels subscribe to events; they never touch IApiClient or IWebSocketService directly. -/// -public interface IChatService -{ - // ── State ─────────────────────────────────────────────────────────────── - - bool IsConnected { get; } - string? CurrentToken { get; } - string? CurrentHost { get; } - ApiUser? CurrentUser { get; } - - // ── Auth ──────────────────────────────────────────────────────────────── - - Task LoginAsync(string host, string username, string password, CancellationToken ct = default); - Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default); - Task LogoutAsync(CancellationToken ct = default); - Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default); - - // ── WebSocket lifecycle ───────────────────────────────────────────────── - - Task ConnectWebSocketAsync(string host, string token, CancellationToken ct = default); - Task DisconnectWebSocketAsync(); - - // ── REST data fetches ─────────────────────────────────────────────────── - - Task> GetChannelsAsync(CancellationToken ct = default); - Task GetMessagesAsync(long channelId, int limit = 50, long? before = null, CancellationToken ct = default); - - // ── Outbound actions (sent over WebSocket) ────────────────────────────── - - Task SendMessageAsync(long channelId, string content, long? replyTo = null, CancellationToken ct = default); - Task EditMessageAsync(long messageId, string content, CancellationToken ct = default); - Task DeleteMessageAsync(long messageId, CancellationToken ct = default); - Task SendTypingAsync(long channelId, CancellationToken ct = default); - Task SendChannelFocusAsync(long channelId, CancellationToken ct = default); - Task SendStatusChangeAsync(string status, CancellationToken ct = default); - - // ── Voice outbound actions ────────────────────────────────────────────── - - Task JoinVoiceAsync(long channelId, CancellationToken ct = default); - Task LeaveVoiceAsync(CancellationToken ct = default); - Task SendVoiceMuteAsync(bool muted, CancellationToken ct = default); - Task SendVoiceDeafenAsync(bool deafened, CancellationToken ct = default); - - // ── Events (server → client) ──────────────────────────────────────────── - - event Action? AuthOk; - event Action? Ready; - event Action? ChatMessageReceived; - event Action? ChatSendOk; - event Action? ChatEdited; - event Action? ChatDeleted; - event Action? TypingReceived; - event Action? PresenceChanged; - event Action? ReactionUpdated; - event Action? ErrorReceived; - event Action? ServerRestarting; - event Action? MemberJoined; - event Action? ChannelCreated; - event Action? ChannelUpdated; - event Action? ChannelDeleted; - event Action? ConnectionLost; - - // ── Voice events ──────────────────────────────────────────────────────── - - event Action? VoiceStateReceived; - event Action? VoiceLeaveReceived; - event Action? VoiceConfigReceived; - event Action? VoiceSpeakersReceived; -} diff --git a/Client/OwnCord.Client/Services/ICredentialService.cs b/Client/OwnCord.Client/Services/ICredentialService.cs deleted file mode 100644 index acd96d3f..00000000 --- a/Client/OwnCord.Client/Services/ICredentialService.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OwnCord.Client.Services; - -public interface ICredentialService -{ - void SaveToken(string host, string username, string token); - string? LoadToken(string host, string username); - void DeleteToken(string host, string username); - - void SavePassword(string host, string username, string password); - string? LoadPassword(string host, string username); - void DeletePassword(string host, string username); -} diff --git a/Client/OwnCord.Client/Services/IProfileService.cs b/Client/OwnCord.Client/Services/IProfileService.cs deleted file mode 100644 index 3f682cfe..00000000 --- a/Client/OwnCord.Client/Services/IProfileService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -public interface IProfileService -{ - IReadOnlyList LoadProfiles(); - IReadOnlyList AddProfile(IReadOnlyList profiles, ServerProfile profile); - IReadOnlyList RemoveProfile(IReadOnlyList profiles, string id); - IReadOnlyList UpdateProfile(IReadOnlyList profiles, ServerProfile updated); - void SaveProfiles(IReadOnlyList profiles); -} diff --git a/Client/OwnCord.Client/Services/IUpdateService.cs b/Client/OwnCord.Client/Services/IUpdateService.cs deleted file mode 100644 index f8e4b6d5..00000000 --- a/Client/OwnCord.Client/Services/IUpdateService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading.Tasks; - -namespace OwnCord.Client.Services; - -public record UpdateInfo( - string CurrentVersion, - string LatestVersion, - string ReleaseNotes, - string DownloadUrl, - string ChecksumUrl, - bool UpdateAvailable -); - -public interface IUpdateService -{ - Task CheckForUpdateAsync(); - Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath); - void ApplyUpdate(string newExePath); - void CleanupOldVersion(); - void SkipVersion(string version); -} diff --git a/Client/OwnCord.Client/Services/IWebSocketService.cs b/Client/OwnCord.Client/Services/IWebSocketService.cs deleted file mode 100644 index cd17dfdd..00000000 --- a/Client/OwnCord.Client/Services/IWebSocketService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Net.WebSockets; - -namespace OwnCord.Client.Services; - -public interface IWebSocketService -{ - bool IsConnected { get; } - WebSocketState State { get; } - - /// Fires for each raw JSON message received. - event Action? MessageReceived; - - /// Fires when the connection drops unexpectedly, with a reason string. - event Action? Disconnected; - - Task ConnectAsync(string uri, string token, CancellationToken ct = default); - Task SendAsync(object message, CancellationToken ct = default); - - /// Starts the receive loop, firing MessageReceived for each message. - /// Returns when the connection closes. - Task RunReceiveLoopAsync(CancellationToken ct); - - IAsyncEnumerable ReceiveAsync(CancellationToken ct); - Task DisconnectAsync(); -} diff --git a/Client/OwnCord.Client/Services/MessageContentParser.cs b/Client/OwnCord.Client/Services/MessageContentParser.cs deleted file mode 100644 index d3c8405b..00000000 --- a/Client/OwnCord.Client/Services/MessageContentParser.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Text.RegularExpressions; - -namespace OwnCord.Client.Services; - -/// -/// Parses message content into segments for rich rendering. -/// Handles code blocks (```), inline code (`), bold (**), italic (*), and plain text. -/// -public static class MessageContentParser -{ - public enum SegmentType { Text, CodeBlock, InlineCode, Bold, Italic } - - public record ContentSegment(SegmentType Type, string Text, string? Language = null); - - // Matches ```language\n...\n``` (multiline) - private static readonly Regex CodeBlockRegex = new( - @"```(\w*)\n?([\s\S]*?)```", - RegexOptions.Compiled); - - // Matches `...` (single backtick inline code, no newlines) - private static readonly Regex InlineCodeRegex = new( - @"`([^`\n]+)`", - RegexOptions.Compiled); - - // Matches **...** (bold) - private static readonly Regex BoldRegex = new( - @"\*\*(.+?)\*\*", - RegexOptions.Compiled); - - // Matches *...* (italic, but not **) - private static readonly Regex ItalicRegex = new( - @"(? Parse(string content) - { - if (string.IsNullOrEmpty(content)) - return Array.Empty(); - - var segments = new List(); - ParseCodeBlocks(content, segments); - return segments; - } - - private static void ParseCodeBlocks(string text, List segments) - { - var lastIndex = 0; - - foreach (Match match in CodeBlockRegex.Matches(text)) - { - if (match.Index > lastIndex) - { - ParseInlineCode(text[lastIndex..match.Index], segments); - } - - var language = match.Groups[1].Value; - var code = match.Groups[2].Value; - segments.Add(new ContentSegment( - SegmentType.CodeBlock, - code, - string.IsNullOrEmpty(language) ? null : language)); - - lastIndex = match.Index + match.Length; - } - - if (lastIndex < text.Length) - { - ParseInlineCode(text[lastIndex..], segments); - } - } - - private static void ParseInlineCode(string text, List segments) - { - var lastIndex = 0; - - foreach (Match match in InlineCodeRegex.Matches(text)) - { - if (match.Index > lastIndex) - { - ParseBoldAndItalic(text[lastIndex..match.Index], segments); - } - - segments.Add(new ContentSegment(SegmentType.InlineCode, match.Groups[1].Value)); - lastIndex = match.Index + match.Length; - } - - if (lastIndex < text.Length) - { - ParseBoldAndItalic(text[lastIndex..], segments); - } - } - - private static void ParseBoldAndItalic(string text, List segments) - { - var lastIndex = 0; - - foreach (Match match in BoldRegex.Matches(text)) - { - if (match.Index > lastIndex) - { - ParseItalic(text[lastIndex..match.Index], segments); - } - - segments.Add(new ContentSegment(SegmentType.Bold, match.Groups[1].Value)); - lastIndex = match.Index + match.Length; - } - - if (lastIndex < text.Length) - { - ParseItalic(text[lastIndex..], segments); - } - } - - private static void ParseItalic(string text, List segments) - { - var lastIndex = 0; - - foreach (Match match in ItalicRegex.Matches(text)) - { - if (match.Index > lastIndex) - { - AddTextSegment(text[lastIndex..match.Index], segments); - } - - segments.Add(new ContentSegment(SegmentType.Italic, match.Groups[1].Value)); - lastIndex = match.Index + match.Length; - } - - if (lastIndex < text.Length) - { - AddTextSegment(text[lastIndex..], segments); - } - } - - private static void AddTextSegment(string text, List segments) - { - if (!string.IsNullOrEmpty(text)) - { - segments.Add(new ContentSegment(SegmentType.Text, text)); - } - } -} diff --git a/Client/OwnCord.Client/Services/ProfileService.cs b/Client/OwnCord.Client/Services/ProfileService.cs deleted file mode 100644 index ca982797..00000000 --- a/Client/OwnCord.Client/Services/ProfileService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.IO; -using System.Text.Json; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Services; - -public sealed class ProfileService(string dataDir) : IProfileService -{ - private readonly string _path = Path.Combine(dataDir, "profiles.json"); - - public IReadOnlyList LoadProfiles() - { - if (!File.Exists(_path)) return []; - var json = File.ReadAllText(_path); - return JsonSerializer.Deserialize>(json) ?? []; - } - - public IReadOnlyList AddProfile(IReadOnlyList profiles, ServerProfile profile) - => [.. profiles, profile]; - - public IReadOnlyList RemoveProfile(IReadOnlyList profiles, string id) - => profiles.Where(p => p.Id != id).ToList(); - - public IReadOnlyList UpdateProfile(IReadOnlyList profiles, ServerProfile updated) - => profiles.Select(p => p.Id == updated.Id ? updated : p).ToList(); - - public void SaveProfiles(IReadOnlyList profiles) - { - Directory.CreateDirectory(dataDir); - File.WriteAllText(_path, JsonSerializer.Serialize(profiles)); - } -} diff --git a/Client/OwnCord.Client/Services/ToastService.cs b/Client/OwnCord.Client/Services/ToastService.cs deleted file mode 100644 index f39b5fc0..00000000 --- a/Client/OwnCord.Client/Services/ToastService.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace OwnCord.Client.Services; - -public sealed class ToastService -{ - public string? CurrentMessage { get; private set; } - public bool IsVisible { get; private set; } - - public event Action? ToastChanged; - - public void Show(string message) - { - CurrentMessage = message; - IsVisible = true; - ToastChanged?.Invoke(); - } - - public void Hide() - { - IsVisible = false; - ToastChanged?.Invoke(); - } -} diff --git a/Client/OwnCord.Client/Services/UpdateService.cs b/Client/OwnCord.Client/Services/UpdateService.cs deleted file mode 100644 index 5e056f05..00000000 --- a/Client/OwnCord.Client/Services/UpdateService.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Net.Http; -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using System.Reflection; -using System.Linq; -using System.Collections.Generic; - -namespace OwnCord.Client.Services; - -public class UpdateService : IUpdateService -{ - private const string GitHubApiUrl = "https://api.github.com/repos/J3vb/OwnCord/releases/latest"; - private const string ValidUrlPrefix = "https://github.com/J3vb/OwnCord/releases/download/"; - private static readonly string SettingsDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OwnCord"); - private static readonly string SettingsPath = Path.Combine(SettingsDir, "update-settings.json"); - - private readonly HttpClient _httpClient; - private UpdateSettings _settings; - - public UpdateService() : this(new HttpClient()) { } - - public UpdateService(HttpClient httpClient) - { - _httpClient = httpClient; - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("OwnCord-Client/1.0"); - _settings = LoadSettings(); - } - - public async Task CheckForUpdateAsync() - { - // Check 24-hour cache - if (_settings.LastCheckUtc.HasValue && - DateTime.UtcNow - _settings.LastCheckUtc.Value < TimeSpan.FromHours(24)) - { - return null; - } - - try - { - var response = await _httpClient.GetAsync(GitHubApiUrl); - if (!response.IsSuccessStatusCode) return null; - - var release = await response.Content.ReadFromJsonAsync(); - if (release == null) return null; - - var currentVersion = GetCurrentVersion(); - var latestVersion = release.TagName.TrimStart('v'); - - // Update cache timestamp - _settings.LastCheckUtc = DateTime.UtcNow; - SaveSettings(); - - var updateAvailable = CompareVersions(currentVersion, latestVersion) < 0; - - // Check skip list - if (updateAvailable && _settings.SkippedVersions.Contains(latestVersion)) - { - return null; - } - - var downloadUrl = release.Assets? - .FirstOrDefault(a => a.Name == "OwnCord.Client.exe")?.BrowserDownloadUrl ?? ""; - var checksumUrl = release.Assets? - .FirstOrDefault(a => a.Name == "checksums.sha256")?.BrowserDownloadUrl ?? ""; - - return new UpdateInfo( - CurrentVersion: currentVersion, - LatestVersion: latestVersion, - ReleaseNotes: release.Body ?? "", - DownloadUrl: downloadUrl, - ChecksumUrl: checksumUrl, - UpdateAvailable: updateAvailable - ); - } - catch - { - return null; - } - } - - public async Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath) - { - ValidateDownloadUrl(downloadUrl); - - // Download checksum file - var checksumContent = await _httpClient.GetStringAsync(checksumUrl); - var expectedHash = ParseChecksumFile(checksumContent, Path.GetFileName(destPath)); - - // Download binary - using var response = await _httpClient.GetAsync(downloadUrl); - response.EnsureSuccessStatusCode(); - - await using var fileStream = File.Create(destPath); - await response.Content.CopyToAsync(fileStream); - fileStream.Close(); - - // Verify checksum - var actualHash = ComputeFileHash(destPath); - if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) - { - File.Delete(destPath); - throw new InvalidOperationException( - $"Checksum mismatch: expected {expectedHash}, got {actualHash}"); - } - } - - public void ApplyUpdate(string newExePath) - { - var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName - ?? throw new InvalidOperationException("Cannot determine current executable path"); - - var oldPath = currentExe + ".old"; - - // Remove stale .old if present - if (File.Exists(oldPath)) File.Delete(oldPath); - - // Rename: current -> .old - File.Move(currentExe, oldPath); - - // Move: new -> current - File.Move(newExePath, currentExe); - - // Restart - Process.Start(new ProcessStartInfo - { - FileName = currentExe, - UseShellExecute = true - }); - - Environment.Exit(0); - } - - public void CleanupOldVersion() - { - var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; - if (currentExe == null) return; - - var oldPath = currentExe + ".old"; - if (File.Exists(oldPath)) - { - try { File.Delete(oldPath); } catch { /* best effort */ } - } - } - - public void SkipVersion(string version) - { - if (!_settings.SkippedVersions.Contains(version)) - { - _settings.SkippedVersions.Add(version); - SaveSettings(); - } - } - - private static string GetCurrentVersion() - { - var version = Assembly.GetExecutingAssembly().GetName().Version; - return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0"; - } - - private static int CompareVersions(string current, string latest) - { - if (Version.TryParse(current, out var v1) && Version.TryParse(latest, out var v2)) - return v1.CompareTo(v2); - return string.Compare(current, latest, StringComparison.Ordinal); - } - - private static void ValidateDownloadUrl(string url) - { - if (!url.StartsWith(ValidUrlPrefix, StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException($"Invalid download URL: {url}"); - } - - private static string ParseChecksumFile(string content, string filename) - { - foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - var trimmed = line.Trim(); - var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts[^1] == filename) - return parts[0]; - } - throw new InvalidOperationException($"File '{filename}' not found in checksum data"); - } - - private static string ComputeFileHash(string filePath) - { - using var stream = File.OpenRead(filePath); - var hash = SHA256.HashData(stream); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private UpdateSettings LoadSettings() - { - try - { - if (File.Exists(SettingsPath)) - { - var json = File.ReadAllText(SettingsPath); - return JsonSerializer.Deserialize(json) ?? new UpdateSettings(); - } - } - catch { /* ignore corrupt settings */ } - return new UpdateSettings(); - } - - private void SaveSettings() - { - try - { - Directory.CreateDirectory(SettingsDir); - var json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(SettingsPath, json); - } - catch { /* best effort */ } - } -} - -internal class UpdateSettings -{ - [JsonPropertyName("last_check_utc")] - public DateTime? LastCheckUtc { get; set; } - - [JsonPropertyName("skipped_versions")] - public List SkippedVersions { get; set; } = new(); -} - -internal class GitHubRelease -{ - [JsonPropertyName("tag_name")] - public string TagName { get; set; } = ""; - - [JsonPropertyName("body")] - public string? Body { get; set; } - - [JsonPropertyName("html_url")] - public string HtmlUrl { get; set; } = ""; - - [JsonPropertyName("assets")] - public List? Assets { get; set; } -} - -internal class GitHubAsset -{ - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - [JsonPropertyName("browser_download_url")] - public string BrowserDownloadUrl { get; set; } = ""; -} diff --git a/Client/OwnCord.Client/Services/WebSocketService.cs b/Client/OwnCord.Client/Services/WebSocketService.cs deleted file mode 100644 index 77983544..00000000 --- a/Client/OwnCord.Client/Services/WebSocketService.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.IO; -using System.Net.WebSockets; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace OwnCord.Client.Services; - -public sealed class WebSocketService : IWebSocketService, IDisposable -{ - private readonly ICertificateTrustService _trustService; - private ClientWebSocket? _ws; - - public WebSocketService(ICertificateTrustService trustService) - { - _trustService = trustService; - } - - public bool IsConnected => _ws?.State == WebSocketState.Open; - public WebSocketState State => _ws?.State ?? WebSocketState.None; - - public event Action? MessageReceived; - public event Action? Disconnected; - - public async Task ConnectAsync(string uri, string token, CancellationToken ct = default) - { - _ws?.Dispose(); - _ws = new ClientWebSocket(); - - var host = ExtractHost(uri); - - // Trust-On-First-Use (TOFU) certificate pinning. - // On first connection to a host, the certificate SHA-256 fingerprint is stored. - // On subsequent connections, the fingerprint must match the stored value. - _ws.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => - { - if (cert == null) return false; - var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256); - return _trustService.IsTrusted(host, fingerprint); - }; - - await _ws.ConnectAsync(new Uri(uri), ct); - var auth = JsonSerializer.Serialize(new { type = "auth", payload = new { token } }); - await SendRawAsync(auth, ct); - } - - public async Task SendAsync(object message, CancellationToken ct = default) - { - var json = JsonSerializer.Serialize(message); - await SendRawAsync(json, ct); - } - - public async Task RunReceiveLoopAsync(CancellationToken ct) - { - if (_ws is null) return; - var buf = new byte[8192]; - - try - { - while (_ws.State == WebSocketState.Open && !ct.IsCancellationRequested) - { - using var ms = new MemoryStream(); - WebSocketReceiveResult result; - do - { - result = await _ws.ReceiveAsync(buf, ct); - if (result.MessageType == WebSocketMessageType.Close) - { - var desc = _ws.CloseStatusDescription ?? _ws.CloseStatus?.ToString() ?? "server closed connection"; - Disconnected?.Invoke(desc); - return; - } - ms.Write(buf, 0, result.Count); - } while (!result.EndOfMessage); - - var text = Encoding.UTF8.GetString(ms.ToArray()); - MessageReceived?.Invoke(text); - } - } - catch (OperationCanceledException) - { - // Normal shutdown via cancellation. - } - catch (WebSocketException ex) - { - Disconnected?.Invoke($"WebSocket error: {ex.Message}"); - } - } - - public async IAsyncEnumerable ReceiveAsync([EnumeratorCancellation] CancellationToken ct) - { - if (_ws is null) yield break; - var buf = new byte[8192]; - while (_ws.State == WebSocketState.Open && !ct.IsCancellationRequested) - { - using var ms = new MemoryStream(); - WebSocketReceiveResult result; - do - { - result = await _ws.ReceiveAsync(buf, ct); - if (result.MessageType == WebSocketMessageType.Close) yield break; - ms.Write(buf, 0, result.Count); - } while (!result.EndOfMessage); - yield return Encoding.UTF8.GetString(ms.ToArray()); - } - } - - public async Task DisconnectAsync() - { - if (_ws?.State == WebSocketState.Open) - await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", default); - } - - /// - /// Extracts "host:port" from a WebSocket URI for use as the trust store key. - /// e.g. "wss://server.local:8443/ws" → "server.local:8443" - /// - private static string ExtractHost(string uri) - { - try - { - var u = new Uri(uri); - return u.IsDefaultPort ? u.Host : $"{u.Host}:{u.Port}"; - } - catch - { - return uri; - } - } - - private async Task SendRawAsync(string text, CancellationToken ct) - { - if (_ws is null) return; - var bytes = Encoding.UTF8.GetBytes(text); - await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); - } - - public void Dispose() => _ws?.Dispose(); -} diff --git a/Client/OwnCord.Client/Themes/Colors.xaml b/Client/OwnCord.Client/Themes/Colors.xaml deleted file mode 100644 index ef34b759..00000000 --- a/Client/OwnCord.Client/Themes/Colors.xaml +++ /dev/null @@ -1,71 +0,0 @@ - - - - #1e1f22 - #2b2d31 - #313338 - #383a40 - #35373c - #404249 - #B3000000 - - - - - - - - - - - #5865f2 - #4752c4 - #3c45a5 - - - - - - - #dbdee1 - #949ba4 - #80848e - #6d6f78 - #00a8fc - - - - - - - - - #23a55a - #f0b232 - #f23f43 - - - - - - - #3f4147 - #4e5058 - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Themes/Controls.xaml b/Client/OwnCord.Client/Themes/Controls.xaml deleted file mode 100644 index 8ef8a2c4..00000000 --- a/Client/OwnCord.Client/Themes/Controls.xaml +++ /dev/null @@ -1,285 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Themes/Typography.xaml b/Client/OwnCord.Client/Themes/Typography.xaml deleted file mode 100644 index deeb6830..00000000 --- a/Client/OwnCord.Client/Themes/Typography.xaml +++ /dev/null @@ -1,50 +0,0 @@ - - - - Segoe UI Variable Display, Segoe UI, Segoe UI Symbol - Segoe UI Variable Text, Segoe UI, Segoe UI Symbol - Cascadia Code, Consolas, Courier New - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs b/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs deleted file mode 100644 index b1dbc3ce..00000000 --- a/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs +++ /dev/null @@ -1,434 +0,0 @@ -using System.Collections.ObjectModel; -using System.IO; -using System.Text.Json; -using System.Windows.Input; -using Microsoft.Win32; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.ViewModels; - -public sealed class ConnectViewModel : ViewModelBase -{ - private readonly IProfileService _profiles; - private readonly ICredentialService _credentials; - private readonly IApiClient _api; - private readonly Dictionary _healthStatuses = new(); - - private string _host = string.Empty; - private int _port = 8443; - private string _username = string.Empty; - private string _password = string.Empty; - private string _inviteCode = string.Empty; - private bool _isRegisterMode; - private bool _isLoading; - private string? _errorMessage; - private bool _savePassword; - private ServerProfile? _selectedProfile; - private bool _isTotpRequired; - private string _partialToken = string.Empty; - private string _totpCode = string.Empty; - - public ConnectViewModel(IProfileService profiles, ICredentialService credentials, IApiClient api) - { - _profiles = profiles; - _credentials = credentials; - _api = api; - ConnectCommand = new RelayCommand(OnConnect, CanConnect); - SaveProfileCommand = new RelayCommand(OnSaveProfile, CanSaveProfile); - DeleteProfileCommand = new RelayCommand(OnDeleteProfile, () => SelectedProfile is not null); - AddProfileCommand = new RelayCommand(OnAddProfile); - EditProfileCommand = new RelayCommand(OnEditProfile); - VerifyTotpCommand = new RelayCommand(OnVerifyTotp, CanVerifyTotp); - ImportProfilesCommand = new RelayCommand(OnImportProfiles); - ExportProfilesCommand = new RelayCommand(OnExportProfiles, () => Profiles.Count > 0); - CancelTotpCommand = new RelayCommand(OnCancelTotp); - RefreshHealthCommand = new RelayCommand(OnRefreshHealth, () => Profiles.Count > 0); - Profiles = new ObservableCollection(profiles.LoadProfiles()); - Profiles.CollectionChanged += (_, _) => - { - OnPropertyChanged(nameof(HasProfiles)); - OnPropertyChanged(nameof(HasNoProfiles)); - ((RelayCommand)ExportProfilesCommand).RaiseCanExecuteChanged(); - ((RelayCommand)RefreshHealthCommand).RaiseCanExecuteChanged(); - }; - } - - public bool HasProfiles => Profiles.Count > 0; - public bool HasNoProfiles => Profiles.Count == 0; - - public string Host - { - get => _host; - set - { - if (SetField(ref _host, value)) - RaiseCanExecuteChanged(); - } - } - - public int Port - { - get => _port; - set - { - if (SetField(ref _port, value)) - RaiseCanExecuteChanged(); - } - } - - public string Username - { - get => _username; - set - { - if (SetField(ref _username, value)) - RaiseCanExecuteChanged(); - } - } - - public string Password - { - get => _password; - set - { - if (SetField(ref _password, value)) - RaiseCanExecuteChanged(); - } - } - - public string InviteCode - { - get => _inviteCode; - set => SetField(ref _inviteCode, value); - } - - public bool IsRegisterMode - { - get => _isRegisterMode; - set => SetField(ref _isRegisterMode, value); - } - - public bool IsLoading - { - get => _isLoading; - set - { - if (SetField(ref _isLoading, value)) - RaiseCanExecuteChanged(); - } - } - - public string? ErrorMessage - { - get => _errorMessage; - set => SetField(ref _errorMessage, value); - } - - public bool SavePassword - { - get => _savePassword; - set => SetField(ref _savePassword, value); - } - - public bool IsTotpRequired - { - get => _isTotpRequired; - set => SetField(ref _isTotpRequired, value); - } - - public string PartialToken - { - get => _partialToken; - set => SetField(ref _partialToken, value); - } - - public string TotpCode - { - get => _totpCode; - set - { - if (SetField(ref _totpCode, value)) - ((RelayCommand)VerifyTotpCommand).RaiseCanExecuteChanged(); - } - } - - public ServerProfile? SelectedProfile - { - get => _selectedProfile; - set - { - if (SetField(ref _selectedProfile, value) && value is not null) - { - Host = value.Host; - Port = value.Port; - Username = value.LastUsername ?? string.Empty; - - var saved = _credentials.LoadPassword(value.HostDisplay, value.LastUsername ?? ""); - if (saved is not null) - { - Password = saved; - SavePassword = true; - PasswordLoaded?.Invoke(saved); - } - else - { - Password = string.Empty; - SavePassword = false; - PasswordLoaded?.Invoke(null); - } - } - ((RelayCommand)DeleteProfileCommand).RaiseCanExecuteChanged(); - } - } - - /// Raised when a saved password is loaded so the view can set the PasswordBox. - public event Action? PasswordLoaded; - - public ObservableCollection Profiles { get; } - - public ICommand ConnectCommand { get; } - public ICommand SaveProfileCommand { get; } - public ICommand DeleteProfileCommand { get; } - public ICommand AddProfileCommand { get; } - public ICommand EditProfileCommand { get; } - public ICommand VerifyTotpCommand { get; } - public ICommand ImportProfilesCommand { get; } - public ICommand ExportProfilesCommand { get; } - public ICommand CancelTotpCommand { get; } - public ICommand RefreshHealthCommand { get; } - - /// Gets the health status string for a given profile ID. - public string GetHealthStatus(string profileId) - => _healthStatuses.TryGetValue(profileId, out var s) ? s : "unknown"; - - /// Pings every saved server's health endpoint in parallel and updates statuses. - public async Task RefreshHealthAsync() - { - if (Profiles.Count == 0) return; - - var tasks = Profiles.Select(async profile => - { - _healthStatuses[profile.Id] = "checking"; - OnPropertyChanged(nameof(Profiles)); - - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var health = await _api.HealthCheckAsync(profile.HostDisplay, cts.Token); - _healthStatuses[profile.Id] = health.Status == "ok" ? "online" : "offline"; - } - catch - { - _healthStatuses[profile.Id] = "offline"; - } - }); - await Task.WhenAll(tasks); - OnPropertyChanged(nameof(Profiles)); - HealthStatusChanged?.Invoke(); - } - - /// Raised when any health status changes so the view can refresh bindings. - public event Action? HealthStatusChanged; - - /// Args: host, username, password, inviteCode?, isRegister - public event Action? ConnectRequested; - - /// Args: host, partialToken, totpCode - public event Action? TotpVerifyRequested; - - /// Raised to open the add/edit server profile dialog. Arg: profile to edit (null = add new). - public event Action? EditProfileRequested; - - /// - /// Called when login returns requires_2fa. Sets up the TOTP entry UI state. - /// - public void Enter2FAMode(string partialToken) - { - PartialToken = partialToken; - IsTotpRequired = true; - TotpCode = string.Empty; - ErrorMessage = null; - } - - /// Applies a saved or new profile from the dialog. - public void ApplyProfileFromDialog(ServerProfile profile, bool isNew) - { - if (isNew) - { - var updated = _profiles.AddProfile([.. Profiles], profile); - _profiles.SaveProfiles(updated); - Profiles.Add(profile); - } - else - { - var index = -1; - for (var i = 0; i < Profiles.Count; i++) - { - if (Profiles[i].Id == profile.Id) { index = i; break; } - } - if (index >= 0) - { - Profiles[index] = profile; - var updated = _profiles.UpdateProfile([.. Profiles], profile); - _profiles.SaveProfiles(updated); - } - } - } - - private bool CanConnect() => - !_isLoading && - !string.IsNullOrWhiteSpace(Host) && - !string.IsNullOrWhiteSpace(Username); - - private void OnConnect() - { - var hostWithPort = Port == 8443 ? Host : $"{Host}:{Port}"; - ConnectRequested?.Invoke(hostWithPort, Username, Password, IsRegisterMode ? InviteCode : null, IsRegisterMode); - } - - private bool CanSaveProfile() => - !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(Username); - - private void OnSaveProfile() - { - var profile = ServerProfile.Create(Host, Host, Username, port: Port); - var updated = _profiles.AddProfile([.. Profiles], profile); - _profiles.SaveProfiles(updated); - Profiles.Add(profile); - } - - private void OnDeleteProfile() - { - if (SelectedProfile is null) return; - var updated = _profiles.RemoveProfile([.. Profiles], SelectedProfile.Id); - _profiles.SaveProfiles(updated); - Profiles.Remove(SelectedProfile); - SelectedProfile = null; - } - - private void OnAddProfile() => EditProfileRequested?.Invoke(null); - - private void OnEditProfile(ServerProfile? profile) - { - if (profile is not null) - EditProfileRequested?.Invoke(profile); - } - - private bool CanVerifyTotp() => - !_isLoading && _totpCode.Length == 6; - - private void OnVerifyTotp() - { - var hostWithPort = Port == 8443 ? Host : $"{Host}:{Port}"; - TotpVerifyRequested?.Invoke(hostWithPort, PartialToken, TotpCode); - } - - private void OnCancelTotp() - { - IsTotpRequired = false; - PartialToken = string.Empty; - TotpCode = string.Empty; - ErrorMessage = null; - } - - private void OnImportProfiles() - { - var dlg = new OpenFileDialog - { - Filter = "JSON files (*.json)|*.json", - Title = "Import Server Profiles" - }; - if (dlg.ShowDialog() != true) return; - - try - { - var fileInfo = new FileInfo(dlg.FileName); - if (fileInfo.Length > 1_048_576) // 1 MB limit - { - ErrorMessage = "Import file is too large (max 1 MB)."; - return; - } - - var json = File.ReadAllText(dlg.FileName); - var imported = JsonSerializer.Deserialize>(json); - if (imported is null || imported.Count == 0) return; - - var current = Profiles.ToList(); - var existingHosts = new HashSet(current.Select(p => p.HostDisplay), StringComparer.OrdinalIgnoreCase); - - foreach (var profile in imported) - { - if (!existingHosts.Contains(profile.HostDisplay)) - { - current.Add(profile); - Profiles.Add(profile); - existingHosts.Add(profile.HostDisplay); - } - } - - _profiles.SaveProfiles(current); - } - catch (Exception ex) - { - ErrorMessage = $"Import failed: {ex.Message}"; - } - } - - private void OnExportProfiles() - { - var dlg = new SaveFileDialog - { - Filter = "JSON files (*.json)|*.json", - Title = "Export Server Profiles", - FileName = "owncord-profiles.json" - }; - if (dlg.ShowDialog() != true) return; - - try - { - var json = JsonSerializer.Serialize(Profiles.ToList(), new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(dlg.FileName, json); - } - catch (Exception ex) - { - ErrorMessage = $"Export failed: {ex.Message}"; - } - } - - /// Persist or remove the saved password based on the checkbox state. - public void PersistPasswordIfRequested(string host, string username, string password) - { - if (SavePassword) - _credentials.SavePassword(host, username, password); - else - _credentials.DeletePassword(host, username); - } - - /// Updates the LastConnected timestamp on the matching profile. - public void MarkProfileConnected(string host) - { - for (var i = 0; i < Profiles.Count; i++) - { - if (string.Equals(Profiles[i].HostDisplay, host, StringComparison.OrdinalIgnoreCase) || - string.Equals(Profiles[i].Host, host, StringComparison.OrdinalIgnoreCase)) - { - var updated = Profiles[i] with { LastConnected = DateTime.UtcNow, LastUsername = Username }; - Profiles[i] = updated; - _profiles.SaveProfiles([.. Profiles]); - break; - } - } - } - - private async void OnRefreshHealth() - { - await RefreshHealthAsync(); - } - - private void RaiseCanExecuteChanged() - { - ((RelayCommand)ConnectCommand).RaiseCanExecuteChanged(); - ((RelayCommand)SaveProfileCommand).RaiseCanExecuteChanged(); - } -} diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs deleted file mode 100644 index f7306ee7..00000000 --- a/Client/OwnCord.Client/ViewModels/MainViewModel.cs +++ /dev/null @@ -1,1313 +0,0 @@ -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading; -using System.Windows; -using System.Windows.Input; -using OwnCord.Client.Models; -using OwnCord.Client.Services; - -namespace OwnCord.Client.ViewModels; - -public sealed class MainViewModel : ViewModelBase, IDisposable -{ - private IChatService? _chat; - private Channel? _selectedChannel; - private string _messageInput = string.Empty; - private bool _isTyping; - private string? _connectionStatus; - private Timer? _typingTimer; - private bool _isMemberListVisible = true; - private bool _isInVoice; - private string? _voiceChannelName; - private long _voiceChannelId; - private bool _isMuted; - private bool _isDeafened; - private Message? _replyingToMessage; - private long? _editingMessageId; - private ObservableCollection _serverProfiles = []; - private ServerProfile? _activeServer; - private bool _showStatusPicker; - private bool _showSettings; - private bool _showEmojiPicker; - private string _toastMessage = string.Empty; - private bool _showToast; - private User? _popupUser; - private bool _showUserPopup; - private double _userPopupX; - private double _userPopupY; - private bool _isHomeView; - private User? _activeDmUser; - private bool _isDmView; - private string _selectedFriendsTab = "online"; - private string _friendSearchText = string.Empty; - private ObservableCollection _filteredFriends = []; - private string _searchText = string.Empty; - private string _currentUserStatusText = "Offline"; - private Timer? _errorClearTimer; - private bool _isTransientError; - private Timer? _statusDebounceTimer; - private string? _pendingStatus; - - public MainViewModel() - { - Channels = []; - Members = []; - Messages = []; - DisplayMessages = []; - Roles = []; - ChannelGroups = []; - MemberGroups = []; - VoiceStates = []; - - SendMessageCommand = new RelayCommand(OnSendMessage, () => !string.IsNullOrWhiteSpace(MessageInput) && SelectedChannel is not null); - ToggleMemberListCommand = new RelayCommand(() => IsMemberListVisible = !IsMemberListVisible); - JoinVoiceCommand = new RelayCommand(OnJoinVoice); - LeaveVoiceCommand = new RelayCommand(OnLeaveVoice); - ToggleMuteCommand = new RelayCommand(OnToggleMute); - ToggleDeafenCommand = new RelayCommand(OnToggleDeafen); - ToggleCategoryCommand = new RelayCommand(OnToggleCategory); - SelectChannelCommand = new RelayCommand(OnSelectChannel); - StartReplyCommand = new RelayCommand(OnStartReply); - CancelReplyCommand = new RelayCommand(OnCancelReply); - DeleteMessageCommand = new RelayCommand(OnDeleteMessage); - StartEditCommand = new RelayCommand(OnStartEdit); - SelectServerCommand = new RelayCommand(p => { ActiveServer = p; IsHomeView = false; IsDmView = false; ActiveDmUser = null; }); - AddServerCommand = new RelayCommand(() => { /* placeholder for future dialog */ }); - ToggleStatusPickerCommand = new RelayCommand(() => ShowStatusPicker = !ShowStatusPicker); - ChangeStatusCommand = new RelayCommand(OnChangeStatus); - OpenSettingsCommand = new RelayCommand(() => ShowSettings = true); - CloseSettingsCommand = new RelayCommand(() => ShowSettings = false); - ToggleEmojiPickerCommand = new RelayCommand(() => ShowEmojiPicker = !ShowEmojiPicker); - InsertEmojiCommand = new RelayCommand(OnInsertEmoji); - ShowUserPopupCommand = new RelayCommand(OnShowUserPopup); - CloseUserPopupCommand = new RelayCommand(OnCloseUserPopup); - AttachFileCommand = new RelayCommand(OnAttachFile); - LogoutCommand = new RelayCommand(OnLogout); - ShowPinnedCommand = new RelayCommand(() => ShowToastMessage("Pinned messages coming soon")); - ToggleReactionCommand = new RelayCommand(OnToggleReaction); - HomeCommand = new RelayCommand(OnHome); - SelectFriendsTabCommand = new RelayCommand(OnSelectFriendsTab); - MessageFriendCommand = new RelayCommand(OnMessageFriend); - SelectDmCommand = new RelayCommand(OnSelectDm); - CloseDmCommand = new RelayCommand(OnCloseDm); - } - - /// Wire up ChatService events. Called once after login succeeds. - public void Initialize(IChatService chat) - { - _chat = chat; - - chat.AuthOk += p => RunOnUI(() => OnAuthOk(p)); - chat.Ready += p => RunOnUI(() => OnReady(p)); - chat.ChatMessageReceived += p => RunOnUI(() => OnChatMessage(p)); - chat.TypingReceived += p => RunOnUI(() => OnTyping(p)); - chat.PresenceChanged += p => RunOnUI(() => OnPresence(p)); - chat.ChatEdited += p => RunOnUI(() => OnChatEdited(p)); - chat.ChatDeleted += p => RunOnUI(() => OnChatDeleted(p)); - chat.MemberJoined += p => RunOnUI(() => OnMemberJoined(p)); - chat.ChannelCreated += p => RunOnUI(() => OnChannelCreated(p)); - chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p)); - chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id)); - chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r)); - chat.ErrorReceived += p => RunOnUI(() => OnWsError(p)); - chat.VoiceStateReceived += p => RunOnUI(() => OnVoiceState(p)); - chat.VoiceLeaveReceived += p => RunOnUI(() => OnVoiceLeave(p)); - chat.VoiceSpeakersReceived += p => RunOnUI(() => OnVoiceSpeakers(p)); - } - - private static void RunOnUI(Action action) - { - if (Application.Current?.Dispatcher is { } dispatcher && !dispatcher.CheckAccess()) - dispatcher.Invoke(action); - else - action(); - } - - // ── Connection status ──────────────────────────────────────────────────── - - public string? ConnectionStatus - { - get => _connectionStatus; - set - { - if (SetField(ref _connectionStatus, value)) - OnPropertyChanged(nameof(HasConnectionIssue)); - } - } - - public bool HasConnectionIssue => _connectionStatus is not null; - - // ── Collections ────────────────────────────────────────────────────────── - - public ObservableCollection Channels { get; } - public ObservableCollection Members { get; } - public ObservableCollection Messages { get; } - public ObservableCollection DisplayMessages { get; } - public ObservableCollection Roles { get; } - public ObservableCollection ChannelGroups { get; } - public ObservableCollection MemberGroups { get; } - public ObservableCollection VoiceStates { get; } - - public ObservableCollection FilteredFriends - { - get => _filteredFriends; - private set => SetField(ref _filteredFriends, value); - } - - public int FilteredFriendsCount => FilteredFriends.Count; - - // ── Selected channel ───────────────────────────────────────────────────── - - public Channel? SelectedChannel - { - get => _selectedChannel; - set - { - if (SetField(ref _selectedChannel, value)) - { - OnPropertyChanged(nameof(SelectedChannelTopic)); - Messages.Clear(); - DisplayMessages.Clear(); - ((RelayCommand)SendMessageCommand).RaiseCanExecuteChanged(); - if (value is not null) - { - // Voice channels join voice instead of loading messages - if (value.Type == ChannelType.Voice) - { - _ = _chat?.JoinVoiceAsync(value.Id); - return; - } - - _ = _chat?.SendChannelFocusAsync(value.Id); - _ = LoadMessagesForChannelAsync(value.Id); - } - } - } - } - - public string? SelectedChannelTopic => _selectedChannel?.Topic; - - // ── Message input ──────────────────────────────────────────────────────── - - public string MessageInput - { - get => _messageInput; - set - { - if (SetField(ref _messageInput, value)) - ((RelayCommand)SendMessageCommand).RaiseCanExecuteChanged(); - } - } - - public bool IsTyping - { - get => _isTyping; - set => SetField(ref _isTyping, value); - } - - public string? TypingText { get; private set; } - - // ── Member list visibility ─────────────────────────────────────────────── - - public bool IsMemberListVisible - { - get => _isMemberListVisible; - set => SetField(ref _isMemberListVisible, value); - } - - // ── Voice state (local user) ───────────────────────────────────────────── - - public bool IsInVoice - { - get => _isInVoice; - set => SetField(ref _isInVoice, value); - } - - public string? VoiceChannelName - { - get => _voiceChannelName; - set => SetField(ref _voiceChannelName, value); - } - - public bool IsMuted - { - get => _isMuted; - set => SetField(ref _isMuted, value); - } - - public bool IsDeafened - { - get => _isDeafened; - set => SetField(ref _isDeafened, value); - } - - // ── Current user info (for user bar) ───────────────────────────────────── - - public string CurrentUsername => _chat?.CurrentUser?.Username ?? "Unknown"; - - public string CurrentUserStatus - { - get => _currentUserStatusText; - private set - { - if (SetField(ref _currentUserStatusText, value)) - { - OnPropertyChanged(nameof(CurrentUserStatusEnum)); - } - } - } - - public UserStatus CurrentUserStatusEnum => _currentUserStatusText switch - { - "Online" => UserStatus.Online, - "Idle" => UserStatus.Idle, - "Do Not Disturb" => UserStatus.Dnd, - "Invisible" => UserStatus.Offline, - _ => UserStatus.Offline - }; - - // ── Home / Friends view ──────────────────────────────────────────────── - - public bool IsHomeView - { - get => _isHomeView; - set => SetField(ref _isHomeView, value); - } - - public User? ActiveDmUser - { - get => _activeDmUser; - set => SetField(ref _activeDmUser, value); - } - - public bool IsDmView - { - get => _isDmView; - set => SetField(ref _isDmView, value); - } - - public string SelectedFriendsTab - { - get => _selectedFriendsTab; - set => SetField(ref _selectedFriendsTab, value); - } - - public string FriendSearchText - { - get => _friendSearchText; - set - { - if (SetField(ref _friendSearchText, value)) - RebuildFilteredFriends(); - } - } - - public string SearchText - { - get => _searchText; - set => SetField(ref _searchText, value); - } - - // ── Settings overlay ────────────────────────────────────────────────── - - public bool ShowSettings - { - get => _showSettings; - set => SetField(ref _showSettings, value); - } - - // ── Status picker ───────────────────────────────────────────────────── - - public bool ShowStatusPicker - { - get => _showStatusPicker; - set => SetField(ref _showStatusPicker, value); - } - - // ── Emoji picker ────────────────────────────────────────────────────── - - public bool ShowEmojiPicker - { - get => _showEmojiPicker; - set => SetField(ref _showEmojiPicker, value); - } - - // ── Toast notification ────────────────────────────────────────────────── - - public string ToastMessage - { - get => _toastMessage; - set => SetField(ref _toastMessage, value); - } - - public bool ShowToast - { - get => _showToast; - set => SetField(ref _showToast, value); - } - - public void ShowToastMessage(string message) - { - ToastMessage = message; - ShowToast = true; - } - - // ── Commands ───────────────────────────────────────────────────────────── - - public ICommand SendMessageCommand { get; } - public ICommand ToggleMemberListCommand { get; } - public ICommand JoinVoiceCommand { get; } - public ICommand LeaveVoiceCommand { get; } - public ICommand ToggleMuteCommand { get; } - public ICommand ToggleDeafenCommand { get; } - public ICommand ToggleCategoryCommand { get; } - public ICommand SelectChannelCommand { get; } - public ICommand StartReplyCommand { get; } - public ICommand CancelReplyCommand { get; } - public ICommand DeleteMessageCommand { get; } - public ICommand StartEditCommand { get; } - public ICommand SelectServerCommand { get; } - public ICommand AddServerCommand { get; } - public ICommand ToggleStatusPickerCommand { get; } - public ICommand ChangeStatusCommand { get; } - public ICommand OpenSettingsCommand { get; } - public ICommand CloseSettingsCommand { get; } - public ICommand ToggleEmojiPickerCommand { get; } - public ICommand InsertEmojiCommand { get; } - public ICommand ShowUserPopupCommand { get; } - public ICommand CloseUserPopupCommand { get; } - public ICommand AttachFileCommand { get; } - public ICommand LogoutCommand { get; } - public ICommand ShowPinnedCommand { get; } - public ICommand HomeCommand { get; } - public ICommand SelectFriendsTabCommand { get; } - public ICommand MessageFriendCommand { get; } - public ICommand SelectDmCommand { get; } - public ICommand CloseDmCommand { get; } - public ICommand ToggleReactionCommand { get; } - - // ── User popup state ──────────────────────────────────────────────────── - - public User? PopupUser - { - get => _popupUser; - set => SetField(ref _popupUser, value); - } - - public bool ShowUserPopup - { - get => _showUserPopup; - set => SetField(ref _showUserPopup, value); - } - - public double UserPopupX - { - get => _userPopupX; - set => SetField(ref _userPopupX, value); - } - - public double UserPopupY - { - get => _userPopupY; - set => SetField(ref _userPopupY, value); - } - - /// Resolved role name for the popup user. - public string PopupUserRoleName - { - get - { - if (_popupUser is null) return "Member"; - var role = Roles.FirstOrDefault(r => r.Id == _popupUser.RoleId); - return role?.Name ?? "Member"; - } - } - - /// Resolved role color for the popup user. - public string PopupUserRoleColor - { - get - { - if (_popupUser is null) return "#949ba4"; - var role = Roles.FirstOrDefault(r => r.Id == _popupUser.RoleId); - return role?.Color ?? "#949ba4"; - } - } - - /// Status text for the popup user. - public string PopupUserStatusText => _popupUser?.Status switch - { - UserStatus.Online => "Online", - UserStatus.Idle => "Idle", - UserStatus.Dnd => "Do Not Disturb", - _ => "Offline" - }; - - // ── Reply state ─────────────────────────────────────────────────────── - - public Message? ReplyingToMessage - { - get => _replyingToMessage; - set - { - if (SetField(ref _replyingToMessage, value)) - OnPropertyChanged(nameof(IsReplying)); - } - } - - public bool IsReplying => _replyingToMessage is not null; - - // ── Edit state ──────────────────────────────────────────────────────── - - public long? EditingMessageId - { - get => _editingMessageId; - set => SetField(ref _editingMessageId, value); - } - - // ── Server strip ────────────────────────────────────────────────────────── - - public ObservableCollection ServerProfiles - { - get => _serverProfiles; - set => SetField(ref _serverProfiles, value); - } - - public ServerProfile? ActiveServer - { - get => _activeServer; - set => SetField(ref _activeServer, value); - } - - // ── Public helpers ─────────────────────────────────────────────────────── - - public void LoadServerProfiles(IReadOnlyList profiles) - { - ServerProfiles = new ObservableCollection(profiles); - if (ActiveServer is null && ServerProfiles.Count > 0) - ActiveServer = ServerProfiles[0]; - } - - public void LoadChannels(IEnumerable channels) - { - Channels.Clear(); - foreach (var ch in channels) Channels.Add(ch); - RebuildChannelGroups(); - } - - public void LoadMembers(IEnumerable members) - { - Members.Clear(); - foreach (var m in members) Members.Add(m); - RebuildMemberGroups(); - } - - public void AddMessage(Message message) - { - Messages.Add(message); - AppendDisplayMessage(message); - } - - public void UpdateUnreadCount(long channelId, int count) - { - for (var i = 0; i < Channels.Count; i++) - { - if (Channels[i].Id == channelId) - { - if (Channels[i].UnreadCount == count) return; - Channels[i] = Channels[i] with { UnreadCount = count }; - - // Update the specific ChannelItem in-place instead of rebuilding all groups - foreach (var group in ChannelGroups) - { - for (var j = 0; j < group.Items.Count; j++) - { - if (group.Items[j].Channel.Id == channelId) - { - group.Items[j] = new ChannelItem { Channel = Channels[i] }; - return; - } - } - } - return; - } - } - } - - public void ShowTyping(string username) - { - TypingText = $"{username} is typing..."; - IsTyping = true; - OnPropertyChanged(nameof(TypingText)); - } - - public void HideTyping() - { - IsTyping = false; - TypingText = null; - OnPropertyChanged(nameof(TypingText)); - } - - /// Get voice users for a specific channel. - public IEnumerable GetVoiceUsersForChannel(long channelId) - => VoiceStates.Where(vs => vs.ChannelId == channelId); - - // ── Command handlers ───────────────────────────────────────────────────── - - private void OnInsertEmoji(string? emoji) - { - if (string.IsNullOrEmpty(emoji)) return; - MessageInput += emoji; - ShowEmojiPicker = false; - } - - private void OnChangeStatus(string? status) - { - if (string.IsNullOrWhiteSpace(status)) return; - ShowStatusPicker = false; - - // Update UI immediately (optimistic) - CurrentUserStatus = CapitalizeStatus(status); - - // Debounce the actual server call (1 second) to avoid rate limits - _pendingStatus = status; - _statusDebounceTimer?.Dispose(); - _statusDebounceTimer = new Timer(_ => - { - var pending = _pendingStatus; - if (pending is not null && _chat is not null) - _ = _chat.SendStatusChangeAsync(pending); - }, null, 1000, Timeout.Infinite); - } - - private void OnSendMessage() - { - if (_chat is null || SelectedChannel is null || string.IsNullOrWhiteSpace(MessageInput)) return; - var channelId = SelectedChannel.Id; - var content = MessageInput; - MessageInput = string.Empty; - - if (EditingMessageId is { } editId) - { - EditingMessageId = null; - _ = _chat.EditMessageAsync(editId, content); - } - else - { - var replyTo = ReplyingToMessage?.Id; - ReplyingToMessage = null; - _ = _chat.SendMessageAsync(channelId, content, replyTo); - } - } - - private void OnSelectChannel(object? param) - { - IsDmView = false; - ActiveDmUser = null; - - var channel = param switch - { - Channel ch => ch, - ChannelItem ci => ci.Channel, - _ => null - }; - if (channel is null) return; - SelectedChannel = channel; - } - - private void OnJoinVoice(Channel? channel) - { - if (_chat is null || channel is null || channel.Type != ChannelType.Voice) return; - _ = _chat.JoinVoiceAsync(channel.Id); - } - - private void OnLeaveVoice() - { - if (_chat is null) return; - _ = _chat.LeaveVoiceAsync(); - IsInVoice = false; - VoiceChannelName = null; - IsMuted = false; - IsDeafened = false; - } - - private void OnToggleMute() - { - if (_chat is null || !IsInVoice) return; - IsMuted = !IsMuted; - _ = _chat.SendVoiceMuteAsync(IsMuted); - } - - private void OnToggleDeafen() - { - if (_chat is null || !IsInVoice) return; - IsDeafened = !IsDeafened; - if (IsDeafened) IsMuted = true; - _ = _chat.SendVoiceDeafenAsync(IsDeafened); - } - - private static void OnToggleCategory(ChannelGroup? group) - { - if (group is null) return; - group.IsExpanded = !group.IsExpanded; - } - - private void OnStartReply(Message? message) - { - if (message is null) return; - ReplyingToMessage = message; - } - - private void OnCancelReply() - { - ReplyingToMessage = null; - } - - private void OnDeleteMessage(Message? message) - { - if (_chat is null || message is null) return; - _ = _chat.DeleteMessageAsync(message.Id); - } - - private void OnStartEdit(Message? message) - { - if (message is null) return; - EditingMessageId = message.Id; - MessageInput = message.Content; - } - - private void OnShowUserPopup(object? param) - { - var user = param switch - { - User u => u, - MessageDisplayItem di => di.Author, - Message m => m.Author, - _ => null - }; - if (user is null) return; - - PopupUser = user; - ShowUserPopup = true; - OnPropertyChanged(nameof(PopupUserRoleName)); - OnPropertyChanged(nameof(PopupUserRoleColor)); - OnPropertyChanged(nameof(PopupUserStatusText)); - } - - private void OnCloseUserPopup() - { - ShowUserPopup = false; - PopupUser = null; - } - - private void OnAttachFile() - { - ShowToastMessage("File upload is not yet implemented."); - } - - private void OnLogout() - { - ShowSettings = false; - _ = LogoutAndReturnToConnectAsync(); - } - - private async Task LogoutAndReturnToConnectAsync() - { - if (_chat is null) return; - try - { - await _chat.LogoutAsync(); - } - catch - { - // Best-effort logout - } - - RunOnUI(() => - { - if (Application.Current?.MainWindow is MainWindow mainWindow) - mainWindow.NavigateToConnect(); - }); - } - - private void OnHome() - { - IsDmView = false; - ActiveDmUser = null; - IsHomeView = true; - } - - private void OnSelectFriendsTab(string? tab) - { - if (string.IsNullOrWhiteSpace(tab)) return; - SelectedFriendsTab = tab; - RebuildFilteredFriends(); - } - - private void OnMessageFriend(object? param) - { - if (param is User user) - { - ActiveDmUser = user; - IsDmView = true; - Messages.Clear(); - DisplayMessages.Clear(); - } - } - - private void OnSelectDm(object? param) - { - if (param is User user) - { - ActiveDmUser = user; - IsDmView = true; - // In future: load DM messages from server - Messages.Clear(); - DisplayMessages.Clear(); - } - } - - private void OnCloseDm() - { - ActiveDmUser = null; - IsDmView = false; - } - - private void OnToggleReaction(object? parameter) - { - // Placeholder — server reaction API not yet implemented - ShowToastMessage("Reactions coming soon"); - } - - // ── Message display items ────────────────────────────────────────────────── - - private void RebuildDisplayMessages() - { - DisplayMessages.Clear(); - Message? prev = null; - foreach (var msg in Messages) - { - var item = new MessageDisplayItem(msg, prev) - { - ReplyToMessage = msg.ReplyToId is not null - ? Messages.FirstOrDefault(m => m.Id == msg.ReplyToId) - : null, - IsOwnMessage = _chat?.CurrentUser is { } u && msg.Author.Id == u.Id, - AuthorRoleColor = Roles.FirstOrDefault(r => r.Id == msg.Author.RoleId)?.Color - }; - DisplayMessages.Add(item); - prev = msg; - } - } - - private void AppendDisplayMessage(Message message) - { - var prev = Messages.Count > 1 ? Messages[^2] : null; - var item = new MessageDisplayItem(message, prev) - { - ReplyToMessage = message.ReplyToId is not null - ? Messages.FirstOrDefault(m => m.Id == message.ReplyToId) - : null, - IsOwnMessage = _chat?.CurrentUser is { } u && message.Author.Id == u.Id, - AuthorRoleColor = Roles.FirstOrDefault(r => r.Id == message.Author.RoleId)?.Color - }; - DisplayMessages.Add(item); - } - - // ── Channel grouping ───────────────────────────────────────────────────── - - private void RebuildChannelGroups() - { - // Preserve expanded state across rebuilds - var expandedState = new Dictionary(); - foreach (var g in ChannelGroups) - expandedState.TryAdd(g.CategoryName ?? "", g.IsExpanded); - - ChannelGroups.Clear(); - - var grouped = Channels - .OrderBy(c => c.Position) - .GroupBy(c => c.Category); - - foreach (var g in grouped.OrderBy(g => g.Key is null ? 0 : 1)) - { - var group = new ChannelGroup { CategoryName = g.Key }; - - // Restore expanded state - if (expandedState.TryGetValue(g.Key ?? "", out var wasExpanded)) - group.IsExpanded = wasExpanded; - - foreach (var ch in g) - { - var item = new ChannelItem { Channel = ch }; - - // Populate voice users for voice channels - if (ch.Type == ChannelType.Voice) - { - foreach (var vs in VoiceStates.Where(vs => vs.ChannelId == ch.Id)) - item.VoiceUsers.Add(vs); - } - - group.Items.Add(item); - } - - ChannelGroups.Add(group); - } - } - - // ── Member grouping by role ────────────────────────────────────────────── - - private void RebuildMemberGroups() - { - MemberGroups.Clear(); - - var roleMap = new Dictionary(); - foreach (var r in Roles) - roleMap.TryAdd(r.Id, r); - - var grouped = Members - .GroupBy(m => m.RoleId) - .Select(g => - { - roleMap.TryGetValue(g.Key, out var role); - return new { Role = role, Members = g.ToList() }; - }) - .OrderBy(g => g.Role?.Position ?? int.MaxValue); - - foreach (var g in grouped) - { - var mg = new MemberGroup - { - RoleName = g.Role?.Name ?? "Members", - RoleColor = g.Role?.Color, - Position = g.Role?.Position ?? int.MaxValue - }; - foreach (var m in g.Members) mg.Members.Add(m); - MemberGroups.Add(mg); - } - } - - // ── Filtered friends list ──────────────────────────────────────────────── - - private void RebuildFilteredFriends() - { - var filtered = Members.AsEnumerable(); - - if (SelectedFriendsTab == "online") - filtered = filtered.Where(m => m.Status != UserStatus.Offline); - - if (!string.IsNullOrWhiteSpace(FriendSearchText)) - filtered = filtered.Where(m => m.Username.Contains(FriendSearchText, StringComparison.OrdinalIgnoreCase)); - - FilteredFriends.Clear(); - foreach (var m in filtered) - FilteredFriends.Add(m); - - OnPropertyChanged(nameof(FilteredFriendsCount)); - } - - // ── Message loading ────────────────────────────────────────────────────── - - private async Task LoadMessagesForChannelAsync(long channelId) - { - if (_chat is null) return; - try - { - var response = await _chat.GetMessagesAsync(channelId); - Messages.Clear(); - DisplayMessages.Clear(); - foreach (var msg in response.Messages) - { - var attachments = msg.Attachments? - .Select(a => new Attachment(a.Id, a.Filename, a.Size, a.Mime, a.Url)) - .ToList() as IReadOnlyList ?? Array.Empty(); - Messages.Add(new Message( - msg.Id, - msg.ChannelId, - new User(msg.UserId, msg.Username ?? "Unknown", msg.Avatar, 0, UserStatus.Online), - msg.Content, - DateTime.TryParse(msg.Timestamp, out var ts) ? ts : DateTime.UtcNow, - msg.ReplyTo, - msg.EditedAt, - msg.Deleted, - [], - attachments - )); - } - RebuildDisplayMessages(); - } - catch (Exception ex) - { - ConnectionStatus = $"Failed to load messages: {ex.Message}"; - } - } - - // ── ChatService event handlers ────────────────────────────────────────── - - private void OnAuthOk(AuthOkPayload payload) - { - // Add the connected server to the server strip - var host = _chat?.CurrentHost ?? "unknown"; - var serverName = payload.ServerName ?? host; - - // Only add if not already in the strip - var existing = ServerProfiles.FirstOrDefault(p => - string.Equals(p.Host, host, StringComparison.OrdinalIgnoreCase)); - - if (existing is null) - { - var profile = ServerProfile.Create(serverName, host); - ServerProfiles.Add(profile); - ActiveServer = profile; - } - else - { - ActiveServer = existing; - } - - IsHomeView = false; - } - - private void OnReady(ReadyPayload payload) - { - ConnectionStatus = null; - - // Store roles - Roles.Clear(); - foreach (var r in payload.Roles) Roles.Add(r); - - // Load channels - Channels.Clear(); - foreach (var ch in payload.Channels) - { - var type = ch.Type switch - { - "voice" => ChannelType.Voice, - "announcement" => ChannelType.Announcement, - _ => ChannelType.Text - }; - Channels.Add(new Channel(ch.Id, ch.Name, type, ch.Category, ch.Position, 0, null, ch.Topic)); - } - - // Load members - Members.Clear(); - foreach (var m in payload.Members) - { - var status = m.Status switch - { - "online" => UserStatus.Online, - "idle" => UserStatus.Idle, - "dnd" => UserStatus.Dnd, - _ => UserStatus.Offline - }; - Members.Add(new User(m.Id, m.Username, m.Avatar, m.RoleId, status)); - } - RebuildMemberGroups(); - - // Load voice states - VoiceStates.Clear(); - foreach (var vs in payload.VoiceStates) - { - VoiceStates.Add(new VoiceStateInfo - { - UserId = vs.UserId, - ChannelId = vs.ChannelId, - Username = vs.Username, - Muted = vs.Muted, - Deafened = vs.Deafened, - Speaking = vs.Speaking - }); - } - - // Rebuild channel groups now that voice states are loaded - RebuildChannelGroups(); - - // Set current user status from the member list - if (_chat?.CurrentUser is { } cu) - { - var self = payload.Members.FirstOrDefault(m => m.Id == cu.Id); - CurrentUserStatus = CapitalizeStatus(self?.Status ?? "online"); - } - - // Notify user bar - OnPropertyChanged(nameof(CurrentUsername)); - - // Build filtered friends list - RebuildFilteredFriends(); - - // Select first text channel - var firstText = Channels.FirstOrDefault(c => c.Type == ChannelType.Text); - if (firstText is not null) - SelectedChannel = firstText; - else if (Channels.Count > 0) - SelectedChannel = Channels[0]; - } - - private void OnChatMessage(ChatMessagePayload payload) - { - if (SelectedChannel is not null && payload.ChannelId == SelectedChannel.Id) - { - var attachments = payload.Attachments? - .Select(a => new Attachment(a.Id, a.Filename, a.Size, a.Mime, a.Url)) - .ToList() as IReadOnlyList ?? Array.Empty(); - var msg = new Message( - payload.Id, - payload.ChannelId, - new User(payload.User.Id, payload.User.Username, payload.User.Avatar, 0, UserStatus.Online), - payload.Content, - DateTime.TryParse(payload.Timestamp, out var ts) ? ts : DateTime.UtcNow, - payload.ReplyTo, - null, - false, - [], - attachments - ); - AddMessage(msg); - } - else - { - UpdateUnreadCount(payload.ChannelId, GetUnreadCount(payload.ChannelId) + 1); - } - } - - private void OnTyping(TypingPayload payload) - { - if (SelectedChannel is not null && payload.ChannelId == SelectedChannel.Id) - { - ShowTyping(payload.Username); - _typingTimer?.Dispose(); - _typingTimer = new Timer(_ => RunOnUI(HideTyping), null, 5000, Timeout.Infinite); - } - } - - private void OnPresence(PresencePayload payload) - { - var status = payload.Status switch - { - "online" => UserStatus.Online, - "idle" => UserStatus.Idle, - "dnd" => UserStatus.Dnd, - _ => UserStatus.Offline - }; - for (var i = 0; i < Members.Count; i++) - { - if (Members[i].Id == payload.UserId) - { - Members[i] = Members[i] with { Status = status }; - RebuildMemberGroups(); - RebuildFilteredFriends(); - break; - } - } - - // Update user bar if this is the current user - if (_chat?.CurrentUser is { } cu && payload.UserId == cu.Id) - CurrentUserStatus = CapitalizeStatus(payload.Status); - } - - private void OnChatEdited(ChatEditedPayload payload) - { - for (var i = 0; i < Messages.Count; i++) - { - if (Messages[i].Id == payload.MessageId) - { - Messages[i] = Messages[i] with { Content = payload.Content, EditedAt = payload.EditedAt }; - RebuildDisplayMessages(); - break; - } - } - } - - private void OnChatDeleted(ChatDeletedPayload payload) - { - for (var i = 0; i < Messages.Count; i++) - { - if (Messages[i].Id == payload.MessageId) - { - Messages[i] = Messages[i] with { Deleted = true, Content = "[deleted]" }; - RebuildDisplayMessages(); - break; - } - } - } - - private void OnMemberJoined(WsMember payload) - { - if (Members.Any(m => m.Id == payload.Id)) - return; - - var status = payload.Status switch - { - "online" => UserStatus.Online, - "idle" => UserStatus.Idle, - "dnd" => UserStatus.Dnd, - _ => UserStatus.Offline - }; - Members.Add(new User(payload.Id, payload.Username, payload.Avatar, payload.RoleId, status)); - RebuildMemberGroups(); - RebuildFilteredFriends(); - } - - private void OnChannelCreated(ChannelEventPayload payload) - { - if (Channels.Any(c => c.Id == payload.Id)) return; - var type = payload.Type switch - { - "voice" => ChannelType.Voice, - "announcement" => ChannelType.Announcement, - _ => ChannelType.Text - }; - Channels.Add(new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, 0, null, payload.Topic)); - RebuildChannelGroups(); - } - - private void OnChannelUpdated(ChannelEventPayload payload) - { - var idx = -1; - for (var i = 0; i < Channels.Count; i++) - { - if (Channels[i].Id == payload.Id) - { - idx = i; - break; - } - } - if (idx < 0) return; - var type = payload.Type switch - { - "voice" => ChannelType.Voice, - "announcement" => ChannelType.Announcement, - _ => ChannelType.Text - }; - Channels[idx] = new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, Channels[idx].UnreadCount, Channels[idx].LastMessageId, payload.Topic); - RebuildChannelGroups(); - if (SelectedChannel?.Id == payload.Id) - OnPropertyChanged(nameof(SelectedChannelTopic)); - } - - private void OnChannelDeleted(long channelId) - { - var ch = Channels.FirstOrDefault(c => c.Id == channelId); - if (ch is not null) - { - Channels.Remove(ch); - RebuildChannelGroups(); - if (SelectedChannel?.Id == channelId) - SelectedChannel = Channels.FirstOrDefault(); - } - } - - private void OnConnectionLost(string reason) - { - _isTransientError = false; - _errorClearTimer?.Dispose(); - ConnectionStatus = $"Disconnected \u2014 {reason}"; - } - - private void OnWsError(WsErrorPayload error) - { - _isTransientError = true; - ConnectionStatus = $"Server error: {error.Message}"; - - // Auto-clear transient errors after 5 seconds - _errorClearTimer?.Dispose(); - _errorClearTimer = new Timer(_ => RunOnUI(() => - { - if (_isTransientError) - ConnectionStatus = null; - }), null, 5000, Timeout.Infinite); - } - - // ── Voice event handlers ───────────────────────────────────────────────── - - private void OnVoiceState(VoiceStatePayload payload) - { - // Update or add voice state - var existing = VoiceStates.FirstOrDefault(vs => vs.UserId == payload.UserId); - if (existing is not null) - { - existing.ChannelId = payload.ChannelId; - existing.Muted = payload.Muted; - existing.Deafened = payload.Deafened; - } - else - { - VoiceStates.Add(new VoiceStateInfo - { - UserId = payload.UserId, - ChannelId = payload.ChannelId, - Username = payload.Username, - Muted = payload.Muted, - Deafened = payload.Deafened - }); - } - - // If this is the local user, update voice widget state - if (_chat?.CurrentUser is not null && payload.UserId == _chat.CurrentUser.Id) - { - IsInVoice = true; - _voiceChannelId = payload.ChannelId; - VoiceChannelName = Channels.FirstOrDefault(c => c.Id == payload.ChannelId)?.Name ?? "Voice"; - IsMuted = payload.Muted; - IsDeafened = payload.Deafened; - } - - RebuildChannelGroups(); - } - - private void OnVoiceLeave(VoiceLeavePayload payload) - { - var existing = VoiceStates.FirstOrDefault(vs => vs.UserId == payload.UserId); - if (existing is not null) - VoiceStates.Remove(existing); - - // If this is the local user, clear voice widget - if (_chat?.CurrentUser is not null && payload.UserId == _chat.CurrentUser.Id) - { - IsInVoice = false; - VoiceChannelName = null; - _voiceChannelId = 0; - IsMuted = false; - IsDeafened = false; - } - - RebuildChannelGroups(); - } - - private void OnVoiceSpeakers(VoiceSpeakersPayload payload) - { - var speakerSet = new HashSet(payload.Speakers); - foreach (var vs in VoiceStates.Where(vs => vs.ChannelId == payload.ChannelId)) - { - vs.Speaking = speakerSet.Contains(vs.UserId); - } - } - - private static string CapitalizeStatus(string status) => status switch - { - "online" => "Online", - "idle" => "Idle", - "dnd" => "Do Not Disturb", - "invisible" => "Invisible", - "offline" => "Offline", - _ => status - }; - - private int GetUnreadCount(long channelId) - { - var ch = Channels.FirstOrDefault(c => c.Id == channelId); - return ch?.UnreadCount ?? 0; - } - - // ── IDisposable ────────────────────────────────────────────────────────── - - public void Dispose() - { - _typingTimer?.Dispose(); - _typingTimer = null; - _errorClearTimer?.Dispose(); - _errorClearTimer = null; - _statusDebounceTimer?.Dispose(); - _statusDebounceTimer = null; - } -} diff --git a/Client/OwnCord.Client/ViewModels/RelayCommand.cs b/Client/OwnCord.Client/ViewModels/RelayCommand.cs deleted file mode 100644 index 8bb027bc..00000000 --- a/Client/OwnCord.Client/ViewModels/RelayCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Windows.Input; - -namespace OwnCord.Client.ViewModels; - -public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand -{ - public event EventHandler? CanExecuteChanged; - - public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true; - - public void Execute(object? parameter) => execute(); - - public void RaiseCanExecuteChanged() => - CanExecuteChanged?.Invoke(this, EventArgs.Empty); -} - -public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand -{ - public event EventHandler? CanExecuteChanged; - - public bool CanExecute(object? parameter) => canExecute?.Invoke((T?)parameter) ?? true; - - public void Execute(object? parameter) => execute((T?)parameter); - - public void RaiseCanExecuteChanged() => - CanExecuteChanged?.Invoke(this, EventArgs.Empty); -} diff --git a/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs b/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs deleted file mode 100644 index 3f6bb120..00000000 --- a/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace OwnCord.Client.ViewModels; - -public sealed class SettingsViewModel : ViewModelBase -{ - private bool _isDarkTheme; - private bool _mentionsOnly; - private string _pushToTalkKey = "F4"; - - public bool IsDarkTheme - { - get => _isDarkTheme; - set => SetField(ref _isDarkTheme, value); - } - - public bool MentionsOnly - { - get => _mentionsOnly; - set => SetField(ref _mentionsOnly, value); - } - - public string PushToTalkKey - { - get => _pushToTalkKey; - set => SetField(ref _pushToTalkKey, value); - } -} diff --git a/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs b/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs deleted file mode 100644 index 5a6288bb..00000000 --- a/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using System.Windows.Input; -using OwnCord.Client.Services; - -namespace OwnCord.Client.ViewModels; - -public class UpdateViewModel : ViewModelBase -{ - private readonly IUpdateService _updateService; - private readonly UpdateInfo _updateInfo; - - private bool _isDownloading; - private string _statusText = ""; - - public string CurrentVersion => _updateInfo.CurrentVersion; - public string NewVersion => _updateInfo.LatestVersion; - public string ReleaseNotes => _updateInfo.ReleaseNotes; - - public bool IsDownloading - { - get => _isDownloading; - private set { _isDownloading = value; OnPropertyChanged(); } - } - - public string StatusText - { - get => _statusText; - private set { _statusText = value; OnPropertyChanged(); } - } - - public ICommand UpdateNowCommand { get; } - public ICommand SkipVersionCommand { get; } - public ICommand RemindLaterCommand { get; } - - // Result: true = update started, false = skipped, null = remind later - public bool? Result { get; private set; } - - public UpdateViewModel(IUpdateService updateService, UpdateInfo updateInfo) - { - _updateService = updateService; - _updateInfo = updateInfo; - - UpdateNowCommand = new RelayCommand( - () => _ = UpdateNowAsync(), - () => !IsDownloading); - SkipVersionCommand = new RelayCommand( - SkipVersion, - () => !IsDownloading); - RemindLaterCommand = new RelayCommand(RemindLater); - } - - private async Task UpdateNowAsync() - { - IsDownloading = true; - StatusText = "Downloading update..."; - - try - { - var tempPath = Path.GetTempFileName(); - await _updateService.DownloadAndVerifyAsync( - _updateInfo.DownloadUrl, _updateInfo.ChecksumUrl, tempPath); - - StatusText = "Applying update..."; - _updateService.ApplyUpdate(tempPath); - Result = true; - } - catch (Exception ex) - { - StatusText = $"Update failed: {ex.Message}"; - IsDownloading = false; - } - } - - private void SkipVersion() - { - _updateService.SkipVersion(_updateInfo.LatestVersion); - Result = false; - CloseRequested?.Invoke(); - } - - private void RemindLater() - { - Result = null; - CloseRequested?.Invoke(); - } - - public event Action? CloseRequested; -} diff --git a/Client/OwnCord.Client/ViewModels/ViewModelBase.cs b/Client/OwnCord.Client/ViewModels/ViewModelBase.cs deleted file mode 100644 index f0ccf1ba..00000000 --- a/Client/OwnCord.Client/ViewModels/ViewModelBase.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace OwnCord.Client.ViewModels; - -public abstract class ViewModelBase : INotifyPropertyChanged -{ - public event PropertyChangedEventHandler? PropertyChanged; - - protected void OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); - - protected bool SetField(ref T field, T value, [CallerMemberName] string? name = null) - { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(name); - return true; - } -} diff --git a/Client/OwnCord.Client/Views/ConnectPage.xaml b/Client/OwnCord.Client/Views/ConnectPage.xaml deleted file mode 100644 index 8ae55d17..00000000 --- a/Client/OwnCord.Client/Views/ConnectPage.xaml +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Views/MainPage.xaml.cs b/Client/OwnCord.Client/Views/MainPage.xaml.cs deleted file mode 100644 index 09201c83..00000000 --- a/Client/OwnCord.Client/Views/MainPage.xaml.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Specialized; -using System.Windows.Controls; -using System.Windows.Input; -using OwnCord.Client.ViewModels; - -namespace OwnCord.Client.Views; - -public partial class MainPage : Page -{ - private readonly MainViewModel _vm; - - public MainPage(MainViewModel vm) - { - InitializeComponent(); - DataContext = vm; - _vm = vm; - PreviewKeyDown += OnPreviewKeyDown; - - Loaded += (_, _) => - { - if (vm.DisplayMessages is INotifyCollectionChanged ncc) - ncc.CollectionChanged += (_, _) => Dispatcher.InvokeAsync(() => - MessagesScrollViewer?.ScrollToEnd(), - System.Windows.Threading.DispatcherPriority.Background); - }; - } - - private void OnPreviewKeyDown(object sender, KeyEventArgs e) - { - if (e.Key != Key.Escape) return; - - if (_vm.ShowSettings) - { - _vm.ShowSettings = false; - e.Handled = true; - } - else if (_vm.ShowEmojiPicker) - { - _vm.ShowEmojiPicker = false; - e.Handled = true; - } - else if (_vm.ShowStatusPicker) - { - _vm.ShowStatusPicker = false; - e.Handled = true; - } - } -} diff --git a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml deleted file mode 100644 index a0c8fe86..00000000 --- a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs deleted file mode 100644 index 2a6a2e8c..00000000 --- a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using OwnCord.Client.Models; - -namespace OwnCord.Client.Views; - -public partial class ServerProfileDialog : Window -{ - private readonly ServerProfile? _existing; - private string _selectedColor = "#5865f2"; - private readonly Border[] _colorBorders; - - /// The resulting profile after Save, or null if cancelled. - public ServerProfile? ResultProfile { get; private set; } - - /// True when a new profile was created, false when an existing one was edited. - public bool IsNewProfile => _existing is null; - - public ServerProfileDialog(ServerProfile? existing = null) - { - InitializeComponent(); - _existing = existing; - _colorBorders = [Color1, Color2, Color3, Color4, Color5, Color6]; - - if (existing is not null) - { - TitleText.Text = "Edit Server"; - NameBox.Text = existing.Name; - HostBox.Text = existing.Host; - PortBox.Text = existing.Port.ToString(); - AutoConnectBox.IsChecked = existing.AutoConnect; - _selectedColor = existing.Color; - } - - UpdateColorSelection(); - } - - private void UpdateColorSelection() - { - foreach (var border in _colorBorders) - { - var tag = border.Tag as string ?? ""; - border.BorderBrush = string.Equals(tag, _selectedColor, StringComparison.OrdinalIgnoreCase) - ? System.Windows.Media.Brushes.White - : System.Windows.Media.Brushes.Transparent; - } - } - - private void ColorPick_Click(object sender, MouseButtonEventArgs e) - { - if (sender is Border border && border.Tag is string color) - { - _selectedColor = color; - UpdateColorSelection(); - } - } - - private void Save_Click(object sender, RoutedEventArgs e) - { - var name = NameBox.Text.Trim(); - var host = HostBox.Text.Trim(); - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(host)) - return; - - if (!int.TryParse(PortBox.Text.Trim(), out var port) || port < 1 || port > 65535) - port = 8443; - - var autoConnect = AutoConnectBox.IsChecked == true; - - ResultProfile = _existing is not null - ? _existing with { Name = name, Host = host, Port = port, Color = _selectedColor, AutoConnect = autoConnect } - : ServerProfile.Create(name, host, port: port, color: _selectedColor, autoConnect: autoConnect); - - DialogResult = true; - Close(); - } - - private void Cancel_Click(object sender, RoutedEventArgs e) - { - DialogResult = false; - Close(); - } - - private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left) - DragMove(); - } -} diff --git a/Client/OwnCord.Client/Views/UpdateDialog.xaml b/Client/OwnCord.Client/Views/UpdateDialog.xaml deleted file mode 100644 index b7e9efe1..00000000 --- a/Client/OwnCord.Client/Views/UpdateDialog.xaml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -