chore: remove legacy WPF client code and references

The WPF/.NET 8 client has been fully replaced by the Tauri v2
client. Remove all WPF source, tests, solution file, and build
output directories. Update CLAUDE.md, CONTRIBUTING.md, and
SETUP.md to remove WPF references and simplify branch strategy.

Removed:
- Client/OwnCord.Client/ (WPF source)
- Client/OwnCord.Client.Tests/ (WPF tests)
- Client/OwnCord.Client.sln
- Client/publish*/ (build outputs)
This commit is contained in:
jevb
2026-03-17 02:49:45 +01:00
parent 73576cd27c
commit 5903d4e39c
111 changed files with 15 additions and 15073 deletions
+15 -72
View File
@@ -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/<name>` -- bug fixes
- `docs/<name>` -- 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)
@@ -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<NotSupportedException>(() =>
_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<NotSupportedException>(() =>
_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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(result);
Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color);
}
[Fact]
public void ConvertBack_ThrowsNotSupported()
{
Assert.Throws<NotSupportedException>(() =>
_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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<NotSupportedException>(() =>
_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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<SolidColorBrush>(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<NotSupportedException>(() =>
_converter.ConvertBack("▾", typeof(bool), null!, _culture));
}
}
@@ -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<AuthResponse>(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<MessagesResponse>(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<List<ApiChannel>>(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<HealthResponse>(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<ApiError>(json, JsonOpts)!;
Assert.Equal("UNAUTHORIZED", result.Error);
Assert.Equal("invalid credentials", result.Message);
}
}
@@ -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<UserStatus>().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);
}
}
@@ -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<WsEnvelope>(json)!;
Assert.Equal("auth_ok", env.Type);
Assert.Null(env.Id);
var payload = env.Payload!.Value.Deserialize<AuthOkPayload>()!;
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<WsEnvelope>(json)!;
Assert.Equal("ready", env.Type);
var payload = env.Payload!.Value.Deserialize<ReadyPayload>()!;
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<WsEnvelope>(json)!;
Assert.Equal("chat_message", env.Type);
var payload = env.Payload!.Value.Deserialize<ChatMessagePayload>()!;
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<WsEnvelope>(json)!;
Assert.Equal("chat_send_ok", env.Type);
Assert.Equal("req-123", env.Id);
var payload = env.Payload!.Value.Deserialize<ChatSendOkPayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<TypingPayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<PresencePayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<ChatEditedPayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<ChatDeletedPayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<ServerRestartPayload>()!;
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<WsEnvelope>(json)!;
Assert.Equal("error", env.Type);
Assert.Equal("req-456", env.Id);
var payload = env.Payload!.Value.Deserialize<WsErrorPayload>()!;
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<WsEnvelope>(json)!;
var payload = env.Payload!.Value.Deserialize<ReactionUpdatePayload>()!;
Assert.Equal(5, payload.MessageId);
Assert.Equal("👍", payload.Emoji);
Assert.Equal("add", payload.Action);
}
}
@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OwnCord.Client\OwnCord.Client.csproj" />
</ItemGroup>
</Project>
@@ -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<ApiException>(
() => 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<ApiException>(
() => 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<object>(), 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<HttpRequestException>(
() => 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<HttpResponseMessage> 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")
};
}
}
}
@@ -1,225 +0,0 @@
using System.IO;
using OwnCord.Client.Services;
namespace OwnCord.Client.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -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<ApiChannel>? 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<AuthResponse> 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<AuthResponse> 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<ApiUser> GetMeAsync(string host, string token, CancellationToken ct)
=> Task.FromResult(MeResult ?? throw new InvalidOperationException("MeResult not set"));
public Task<IReadOnlyList<ApiChannel>> GetChannelsAsync(string host, string token, CancellationToken ct)
=> Task.FromResult(ChannelsResult ?? throw new InvalidOperationException("ChannelsResult not set"));
public Task<MessagesResponse> GetMessagesAsync(string host, string token, long channelId, int limit, long? before, CancellationToken ct)
=> Task.FromResult(MessagesResult ?? throw new InvalidOperationException("MessagesResult not set"));
public Task<HealthResponse> HealthCheckAsync(string host, CancellationToken ct)
=> Task.FromResult(HealthResult ?? throw new InvalidOperationException("HealthResult not set"));
public AuthResponse? VerifyTotpResult { get; set; }
public Task<AuthResponse> 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<string>? MessageReceived;
public event Action<string>? Disconnected;
public string? LastConnectUri { get; private set; }
public string? LastConnectToken { get; private set; }
public bool DisconnectCalled { get; private set; }
public List<string> 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<string> 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<InvalidOperationException>(
() => 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<ApiChannel>
{
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<ApiMessage> { 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);
}
}
@@ -1,441 +0,0 @@
using System.Text.Json;
using OwnCord.Client.Models;
using OwnCord.Client.Services;
namespace OwnCord.Client.Tests.Services;
/// <summary>Tests for ChatService voice commands and additional dispatch events.</summary>
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<ChatService> 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 _));
}
}
@@ -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<script>alert('xss')</script>\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 (?<!\*)\*(?!\*)
// prevents ** from matching italic
var result = Parse("**bold**");
Assert.Single(result);
Assert.Equal(SegmentType.Bold, result[0].Type);
}
[Fact]
public void Parse_MultipleCodeBlocks_AllCapturedInOrder()
{
var input = "```\nfirst\n``` middle ```\nsecond\n```";
var result = Parse(input);
// Two code blocks with text between them
var codeBlocks = result.Where(s => 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}"));
}
}
}
@@ -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<ServerProfile> 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);
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace OwnCord.Client.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -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<string, string> _tokens = new();
private readonly Dictionary<string, string> _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<AuthResponse> LoginAsync(string host, string username, string password, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<AuthResponse> 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<ApiUser> GetMeAsync(string host, string token, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<ApiChannel>> GetChannelsAsync(string host, string token, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<MessagesResponse> GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<AuthResponse> VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<HealthResponse> 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<ServerProfile> Saved = [];
public IReadOnlyList<ServerProfile> LoadProfiles() => Saved;
public IReadOnlyList<ServerProfile> AddProfile(IReadOnlyList<ServerProfile> p, ServerProfile profile)
=> [.. p, profile];
public IReadOnlyList<ServerProfile> RemoveProfile(IReadOnlyList<ServerProfile> p, string id)
=> p.Where(x => x.Id != id).ToList();
public IReadOnlyList<ServerProfile> UpdateProfile(IReadOnlyList<ServerProfile> p, ServerProfile updated)
=> p.Select(x => x.Id == updated.Id ? updated : x).ToList();
public void SaveProfiles(IReadOnlyList<ServerProfile> profiles) => Saved = [.. profiles];
}
@@ -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);
}
}
@@ -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;
/// <summary>Tests for MainViewModel voice events, channel CRUD, member events, and grouping.</summary>
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);
}
}
-28
View File
@@ -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
-34
View File
@@ -1,34 +0,0 @@
<Application x:Class="OwnCord.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:OwnCord.Client.Converters"
Startup="Application_Startup">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Colors.xaml"/>
<ResourceDictionary Source="Themes/Typography.xaml"/>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
<conv:BoolToVisibilityConverter x:Key="BoolToVisibility"/>
<conv:IntToVisibilityConverter x:Key="IntToVisibility"/>
<conv:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibility"/>
<conv:NullToVisibilityConverter x:Key="NullToVisibility"/>
<conv:InverseBoolConverter x:Key="InverseBool"/>
<conv:RelativeTimeConverter x:Key="RelativeTime"/>
<conv:FirstCharConverter x:Key="FirstChar"/>
<conv:ColorToBrushConverter x:Key="ColorToBrush"/>
<conv:HostPortConverter x:Key="HostPort"/>
<conv:HexColorToBrushConverter x:Key="HexColorToBrush"/>
<conv:StatusToBrushConverter x:Key="StatusToBrush"/>
<conv:FirstLetterConverter x:Key="FirstLetter"/>
<conv:BoolToRedBrushConverter x:Key="BoolToRedBrush"/>
<conv:SpeakingToStrokeBrushConverter x:Key="SpeakingToStroke"/>
<conv:BoolToArrowConverter x:Key="BoolToArrow"/>
<conv:HealthStatusToBrushConverter x:Key="HealthStatusToBrush"/>
<conv:StringEqualsConverter x:Key="StringEquals"/>
<conv:EqualityConverter x:Key="EqualityConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>
-50
View File
@@ -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();
});
}
});
}
}
-13
View File
@@ -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)
)]
@@ -1,79 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.AttachmentControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<Grid MaxWidth="400">
<!-- Image attachment: shown when mime starts with "image/" -->
<Border x:Name="ImagePanel"
Visibility="Collapsed"
CornerRadius="8"
ClipToBounds="True"
Cursor="Hand">
<Grid>
<!-- Gradient placeholder (shown while loading) -->
<Border x:Name="ImagePlaceholder" CornerRadius="8">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="{StaticResource BgSecondaryColor}" Offset="0"/>
<GradientStop Color="{StaticResource BgActiveColor}" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<TextBlock Text="&#x1F5BC;" FontSize="36"
Foreground="{StaticResource TextMuted}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- Actual image -->
<Image x:Name="AttachmentImage"
MaxWidth="400" MaxHeight="300"
Stretch="Uniform"
HorizontalAlignment="Left"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
</Border>
<!-- File attachment: shown for non-image mimes -->
<Border x:Name="FilePanel"
Visibility="Collapsed"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8"
ClipToBounds="True">
<Border Background="{StaticResource BgSecondary}"
Padding="12"
CornerRadius="8">
<StackPanel Orientation="Horizontal">
<!-- File icon -->
<Border Width="36" Height="36"
CornerRadius="6"
Background="{StaticResource Accent}">
<TextBlock Text="&#x1F4C4;"
FontSize="16"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- File info -->
<StackPanel Margin="12,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="FilenameText"
FontFamily="{StaticResource FontBody}"
FontSize="13"
Foreground="{StaticResource TextLink}"
Cursor="Hand"
TextTrimming="CharacterEllipsis"
MaxWidth="320"/>
<TextBlock x:Name="FileSizeText"
FontFamily="{StaticResource FontBody}"
FontSize="11"
Foreground="{StaticResource TextMuted}"
Margin="0,2,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -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"
};
}
}
@@ -1,34 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.CodeBlockControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<Border Background="{StaticResource BgTertiary}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="4"
Padding="12">
<Grid>
<!-- Language label in top-right corner -->
<TextBlock x:Name="LanguageLabel"
HorizontalAlignment="Right"
VerticalAlignment="Top"
FontFamily="{StaticResource FontBody}"
FontSize="11"
Foreground="{StaticResource TextMuted}"
Margin="0,-2,0,0"/>
<!-- Code content with horizontal scrolling -->
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Margin="0,14,0,0">
<TextBlock x:Name="CodeText"
FontFamily="{StaticResource FontMono}"
FontSize="13"
Foreground="{StaticResource TextNormal}"
TextWrapping="NoWrap"
xml:space="preserve"/>
</ScrollViewer>
</Grid>
</Border>
</UserControl>
@@ -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;
}
}
@@ -1,197 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.DmSidebarControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="240"
Background="{StaticResource BgSecondary}">
<UserControl.Resources>
<!-- DM item hover button -->
<Style x:Key="DmItemButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="8,6" Background="Transparent">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Close DM button (visible on hover via parent) -->
<Style x:Key="CloseDmButton" TargetType="Button">
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Visibility" Value="Hidden"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4">
<TextBlock Text="&#x2715;" FontSize="10" Foreground="{StaticResource TextMuted}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Nav item button (Friends, Nitro) -->
<Style x:Key="NavItemButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="10,8" Background="Transparent" Margin="8,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="48"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Search header -->
<Border Grid.Row="0" BorderThickness="0,0,0,1" BorderBrush="#1e1f22" Padding="10,8">
<Border Background="{StaticResource BgTertiary}" CornerRadius="4" Padding="8,5">
<TextBlock Text="Find or start a conversation"
Foreground="{StaticResource TextMuted}" FontSize="13"
VerticalAlignment="Center"/>
</Border>
</Border>
<!-- Navigation items -->
<StackPanel Grid.Row="1" Margin="0,8,0,0">
<!-- Friends -->
<Button Style="{StaticResource NavItemButton}"
Command="{Binding FriendsCommand, RelativeSource={RelativeSource AncestorType=UserControl}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F465;" FontSize="16" Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Friends" Foreground="{StaticResource TextNormal}" FontSize="14"
FontWeight="Medium" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- Nitro placeholder -->
<Button Style="{StaticResource NavItemButton}" IsEnabled="False">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2728;" FontSize="16" Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Nitro" Foreground="{StaticResource TextMuted}" FontSize="14"
FontWeight="Medium" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
<!-- DM list -->
<Grid Grid.Row="2" Margin="0,8,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- DM list header -->
<Grid Grid.Row="0" Margin="16,8,8,4">
<TextBlock Text="DIRECT MESSAGES" Foreground="{StaticResource TextMicro}"
FontSize="11" FontWeight="Bold" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" Cursor="Hand" ToolTip="Create DM">
<Button.Template>
<ControlTemplate TargetType="Button">
<TextBlock x:Name="Txt" Text="+" Foreground="{StaticResource TextMuted}"
FontSize="16" FontWeight="Bold"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Txt" Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
<!-- DM conversation items -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding DirectMessages, RelativeSource={RelativeSource AncestorType=UserControl}}"
Margin="8,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource DmItemButton}"
Command="{Binding DataContext.SelectDmCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch"
Margin="0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Avatar with status dot -->
<Grid Grid.Column="0" Width="32" Height="32" Margin="0,0,10,0">
<Border Width="32" Height="32" CornerRadius="16" Background="#5865f2">
<TextBlock Text="{Binding Username, Converter={StaticResource FirstLetter}}"
Foreground="White" FontWeight="Bold" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Ellipse Width="10" Height="10"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Fill="{Binding Status, Converter={StaticResource StatusToBrush}}"
Stroke="{StaticResource BgSecondary}" StrokeThickness="2"/>
</Grid>
<!-- Username -->
<TextBlock Grid.Column="1" Text="{Binding Username}" FontSize="14"
Foreground="{StaticResource TextMuted}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<!-- Close button (shows on hover) -->
<Button Grid.Column="2"
Command="{Binding DataContext.CloseDmCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
VerticalAlignment="Center"
Width="16" Height="16" Cursor="Hand">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource CloseDmButton}">
<Setter Property="Visibility" Value="Hidden"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=Button, AncestorLevel=2}}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
</UserControl>
@@ -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);
}
}
@@ -1,93 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.EmojiPickerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="320">
<UserControl.Resources>
<Style x:Key="EmojiButton" TargetType="Button">
<Setter Property="Width" Value="28"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Background="Transparent"
HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{StaticResource BgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Border Background="{StaticResource BgPrimary}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="4" Opacity="0.4" Color="Black"/>
</Border.Effect>
<DockPanel>
<!-- Search header -->
<Border DockPanel.Dock="Top" Margin="8,8,8,4">
<TextBox x:Name="SearchBox"
Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=UserControl}, UpdateSourceTrigger=PropertyChanged}"
Background="{StaticResource BgTertiary}"
Foreground="{StaticResource TextNormal}"
FontSize="13"
Padding="8,6"
BorderThickness="0"
Tag="Search emoji..."/>
<!-- Placeholder handled via style or code-behind -->
</Border>
<!-- Scrollable emoji categories -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
MaxHeight="320"
Margin="4,0,4,4">
<ItemsControl x:Name="CategoryList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="4,4,4,0">
<!-- Category label -->
<TextBlock Text="{Binding Name}"
FontSize="11"
FontWeight="Bold"
Foreground="{StaticResource TextFaint}"
Margin="4,4,0,4">
</TextBlock>
<!-- Emoji grid -->
<ItemsControl ItemsSource="{Binding Emojis}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource EmojiButton}"
Content="{Binding}"
Command="{Binding EmojiSelectedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</UserControl>
@@ -1,64 +0,0 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using OwnCord.Client.Services;
namespace OwnCord.Client.Controls;
public partial class EmojiPickerControl : UserControl
{
public static readonly DependencyProperty EmojiSelectedCommandProperty =
DependencyProperty.Register(
nameof(EmojiSelectedCommand),
typeof(ICommand),
typeof(EmojiPickerControl),
new PropertyMetadata(null));
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register(
nameof(SearchText),
typeof(string),
typeof(EmojiPickerControl),
new PropertyMetadata(string.Empty, OnSearchTextChanged));
public EmojiPickerControl()
{
InitializeComponent();
RefreshCategories();
}
public ICommand EmojiSelectedCommand
{
get => (ICommand)GetValue(EmojiSelectedCommandProperty);
set => SetValue(EmojiSelectedCommandProperty, value);
}
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is EmojiPickerControl picker)
picker.RefreshCategories();
}
private void RefreshCategories()
{
var query = SearchText?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(query))
{
CategoryList.ItemsSource = EmojiData.Categories;
return;
}
var filtered = EmojiData.Categories
.Where(c => c.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.ToList();
CategoryList.ItemsSource = filtered;
}
}
@@ -1,426 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.FriendsViewControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:OwnCord.Client.Converters"
Background="{StaticResource BgPrimary}">
<UserControl.Resources>
<converters:StringEqualsConverter x:Key="StringEquals"/>
<!-- Tab button style -->
<Style x:Key="FriendsTabButton" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,8,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="8,4" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgHover}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Active tab button style -->
<Style x:Key="FriendsTabButtonActive" TargetType="Button" BasedOn="{StaticResource FriendsTabButton}">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="8,4" Background="{StaticResource BgActive}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Add Friend tab (green) -->
<Style x:Key="AddFriendTabButton" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,8,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="8,4" Background="{StaticResource Green}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1a8f4a"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Friend item hover -->
<Style x:Key="FriendItemBorder" TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Cursor" Value="Hand"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource BgHover}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Friend action button -->
<Style x:Key="FriendActionButton" TargetType="Button">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Width="32" Height="32" CornerRadius="16"
Background="{StaticResource BgSecondary}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="48"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Header ═══ -->
<Border Grid.Row="0" BorderThickness="0,0,0,1" BorderBrush="#1e1f22">
<StackPanel Orientation="Horizontal" Margin="16,0" VerticalAlignment="Center">
<!-- Friends icon + title -->
<TextBlock Text="&#x1F465;" FontSize="16" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Friends" Foreground="White" FontSize="15" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,16,0"/>
<!-- Divider -->
<Border Width="1" Height="24" Background="{StaticResource BorderBrush}" Margin="0,0,16,0"/>
<!-- Online tab -->
<Button Command="{Binding SelectTabCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="online">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource FriendsTabButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource StringEquals}, ConverterParameter=online}" Value="True">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="4" Padding="8,4" Background="{StaticResource BgActive}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="Online" FontSize="13" FontWeight="Medium"/>
</Button>
<!-- All tab -->
<Button Command="{Binding SelectTabCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="all">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource FriendsTabButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource StringEquals}, ConverterParameter=all}" Value="True">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="4" Padding="8,4" Background="{StaticResource BgActive}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="All" FontSize="13" FontWeight="Medium"/>
</Button>
<!-- Pending tab -->
<Button Command="{Binding SelectTabCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="pending">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource FriendsTabButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource StringEquals}, ConverterParameter=pending}" Value="True">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="4" Padding="8,4" Background="{StaticResource BgActive}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="Pending" FontSize="13" FontWeight="Medium"/>
</Button>
<!-- Add Friend tab (green) -->
<Button Style="{StaticResource AddFriendTabButton}"
Command="{Binding SelectTabCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="add">
<TextBlock Text="Add Friend" FontSize="13" FontWeight="Bold"/>
</Button>
</StackPanel>
</Border>
<!-- ═══ Friends list content ═══ -->
<Grid Grid.Row="1" Margin="20,16,20,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Search bar -->
<Border Grid.Row="0" Background="{StaticResource BgTertiary}" CornerRadius="4"
Padding="10,7" Margin="0,0,0,16">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="add">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="pending">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBox Text="{Binding FriendSearchText, RelativeSource={RelativeSource AncestorType=UserControl}, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextNormal}"
CaretBrush="{StaticResource TextNormal}"
FontSize="13" VerticalAlignment="Center">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Tag" Value="Search"/>
</Style>
</TextBox.Style>
</TextBox>
</Border>
<!-- Count label -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock FontSize="11" FontWeight="Bold" Foreground="{StaticResource TextMicro}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="ALL FRIENDS"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="online">
<Setter Property="Text" Value="ONLINE"/>
</DataTrigger>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="pending">
<Setter Property="Text" Value="PENDING"/>
</DataTrigger>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="add">
<Setter Property="Text" Value="ADD FRIEND"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock FontSize="11" FontWeight="Bold" Foreground="{StaticResource TextMicro}" Margin="6,0,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding Friends.Count, RelativeSource={RelativeSource AncestorType=UserControl}, StringFormat='&#x2014; {0}'}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="add">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="pending">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- Friend items list -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<ScrollViewer.Style>
<Style TargetType="ScrollViewer">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="add">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="pending">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ScrollViewer.Style>
<ItemsControl ItemsSource="{Binding Friends, RelativeSource={RelativeSource AncestorType=UserControl}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource FriendItemBorder}"
CornerRadius="8" Padding="8,8" Margin="0,0,0,1"
BorderThickness="0,1,0,0" BorderBrush="{StaticResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Avatar with status dot -->
<Grid Grid.Column="0" Width="40" Height="40" Margin="0,0,12,0">
<Border Width="40" Height="40" CornerRadius="20" Background="#5865f2">
<TextBlock Text="{Binding Username, Converter={StaticResource FirstLetter}}"
Foreground="White" FontWeight="Bold" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Ellipse Width="14" Height="14"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Fill="{Binding Status, Converter={StaticResource StatusToBrush}}"
Stroke="{StaticResource BgPrimary}" StrokeThickness="3"/>
</Grid>
<!-- Username and status text -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Username}" Foreground="White"
FontSize="14" FontWeight="Bold"/>
<TextBlock Foreground="{StaticResource TextMuted}" FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="Offline"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Online">
<Setter Property="Text" Value="Online"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Idle">
<Setter Property="Text" Value="Idle"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Dnd">
<Setter Property="Text" Value="Do Not Disturb"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- Action buttons -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Message button -->
<Button Style="{StaticResource FriendActionButton}"
Command="{Binding DataContext.MessageFriendCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
ToolTip="Message" Margin="0,0,8,0">
<TextBlock Text="&#x1F4AC;" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Button>
<!-- More button -->
<Button Style="{StaticResource FriendActionButton}" ToolTip="More">
<TextBlock Text="&#x22EF;" FontSize="16" Foreground="{StaticResource TextMuted}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Pending placeholder (shown when pending tab selected) -->
<StackPanel Grid.Row="2" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="pending">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="No pending friend requests" Foreground="{StaticResource TextMuted}" FontSize="14" HorizontalAlignment="Center"/>
</StackPanel>
<!-- Add Friend form (shown when add tab selected) -->
<StackPanel Grid.Row="2" Margin="0,8,0,0">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedTab, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="add">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="ADD FRIEND" Foreground="White" FontSize="15" FontWeight="Bold" Margin="0,0,0,4"/>
<TextBlock Text="You can add friends with their username." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,0,0,16"/>
<Border Background="{StaticResource BgTertiary}" CornerRadius="8" Padding="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextNormal}" CaretBrush="{StaticResource TextNormal}"
FontSize="14" VerticalAlignment="Center" Padding="8,8"
Text="" x:Name="AddFriendInput"/>
<Button Grid.Column="1" Margin="4" Padding="16,8" Cursor="Hand">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{StaticResource Accent}" CornerRadius="4" Padding="16,8">
<TextBlock Text="Send Friend Request" Foreground="White" FontSize="13" FontWeight="Bold"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#4752c4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</UserControl>
@@ -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);
}
}
@@ -1,83 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.MessageActionsBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<UserControl.Resources>
<!-- BgFloating not in global theme; define locally -->
<SolidColorBrush x:Key="BgFloating" Color="#232428"/>
<Style x:Key="ActionButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgHover}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Visibility converter for IsOwnMessage -->
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</UserControl.Resources>
<Border Background="{StaticResource BgFloating}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="4"
Padding="2">
<StackPanel Orientation="Horizontal">
<!-- React button (always visible, placeholder) -->
<Button Style="{StaticResource ActionButton}" ToolTip="Add Reaction">
<TextBlock Text="&#x1F600;" FontSize="14"/>
</Button>
<!-- Reply button (always visible) -->
<Button Command="{Binding ReplyCommand, ElementName=Root}"
CommandParameter="{Binding CommandParameter, ElementName=Root}"
Style="{StaticResource ActionButton}"
ToolTip="Reply">
<TextBlock Text="&#x21A9;" FontSize="14"/>
</Button>
<!-- Edit button (own messages only) -->
<Button Command="{Binding EditCommand, ElementName=Root}"
CommandParameter="{Binding CommandParameter, ElementName=Root}"
Style="{StaticResource ActionButton}"
Visibility="{Binding IsOwnMessage, ElementName=Root, Converter={StaticResource BoolToVis}}"
ToolTip="Edit">
<TextBlock Text="&#x270E;" FontSize="14"/>
</Button>
<!-- Delete button (own messages only) -->
<Button Command="{Binding DeleteCommand, ElementName=Root}"
CommandParameter="{Binding CommandParameter, ElementName=Root}"
Style="{StaticResource ActionButton}"
Visibility="{Binding IsOwnMessage, ElementName=Root, Converter={StaticResource BoolToVis}}"
ToolTip="Delete">
<TextBlock Text="&#x1F5D1;" FontSize="13"/>
</Button>
<!-- More button (always visible, placeholder) -->
<Button Style="{StaticResource ActionButton}" ToolTip="More">
<TextBlock Text="&#x22EF;" FontSize="16" FontWeight="Bold"/>
</Button>
</StackPanel>
</Border>
</UserControl>
@@ -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();
}
}
@@ -1,42 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.ReplyComposeBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root">
<Border Background="{StaticResource BgSecondary}"
BorderBrush="{StaticResource Accent}"
BorderThickness="0,2,0,0"
Padding="12,6"
CornerRadius="8,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Reply indicator text -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Replying to "
Foreground="{StaticResource TextMuted}"
FontSize="13"
FontFamily="{StaticResource FontBody}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Username, ElementName=Root}"
Foreground="{StaticResource TextNormal}"
FontSize="13"
FontWeight="SemiBold"
FontFamily="{StaticResource FontBody}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Cancel button -->
<Button Grid.Column="1"
Command="{Binding CancelCommand, ElementName=Root}"
Style="{StaticResource SmallIconButton}"
ToolTip="Cancel reply"
VerticalAlignment="Center">
<TextBlock Text="&#x2715;" FontSize="14" Foreground="{StaticResource TextMuted}"/>
</Button>
</Grid>
</Border>
</UserControl>
@@ -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();
}
}
@@ -1,185 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.ServerStripControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:OwnCord.Client.Converters"
Width="72"
Background="{StaticResource BgTertiary}">
<UserControl.Resources>
<converters:FirstCharConverter x:Key="FirstCharConverter"/>
<converters:ColorToBrushConverter x:Key="ColorToBrushConverter"/>
<!-- Server icon button base template -->
<Style x:Key="ServerIconButton" TargetType="Button">
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<!-- Active indicator bar (left side) -->
<Rectangle x:Name="Indicator"
Width="4" Height="0"
Fill="White"
HorizontalAlignment="Left"
VerticalAlignment="Center"
RadiusX="2" RadiusY="2"
Margin="-12,0,0,0"/>
<!-- Icon circle -->
<Border x:Name="IconBorder"
Width="48" Height="48"
CornerRadius="24"
Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="IconBorder" Property="CornerRadius" Value="12"/>
<Setter TargetName="Indicator" Property="Height" Value="20"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Add server button style -->
<Style x:Key="AddServerButton" TargetType="Button">
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="AddBorder"
Width="48" Height="48"
CornerRadius="24"
Background="Transparent"
BorderBrush="{StaticResource Green}"
BorderThickness="2"
Style="{x:Null}">
<Border.Resources>
<Style TargetType="Border"/>
</Border.Resources>
<TextBlock Text="+"
Foreground="{StaticResource Green}"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="AddBorder" Property="CornerRadius" Value="12"/>
<Setter TargetName="AddBorder" Property="Background" Value="{StaticResource Green}"/>
<Setter TargetName="AddBorder" Property="BorderThickness" Value="0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0">
<ScrollViewer.Resources>
<!-- Hide scrollbar but keep scrolling -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="0"/>
</Style>
</ScrollViewer.Resources>
<StackPanel HorizontalAlignment="Center"
Orientation="Vertical"
Margin="0,12,0,12">
<!-- Home / DM button -->
<Button x:Name="HomeButton"
Style="{StaticResource ServerIconButton}"
Background="{StaticResource Accent}"
Command="{Binding HomeCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
ToolTip="Direct Messages"
Margin="0,0,0,8">
<Grid>
<Path Data="M12,4 C13.66,4 15,5.34 15,7 C15,8.66 13.66,10 12,10 C10.34,10 9,8.66 9,7 C9,5.34 10.34,4 12,4 M12,12 C14.21,12 18,13.1 18,15.33 L18,17 L6,17 L6,15.33 C6,13.1 9.79,12 12,12"
Fill="White"
Stretch="Uniform"
Width="22" Height="22"/>
</Grid>
</Button>
<!-- Separator -->
<Border Width="32" Height="2"
Background="{StaticResource BorderBrush}"
CornerRadius="1"
Margin="0,0,0,8"/>
<!-- Server list -->
<ItemsControl x:Name="ServerList"
ItemsSource="{Binding Servers, RelativeSource={RelativeSource AncestorType=UserControl}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource ServerIconButton}"
Background="{Binding Color, Converter={StaticResource ColorToBrushConverter}}"
Command="{Binding DataContext.SelectServerCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
ToolTip="{Binding Name}"
Tag="{Binding}"
Margin="0,0,0,8">
<!-- Re-template to handle active state via Tag comparison -->
<Button.Template>
<ControlTemplate TargetType="Button">
<Grid>
<!-- Active indicator bar -->
<Rectangle x:Name="Indicator"
Width="4" Height="0"
Fill="White"
HorizontalAlignment="Left"
VerticalAlignment="Center"
RadiusX="2" RadiusY="2"
Margin="-12,0,0,0"/>
<!-- Icon circle -->
<Border x:Name="IconBorder"
Width="48" Height="48"
CornerRadius="24"
Background="{TemplateBinding Background}">
<TextBlock Text="{Binding Name, Converter={StaticResource FirstCharConverter}}"
Foreground="White"
FontSize="18"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="IconBorder" Property="CornerRadius" Value="12"/>
<Setter TargetName="Indicator" Property="Height" Value="20"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Add server button -->
<Button x:Name="AddButton"
Style="{StaticResource AddServerButton}"
Command="{Binding AddServerCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
ToolTip="Add a Server"
Margin="0,0,0,0"/>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,161 +0,0 @@
using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace OwnCord.Client.Controls;
public partial class ServerStripControl : UserControl
{
public static readonly DependencyProperty ServersProperty =
DependencyProperty.Register(
nameof(Servers),
typeof(IEnumerable),
typeof(ServerStripControl),
new PropertyMetadata(null));
public static readonly DependencyProperty SelectedServerProperty =
DependencyProperty.Register(
nameof(SelectedServer),
typeof(object),
typeof(ServerStripControl),
new PropertyMetadata(null, OnSelectedServerChanged));
public static readonly DependencyProperty SelectServerCommandProperty =
DependencyProperty.Register(
nameof(SelectServerCommand),
typeof(ICommand),
typeof(ServerStripControl),
new PropertyMetadata(null));
public static readonly DependencyProperty AddServerCommandProperty =
DependencyProperty.Register(
nameof(AddServerCommand),
typeof(ICommand),
typeof(ServerStripControl),
new PropertyMetadata(null));
public static readonly DependencyProperty HomeCommandProperty =
DependencyProperty.Register(
nameof(HomeCommand),
typeof(ICommand),
typeof(ServerStripControl),
new PropertyMetadata(null));
public static readonly DependencyProperty IsHomeViewProperty =
DependencyProperty.Register(
nameof(IsHomeView),
typeof(bool),
typeof(ServerStripControl),
new PropertyMetadata(false, OnIsHomeViewChanged));
public ServerStripControl()
{
InitializeComponent();
Loaded += (_, _) =>
{
UpdateActiveIndicators();
UpdateHomeIndicator();
};
}
public IEnumerable? Servers
{
get => (IEnumerable?)GetValue(ServersProperty);
set => SetValue(ServersProperty, value);
}
public object? SelectedServer
{
get => GetValue(SelectedServerProperty);
set => SetValue(SelectedServerProperty, value);
}
public ICommand? SelectServerCommand
{
get => (ICommand?)GetValue(SelectServerCommandProperty);
set => SetValue(SelectServerCommandProperty, value);
}
public ICommand? AddServerCommand
{
get => (ICommand?)GetValue(AddServerCommandProperty);
set => SetValue(AddServerCommandProperty, value);
}
public ICommand? HomeCommand
{
get => (ICommand?)GetValue(HomeCommandProperty);
set => SetValue(HomeCommandProperty, value);
}
public bool IsHomeView
{
get => (bool)GetValue(IsHomeViewProperty);
set => SetValue(IsHomeViewProperty, value);
}
private static void OnSelectedServerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ServerStripControl control)
{
control.UpdateActiveIndicators();
control.UpdateHomeIndicator();
}
}
private static void OnIsHomeViewChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ServerStripControl control)
{
control.UpdateHomeIndicator();
control.UpdateActiveIndicators();
}
}
private void UpdateActiveIndicators()
{
var container = ServerList.ItemContainerGenerator;
for (int i = 0; i < ServerList.Items.Count; i++)
{
var element = container.ContainerFromIndex(i) as FrameworkElement;
if (element == null) continue;
var isActive = ServerList.Items[i] == SelectedServer;
var indicator = FindChild<Rectangle>(element, "Indicator");
var iconBorder = FindChild<Border>(element, "IconBorder");
if (indicator != null)
indicator.Height = isActive ? 36 : 0;
if (iconBorder != null)
iconBorder.CornerRadius = isActive ? new CornerRadius(12) : new CornerRadius(24);
}
}
private void UpdateHomeIndicator()
{
var indicator = FindChild<Rectangle>(HomeButton, "Indicator");
var iconBorder = FindChild<Border>(HomeButton, "IconBorder");
if (indicator != null)
indicator.Height = IsHomeView ? 36 : 0;
if (iconBorder != null)
iconBorder.CornerRadius = IsHomeView ? new CornerRadius(12) : new CornerRadius(24);
}
private static T? FindChild<T>(DependencyObject parent, string name) where T : FrameworkElement
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T fe && fe.Name == name)
return fe;
var result = FindChild<T>(child, name);
if (result != null) return result;
}
return null;
}
}
@@ -1,710 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.SettingsOverlayControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<!-- Sidebar category header -->
<Style x:Key="CategoryHeader" TargetType="TextBlock">
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
<Setter Property="Padding" Value="10,16,10,4"/>
</Style>
<!-- Sidebar nav button with active state -->
<Style x:Key="SidebarButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Height" Value="32"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="10,0" Margin="0,1">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{StaticResource BgHover}"/>
<Setter Property="Foreground"
Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<!-- Active state: highlight when Tag matches SelectedSection -->
<DataTrigger Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource EqualityConverter}">
<Binding Path="Tag" RelativeSource="{RelativeSource Self}"/>
<Binding Path="SelectedSection" RelativeSource="{RelativeSource AncestorType=UserControl}"/>
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="#404249" CornerRadius="4" Padding="10,0" Margin="0,1">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- Logout button (red text) -->
<Style x:Key="LogoutButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Height" Value="32"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Red}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="10,0" Margin="0,1">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1Af23f43"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Close button -->
<Style x:Key="CloseButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Width" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="20"
BorderBrush="{StaticResource TextMuted}" BorderThickness="2"
Width="40" Height="40">
<TextBlock Text="X" FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{TemplateBinding Foreground}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Toggle switch (pill-shaped CheckBox) -->
<Style x:Key="ToggleSwitch" TargetType="CheckBox">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Border x:Name="Track" Width="40" Height="24" CornerRadius="12"
Background="#80848e">
<Border x:Name="Thumb" Width="18" Height="18" CornerRadius="9"
Background="White" HorizontalAlignment="Left" Margin="3,3,0,3"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="{StaticResource Green}"/>
<Setter TargetName="Thumb" Property="Margin" Value="19,3,0,3"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Settings slider -->
<Style x:Key="SettingsSlider" TargetType="Slider">
<Setter Property="Height" Value="20"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<!-- Kbd tag border -->
<Style x:Key="KbdTag" TargetType="Border">
<Setter Property="Background" Value="#1e1f22"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="6,2"/>
</Style>
<!-- Setting row -->
<Style x:Key="SettingRow" TargetType="Border">
<Setter Property="Padding" Value="0,14"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
</Style>
<!-- Section sub-header -->
<Style x:Key="SectionSubHeader" TargetType="TextBlock">
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
<Setter Property="Margin" Value="0,24,0,8"/>
</Style>
<!-- Action button (Edit Profile, Change, etc.) -->
<Style x:Key="ActionBtn" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="{StaticResource Accent}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="14,6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#4752c4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Theme option box -->
<Style x:Key="ThemeOption" TargetType="Border">
<Setter Property="Width" Value="80"/>
<Setter Property="Height" Value="56"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<!-- ComboBox dark style -->
<Style x:Key="SettingsCombo" TargetType="ComboBox">
<Setter Property="Background" Value="#1e1f22"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Width" Value="240"/>
</Style>
</UserControl.Resources>
<!-- Full-screen overlay -->
<Grid Background="#F0313338">
<!-- Close button — top-right -->
<Button Style="{StaticResource CloseButton}"
HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,16,16,0"
Command="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
Panel.ZIndex="10"/>
<DockPanel>
<!-- ══ Sidebar ══ -->
<Border DockPanel.Dock="Left" Width="218"
Background="{StaticResource BgSecondary}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,12,8,12">
<!-- User Settings -->
<TextBlock Text="USER SETTINGS" Style="{StaticResource CategoryHeader}"/>
<Button Style="{StaticResource SidebarButton}" Content="My Account"
Click="OnSidebarClick" Tag="My Account"/>
<Button Style="{StaticResource SidebarButton}" Content="Appearance"
Click="OnSidebarClick" Tag="Appearance"/>
<Button Style="{StaticResource SidebarButton}" Content="Notifications"
Click="OnSidebarClick" Tag="Notifications"/>
<!-- Separator -->
<Border Height="1" Background="{StaticResource BorderBrush}" Margin="10,8"/>
<!-- App Settings -->
<TextBlock Text="APP SETTINGS" Style="{StaticResource CategoryHeader}"/>
<Button Style="{StaticResource SidebarButton}" Content="Voice &amp; Audio"
Click="OnSidebarClick" Tag="Voice &amp; Audio"/>
<Button Style="{StaticResource SidebarButton}" Content="Keybinds"
Click="OnSidebarClick" Tag="Keybinds"/>
<!-- Separator -->
<Border Height="1" Background="{StaticResource BorderBrush}" Margin="10,8"/>
<!-- About -->
<TextBlock Text="ABOUT" Style="{StaticResource CategoryHeader}"/>
<Button Style="{StaticResource SidebarButton}" Content="Info"
Click="OnSidebarClick" Tag="Info"/>
<!-- Separator -->
<Border Height="1" Background="{StaticResource BorderBrush}" Margin="10,8"/>
<!-- Log Out -->
<Button Style="{StaticResource LogoutButton}" Content="Log Out"
Command="{Binding LogoutCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
</StackPanel>
</ScrollViewer>
</Border>
<!-- ══ Content Area ══ -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="40,16,60,16" MaxWidth="660">
<DockPanel>
<!-- Section header -->
<TextBlock DockPanel.Dock="Top"
Text="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="20" FontWeight="Bold"
Foreground="White"
Margin="0,0,0,20"/>
<Grid>
<!-- ═══ My Account ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="My Account">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<!-- Profile card -->
<Border Background="{StaticResource BgSecondary}" CornerRadius="8" Padding="16" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="64" Height="64" CornerRadius="32" Background="#5865f2" Margin="0,0,16,0">
<TextBlock Text="{Binding Username, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource FirstLetter}}"
Foreground="White" FontWeight="Bold" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Username, RelativeSource={RelativeSource AncestorType=UserControl}}"
Foreground="White" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding UserStatus, RelativeSource={RelativeSource AncestorType=UserControl}}"
Foreground="{StaticResource TextMuted}" FontSize="12" Margin="0,2,0,0"/>
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource ActionBtn}" Content="Edit Profile"
VerticalAlignment="Center" Click="OnPlaceholderClick" Tag="Edit profile dialog would open"/>
</Grid>
</Border>
<!-- Password -->
<TextBlock Text="PASSWORD" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Change Password" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="You'll need your current password to set a new one." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<Button Style="{StaticResource ActionBtn}" Content="Change" HorizontalAlignment="Right"
VerticalAlignment="Center" FontSize="12" Click="OnPlaceholderClick" Tag="Change password dialog would open"/>
</Grid>
</Border>
<!-- 2FA -->
<TextBlock Text="TWO-FACTOR AUTHENTICATION" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Enable 2FA" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Add an extra layer of security with TOTP." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
Click="OnToggleClick" Tag="2FA"/>
</Grid>
</Border>
<!-- Sessions -->
<TextBlock Text="SESSIONS" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}" BorderThickness="0">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Active Sessions" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="1 active session (this device)." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<Button Style="{StaticResource ActionBtn}" Content="Revoke All" Background="{StaticResource Red}"
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12"
Click="OnPlaceholderClick" Tag="Would revoke all other sessions"/>
</Grid>
</Border>
</StackPanel>
<!-- ═══ Appearance ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="Appearance">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="THEME" Style="{StaticResource SectionSubHeader}" Margin="0,0,0,8"/>
<TextBlock Text="Color Theme" Foreground="{StaticResource TextNormal}" FontSize="14" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Border Style="{StaticResource ThemeOption}" Background="#313338" BorderBrush="{StaticResource Accent}" Margin="0,0,12,0">
<TextBlock Text="Dark" Foreground="White" FontSize="12" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Style="{StaticResource ThemeOption}" Background="#0d0d0d" Margin="0,0,12,0">
<TextBlock Text="Midnight" Foreground="#b5bac1" FontSize="12" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Style="{StaticResource ThemeOption}" Background="#f2f3f5">
<TextBlock Text="Light" Foreground="#313338" FontSize="12" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
<TextBlock Text="CHAT" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Compact Mode" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Reduce spacing between messages." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}" BorderThickness="0">
<StackPanel>
<TextBlock Text="Font Size" Foreground="{StaticResource TextNormal}" FontSize="14" Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<Slider Grid.Column="0" Minimum="12" Maximum="20" Value="14"
Style="{StaticResource SettingsSlider}" TickFrequency="1" IsSnapToTickEnabled="True"
x:Name="FontSizeSlider"/>
<TextBlock Grid.Column="1" Foreground="{StaticResource TextMuted}" FontSize="13"
HorizontalAlignment="Right" VerticalAlignment="Center"
Text="{Binding Value, ElementName=FontSizeSlider, StringFormat={}{0:0}px}"/>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- ═══ Notifications ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="Notifications">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="DESKTOP NOTIFICATIONS" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Enable Desktop Notifications" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Show Windows toast notifications for new messages." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Notification Sounds" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Play a sound when you receive a notification." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Flash Taskbar" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Flash the taskbar icon for unread messages." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<TextBlock Text="MUTING" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}" BorderThickness="0">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Suppress @everyone" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Don't notify for @everyone mentions." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<!-- ═══ Voice & Audio ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="Voice &amp; Audio">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="INPUT" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Input Device" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Microphone for voice chat." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<ComboBox Style="{StaticResource SettingsCombo}" HorizontalAlignment="Right" VerticalAlignment="Center">
<ComboBoxItem Content="Default - Microphone" IsSelected="True"/>
</ComboBox>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<StackPanel>
<TextBlock Text="Input Volume" Foreground="{StaticResource TextNormal}" FontSize="14" Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<Slider Grid.Column="0" Minimum="0" Maximum="100" Value="80"
Style="{StaticResource SettingsSlider}" x:Name="InputVolSlider"/>
<TextBlock Grid.Column="1" Foreground="{StaticResource TextMuted}" FontSize="13"
HorizontalAlignment="Right" VerticalAlignment="Center"
Text="{Binding Value, ElementName=InputVolSlider, StringFormat={}{0:0}%}"/>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="OUTPUT" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Output Device" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Speakers or headphones." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<ComboBox Style="{StaticResource SettingsCombo}" HorizontalAlignment="Right" VerticalAlignment="Center">
<ComboBoxItem Content="Default - Speakers" IsSelected="True"/>
</ComboBox>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<StackPanel>
<TextBlock Text="Output Volume" Foreground="{StaticResource TextNormal}" FontSize="14" Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<Slider Grid.Column="0" Minimum="0" Maximum="100" Value="100"
Style="{StaticResource SettingsSlider}" x:Name="OutputVolSlider"/>
<TextBlock Grid.Column="1" Foreground="{StaticResource TextMuted}" FontSize="13"
HorizontalAlignment="Right" VerticalAlignment="Center"
Text="{Binding Value, ElementName=OutputVolSlider, StringFormat={}{0:0}%}"/>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="PROCESSING" Style="{StaticResource SectionSubHeader}"/>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Noise Suppression" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Reduce background noise (RNNoise)." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Voice Activity Detection" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Automatically detect when you're speaking." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingRow}">
<StackPanel>
<TextBlock Text="VAD Sensitivity" Foreground="{StaticResource TextNormal}" FontSize="14" Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<Slider Grid.Column="0" Minimum="0" Maximum="100" Value="60"
Style="{StaticResource SettingsSlider}" x:Name="VadSlider"/>
<TextBlock Grid.Column="1" Foreground="{StaticResource TextMuted}" FontSize="13"
HorizontalAlignment="Right" VerticalAlignment="Center"
Text="{Binding Value, ElementName=VadSlider, StringFormat={}{0:0}%}"/>
</Grid>
</StackPanel>
</Border>
<Border Style="{StaticResource SettingRow}" BorderThickness="0">
<Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Echo Cancellation" Foreground="{StaticResource TextNormal}" FontSize="14"/>
<TextBlock Text="Prevent feedback loops from speakers." Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,4,0,0"/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" IsChecked="True" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<!-- ═══ Keybinds ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="Keybinds">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="Click a keybind to change it. Press Escape to cancel."
Foreground="{StaticResource TextMuted}" FontSize="13" Margin="0,0,0,16"/>
<!-- Push to Talk -->
<Border Style="{StaticResource SettingRow}">
<Grid>
<TextBlock Text="Push to Talk" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border Style="{StaticResource KbdTag}">
<TextBlock Text="V" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- Toggle Mute -->
<Border Style="{StaticResource SettingRow}">
<Grid>
<TextBlock Text="Toggle Mute" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border Style="{StaticResource KbdTag}"><TextBlock Text="Ctrl" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
<TextBlock Text=" + " Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="4,0"/>
<Border Style="{StaticResource KbdTag}"><TextBlock Text="Shift" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
<TextBlock Text=" + " Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="4,0"/>
<Border Style="{StaticResource KbdTag}"><TextBlock Text="M" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
</StackPanel>
</Grid>
</Border>
<!-- Toggle Deafen -->
<Border Style="{StaticResource SettingRow}">
<Grid>
<TextBlock Text="Toggle Deafen" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border Style="{StaticResource KbdTag}"><TextBlock Text="Ctrl" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
<TextBlock Text=" + " Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="4,0"/>
<Border Style="{StaticResource KbdTag}"><TextBlock Text="Shift" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
<TextBlock Text=" + " Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="4,0"/>
<Border Style="{StaticResource KbdTag}"><TextBlock Text="D" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
</StackPanel>
</Grid>
</Border>
<!-- Quick Switcher -->
<Border Style="{StaticResource SettingRow}">
<Grid>
<TextBlock Text="Quick Switcher" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border Style="{StaticResource KbdTag}"><TextBlock Text="Ctrl" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
<TextBlock Text=" + " Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="4,0"/>
<Border Style="{StaticResource KbdTag}"><TextBlock Text="K" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/></Border>
</StackPanel>
</Grid>
</Border>
<!-- Edit Last Message -->
<Border Style="{StaticResource SettingRow}">
<Grid>
<TextBlock Text="Edit Last Message" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<Border Style="{StaticResource KbdTag}" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="&#x2191;" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/>
</Border>
</Grid>
</Border>
<!-- Close Panel -->
<Border Style="{StaticResource SettingRow}" BorderThickness="0">
<Grid>
<TextBlock Text="Close Panel" Foreground="{StaticResource TextNormal}" FontSize="14" VerticalAlignment="Center"/>
<Border Style="{StaticResource KbdTag}" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="Esc" FontFamily="Cascadia Code,Consolas,monospace" FontSize="12" Foreground="{StaticResource TextNormal}"/>
</Border>
</Grid>
</Border>
</StackPanel>
<!-- ═══ Info ═══ -->
<StackPanel>
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedSection, RelativeSource={RelativeSource AncestorType=UserControl}}"
Value="Info">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Border Background="{StaticResource BgSecondary}" CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OwnCord" Foreground="White" FontSize="24" FontWeight="Bold"/>
<TextBlock Text="Version 1.0.0" Foreground="{StaticResource TextMuted}" FontSize="14" Margin="0,4,0,16"/>
<TextBlock Foreground="{StaticResource TextMuted}" FontSize="14" LineHeight="22" TextWrapping="Wrap">
Self-hosted chat platform for friends.<LineBreak/>
Server: Go + SQLite + Pion WebRTC<LineBreak/>
Client: Native Windows (WPF)
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</DockPanel>
</Grid>
</ScrollViewer>
</DockPanel>
</Grid>
</UserControl>
@@ -1,117 +0,0 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace OwnCord.Client.Controls;
public partial class SettingsOverlayControl : UserControl
{
public static readonly DependencyProperty SelectedSectionProperty =
DependencyProperty.Register(
nameof(SelectedSection),
typeof(string),
typeof(SettingsOverlayControl),
new PropertyMetadata("My Account"));
public static readonly DependencyProperty CloseCommandProperty =
DependencyProperty.Register(
nameof(CloseCommand),
typeof(ICommand),
typeof(SettingsOverlayControl),
new PropertyMetadata(null));
public static readonly DependencyProperty LogoutCommandProperty =
DependencyProperty.Register(
nameof(LogoutCommand),
typeof(ICommand),
typeof(SettingsOverlayControl),
new PropertyMetadata(null));
public static readonly DependencyProperty UsernameProperty =
DependencyProperty.Register(
nameof(Username),
typeof(string),
typeof(SettingsOverlayControl),
new PropertyMetadata("Unknown"));
public static readonly DependencyProperty UserStatusProperty =
DependencyProperty.Register(
nameof(UserStatus),
typeof(string),
typeof(SettingsOverlayControl),
new PropertyMetadata("Offline"));
public static readonly DependencyProperty ServerNameProperty =
DependencyProperty.Register(
nameof(ServerName),
typeof(string),
typeof(SettingsOverlayControl),
new PropertyMetadata("Not connected"));
public SettingsOverlayControl()
{
InitializeComponent();
}
public string SelectedSection
{
get => (string)GetValue(SelectedSectionProperty);
set => SetValue(SelectedSectionProperty, value);
}
public ICommand CloseCommand
{
get => (ICommand)GetValue(CloseCommandProperty);
set => SetValue(CloseCommandProperty, value);
}
public ICommand LogoutCommand
{
get => (ICommand)GetValue(LogoutCommandProperty);
set => SetValue(LogoutCommandProperty, value);
}
public string Username
{
get => (string)GetValue(UsernameProperty);
set => SetValue(UsernameProperty, value);
}
public string UserStatus
{
get => (string)GetValue(UserStatusProperty);
set => SetValue(UserStatusProperty, value);
}
public string ServerName
{
get => (string)GetValue(ServerNameProperty);
set => SetValue(ServerNameProperty, value);
}
private void OnSidebarClick(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is string section)
{
SelectedSection = section;
}
}
private void OnPlaceholderClick(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement el && el.Tag is string description)
{
MessageBox.Show(description, "Coming Soon", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
private void OnToggleClick(object sender, RoutedEventArgs e)
{
if (sender is CheckBox cb && cb.Tag is string label)
{
var state = cb.IsChecked == true ? "enabled" : "disabled";
// Placeholder — in future these will persist to settings
System.Diagnostics.Debug.WriteLine($"Setting '{label}' {state}");
}
}
}
@@ -1,79 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.StatusPickerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<Style x:Key="StatusOptionButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="8,6" Background="Transparent">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{StaticResource BgHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Border Background="{StaticResource BgPrimary}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="6"
MinWidth="160">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="4" Opacity="0.4" Color="Black"/>
</Border.Effect>
<StackPanel>
<!-- Online -->
<Button Style="{StaticResource StatusOptionButton}"
Command="{Binding StatusChangedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="online">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="{StaticResource StatusOnline}" Margin="0,0,10,0"/>
<TextBlock Text="Online" FontSize="13" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Button>
<!-- Idle -->
<Button Style="{StaticResource StatusOptionButton}"
Command="{Binding StatusChangedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="idle">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="{StaticResource StatusIdle}" Margin="0,0,10,0"/>
<TextBlock Text="Idle" FontSize="13" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Button>
<!-- Do Not Disturb -->
<Button Style="{StaticResource StatusOptionButton}"
Command="{Binding StatusChangedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="dnd">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="{StaticResource StatusDnd}" Margin="0,0,10,0"/>
<TextBlock Text="Do Not Disturb" FontSize="13" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Button>
<!-- Invisible -->
<Button Style="{StaticResource StatusOptionButton}"
Command="{Binding StatusChangedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="invisible">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="{StaticResource StatusOffline}" Margin="0,0,10,0"/>
<TextBlock Text="Invisible" FontSize="13" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</UserControl>
@@ -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);
}
}
@@ -1,23 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.ToastControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root"
Opacity="0"
IsHitTestVisible="False">
<Border Background="{StaticResource BgSecondary}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="20,12">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="4" Opacity="0.5" Color="Black"/>
</Border.Effect>
<TextBlock Text="{Binding Message, ElementName=Root}"
FontSize="13"
Foreground="{StaticResource TextNormal}"
TextWrapping="Wrap"
MaxWidth="400"/>
</Border>
</UserControl>
@@ -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;
}
}
@@ -1,133 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.UserBarControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:OwnCord.Client.Controls">
<UserControl.Resources>
<Style x:Key="UserBarButton" TargetType="Button">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Background="Transparent"
Width="32" Height="32">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#35373c"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Invisible button style for avatar click area -->
<Style x:Key="AvatarButton" TargetType="Button">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Border Height="52" Padding="8,0">
<Border.Background>
<SolidColorBrush Color="#111214" Opacity="0.6"/>
</Border.Background>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Avatar with status dot — clickable to toggle status picker -->
<Grid Grid.Column="0" Margin="0,0,8,0" VerticalAlignment="Center">
<Button Style="{StaticResource AvatarButton}"
Command="{Binding ToggleStatusPickerCommand}"
ToolTip="Change status">
<Grid>
<!-- Avatar circle with initial -->
<Border Width="32" Height="32" CornerRadius="16" Background="#5865f2">
<TextBlock Text="{Binding CurrentUsername, Converter={StaticResource FirstLetter}}"
Foreground="White" FontWeight="Bold" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- Status dot overlay -->
<Ellipse Width="12" Height="12" HorizontalAlignment="Right" VerticalAlignment="Bottom"
Fill="{Binding CurrentUserStatusEnum, Converter={StaticResource StatusToBrush}}"
Stroke="#111214" StrokeThickness="3"/>
</Grid>
</Button>
<!-- Status picker popup -->
<Popup IsOpen="{Binding ShowStatusPicker}"
Placement="Top"
PlacementTarget="{Binding RelativeSource={RelativeSource AncestorType=Grid}}"
StaysOpen="False"
AllowsTransparency="True"
PopupAnimation="Fade"
HorizontalOffset="-4"
VerticalOffset="-4">
<controls:StatusPickerControl
SelectedStatus="{Binding CurrentUserStatus}"
StatusChangedCommand="{Binding ChangeStatusCommand}"/>
</Popup>
</Grid>
<!-- Username and status text -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,4,0">
<TextBlock Text="{Binding CurrentUsername}" Foreground="White" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding CurrentUserStatus}" Foreground="#949ba4" FontSize="11"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- Mic / Deafen / Settings buttons -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Command="{Binding ToggleMuteCommand}" Style="{StaticResource UserBarButton}"
ToolTip="Toggle Mute">
<TextBlock FontSize="14" Foreground="{Binding IsMuted, Converter={StaticResource BoolToRedBrush}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="&#x1F399;"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMuted}" Value="True">
<Setter Property="Text" Value="&#x1F507;"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<Button Command="{Binding ToggleDeafenCommand}" Style="{StaticResource UserBarButton}"
ToolTip="Toggle Deafen">
<TextBlock FontSize="14" Foreground="{Binding IsDeafened, Converter={StaticResource BoolToRedBrush}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="&#x1F50A;"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsDeafened}" Value="True">
<Setter Property="Text" Value="&#x1F508;"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<Button Command="{Binding OpenSettingsCommand}"
Style="{StaticResource UserBarButton}" ToolTip="Settings">
<TextBlock Text="&#x2699;" FontSize="16" Foreground="#b5bac1"/>
</Button>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace OwnCord.Client.Controls;
public partial class UserBarControl : UserControl
{
public UserBarControl()
{
InitializeComponent();
}
}
@@ -1,110 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.UserPopupControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="300">
<Border Background="{StaticResource BgPrimary}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="4" Opacity="0.4" Color="Black"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Banner -->
<Border Grid.Row="0" CornerRadius="8,8,0,0"
Background="{Binding AvatarColor, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource HexColorToBrush}, FallbackValue=#5865f2}"/>
<!-- Body -->
<StackPanel Grid.Row="1" Margin="16,36,16,16">
<!-- Avatar (overlapping banner) -->
<Border Width="64" Height="64" CornerRadius="32"
BorderThickness="4" BorderBrush="{StaticResource BgPrimary}"
HorizontalAlignment="Left"
Margin="0,-64,0,0">
<Border CornerRadius="28"
Background="{Binding AvatarColor, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource HexColorToBrush}, FallbackValue=#5865f2}">
<TextBlock Text="{Binding Username, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource FirstLetter}}"
Foreground="White" FontWeight="Bold" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</Border>
<!-- Username -->
<TextBlock Text="{Binding Username, RelativeSource={RelativeSource AncestorType=UserControl}}"
Foreground="White" FontWeight="Bold" FontSize="18"
Margin="0,8,0,0"/>
<!-- Role -->
<TextBlock Text="{Binding RoleName, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="12" Margin="0,2,0,0"
Foreground="{Binding RoleColor, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource HexColorToBrush}, FallbackValue=#949ba4}"/>
<!-- Status -->
<TextBlock Text="{Binding StatusText, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="12" Foreground="{StaticResource TextMuted}" Margin="0,2,0,0"/>
<!-- Separator -->
<Border Height="1" Background="{StaticResource BorderBrush}" Margin="0,10,0,10"/>
<!-- Member Since section -->
<Border Background="{StaticResource BgSecondary}" CornerRadius="6" Padding="10,8">
<StackPanel>
<TextBlock Text="MEMBER SINCE" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource TextFaint}"
Margin="0,0,0,4"/>
<TextBlock Text="{Binding JoinedDate, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="13" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Border>
<!-- Role tags -->
<WrapPanel Margin="0,10,0,0" Orientation="Horizontal">
<Border CornerRadius="10" Padding="6,3,8,3"
Background="{StaticResource BgHover}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Margin="0,0,5,0"
Fill="{Binding RoleColor, RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource HexColorToBrush}, FallbackValue=#949ba4}"/>
<TextBlock Text="{Binding RoleName, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="11" Foreground="{StaticResource TextNormal}"/>
</StackPanel>
</Border>
</WrapPanel>
<!-- Message button -->
<Button Margin="0,12,0,0" Cursor="Hand"
Command="{Binding MessageCommand, RelativeSource={RelativeSource AncestorType=UserControl}}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4" Padding="0,8"
Background="{StaticResource Accent}">
<TextBlock Text="Message" Foreground="White" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -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);
}
}
@@ -1,128 +0,0 @@
<UserControl x:Class="OwnCord.Client.Controls.VoiceWidgetControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Visibility="{Binding IsInVoice, Converter={StaticResource BoolToVisibility}}">
<UserControl.Resources>
<Style x:Key="VoiceControlButton" TargetType="Button">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Background" Value="#404249"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
Width="32" Height="32">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#35373c"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="DisconnectButton" TargetType="Button">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4"
Background="Transparent"
Width="32" Height="32">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#26f23f43"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Border Background="#2b2d31" BorderThickness="0,1,0,0" BorderBrush="#3f4147" Padding="8">
<StackPanel>
<!-- Voice Connected header -->
<StackPanel Orientation="Horizontal" Margin="4,4,4,2">
<TextBlock Text="Voice Connected" Foreground="#23a55a" FontSize="12" FontWeight="Bold"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Channel name -->
<TextBlock Text="{Binding VoiceChannelName}" Foreground="#949ba4" FontSize="11" Margin="4,0,4,8"/>
<!-- Control buttons row -->
<StackPanel Orientation="Horizontal" Margin="4,0">
<!-- Mute button -->
<Button Command="{Binding ToggleMuteCommand}" Margin="0,0,4,0"
ToolTip="Toggle Mute">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource VoiceControlButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsMuted}" Value="True">
<Setter Property="Background" Value="#33f23f43"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock FontSize="14" Foreground="{Binding IsMuted, Converter={StaticResource BoolToRedBrush}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="&#x1F399;"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMuted}" Value="True">
<Setter Property="Text" Value="&#x1F507;"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<!-- Deafen button -->
<Button Command="{Binding ToggleDeafenCommand}" Margin="0,0,4,0"
ToolTip="Toggle Deafen">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource VoiceControlButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsDeafened}" Value="True">
<Setter Property="Background" Value="#33f23f43"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock FontSize="14" Foreground="{Binding IsDeafened, Converter={StaticResource BoolToRedBrush}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="&#x1F50A;"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsDeafened}" Value="True">
<Setter Property="Text" Value="&#x1F508;"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<!-- Disconnect button -->
<Button Command="{Binding LeaveVoiceCommand}" Style="{StaticResource DisconnectButton}"
ToolTip="Disconnect">
<TextBlock Text="&#x260E;" FontSize="14" Foreground="#f23f43"/>
</Button>
</StackPanel>
</StackPanel>
</Border>
</UserControl>
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace OwnCord.Client.Controls;
public partial class VoiceWidgetControl : UserControl
{
public VoiceWidgetControl()
{
InitializeComponent();
}
}
@@ -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();
}
@@ -1,131 +0,0 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace OwnCord.Client.Converters;
/// <summary>Converts a hex color string (#rrggbb) to a SolidColorBrush.</summary>
[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();
}
/// <summary>Converts a UserStatus enum to the corresponding status dot color brush.</summary>
[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();
}
/// <summary>Gets the first letter of a string (for avatar circle initials).</summary>
[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();
}
/// <summary>Converts a bool to a Foreground color (red when true, muted when false).</summary>
[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();
}
/// <summary>Converts a bool speaking state to a green or transparent stroke brush.</summary>
[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();
}
/// <summary>Converts a boolean expand state to an arrow character.</summary>
[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();
}
@@ -1,33 +0,0 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace OwnCord.Client.Converters;
/// <summary>Converts a hex color string like "#5865f2" to a SolidColorBrush.</summary>
[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();
}
@@ -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);
}
}
@@ -1,22 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace OwnCord.Client.Converters;
/// <summary>
/// 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).
/// </summary>
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();
}
@@ -1,19 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace OwnCord.Client.Converters;
/// <summary>Returns the first character of a string, uppercased.</summary>
[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();
}
@@ -1,39 +0,0 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace OwnCord.Client.Converters;
/// <summary>
/// Converts a health status string to a SolidColorBrush for the status indicator dot.
/// "online" = Green, "checking" = Yellow, "offline" = Red, "unknown"/other = Gray.
/// </summary>
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();
}
@@ -1,18 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace OwnCord.Client.Converters;
/// <summary>Combines Host and Port into a display string. Used as a multi-value converter.</summary>
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();
}
@@ -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;
}
@@ -1,30 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace OwnCord.Client.Converters;
/// <summary>Converts a DateTime? to a human-readable relative time string.</summary>
[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();
}
@@ -1,19 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace OwnCord.Client.Converters;
/// <summary>
/// Returns true when the bound string value equals the converter parameter (case-insensitive).
/// Useful for highlighting the active tab in a tab bar.
/// </summary>
[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();
}
-7
View File
@@ -1,7 +0,0 @@
<Window x:Class="OwnCord.Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="OwnCord" Height="720" Width="1200"
Background="#313338">
<Frame x:Name="RootFrame" NavigationUIVisibility="Hidden"/>
</Window>
-130
View File
@@ -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;
}
}
/// <summary>Navigate back to the connect/login page (called after logout).</summary>
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();
}
}
@@ -1,77 +0,0 @@
using System.Text.Json.Serialization;
namespace OwnCord.Client.Models;
/// <summary>REST API response for login and register endpoints.</summary>
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
);
/// <summary>User shape returned by auth endpoints.</summary>
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
);
/// <summary>Single channel from GET /api/v1/channels or ready payload.</summary>
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
);
/// <summary>Response from GET /api/v1/channels/{id}/messages.</summary>
public record MessagesResponse(
[property: JsonPropertyName("messages")] IReadOnlyList<ApiMessage> Messages,
[property: JsonPropertyName("has_more")] bool HasMore
);
/// <summary>Single message from the REST API (includes flattened user fields).</summary>
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<ApiAttachment>? Attachments = null
);
/// <summary>Single attachment from the REST API.</summary>
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
);
/// <summary>Error response shape from all REST endpoints.</summary>
public record ApiError(
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("message")] string Message
);
/// <summary>Response from GET /health.</summary>
public record HealthResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("version")] string Version
);
-14
View File
@@ -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
);
@@ -1,33 +0,0 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace OwnCord.Client.Models;
/// <summary>
/// Groups channels by category for the sidebar. Supports collapse/expand.
/// </summary>
public sealed class ChannelGroup : INotifyPropertyChanged
{
private bool _isExpanded = true;
public string? CategoryName { get; init; }
public ObservableCollection<ChannelItem> Items { get; } = [];
public bool IsExpanded
{
get => _isExpanded;
set { if (_isExpanded != value) { _isExpanded = value; OnPropertyChanged(); } }
}
/// <summary>Display name: uppercase category or empty for ungrouped.</summary>
public string DisplayName => CategoryName?.ToUpperInvariant() ?? string.Empty;
/// <summary>True if this group has a category name (shows header).</summary>
public bool HasCategory => CategoryName is not null;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
@@ -1,19 +0,0 @@
using System.Collections.ObjectModel;
namespace OwnCord.Client.Models;
/// <summary>
/// Wraps a Channel with its associated voice users for display in the sidebar.
/// </summary>
public sealed class ChannelItem
{
public Channel Channel { get; init; } = null!;
public ObservableCollection<VoiceStateInfo> 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;
}
@@ -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;
}
/// <summary>Parse message content into text and code block segments.</summary>
public static IReadOnlyList<ContentPart> Parse(string content)
{
var parts = new List<ContentPart>();
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;
}
}
@@ -1,15 +0,0 @@
using System.Collections.ObjectModel;
namespace OwnCord.Client.Models;
/// <summary>
/// Groups members by their role for the member list sidebar.
/// </summary>
public sealed class MemberGroup
{
public string RoleName { get; init; } = string.Empty;
public string? RoleColor { get; init; }
public int Position { get; init; }
public ObservableCollection<User> Members { get; } = [];
public int MemberCount => Members.Count;
}
-24
View File
@@ -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<Reaction> Reactions,
IReadOnlyList<Attachment> Attachments
);
public record Reaction(string Emoji, int Count, bool Me);
@@ -1,82 +0,0 @@
using System.Linq;
namespace OwnCord.Client.Models;
/// <summary>
/// Wraps a Message with computed display properties for the UI.
/// Handles message grouping (consecutive same-author) and day dividers.
/// </summary>
public sealed class MessageDisplayItem
{
public Message Message { get; }
/// <summary>True when this message is from the same author as the previous one
/// and within 7 minutes — avatar and author name should be hidden.</summary>
public bool IsGrouped { get; }
/// <summary>True when this message is the first of a new calendar day.</summary>
public bool ShowDayDivider { get; }
/// <summary>Formatted day divider text (e.g. "March 15, 2026").</summary>
public string? DayDividerText { get; }
/// <summary>The message this replies to (if any) — set externally by the ViewModel.</summary>
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<Reaction> Reactions => Message.Reactions;
public IReadOnlyList<Attachment> 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";
/// <summary>Parsed content segments (text and code blocks).</summary>
public IReadOnlyList<ContentPart> ContentParts { get; }
/// <summary>True if the message contains at least one code block.</summary>
public bool HasCodeBlocks => ContentParts.Any(p => p.IsCode);
/// <summary>True when the current user authored this message (for showing edit/delete actions).</summary>
public bool IsOwnMessage { get; init; }
/// <summary>Hex color for the author's role, e.g. "#e74c3c". Null falls back to white.</summary>
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;
}
}
}
-3
View File
@@ -1,3 +0,0 @@
namespace OwnCord.Client.Models;
public record Role(long Id, string Name, string? Color, long Permissions);
@@ -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);
/// <summary>Returns host:port for display, omitting port if it is the default 8443.</summary>
public string HostDisplay => Port == 8443 ? Host : $"{Host}:{Port}";
}
-11
View File
@@ -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
);
@@ -1,47 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace OwnCord.Client.Models;
/// <summary>
/// Mutable view-model-friendly class representing a user's voice state.
/// Implements INotifyPropertyChanged so the UI can bind to Speaking, Muted, etc.
/// </summary>
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));
}
-153
View File
@@ -1,153 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OwnCord.Client.Models;
/// <summary>Top-level WebSocket message envelope.</summary>
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<ApiChannel> Channels,
[property: JsonPropertyName("members")] IReadOnlyList<WsMember> Members,
[property: JsonPropertyName("voice_states")] IReadOnlyList<WsVoiceState> VoiceStates,
[property: JsonPropertyName("roles")] IReadOnlyList<WsRole> 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
);
/// <summary>User shape in WebSocket messages (subset of ApiUser).</summary>
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<ApiAttachment>? 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<long> Speakers,
[property: JsonPropertyName("mode")] string Mode
);
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
</Project>
-192
View File
@@ -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;
/// <summary>
/// HTTP REST client for the OwnCord server API.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<AuthResponse> 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<AuthResponse>(response, ct);
}
public async Task<AuthResponse> 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<AuthResponse>(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<ApiUser> GetMeAsync(string host, string token, CancellationToken ct = default)
{
var response = await GetAuthenticatedAsync(host, "/api/v1/auth/me", token, ct);
return await ReadOrThrowAsync<ApiUser>(response, ct);
}
public async Task<IReadOnlyList<ApiChannel>> GetChannelsAsync(string host, string token, CancellationToken ct = default)
{
var response = await GetAuthenticatedAsync(host, "/api/v1/channels", token, ct);
return await ReadOrThrowAsync<List<ApiChannel>>(response, ct);
}
public async Task<MessagesResponse> 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<MessagesResponse>(response, ct);
}
public async Task<AuthResponse> 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<AuthResponse>(response, ct);
}
public async Task<HealthResponse> HealthCheckAsync(string host, CancellationToken ct = default)
{
var response = await _http.GetAsync(BuildUrl(host, "/health"), ct);
return await ReadOrThrowAsync<HealthResponse>(response, ct);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static string BuildUrl(string host, string path)
=> $"https://{NormalizeHost(host)}{path}";
/// <summary>
/// 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"
/// </summary>
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<HttpResponseMessage> 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<HttpResponseMessage> 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<T> ReadOrThrowAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
try
{
var error = JsonSerializer.Deserialize<ApiError>(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<T>(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<ApiError>(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);
}
}
}
@@ -1,17 +0,0 @@
namespace OwnCord.Client.Services;
/// <summary>
/// Exception thrown when the OwnCord server returns an error response.
/// </summary>
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;
}
}
@@ -1,159 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
namespace OwnCord.Client.Services;
/// <summary>
/// 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..." }
/// </summary>
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")) { }
/// <summary>Internal constructor allowing an isolated directory for unit tests.</summary>
internal CertificateTrustService(string dir)
{
_dir = dir;
_filePath = Path.Combine(_dir, "trusted_certs.json");
}
// ── ICertificateTrustService ──────────────────────────────────────────────
/// <inheritdoc/>
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<string, string>(store, StringComparer.OrdinalIgnoreCase)
{
[host] = fingerprint
};
Save(updated);
return true;
}
return string.Equals(stored, fingerprint, StringComparison.OrdinalIgnoreCase);
}
finally
{
_lock.Release();
}
}
/// <inheritdoc/>
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<string, string>(store, StringComparer.OrdinalIgnoreCase)
{
[host] = fingerprint
};
Save(updated);
}
finally
{
_lock.Release();
}
}
/// <inheritdoc/>
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<string, string>(store, StringComparer.OrdinalIgnoreCase);
updated.Remove(host);
Save(updated);
}
finally
{
_lock.Release();
}
}
/// <inheritdoc/>
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
};
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, string> Load()
{
if (!File.Exists(_filePath))
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var json = File.ReadAllText(_filePath);
var raw = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
return raw is null
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(raw, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Writes the trust store to disk atomically via a temp-file swap.</summary>
private void Save(Dictionary<string, string> 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);
}
}
@@ -1,368 +0,0 @@
using System.Text.Json;
using OwnCord.Client.Models;
namespace OwnCord.Client.Services;
/// <summary>
/// Orchestrates REST API calls and WebSocket lifecycle.
/// ViewModels depend on this — never on IApiClient or IWebSocketService directly.
/// </summary>
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<AuthOkPayload>? AuthOk;
public event Action<ReadyPayload>? Ready;
public event Action<ChatMessagePayload>? ChatMessageReceived;
public event Action<ChatSendOkPayload>? ChatSendOk;
public event Action<ChatEditedPayload>? ChatEdited;
public event Action<ChatDeletedPayload>? ChatDeleted;
public event Action<TypingPayload>? TypingReceived;
public event Action<PresencePayload>? PresenceChanged;
public event Action<ReactionUpdatePayload>? ReactionUpdated;
public event Action<WsErrorPayload>? ErrorReceived;
public event Action<ServerRestartPayload>? ServerRestarting;
public event Action<WsMember>? MemberJoined;
public event Action<ChannelEventPayload>? ChannelCreated;
public event Action<ChannelEventPayload>? ChannelUpdated;
public event Action<long>? ChannelDeleted;
public event Action<string>? ConnectionLost;
public event Action<VoiceStatePayload>? VoiceStateReceived;
public event Action<VoiceLeavePayload>? VoiceLeaveReceived;
public event Action<VoiceConfigPayload>? VoiceConfigReceived;
public event Action<VoiceSpeakersPayload>? VoiceSpeakersReceived;
public ChatService(IApiClient api, IWebSocketService ws)
{
_api = api;
_ws = ws;
_ws.MessageReceived += OnMessageReceived;
_ws.Disconnected += reason => OnDisconnected(reason);
}
// ── Auth ────────────────────────────────────────────────────────────────
public async Task<AuthResponse> 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<AuthResponse> 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<AuthResponse> 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<IReadOnlyList<ApiChannel>> GetChannelsAsync(CancellationToken ct = default)
=> _api.GetChannelsAsync(_host!, CurrentToken!, ct);
public Task<MessagesResponse> 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<WsEnvelope>(json);
if (envelope is null) return;
switch (envelope.Type)
{
case "auth_ok":
AuthOk?.Invoke(Deserialize<AuthOkPayload>(envelope));
break;
case "ready":
Ready?.Invoke(Deserialize<ReadyPayload>(envelope));
break;
case "chat_message":
ChatMessageReceived?.Invoke(Deserialize<ChatMessagePayload>(envelope));
break;
case "chat_send_ok":
ChatSendOk?.Invoke(Deserialize<ChatSendOkPayload>(envelope));
break;
case "chat_edited":
ChatEdited?.Invoke(Deserialize<ChatEditedPayload>(envelope));
break;
case "chat_deleted":
ChatDeleted?.Invoke(Deserialize<ChatDeletedPayload>(envelope));
break;
case "typing":
TypingReceived?.Invoke(Deserialize<TypingPayload>(envelope));
break;
case "presence":
PresenceChanged?.Invoke(Deserialize<PresencePayload>(envelope));
break;
case "reaction_update":
ReactionUpdated?.Invoke(Deserialize<ReactionUpdatePayload>(envelope));
break;
case "error":
ErrorReceived?.Invoke(Deserialize<WsErrorPayload>(envelope));
break;
case "server_restart":
ServerRestarting?.Invoke(Deserialize<ServerRestartPayload>(envelope));
break;
case "member_join":
MemberJoined?.Invoke(Deserialize<WsMember>(envelope));
break;
case "channel_create":
ChannelCreated?.Invoke(Deserialize<ChannelEventPayload>(envelope));
break;
case "channel_update":
ChannelUpdated?.Invoke(Deserialize<ChannelEventPayload>(envelope));
break;
case "channel_delete":
var delPayload = envelope.Payload?.Deserialize<JsonElement>();
if (delPayload?.TryGetProperty("id", out var idEl) == true)
ChannelDeleted?.Invoke(idEl.GetInt64());
break;
case "voice_state":
VoiceStateReceived?.Invoke(Deserialize<VoiceStatePayload>(envelope));
break;
case "voice_leave":
VoiceLeaveReceived?.Invoke(Deserialize<VoiceLeavePayload>(envelope));
break;
case "voice_config":
VoiceConfigReceived?.Invoke(Deserialize<VoiceConfigPayload>(envelope));
break;
case "voice_speakers":
VoiceSpeakersReceived?.Invoke(Deserialize<VoiceSpeakersPayload>(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<T>(WsEnvelope envelope)
=> envelope.Payload!.Value.Deserialize<T>()
?? throw new JsonException($"Failed to deserialize {typeof(T).Name} payload");
}
@@ -1,83 +0,0 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace OwnCord.Client.Services;
/// <summary>
/// Stores auth tokens encrypted with DPAPI (CurrentUser scope) in AppData.
/// Equivalent security to Windows Credential Manager without requiring WinRT.
/// </summary>
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}");
}
@@ -1,16 +0,0 @@
namespace OwnCord.Client.Services;
public static class EmojiData
{
public record EmojiCategory(string Name, IReadOnlyList<string> Emojis);
public static IReadOnlyList<EmojiCategory> 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" })
};
}
@@ -1,16 +0,0 @@
using OwnCord.Client.Models;
namespace OwnCord.Client.Services;
/// <summary>REST API client for the OwnCord server.</summary>
public interface IApiClient
{
Task<AuthResponse> LoginAsync(string host, string username, string password, CancellationToken ct = default);
Task<AuthResponse> RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default);
Task LogoutAsync(string host, string token, CancellationToken ct = default);
Task<ApiUser> GetMeAsync(string host, string token, CancellationToken ct = default);
Task<IReadOnlyList<ApiChannel>> GetChannelsAsync(string host, string token, CancellationToken ct = default);
Task<MessagesResponse> GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default);
Task<HealthResponse> HealthCheckAsync(string host, CancellationToken ct = default);
Task<AuthResponse> VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default);
}
@@ -1,25 +0,0 @@
namespace OwnCord.Client.Services;
/// <summary>
/// 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.
/// </summary>
public interface ICertificateTrustService
{
/// <summary>
/// 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.
/// </summary>
bool IsTrusted(string host, string fingerprint);
/// <summary>Explicitly stores a fingerprint as trusted for the given host.</summary>
void TrustFingerprint(string host, string fingerprint);
/// <summary>Removes any stored trust record for the given host.</summary>
void RemoveTrust(string host);
/// <summary>Returns the stored fingerprint for the host, or null if none is stored.</summary>
string? GetTrustedFingerprint(string host);
}
@@ -1,76 +0,0 @@
using OwnCord.Client.Models;
namespace OwnCord.Client.Services;
/// <summary>
/// High-level orchestrator: login/logout, WebSocket lifecycle, message dispatch.
/// ViewModels subscribe to events; they never touch IApiClient or IWebSocketService directly.
/// </summary>
public interface IChatService
{
// ── State ───────────────────────────────────────────────────────────────
bool IsConnected { get; }
string? CurrentToken { get; }
string? CurrentHost { get; }
ApiUser? CurrentUser { get; }
// ── Auth ────────────────────────────────────────────────────────────────
Task<AuthResponse> LoginAsync(string host, string username, string password, CancellationToken ct = default);
Task<AuthResponse> RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default);
Task LogoutAsync(CancellationToken ct = default);
Task<AuthResponse> 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<IReadOnlyList<ApiChannel>> GetChannelsAsync(CancellationToken ct = default);
Task<MessagesResponse> 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<AuthOkPayload>? AuthOk;
event Action<ReadyPayload>? Ready;
event Action<ChatMessagePayload>? ChatMessageReceived;
event Action<ChatSendOkPayload>? ChatSendOk;
event Action<ChatEditedPayload>? ChatEdited;
event Action<ChatDeletedPayload>? ChatDeleted;
event Action<TypingPayload>? TypingReceived;
event Action<PresencePayload>? PresenceChanged;
event Action<ReactionUpdatePayload>? ReactionUpdated;
event Action<WsErrorPayload>? ErrorReceived;
event Action<ServerRestartPayload>? ServerRestarting;
event Action<WsMember>? MemberJoined;
event Action<ChannelEventPayload>? ChannelCreated;
event Action<ChannelEventPayload>? ChannelUpdated;
event Action<long>? ChannelDeleted;
event Action<string>? ConnectionLost;
// ── Voice events ────────────────────────────────────────────────────────
event Action<VoiceStatePayload>? VoiceStateReceived;
event Action<VoiceLeavePayload>? VoiceLeaveReceived;
event Action<VoiceConfigPayload>? VoiceConfigReceived;
event Action<VoiceSpeakersPayload>? VoiceSpeakersReceived;
}
@@ -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);
}
@@ -1,12 +0,0 @@
using OwnCord.Client.Models;
namespace OwnCord.Client.Services;
public interface IProfileService
{
IReadOnlyList<ServerProfile> LoadProfiles();
IReadOnlyList<ServerProfile> AddProfile(IReadOnlyList<ServerProfile> profiles, ServerProfile profile);
IReadOnlyList<ServerProfile> RemoveProfile(IReadOnlyList<ServerProfile> profiles, string id);
IReadOnlyList<ServerProfile> UpdateProfile(IReadOnlyList<ServerProfile> profiles, ServerProfile updated);
void SaveProfiles(IReadOnlyList<ServerProfile> profiles);
}
@@ -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<UpdateInfo?> CheckForUpdateAsync();
Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath);
void ApplyUpdate(string newExePath);
void CleanupOldVersion();
void SkipVersion(string version);
}
@@ -1,25 +0,0 @@
using System.Net.WebSockets;
namespace OwnCord.Client.Services;
public interface IWebSocketService
{
bool IsConnected { get; }
WebSocketState State { get; }
/// <summary>Fires for each raw JSON message received.</summary>
event Action<string>? MessageReceived;
/// <summary>Fires when the connection drops unexpectedly, with a reason string.</summary>
event Action<string>? Disconnected;
Task ConnectAsync(string uri, string token, CancellationToken ct = default);
Task SendAsync(object message, CancellationToken ct = default);
/// <summary>Starts the receive loop, firing MessageReceived for each message.
/// Returns when the connection closes.</summary>
Task RunReceiveLoopAsync(CancellationToken ct);
IAsyncEnumerable<string> ReceiveAsync(CancellationToken ct);
Task DisconnectAsync();
}
@@ -1,142 +0,0 @@
using System.Text.RegularExpressions;
namespace OwnCord.Client.Services;
/// <summary>
/// Parses message content into segments for rich rendering.
/// Handles code blocks (```), inline code (`), bold (**), italic (*), and plain text.
/// </summary>
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(
@"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)",
RegexOptions.Compiled);
public static IReadOnlyList<ContentSegment> Parse(string content)
{
if (string.IsNullOrEmpty(content))
return Array.Empty<ContentSegment>();
var segments = new List<ContentSegment>();
ParseCodeBlocks(content, segments);
return segments;
}
private static void ParseCodeBlocks(string text, List<ContentSegment> 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<ContentSegment> 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<ContentSegment> 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<ContentSegment> 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<ContentSegment> segments)
{
if (!string.IsNullOrEmpty(text))
{
segments.Add(new ContentSegment(SegmentType.Text, text));
}
}
}
@@ -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<ServerProfile> LoadProfiles()
{
if (!File.Exists(_path)) return [];
var json = File.ReadAllText(_path);
return JsonSerializer.Deserialize<List<ServerProfile>>(json) ?? [];
}
public IReadOnlyList<ServerProfile> AddProfile(IReadOnlyList<ServerProfile> profiles, ServerProfile profile)
=> [.. profiles, profile];
public IReadOnlyList<ServerProfile> RemoveProfile(IReadOnlyList<ServerProfile> profiles, string id)
=> profiles.Where(p => p.Id != id).ToList();
public IReadOnlyList<ServerProfile> UpdateProfile(IReadOnlyList<ServerProfile> profiles, ServerProfile updated)
=> profiles.Select(p => p.Id == updated.Id ? updated : p).ToList();
public void SaveProfiles(IReadOnlyList<ServerProfile> profiles)
{
Directory.CreateDirectory(dataDir);
File.WriteAllText(_path, JsonSerializer.Serialize(profiles));
}
}
@@ -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();
}
}
@@ -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<UpdateInfo?> 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<GitHubRelease>();
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<UpdateSettings>(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<string> 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<GitHubAsset>? Assets { get; set; }
}
internal class GitHubAsset
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
@@ -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<string>? MessageReceived;
public event Action<string>? 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<string> 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);
}
/// <summary>
/// Extracts "host:port" from a WebSocket URI for use as the trust store key.
/// e.g. "wss://server.local:8443/ws" → "server.local:8443"
/// </summary>
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();
}
-71
View File
@@ -1,71 +0,0 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══ Background ═══ -->
<Color x:Key="BgTertiaryColor">#1e1f22</Color>
<Color x:Key="BgSecondaryColor">#2b2d31</Color>
<Color x:Key="BgPrimaryColor">#313338</Color>
<Color x:Key="BgInputColor">#383a40</Color>
<Color x:Key="BgHoverColor">#35373c</Color>
<Color x:Key="BgActiveColor">#404249</Color>
<Color x:Key="BgOverlayColor">#B3000000</Color>
<SolidColorBrush x:Key="BgTertiary" Color="{StaticResource BgTertiaryColor}"/>
<SolidColorBrush x:Key="BgSecondary" Color="{StaticResource BgSecondaryColor}"/>
<SolidColorBrush x:Key="BgPrimary" Color="{StaticResource BgPrimaryColor}"/>
<SolidColorBrush x:Key="BgInput" Color="{StaticResource BgInputColor}"/>
<SolidColorBrush x:Key="BgHover" Color="{StaticResource BgHoverColor}"/>
<SolidColorBrush x:Key="BgActive" Color="{StaticResource BgActiveColor}"/>
<SolidColorBrush x:Key="BgOverlay" Color="{StaticResource BgOverlayColor}"/>
<!-- ═══ Accent ═══ -->
<Color x:Key="AccentColor">#5865f2</Color>
<Color x:Key="AccentHoverColor">#4752c4</Color>
<Color x:Key="AccentActiveColor">#3c45a5</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="AccentHover" Color="{StaticResource AccentHoverColor}"/>
<SolidColorBrush x:Key="AccentActive" Color="{StaticResource AccentActiveColor}"/>
<!-- ═══ Text ═══ -->
<Color x:Key="TextNormalColor">#dbdee1</Color>
<Color x:Key="TextMutedColor">#949ba4</Color>
<Color x:Key="TextFaintColor">#80848e</Color>
<Color x:Key="TextMicroColor">#6d6f78</Color>
<Color x:Key="TextLinkColor">#00a8fc</Color>
<SolidColorBrush x:Key="TextNormal" Color="{StaticResource TextNormalColor}"/>
<SolidColorBrush x:Key="TextMuted" Color="{StaticResource TextMutedColor}"/>
<SolidColorBrush x:Key="TextFaint" Color="{StaticResource TextFaintColor}"/>
<SolidColorBrush x:Key="TextMicro" Color="{StaticResource TextMicroColor}"/>
<SolidColorBrush x:Key="TextLink" Color="{StaticResource TextLinkColor}"/>
<!-- ═══ Semantic ═══ -->
<Color x:Key="GreenColor">#23a55a</Color>
<Color x:Key="YellowColor">#f0b232</Color>
<Color x:Key="RedColor">#f23f43</Color>
<SolidColorBrush x:Key="Green" Color="{StaticResource GreenColor}"/>
<SolidColorBrush x:Key="Yellow" Color="{StaticResource YellowColor}"/>
<SolidColorBrush x:Key="Red" Color="{StaticResource RedColor}"/>
<!-- ═══ Border ═══ -->
<Color x:Key="BorderColor">#3f4147</Color>
<Color x:Key="BorderStrongColor">#4e5058</Color>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="BorderStrong" Color="{StaticResource BorderStrongColor}"/>
<!-- ═══ Role Colors ═══ -->
<SolidColorBrush x:Key="RoleOwner" Color="#e74c3c"/>
<SolidColorBrush x:Key="RoleAdmin" Color="#f39c12"/>
<SolidColorBrush x:Key="RoleMod" Color="#2ecc71"/>
<SolidColorBrush x:Key="RoleMember" Color="#949ba4"/>
<!-- ═══ Status ═══ -->
<SolidColorBrush x:Key="StatusOnline" Color="{StaticResource GreenColor}"/>
<SolidColorBrush x:Key="StatusIdle" Color="{StaticResource YellowColor}"/>
<SolidColorBrush x:Key="StatusDnd" Color="{StaticResource RedColor}"/>
<SolidColorBrush x:Key="StatusOffline" Color="#6d6f78"/>
</ResourceDictionary>
-285
View File
@@ -1,285 +0,0 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══ TextBox ═══ -->
<Style x:Key="ModernTextBox" TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource BgTertiary}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,10"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextNormal}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource BorderStrong}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ PasswordBox ═══ -->
<Style x:Key="ModernPasswordBox" TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource BgTertiary}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,10"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextNormal}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource BorderStrong}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Primary Button ═══ -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Accent}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="16,12">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentActive}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BorderStrong}"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Ghost / Secondary Button ═══ -->
<Style x:Key="GhostButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#b5bac1"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="10,6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgSecondary}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource BorderStrong}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Danger Button ═══ -->
<Style x:Key="DangerButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="6,4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#da373c"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Edit Button ═══ -->
<Style x:Key="EditButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="6,4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgActive}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Link Button ═══ -->
<Style x:Key="LinkButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextLink}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<TextBlock x:Name="Tb" Text="{TemplateBinding Content}"
Foreground="{TemplateBinding Foreground}"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Tb" Property="TextDecorations" Value="Underline"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Modern CheckBox ═══ -->
<Style x:Key="ModernCheckBox" TargetType="CheckBox">
<Setter Property="Foreground" Value="#b5bac1"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Border x:Name="CheckBorder" Width="18" Height="18"
CornerRadius="4" BorderThickness="2"
BorderBrush="{StaticResource BorderStrong}" Background="Transparent"
VerticalAlignment="Center" Margin="0,0,8,0">
<TextBlock x:Name="CheckMark" Text="&#x2713;"
FontSize="12" FontWeight="Bold"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="CheckBorder" Property="Background" Value="{StaticResource Accent}"/>
<Setter TargetName="CheckBorder" Property="BorderBrush" Value="{StaticResource Accent}"/>
<Setter TargetName="CheckMark" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="CheckBorder" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Small Icon Button ═══ -->
<Style x:Key="SmallIconButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextFaint}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="8,5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgActive}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══ Header Tool Button (chat header icons) ═══ -->
<Style x:Key="HeaderToolButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource BgHover}"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -1,50 +0,0 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══ Font Families ═══ -->
<FontFamily x:Key="FontDisplay">Segoe UI Variable Display, Segoe UI, Segoe UI Symbol</FontFamily>
<FontFamily x:Key="FontBody">Segoe UI Variable Text, Segoe UI, Segoe UI Symbol</FontFamily>
<FontFamily x:Key="FontMono">Cascadia Code, Consolas, Courier New</FontFamily>
<!-- ═══ Text Styles ═══ -->
<Style x:Key="HeadingLarge" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontDisplay}"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Style>
<Style x:Key="HeadingMedium" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontDisplay}"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Style>
<Style x:Key="BodyNormal" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Style>
<Style x:Key="BodySmall" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
</Style>
<!-- Note: WPF has no TextTransform — use CharacterCasing on TextBox or uppercase text in code -->
<Style x:Key="Caption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontBody}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{StaticResource TextMuted}"/>
</Style>
<Style x:Key="MonoText" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource FontMono}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource TextNormal}"/>
</Style>
</ResourceDictionary>
@@ -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<string, string> _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<ServerProfile>(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<ServerProfile>(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();
}
}
/// <summary>Raised when a saved password is loaded so the view can set the PasswordBox.</summary>
public event Action<string?>? PasswordLoaded;
public ObservableCollection<ServerProfile> 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; }
/// <summary>Gets the health status string for a given profile ID.</summary>
public string GetHealthStatus(string profileId)
=> _healthStatuses.TryGetValue(profileId, out var s) ? s : "unknown";
/// <summary>Pings every saved server's health endpoint in parallel and updates statuses.</summary>
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();
}
/// <summary>Raised when any health status changes so the view can refresh bindings.</summary>
public event Action? HealthStatusChanged;
/// <summary>Args: host, username, password, inviteCode?, isRegister</summary>
public event Action<string, string, string, string?, bool>? ConnectRequested;
/// <summary>Args: host, partialToken, totpCode</summary>
public event Action<string, string, string>? TotpVerifyRequested;
/// <summary>Raised to open the add/edit server profile dialog. Arg: profile to edit (null = add new).</summary>
public event Action<ServerProfile?>? EditProfileRequested;
/// <summary>
/// Called when login returns requires_2fa. Sets up the TOTP entry UI state.
/// </summary>
public void Enter2FAMode(string partialToken)
{
PartialToken = partialToken;
IsTotpRequired = true;
TotpCode = string.Empty;
ErrorMessage = null;
}
/// <summary>Applies a saved or new profile from the dialog.</summary>
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<List<ServerProfile>>(json);
if (imported is null || imported.Count == 0) return;
var current = Profiles.ToList();
var existingHosts = new HashSet<string>(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}";
}
}
/// <summary>Persist or remove the saved password based on the checkbox state.</summary>
public void PersistPasswordIfRequested(string host, string username, string password)
{
if (SavePassword)
_credentials.SavePassword(host, username, password);
else
_credentials.DeletePassword(host, username);
}
/// <summary>Updates the LastConnected timestamp on the matching profile.</summary>
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();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
using System.Windows.Input;
namespace OwnCord.Client.ViewModels;
public sealed class RelayCommand(Action execute, Func<bool>? 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<T>(Action<T?> execute, Func<T?, bool>? 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);
}
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More