diff --git a/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs b/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs new file mode 100644 index 00000000..dabfef32 --- /dev/null +++ b/Client/OwnCord.Client.Tests/Converters/ConverterTests.cs @@ -0,0 +1,529 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Media; +using OwnCord.Client.Converters; +using OwnCord.Client.Models; + +namespace OwnCord.Client.Tests.Converters; + +public class FirstCharConverterTests +{ + private readonly FirstCharConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Theory] + [InlineData("hello", "H")] + [InlineData("World", "W")] + [InlineData("a", "A")] + [InlineData("123", "1")] + public void Convert_ReturnsFirstCharUppercased(string input, string expected) + { + var result = _converter.Convert(input, typeof(string), null!, _culture); + Assert.Equal(expected, result); + } + + [Fact] + public void Convert_EmptyString_ReturnsQuestionMark() + { + var result = _converter.Convert("", typeof(string), null!, _culture); + Assert.Equal("?", result); + } + + [Fact] + public void Convert_Null_ReturnsQuestionMark() + { + var result = _converter.Convert(null, typeof(string), null!, _culture); + Assert.Equal("?", result); + } + + [Fact] + public void Convert_NonString_ReturnsQuestionMark() + { + var result = _converter.Convert(42, typeof(string), null!, _culture); + Assert.Equal("?", result); + } + + [Fact] + public void ConvertBack_ThrowsNotSupported() + { + Assert.Throws(() => + _converter.ConvertBack("H", typeof(string), null!, _culture)); + } +} + +public class FirstLetterConverterTests +{ + private readonly FirstLetterConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Theory] + [InlineData("alice", "A")] + [InlineData("Bob", "B")] + public void Convert_ReturnsFirstLetterUppercased(string input, string expected) + { + var result = _converter.Convert(input, typeof(string), null!, _culture); + Assert.Equal(expected, result); + } + + [Fact] + public void Convert_EmptyString_ReturnsQuestionMark() + { + var result = _converter.Convert("", typeof(string), null!, _culture); + Assert.Equal("?", result); + } + + [Fact] + public void Convert_Null_ReturnsQuestionMark() + { + var result = _converter.Convert(null, typeof(string), null!, _culture); + Assert.Equal("?", result); + } +} + +public class RelativeTimeConverterTests +{ + private readonly RelativeTimeConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_JustNow_WithinLastMinute() + { + var dt = DateTime.UtcNow.AddSeconds(-30); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + Assert.Equal("just now", result); + } + + [Fact] + public void Convert_MinutesAgo() + { + var dt = DateTime.UtcNow.AddMinutes(-15); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + Assert.Equal("15m ago", result); + } + + [Fact] + public void Convert_HoursAgo() + { + var dt = DateTime.UtcNow.AddHours(-3); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + Assert.Equal("3h ago", result); + } + + [Fact] + public void Convert_Yesterday() + { + var dt = DateTime.UtcNow.AddHours(-30); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + Assert.Equal("yesterday", result); + } + + [Fact] + public void Convert_DaysAgo() + { + var dt = DateTime.UtcNow.AddDays(-4); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + Assert.Equal("4d ago", result); + } + + [Fact] + public void Convert_OlderThanWeek_ReturnsFormattedDate() + { + var dt = DateTime.UtcNow.AddDays(-30); + var result = (string)_converter.Convert(dt, typeof(string), null!, _culture); + // Should be formatted like "Feb 13" (month abbrev + day) + Assert.Matches(@"^[A-Z][a-z]{2} \d{1,2}$", result); + } + + [Fact] + public void Convert_NonDateTime_ReturnsNever() + { + var result = _converter.Convert("not a date", typeof(string), null!, _culture); + Assert.Equal("never", result); + } + + [Fact] + public void Convert_Null_ReturnsNever() + { + var result = _converter.Convert(null, typeof(string), null!, _culture); + Assert.Equal("never", result); + } + + [Fact] + public void ConvertBack_ThrowsNotSupported() + { + Assert.Throws(() => + _converter.ConvertBack("just now", typeof(DateTime?), null!, _culture)); + } +} + +public class ColorToBrushConverterTests +{ + private readonly ColorToBrushConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_ValidHex_ReturnsBrush() + { + var result = _converter.Convert("#ff0000", typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Colors.Red, brush.Color); + } + + [Fact] + public void Convert_Null_ReturnsFallbackBlurple() + { + var result = _converter.Convert(null, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); + } + + [Fact] + public void Convert_ShortString_ReturnsFallback() + { + var result = _converter.Convert("#fff", typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); + } + + [Fact] + public void Convert_InvalidHex_ReturnsFallback() + { + var result = _converter.Convert("#zzzzzz", typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Color.FromRgb(0x58, 0x65, 0xF2), brush.Color); + } + + [Fact] + public void ConvertBack_ThrowsNotSupported() + { + Assert.Throws(() => + _converter.ConvertBack(new SolidColorBrush(), typeof(string), null!, _culture)); + } +} + +public class HexColorToBrushConverterTests +{ + private readonly HexColorToBrushConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_ValidHex_ReturnsBrush() + { + var result = _converter.Convert("#00ff00", typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Color.FromRgb(0, 255, 0), brush.Color); + } + + [Fact] + public void Convert_NoHashPrefix_ReturnsFallback() + { + var result = _converter.Convert("ff0000", typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + // Fallback is #949ba4 + Assert.Equal(Color.FromRgb(0x94, 0x9B, 0xA4), brush.Color); + } + + [Fact] + public void Convert_Null_ReturnsFallback() + { + var result = _converter.Convert(null, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Color.FromRgb(0x94, 0x9B, 0xA4), brush.Color); + } +} + +public class HostPortConverterTests +{ + private readonly HostPortConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_DefaultPort_ReturnsHostOnly() + { + var result = _converter.Convert( + new object[] { "example.com", 8443 }, typeof(string), null!, _culture); + Assert.Equal("example.com", result); + } + + [Fact] + public void Convert_CustomPort_ReturnsHostColon() + { + var result = _converter.Convert( + new object[] { "example.com", 9090 }, typeof(string), null!, _culture); + Assert.Equal("example.com:9090", result); + } + + [Fact] + public void Convert_NullHost_ReturnsEmptyWithPort() + { + var result = _converter.Convert( + new object[] { null!, 9090 }, typeof(string), null!, _culture); + Assert.Equal(":9090", result); + } + + [Fact] + public void Convert_SingleValue_DefaultsPort8443() + { + var result = _converter.Convert( + new object[] { "example.com" }, typeof(string), null!, _culture); + Assert.Equal("example.com", result); + } + + [Fact] + public void ConvertBack_ThrowsNotSupported() + { + Assert.Throws(() => + _converter.ConvertBack("x", new[] { typeof(string) }, null!, _culture)); + } +} + +public class BoolToVisibilityConverterTests +{ + private readonly BoolToVisibilityConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsVisible() + { + var result = _converter.Convert(true, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Visible, result); + } + + [Fact] + public void Convert_False_ReturnsCollapsed() + { + var result = _converter.Convert(false, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } + + [Fact] + public void ConvertBack_Visible_ReturnsTrue() + { + var result = _converter.ConvertBack(Visibility.Visible, typeof(bool), null!, _culture); + Assert.Equal(true, result); + } + + [Fact] + public void ConvertBack_Collapsed_ReturnsFalse() + { + var result = _converter.ConvertBack(Visibility.Collapsed, typeof(bool), null!, _culture); + Assert.Equal(false, result); + } +} + +public class InverseBoolToVisibilityConverterTests +{ + private readonly InverseBoolToVisibilityConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsCollapsed() + { + var result = _converter.Convert(true, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } + + [Fact] + public void Convert_False_ReturnsVisible() + { + var result = _converter.Convert(false, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Visible, result); + } + + [Fact] + public void ConvertBack_Collapsed_ReturnsTrue() + { + var result = _converter.ConvertBack(Visibility.Collapsed, typeof(bool), null!, _culture); + Assert.Equal(true, result); + } +} + +public class IntToVisibilityConverterTests +{ + private readonly IntToVisibilityConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_PositiveInt_ReturnsVisible() + { + var result = _converter.Convert(5, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Visible, result); + } + + [Fact] + public void Convert_Zero_ReturnsCollapsed() + { + var result = _converter.Convert(0, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } + + [Fact] + public void Convert_NegativeInt_ReturnsCollapsed() + { + var result = _converter.Convert(-1, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } + + [Fact] + public void Convert_NonInt_ReturnsCollapsed() + { + var result = _converter.Convert("not an int", typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } +} + +public class NullToVisibilityConverterTests +{ + private readonly NullToVisibilityConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_NonNull_ReturnsVisible() + { + var result = _converter.Convert("something", typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Visible, result); + } + + [Fact] + public void Convert_Null_ReturnsCollapsed() + { + var result = _converter.Convert(null, typeof(Visibility), null!, _culture); + Assert.Equal(Visibility.Collapsed, result); + } +} + +public class InverseBoolConverterTests +{ + private readonly InverseBoolConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsFalse() + { + var result = _converter.Convert(true, typeof(bool), null!, _culture); + Assert.Equal(false, result); + } + + [Fact] + public void Convert_False_ReturnsTrue() + { + var result = _converter.Convert(false, typeof(bool), null!, _culture); + Assert.Equal(true, result); + } + + [Fact] + public void ConvertBack_True_ReturnsFalse() + { + var result = _converter.ConvertBack(true, typeof(bool), null!, _culture); + Assert.Equal(false, result); + } +} + +public class StatusToBrushConverterTests +{ + private readonly StatusToBrushConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_Online_ReturnsGreen() + { + var result = _converter.Convert(UserStatus.Online, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#23a55a"), brush.Color); + } + + [Fact] + public void Convert_Idle_ReturnsYellow() + { + var result = _converter.Convert(UserStatus.Idle, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#f0b232"), brush.Color); + } + + [Fact] + public void Convert_Dnd_ReturnsRed() + { + var result = _converter.Convert(UserStatus.Dnd, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#f23f43"), brush.Color); + } + + [Fact] + public void Convert_Offline_ReturnsGray() + { + var result = _converter.Convert(UserStatus.Offline, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#6d6f78"), brush.Color); + } +} + +public class BoolToRedBrushConverterTests +{ + private readonly BoolToRedBrushConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsRed() + { + var result = _converter.Convert(true, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#f23f43"), brush.Color); + } + + [Fact] + public void Convert_False_ReturnsNormal() + { + var result = _converter.Convert(false, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#b5bac1"), brush.Color); + } +} + +public class SpeakingToStrokeBrushConverterTests +{ + private readonly SpeakingToStrokeBrushConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsGreen() + { + var result = _converter.Convert(true, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal((Color)ColorConverter.ConvertFromString("#23a55a"), brush.Color); + } + + [Fact] + public void Convert_False_ReturnsTransparent() + { + var result = _converter.Convert(false, typeof(SolidColorBrush), null!, _culture); + var brush = Assert.IsType(result); + Assert.Equal(Colors.Transparent, brush.Color); + } +} + +public class BoolToArrowConverterTests +{ + private readonly BoolToArrowConverter _converter = new(); + private readonly CultureInfo _culture = CultureInfo.InvariantCulture; + + [Fact] + public void Convert_True_ReturnsDownArrow() + { + var result = _converter.Convert(true, typeof(string), null!, _culture); + Assert.Equal("\u25BE", result); // ▾ + } + + [Fact] + public void Convert_False_ReturnsRightArrow() + { + var result = _converter.Convert(false, typeof(string), null!, _culture); + Assert.Equal("\u25B8", result); // ▸ + } + + [Fact] + public void ConvertBack_ThrowsNotSupported() + { + Assert.Throws(() => + _converter.ConvertBack("▾", typeof(bool), null!, _culture)); + } +} diff --git a/Client/OwnCord.Client.Tests/Models/ModelTests.cs b/Client/OwnCord.Client.Tests/Models/ModelTests.cs new file mode 100644 index 00000000..752b2707 --- /dev/null +++ b/Client/OwnCord.Client.Tests/Models/ModelTests.cs @@ -0,0 +1,524 @@ +using OwnCord.Client.Models; + +namespace OwnCord.Client.Tests.Models; + +// ── ServerProfile Tests ───────────────────────────────────────────────────── + +public class ServerProfileTests +{ + [Fact] + public void Create_GeneratesUniqueId() + { + var p = ServerProfile.Create("Home", "localhost"); + Assert.False(string.IsNullOrEmpty(p.Id)); + } + + [Fact] + public void Create_TwoCallsProduceDifferentIds() + { + var a = ServerProfile.Create("A", "a.local"); + var b = ServerProfile.Create("B", "b.local"); + Assert.NotEqual(a.Id, b.Id); + } + + [Fact] + public void Create_StoresNameHostUsername() + { + var p = ServerProfile.Create("Home", "192.168.1.1", "alice"); + Assert.Equal("Home", p.Name); + Assert.Equal("192.168.1.1", p.Host); + Assert.Equal("alice", p.LastUsername); + } + + [Fact] + public void Create_DefaultsPortTo8443() + { + var p = ServerProfile.Create("Home", "localhost"); + Assert.Equal(8443, p.Port); + } + + [Fact] + public void Create_DefaultsColorToAccent() + { + var p = ServerProfile.Create("Home", "localhost"); + Assert.Equal("#5865f2", p.Color); + } + + [Fact] + public void Create_CustomPortAndColor() + { + var p = ServerProfile.Create("Home", "localhost", port: 9443, color: "#ff0000"); + Assert.Equal(9443, p.Port); + Assert.Equal("#ff0000", p.Color); + } + + [Fact] + public void Create_AutoConnectDefaultsFalse() + { + var p = ServerProfile.Create("Home", "localhost"); + Assert.False(p.AutoConnect); + } + + [Fact] + public void Create_LastConnectedIsNull() + { + var p = ServerProfile.Create("Home", "localhost"); + Assert.Null(p.LastConnected); + } + + [Fact] + public void HostDisplay_OmitsDefaultPort() + { + var p = ServerProfile.Create("Home", "192.168.1.1", port: 8443); + Assert.Equal("192.168.1.1", p.HostDisplay); + } + + [Fact] + public void HostDisplay_IncludesNonDefaultPort() + { + var p = ServerProfile.Create("Home", "192.168.1.1", port: 9443); + Assert.Equal("192.168.1.1:9443", p.HostDisplay); + } + + [Fact] + public void WithExpression_CreatesNewInstance() + { + var original = ServerProfile.Create("Old", "old.local"); + var updated = original with { Name = "New" }; + Assert.Equal("New", updated.Name); + Assert.Equal("Old", original.Name); + Assert.Equal(original.Id, updated.Id); + } +} + +// ── MessageDisplayItem Tests ──────────────────────────────────────────────── + +public class MessageDisplayItemTests +{ + private static User Alice => new(1, "alice", null, 1, UserStatus.Online); + private static User Bob => new(2, "bob", null, 1, UserStatus.Online); + + private static Message Msg(long id, User author, DateTime ts, long? replyTo = null) + => new(id, 1, author, "hello", ts, replyTo, null, false, [], []); + + [Fact] + public void FirstMessage_NotGrouped() + { + var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); + Assert.False(item.IsGrouped); + } + + [Fact] + public void FirstMessage_ShowsDayDivider() + { + var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); + Assert.True(item.ShowDayDivider); + } + + [Fact] + public void SameAuthor_Within7Min_IsGrouped() + { + var ts = DateTime.Today.AddHours(10); + var prev = Msg(1, Alice, ts); + var curr = Msg(2, Alice, ts.AddMinutes(3)); + var item = new MessageDisplayItem(curr, prev); + Assert.True(item.IsGrouped); + } + + [Fact] + public void SameAuthor_Over7Min_NotGrouped() + { + var ts = DateTime.Today.AddHours(10); + var prev = Msg(1, Alice, ts); + var curr = Msg(2, Alice, ts.AddMinutes(8)); + var item = new MessageDisplayItem(curr, prev); + Assert.False(item.IsGrouped); + } + + [Fact] + public void DifferentAuthor_NotGrouped() + { + var ts = DateTime.Today.AddHours(10); + var prev = Msg(1, Alice, ts); + var curr = Msg(2, Bob, ts.AddMinutes(1)); + var item = new MessageDisplayItem(curr, prev); + Assert.False(item.IsGrouped); + } + + [Fact] + public void Reply_BreaksGrouping() + { + var ts = DateTime.Today.AddHours(10); + var prev = Msg(1, Alice, ts); + var curr = Msg(2, Alice, ts.AddMinutes(1), replyTo: 99); + var item = new MessageDisplayItem(curr, prev); + Assert.False(item.IsGrouped); + } + + [Fact] + public void DifferentDay_ShowsDayDivider() + { + var prev = Msg(1, Alice, DateTime.Today.AddDays(-1).AddHours(23)); + var curr = Msg(2, Alice, DateTime.Today.AddHours(0)); + var item = new MessageDisplayItem(curr, prev); + Assert.True(item.ShowDayDivider); + Assert.False(item.IsGrouped); + } + + [Fact] + public void Today_DayDividerText_SaysToday() + { + var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); + Assert.Equal("Today", item.DayDividerText); + } + + [Fact] + public void Yesterday_DayDividerText_SaysYesterday() + { + var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddDays(-1).AddHours(10)), null); + Assert.Equal("Yesterday", item.DayDividerText); + } + + [Fact] + public void OlderDate_DayDividerText_FormatsDate() + { + var date = new DateTime(2026, 1, 15, 10, 0, 0); + var item = new MessageDisplayItem(Msg(1, Alice, date), null); + Assert.Contains("January", item.DayDividerText); + Assert.Contains("15", item.DayDividerText); + Assert.Contains("2026", item.DayDividerText); + } + + [Fact] + public void SameDay_NoDayDivider() + { + var ts = DateTime.Today.AddHours(10); + var prev = Msg(1, Alice, ts); + var curr = Msg(2, Bob, ts.AddHours(1)); + var item = new MessageDisplayItem(curr, prev); + Assert.False(item.ShowDayDivider); + Assert.Null(item.DayDividerText); + } + + [Fact] + public void PassThroughProperties_MatchMessage() + { + var msg = new Message(42, 1, Alice, "test content", DateTime.UtcNow, 10, "2026-01-01", false, [new Reaction("\ud83d\udc4d", 3, true)], []); + var item = new MessageDisplayItem(msg, null); + Assert.Equal(42, item.Id); + Assert.Equal(Alice, item.Author); + Assert.Equal("test content", item.Content); + Assert.Equal(10, item.ReplyToId); + Assert.Equal("2026-01-01", item.EditedAt); + Assert.True(item.IsEdited); + Assert.True(item.HasReactions); + Assert.Single(item.Reactions); + } + + [Fact] + public void IsReply_TrueWhenBothIdAndMessageSet() + { + var reply = Msg(2, Alice, DateTime.Today.AddHours(10), replyTo: 1); + var replyTarget = Msg(1, Bob, DateTime.Today.AddHours(9)); + var item = new MessageDisplayItem(reply, null) { ReplyToMessage = replyTarget }; + Assert.True(item.IsReply); + } + + [Fact] + public void IsReply_FalseWhenNoReplyTo() + { + var item = new MessageDisplayItem(Msg(1, Alice, DateTime.Today.AddHours(10)), null); + Assert.False(item.IsReply); + } + + [Fact] + public void IsReply_FalseWhenReplyToIdButNoMessage() + { + var reply = Msg(2, Alice, DateTime.Today.AddHours(10), replyTo: 1); + var item = new MessageDisplayItem(reply, null); + Assert.False(item.IsReply); + } +} + +// ── ChannelGroup Tests ────────────────────────────────────────────────────── + +public class ChannelGroupTests +{ + [Fact] + public void HasCategory_TrueWhenSet() + { + var group = new ChannelGroup { CategoryName = "Text Channels" }; + Assert.True(group.HasCategory); + } + + [Fact] + public void HasCategory_FalseWhenNull() + { + var group = new ChannelGroup { CategoryName = null }; + Assert.False(group.HasCategory); + } + + [Fact] + public void DisplayName_UppercaseCategory() + { + var group = new ChannelGroup { CategoryName = "Text Channels" }; + Assert.Equal("TEXT CHANNELS", group.DisplayName); + } + + [Fact] + public void DisplayName_EmptyWhenNoCategory() + { + var group = new ChannelGroup { CategoryName = null }; + Assert.Equal(string.Empty, group.DisplayName); + } + + [Fact] + public void IsExpanded_DefaultsTrue() + { + var group = new ChannelGroup(); + Assert.True(group.IsExpanded); + } + + [Fact] + public void IsExpanded_RaisesPropertyChanged() + { + var group = new ChannelGroup(); + string? changed = null; + group.PropertyChanged += (_, e) => changed = e.PropertyName; + group.IsExpanded = false; + Assert.Equal("IsExpanded", changed); + } + + [Fact] + public void IsExpanded_SameValue_NoEvent() + { + var group = new ChannelGroup(); + string? changed = null; + group.PropertyChanged += (_, e) => changed = e.PropertyName; + group.IsExpanded = true; // same as default + Assert.Null(changed); + } + + [Fact] + public void Items_InitializedEmpty() + { + var group = new ChannelGroup(); + Assert.Empty(group.Items); + } +} + +// ── ChannelItem Tests ─────────────────────────────────────────────────────── + +public class ChannelItemTests +{ + private static Channel TextChannel => new(1, "general", ChannelType.Text, "Chat", 0, 3, null, "Welcome"); + private static Channel VoiceChannel => new(2, "Lounge", ChannelType.Voice, "Voice", 1, 0, null); + + [Fact] + public void PassThrough_Id() => Assert.Equal(1, new ChannelItem { Channel = TextChannel }.Id); + + [Fact] + public void PassThrough_Name() => Assert.Equal("general", new ChannelItem { Channel = TextChannel }.Name); + + [Fact] + public void PassThrough_Type() => Assert.Equal(ChannelType.Text, new ChannelItem { Channel = TextChannel }.Type); + + [Fact] + public void PassThrough_UnreadCount() => Assert.Equal(3, new ChannelItem { Channel = TextChannel }.UnreadCount); + + [Fact] + public void PassThrough_Topic() => Assert.Equal("Welcome", new ChannelItem { Channel = TextChannel }.Topic); + + [Fact] + public void VoiceUsers_InitializedEmpty() + { + var item = new ChannelItem { Channel = VoiceChannel }; + Assert.Empty(item.VoiceUsers); + } + + [Fact] + public void VoiceUsers_CanAddState() + { + var item = new ChannelItem { Channel = VoiceChannel }; + item.VoiceUsers.Add(new VoiceStateInfo { UserId = 1, ChannelId = 2, Username = "alice" }); + Assert.Single(item.VoiceUsers); + } +} + +// ── MemberGroup Tests ─────────────────────────────────────────────────────── + +public class MemberGroupTests +{ + [Fact] + public void Members_InitializedEmpty() + { + var mg = new MemberGroup(); + Assert.Empty(mg.Members); + } + + [Fact] + public void MemberCount_ReflectsCollection() + { + var mg = new MemberGroup { RoleName = "Admin" }; + mg.Members.Add(new User(1, "alice", null, 1, UserStatus.Online)); + mg.Members.Add(new User(2, "bob", null, 1, UserStatus.Online)); + Assert.Equal(2, mg.MemberCount); + } + + [Fact] + public void Properties_StoreValues() + { + var mg = new MemberGroup { RoleName = "Owner", RoleColor = "#e74c3c", Position = 100 }; + Assert.Equal("Owner", mg.RoleName); + Assert.Equal("#e74c3c", mg.RoleColor); + Assert.Equal(100, mg.Position); + } +} + +// ── VoiceStateInfo Tests ──────────────────────────────────────────────────── + +public class VoiceStateInfoTests +{ + [Fact] + public void Muted_RaisesPropertyChanged() + { + var vs = new VoiceStateInfo { UserId = 1 }; + string? changed = null; + vs.PropertyChanged += (_, e) => changed = e.PropertyName; + vs.Muted = true; + Assert.Equal("Muted", changed); + } + + [Fact] + public void Deafened_RaisesPropertyChanged() + { + var vs = new VoiceStateInfo { UserId = 1 }; + string? changed = null; + vs.PropertyChanged += (_, e) => changed = e.PropertyName; + vs.Deafened = true; + Assert.Equal("Deafened", changed); + } + + [Fact] + public void Speaking_RaisesPropertyChanged() + { + var vs = new VoiceStateInfo { UserId = 1 }; + string? changed = null; + vs.PropertyChanged += (_, e) => changed = e.PropertyName; + vs.Speaking = true; + Assert.Equal("Speaking", changed); + } + + [Fact] + public void SameValue_NoEvent() + { + var vs = new VoiceStateInfo { UserId = 1 }; + string? changed = null; + vs.PropertyChanged += (_, e) => changed = e.PropertyName; + vs.Muted = false; // default is false + Assert.Null(changed); + } + + [Fact] + public void ChannelId_IsMutable() + { + var vs = new VoiceStateInfo { UserId = 1, ChannelId = 10 }; + vs.ChannelId = 20; + Assert.Equal(20, vs.ChannelId); + } +} + +// ── Channel Record Tests ──────────────────────────────────────────────────── + +public class ChannelRecordTests +{ + [Fact] + public void WithExpression_CreatesNewInstance() + { + var original = new Channel(1, "general", ChannelType.Text, "Chat", 0, 0, null); + var updated = original with { UnreadCount = 5 }; + Assert.Equal(5, updated.UnreadCount); + Assert.Equal(0, original.UnreadCount); + } + + [Fact] + public void Topic_DefaultsToNull() + { + var ch = new Channel(1, "general", ChannelType.Text, "Chat", 0, 0, null); + Assert.Null(ch.Topic); + } + + [Fact] + public void ChannelType_EnumValues() + { + Assert.Equal(ChannelType.Text, new Channel(1, "g", ChannelType.Text, null, 0, 0, null).Type); + Assert.Equal(ChannelType.Voice, new Channel(2, "v", ChannelType.Voice, null, 0, 0, null).Type); + Assert.Equal(ChannelType.Announcement, new Channel(3, "a", ChannelType.Announcement, null, 0, 0, null).Type); + } +} + +// ── User Record Tests ─────────────────────────────────────────────────────── + +public class UserRecordTests +{ + [Fact] + public void WithExpression_CreatesNewInstance() + { + var original = new User(1, "alice", null, 1, UserStatus.Online); + var updated = original with { Status = UserStatus.Dnd }; + Assert.Equal(UserStatus.Dnd, updated.Status); + Assert.Equal(UserStatus.Online, original.Status); + } + + [Fact] + public void UserStatus_AllValues() + { + Assert.Equal(4, Enum.GetValues().Length); + } +} + +// ── Message Record Tests ──────────────────────────────────────────────────── + +public class MessageRecordTests +{ + [Fact] + public void WithExpression_EditedAt() + { + var msg = new Message(1, 1, new User(1, "alice", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); + var edited = msg with { Content = "edited", EditedAt = "2026-01-01T00:00:00Z" }; + Assert.Equal("edited", edited.Content); + Assert.NotNull(edited.EditedAt); + Assert.Null(msg.EditedAt); + } + + [Fact] + public void WithExpression_Deleted() + { + var msg = new Message(1, 1, new User(1, "alice", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); + var deleted = msg with { Deleted = true, Content = "[deleted]" }; + Assert.True(deleted.Deleted); + Assert.Equal("[deleted]", deleted.Content); + Assert.False(msg.Deleted); + } + + [Fact] + public void Reactions_EmptyByDefault() + { + var msg = new Message(1, 1, new User(1, "a", null, 1, UserStatus.Online), "hi", DateTime.UtcNow, null, null, false, [], []); + Assert.Empty(msg.Reactions); + } +} + +// ── Reaction Record Tests ─────────────────────────────────────────────────── + +public class ReactionRecordTests +{ + [Fact] + public void Stores_Values() + { + var r = new Reaction("👍", 3, true); + Assert.Equal("👍", r.Emoji); + Assert.Equal(3, r.Count); + Assert.True(r.Me); + } +} diff --git a/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj b/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj index 56252c0a..d1545866 100644 --- a/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj +++ b/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj @@ -7,6 +7,7 @@ false true + true @@ -21,6 +22,10 @@ + + + + diff --git a/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs b/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs index 361e374f..46897147 100644 --- a/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs +++ b/Client/OwnCord.Client.Tests/Services/ApiClientTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http; using System.Text.Json; using OwnCord.Client.Models; using OwnCord.Client.Services; diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs index c8095466..af8d2de3 100644 --- a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs +++ b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs @@ -49,6 +49,11 @@ public class FakeApiClient : IApiClient public Task HealthCheckAsync(string host, CancellationToken ct) => Task.FromResult(HealthResult ?? throw new InvalidOperationException("HealthResult not set")); + + public AuthResponse? VerifyTotpResult { get; set; } + + public Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct) + => Task.FromResult(VerifyTotpResult ?? throw new InvalidOperationException("VerifyTotpResult not set")); } public class FakeWebSocketService : IWebSocketService @@ -130,7 +135,7 @@ public class ChatServiceTests var result = await svc.LoginAsync("localhost:8443", "alice", "password"); Assert.Equal("tok_abc", result.Token); - Assert.Equal("alice", result.User.Username); + Assert.Equal("alice", result.User!.Username); Assert.Equal("tok_abc", svc.CurrentToken); Assert.Equal("alice", svc.CurrentUser?.Username); Assert.Equal("localhost:8443", _api.LastLoginHost); diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs new file mode 100644 index 00000000..26d199f4 --- /dev/null +++ b/Client/OwnCord.Client.Tests/Services/ChatServiceVoiceTests.cs @@ -0,0 +1,441 @@ +using System.Text.Json; +using OwnCord.Client.Models; +using OwnCord.Client.Services; + +namespace OwnCord.Client.Tests.Services; + +/// Tests for ChatService voice commands and additional dispatch events. +public class ChatServiceVoiceTests +{ + private static readonly ApiUser TestUser = new(1, "alice", null, "online", 1, "2026-01-01T00:00:00Z"); + private static readonly AuthResponse TestAuthResponse = new("tok_abc", TestUser); + + private readonly FakeApiClient _api = new(); + private readonly FakeWebSocketService _ws = new(); + + private ChatService CreateService() => new(_api, _ws); + + private async Task CreateConnectedService() + { + var svc = CreateService(); + await svc.ConnectWebSocketAsync("host:8443", "tok"); + return svc; + } + + // ── Voice outbound commands ────────────────────────────────────────── + + [Fact] + public async Task JoinVoiceAsync_SendsCorrectEnvelope() + { + var svc = await CreateConnectedService(); + + await svc.JoinVoiceAsync(42); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("voice_join", sent.RootElement.GetProperty("type").GetString()); + Assert.Equal(42, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); + } + + [Fact] + public async Task LeaveVoiceAsync_SendsCorrectEnvelope() + { + var svc = await CreateConnectedService(); + + await svc.LeaveVoiceAsync(); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("voice_leave", sent.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task SendVoiceMuteAsync_SendsCorrectEnvelope() + { + var svc = await CreateConnectedService(); + + await svc.SendVoiceMuteAsync(true); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("voice_mute", sent.RootElement.GetProperty("type").GetString()); + Assert.True(sent.RootElement.GetProperty("payload").GetProperty("muted").GetBoolean()); + } + + [Fact] + public async Task SendVoiceDeafenAsync_SendsCorrectEnvelope() + { + var svc = await CreateConnectedService(); + + await svc.SendVoiceDeafenAsync(true); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("voice_deafen", sent.RootElement.GetProperty("type").GetString()); + Assert.True(sent.RootElement.GetProperty("payload").GetProperty("deafened").GetBoolean()); + } + + [Fact] + public async Task SendChannelFocusAsync_SendsCorrectEnvelope() + { + var svc = await CreateConnectedService(); + + await svc.SendChannelFocusAsync(7); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("channel_focus", sent.RootElement.GetProperty("type").GetString()); + Assert.Equal(7, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); + } + + // ── Voice inbound events ───────────────────────────────────────────── + + [Fact] + public async Task Dispatches_VoiceState_Event() + { + var svc = await CreateConnectedService(); + VoiceStatePayload? received = null; + svc.VoiceStateReceived += p => received = p; + + _ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": true, "deafened": false } }"""); + + Assert.NotNull(received); + Assert.Equal(1, received!.UserId); + Assert.Equal(5, received.ChannelId); + Assert.True(received.Muted); + Assert.False(received.Deafened); + } + + [Fact] + public async Task Dispatches_VoiceLeave_Event() + { + var svc = await CreateConnectedService(); + VoiceLeavePayload? received = null; + svc.VoiceLeaveReceived += p => received = p; + + _ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 2, "channel_id": 5 } }"""); + + Assert.NotNull(received); + Assert.Equal(2, received!.UserId); + Assert.Equal(5, received.ChannelId); + } + + [Fact] + public async Task Dispatches_VoiceConfig_Event() + { + var svc = await CreateConnectedService(); + VoiceConfigPayload? received = null; + svc.VoiceConfigReceived += p => received = p; + + _ws.SimulateMessage("""{ "type": "voice_config", "payload": { "channel_id": 5, "quality": "high", "bitrate": 128000, "mode": "sfu" } }"""); + + Assert.NotNull(received); + Assert.Equal("high", received!.Quality); + Assert.Equal(128000, received.Bitrate); + } + + [Fact] + public async Task Dispatches_VoiceSpeakers_Event() + { + var svc = await CreateConnectedService(); + VoiceSpeakersPayload? received = null; + svc.VoiceSpeakersReceived += p => received = p; + + _ws.SimulateMessage("""{ "type": "voice_speakers", "payload": { "channel_id": 5, "speakers": [1, 3], "mode": "sfu" } }"""); + + Assert.NotNull(received); + Assert.Equal(5, received!.ChannelId); + Assert.Equal(new long[] { 1, 3 }, received.Speakers); + } + + // ── Additional dispatch events ─────────────────────────────────────── + + [Fact] + public async Task Dispatches_ChatSendOk_Event() + { + var svc = await CreateConnectedService(); + ChatSendOkPayload? received = null; + svc.ChatSendOk += p => received = p; + + _ws.SimulateMessage("""{ "type": "chat_send_ok", "payload": { "message_id": 99, "timestamp": "2026-01-01T00:00:00Z" } }"""); + + Assert.NotNull(received); + Assert.Equal(99, received!.MessageId); + } + + [Fact] + public async Task Dispatches_ReactionUpdate_Event() + { + var svc = await CreateConnectedService(); + ReactionUpdatePayload? received = null; + svc.ReactionUpdated += p => received = p; + + _ws.SimulateMessage("""{ "type": "reaction_update", "payload": { "message_id": 10, "channel_id": 1, "emoji": "👍", "user_id": 2, "action": "add" } }"""); + + Assert.NotNull(received); + Assert.Equal("add", received!.Action); + Assert.Equal(10, received.MessageId); + } + + [Fact] + public async Task Dispatches_ServerRestart_Event() + { + var svc = await CreateConnectedService(); + ServerRestartPayload? received = null; + svc.ServerRestarting += p => received = p; + + _ws.SimulateMessage("""{ "type": "server_restart", "payload": { "reason": "update", "delay_seconds": 30 } }"""); + + Assert.NotNull(received); + Assert.Equal("update", received!.Reason); + Assert.Equal(30, received.DelaySeconds); + } + + [Fact] + public async Task Dispatches_MemberJoin_Event() + { + var svc = await CreateConnectedService(); + WsMember? received = null; + svc.MemberJoined += p => received = p; + + _ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 10, "username": "newuser", "avatar": null, "status": "online", "role_id": 1 } }"""); + + Assert.NotNull(received); + Assert.Equal("newuser", received!.Username); + } + + [Fact] + public async Task Dispatches_ChannelCreate_Event() + { + var svc = await CreateConnectedService(); + ChannelEventPayload? received = null; + svc.ChannelCreated += p => received = p; + + _ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 5, "name": "new-channel", "type": "text", "category": "Chat", "topic": "Hello", "position": 3 } }"""); + + Assert.NotNull(received); + Assert.Equal("new-channel", received!.Name); + } + + [Fact] + public async Task Dispatches_ChannelUpdate_Event() + { + var svc = await CreateConnectedService(); + ChannelEventPayload? received = null; + svc.ChannelUpdated += p => received = p; + + _ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 5, "name": "renamed-channel", "type": "text", "category": "Chat", "topic": null, "position": 3 } }"""); + + Assert.NotNull(received); + Assert.Equal("renamed-channel", received!.Name); + } + + [Fact] + public async Task Dispatches_ChannelDelete_Event() + { + var svc = await CreateConnectedService(); + long? received = null; + svc.ChannelDeleted += id => received = id; + + _ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 5 } }"""); + + Assert.NotNull(received); + Assert.Equal(5, received); + } + + // ── TOTP ───────────────────────────────────────────────────────────── + + [Fact] + public async Task VerifyTotpAsync_CallsApiAndStoresState() + { + _api.VerifyTotpResult = TestAuthResponse; + var svc = CreateService(); + + var result = await svc.VerifyTotpAsync("localhost:8443", "partial_tok", "123456"); + + Assert.Equal("tok_abc", result.Token); + Assert.Equal("alice", result.User!.Username); + Assert.Equal("tok_abc", svc.CurrentToken); + } + + // ── Register ───────────────────────────────────────────────────────── + + [Fact] + public async Task RegisterAsync_CallsApiAndStoresState() + { + _api.RegisterResult = TestAuthResponse; + var svc = CreateService(); + + var result = await svc.RegisterAsync("localhost:8443", "alice", "pass", "invite123"); + + Assert.Equal("tok_abc", result.Token); + Assert.Equal("alice", svc.CurrentUser?.Username); + } + + // ── Edge cases ─────────────────────────────────────────────────────── + + [Fact] + public async Task MalformedJson_DoesNotThrow() + { + var svc = await CreateConnectedService(); + + var exception = Record.Exception(() => _ws.SimulateMessage("not json at all")); + + Assert.Null(exception); + } + + [Fact] + public async Task KnownType_NullPayload_DoesNotThrow() + { + var svc = await CreateConnectedService(); + + // A known type like "chat_message" with null payload should not crash + var exception = Record.Exception(() => + _ws.SimulateMessage("""{ "type": "chat_message", "payload": null }""")); + + Assert.Null(exception); + } + + [Fact] + public async Task KnownType_MissingPayload_DoesNotThrow() + { + var svc = await CreateConnectedService(); + + // A known type with no payload key at all + var exception = Record.Exception(() => + _ws.SimulateMessage("""{ "type": "chat_message" }""")); + + Assert.Null(exception); + } + + [Fact] + public async Task DisconnectWebSocketAsync_SetsIntentionalFlag() + { + var svc = await CreateConnectedService(); + + await svc.DisconnectWebSocketAsync(); + + Assert.True(_ws.DisconnectCalled); + } + + [Fact] + public async Task VoiceMute_False_SendsCorrectPayload() + { + var svc = await CreateConnectedService(); + + await svc.SendVoiceMuteAsync(false); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.False(sent.RootElement.GetProperty("payload").GetProperty("muted").GetBoolean()); + } + + // ── Edit / Delete message outbound commands ────────────────────────── + + [Fact] + public async Task EditMessageAsync_SendsCorrectType() + { + var svc = await CreateConnectedService(); + + await svc.EditMessageAsync(77, "updated content"); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("chat_edit", sent.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task EditMessageAsync_SendsCorrectMessageId() + { + var svc = await CreateConnectedService(); + + await svc.EditMessageAsync(77, "updated content"); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal(77, sent.RootElement.GetProperty("payload").GetProperty("message_id").GetInt64()); + } + + [Fact] + public async Task EditMessageAsync_SendsCorrectContent() + { + var svc = await CreateConnectedService(); + + await svc.EditMessageAsync(77, "updated content"); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("updated content", sent.RootElement.GetProperty("payload").GetProperty("content").GetString()); + } + + [Fact] + public async Task EditMessageAsync_IncludesNonEmptyId() + { + var svc = await CreateConnectedService(); + + await svc.EditMessageAsync(77, "updated content"); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + var id = sent.RootElement.GetProperty("id").GetString(); + Assert.NotNull(id); + Assert.NotEmpty(id); + } + + [Fact] + public async Task DeleteMessageAsync_SendsCorrectType() + { + var svc = await CreateConnectedService(); + + await svc.DeleteMessageAsync(55); + + Assert.Single(_ws.SentMessages); + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal("chat_delete", sent.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task DeleteMessageAsync_SendsCorrectMessageId() + { + var svc = await CreateConnectedService(); + + await svc.DeleteMessageAsync(55); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + Assert.Equal(55, sent.RootElement.GetProperty("payload").GetProperty("message_id").GetInt64()); + } + + [Fact] + public async Task DeleteMessageAsync_IncludesNonEmptyId() + { + var svc = await CreateConnectedService(); + + await svc.DeleteMessageAsync(55); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + var id = sent.RootElement.GetProperty("id").GetString(); + Assert.NotNull(id); + Assert.NotEmpty(id); + } + + [Fact] + public async Task EditMessageAsync_EachCallProducesUniqueId() + { + var svc = await CreateConnectedService(); + + await svc.EditMessageAsync(1, "first"); + await svc.EditMessageAsync(2, "second"); + + var id1 = JsonDocument.Parse(_ws.SentMessages[0]).RootElement.GetProperty("id").GetString(); + var id2 = JsonDocument.Parse(_ws.SentMessages[1]).RootElement.GetProperty("id").GetString(); + Assert.NotEqual(id1, id2); + } + + [Fact] + public async Task DeleteMessageAsync_DoesNotIncludeContentField() + { + var svc = await CreateConnectedService(); + + await svc.DeleteMessageAsync(55); + + var sent = JsonDocument.Parse(_ws.SentMessages[0]); + var payload = sent.RootElement.GetProperty("payload"); + Assert.False(payload.TryGetProperty("content", out _)); + } +} diff --git a/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs b/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs new file mode 100644 index 00000000..c95dd3ff --- /dev/null +++ b/Client/OwnCord.Client.Tests/Services/MessageContentParserTests.cs @@ -0,0 +1,521 @@ +using OwnCord.Client.Services; +using static OwnCord.Client.Services.MessageContentParser; + +namespace OwnCord.Client.Tests.Services; + +public class MessageContentParserTests +{ + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ContentSegment Text(string text) => + new(SegmentType.Text, text); + + private static ContentSegment Code(string text, string? lang = null) => + new(SegmentType.CodeBlock, text, lang); + + private static ContentSegment Inline(string text) => + new(SegmentType.InlineCode, text); + + private static ContentSegment Bold(string text) => + new(SegmentType.Bold, text); + + private static ContentSegment Italic(string text) => + new(SegmentType.Italic, text); + + // ── 1. Plain text → single Text segment ────────────────────────────────── + + [Fact] + public void Parse_PlainText_ReturnsSingleTextSegment() + { + var result = Parse("Hello, world!"); + + Assert.Single(result); + Assert.Equal(Text("Hello, world!"), result[0]); + } + + [Fact] + public void Parse_PlainTextWithSpaces_PreservesWhitespace() + { + var result = Parse(" spaces around "); + + Assert.Single(result); + Assert.Equal(Text(" spaces around "), result[0]); + } + + // ── 2. Empty string → empty list ───────────────────────────────────────── + + [Fact] + public void Parse_EmptyString_ReturnsEmptyList() + { + var result = Parse(string.Empty); + + Assert.Empty(result); + } + + [Fact] + public void Parse_NullString_ReturnsEmptyList() + { + var result = Parse(null!); + + Assert.Empty(result); + } + + // ── 3. Code block with language ─────────────────────────────────────────── + + [Fact] + public void Parse_CodeBlockWithLanguage_ReturnsCodeBlockSegmentWithLanguage() + { + var result = Parse("```csharp\nvar x = 1;\n```"); + + Assert.Single(result); + var seg = result[0]; + Assert.Equal(SegmentType.CodeBlock, seg.Type); + Assert.Equal("var x = 1;\n", seg.Text); + Assert.Equal("csharp", seg.Language); + } + + [Fact] + public void Parse_CodeBlockWithLanguage_CapturesMultilineCode() + { + var input = "```go\nfunc main() {\n fmt.Println(\"hello\")\n}\n```"; + + var result = Parse(input); + + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + Assert.Equal("go", result[0].Language); + Assert.Contains("func main()", result[0].Text); + } + + // ── 4. Code block without language ─────────────────────────────────────── + + [Fact] + public void Parse_CodeBlockWithoutLanguage_ReturnsNullLanguage() + { + var result = Parse("```\nsome code\n```"); + + Assert.Single(result); + var seg = result[0]; + Assert.Equal(SegmentType.CodeBlock, seg.Type); + Assert.Null(seg.Language); + Assert.Equal("some code\n", seg.Text); + } + + [Fact] + public void Parse_CodeBlockWithoutLanguageNoNewline_ReturnsNullLanguage() + { + // Regex allows optional newline after language: ```(\w*)\n? + var result = Parse("```some code```"); + + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + // "some" would be captured as language since it matches \w+ + // "some" is captured by group 1 (\w*), space breaks \w so "some" is language + Assert.Equal("some", result[0].Language); + } + + // ── 5. Inline code ──────────────────────────────────────────────────────── + + [Fact] + public void Parse_InlineCode_ReturnsInlineCodeSegment() + { + var result = Parse("`var x = 1`"); + + Assert.Single(result); + Assert.Equal(Inline("var x = 1"), result[0]); + } + + [Fact] + public void Parse_InlineCode_CapturedTextExcludesBackticks() + { + var result = Parse("`hello`"); + + Assert.Single(result); + Assert.Equal("hello", result[0].Text); + Assert.Equal(SegmentType.InlineCode, result[0].Type); + Assert.Null(result[0].Language); + } + + // ── 6. Bold text ───────────────────────────────────────────────────────── + + [Fact] + public void Parse_BoldText_ReturnsBoldSegment() + { + var result = Parse("**bold text**"); + + Assert.Single(result); + Assert.Equal(Bold("bold text"), result[0]); + } + + [Fact] + public void Parse_BoldText_CapturedTextExcludesAsterisks() + { + var result = Parse("**important**"); + + Assert.Single(result); + Assert.Equal(SegmentType.Bold, result[0].Type); + Assert.Equal("important", result[0].Text); + } + + // ── 7. Italic text ──────────────────────────────────────────────────────── + + [Fact] + public void Parse_ItalicText_ReturnsItalicSegment() + { + var result = Parse("*italic text*"); + + Assert.Single(result); + Assert.Equal(Italic("italic text"), result[0]); + } + + [Fact] + public void Parse_ItalicText_CapturedTextExcludesAsterisks() + { + var result = Parse("*emphasis*"); + + Assert.Single(result); + Assert.Equal(SegmentType.Italic, result[0].Type); + Assert.Equal("emphasis", result[0].Text); + } + + // ── 8. Mixed: "Hello `code` world" ─────────────────────────────────────── + + [Fact] + public void Parse_TextInlineCodeText_ReturnsThreeSegments() + { + var result = Parse("Hello `code` world"); + + Assert.Equal(3, result.Count); + Assert.Equal(Text("Hello "), result[0]); + Assert.Equal(Inline("code"), result[1]); + Assert.Equal(Text(" world"), result[2]); + } + + [Fact] + public void Parse_InlineCodeAtStart_ReturnsInlineCodeThenText() + { + var result = Parse("`start` and more"); + + Assert.Equal(2, result.Count); + Assert.Equal(Inline("start"), result[0]); + Assert.Equal(Text(" and more"), result[1]); + } + + [Fact] + public void Parse_InlineCodeAtEnd_ReturnsTextThenInlineCode() + { + var result = Parse("prefix `end`"); + + Assert.Equal(2, result.Count); + Assert.Equal(Text("prefix "), result[0]); + Assert.Equal(Inline("end"), result[1]); + } + + // ── 9. Code block with surrounding text ────────────────────────────────── + + [Fact] + public void Parse_TextCodeBlockText_ReturnsThreeSegments() + { + var input = "Before:\n```python\nprint(\"hi\")\n```\nAfter"; + + var result = Parse(input); + + Assert.Equal(3, result.Count); + Assert.Equal(SegmentType.Text, result[0].Type); + Assert.Equal("Before:\n", result[0].Text); + Assert.Equal(SegmentType.CodeBlock, result[1].Type); + Assert.Equal("python", result[1].Language); + Assert.Equal(SegmentType.Text, result[2].Type); + Assert.Equal("\nAfter", result[2].Text); + } + + [Fact] + public void Parse_CodeBlockAtStart_ReturnsCodeBlockThenText() + { + var input = "```js\nconsole.log(1)\n```\nDone."; + + var result = Parse(input); + + Assert.Equal(2, result.Count); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + Assert.Equal("js", result[0].Language); + Assert.Equal(SegmentType.Text, result[1].Type); + Assert.Equal("\nDone.", result[1].Text); + } + + // ── 10. Multiple inline codes in one message ────────────────────────────── + + [Fact] + public void Parse_MultipleInlineCodes_AllCaptured() + { + var result = Parse("`foo` and `bar` and `baz`"); + + Assert.Equal(5, result.Count); + Assert.Equal(Inline("foo"), result[0]); + Assert.Equal(Text(" and "), result[1]); + Assert.Equal(Inline("bar"), result[2]); + Assert.Equal(Text(" and "), result[3]); + Assert.Equal(Inline("baz"), result[4]); + } + + [Fact] + public void Parse_TwoAdjacentInlineCodes_BothCaptured() + { + var result = Parse("`a``b`"); + + // `a` matches, then `` ` `` (empty) is skipped (regex requires [^`\n]+), + // then `b` matches: result is [Inline("a"), Inline("b")] + Assert.Equal(2, result.Count); + Assert.Equal(Inline("a"), result[0]); + Assert.Equal(Inline("b"), result[1]); + } + + // ── 11. Bold and italic mixed ───────────────────────────────────────────── + + [Fact] + public void Parse_BoldAndItalic_BothSegmentsPresent() + { + var result = Parse("**bold** and *italic*"); + + Assert.Equal(3, result.Count); + Assert.Equal(Bold("bold"), result[0]); + Assert.Equal(Text(" and "), result[1]); + Assert.Equal(Italic("italic"), result[2]); + } + + [Fact] + public void Parse_ItalicThenBold_BothSegmentsPresent() + { + var result = Parse("*em* then **strong**"); + + Assert.Equal(3, result.Count); + Assert.Equal(SegmentType.Italic, result[0].Type); + Assert.Equal("em", result[0].Text); + Assert.Equal(Text(" then "), result[1]); + Assert.Equal(Bold("strong"), result[2]); + } + + // ── 12. Bold containing text (no nesting) ──────────────────────────────── + + [Fact] + public void Parse_BoldSpan_InnerTextIsPreservedVerbatim() + { + // The parser does NOT recurse into bold/italic — inner text is raw. + var result = Parse("**hello world**"); + + Assert.Single(result); + Assert.Equal(SegmentType.Bold, result[0].Type); + Assert.Equal("hello world", result[0].Text); + } + + [Fact] + public void Parse_BoldContainingAsterisk_MatchesInnerContent() + { + // Bold uses .+? so it stops at the first ** + var result = Parse("**a * b**"); + + Assert.Single(result); + Assert.Equal(SegmentType.Bold, result[0].Type); + Assert.Equal("a * b", result[0].Text); + } + + // ── 13. Unclosed backtick → plain text ─────────────────────────────────── + + [Fact] + public void Parse_UnclosedInlineBacktick_TreatedAsPlainText() + { + // InlineCode regex requires a closing backtick on the same line + var result = Parse("hello `world"); + + Assert.Single(result); + Assert.Equal(Text("hello `world"), result[0]); + } + + [Fact] + public void Parse_BacktickWithNewlineInside_TreatedAsPlainText() + { + // [^`\n]+ excludes newlines, so a backtick spanning lines cannot match + var result = Parse("`line1\nline2`"); + + Assert.Single(result); + Assert.Equal(SegmentType.Text, result[0].Type); + Assert.Equal("`line1\nline2`", result[0].Text); + } + + [Fact] + public void Parse_OnlyOpeningBacktick_TreatedAsPlainText() + { + var result = Parse("`"); + + Assert.Single(result); + Assert.Equal(Text("`"), result[0]); + } + + // ── 14. Empty code block ────────────────────────────────────────────────── + + [Fact] + public void Parse_EmptyCodeBlockNoLanguage_CodeBlockWithEmptyText() + { + // ```(\w*)\n?([\s\S]*?)``` — lazy *? can match empty string + var result = Parse("``````"); + + // ``` `` ``` — three backticks open, zero chars, three backticks close + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + Assert.Equal(string.Empty, result[0].Text); + Assert.Null(result[0].Language); + } + + [Fact] + public void Parse_CodeBlockWithOnlyNewline_CodeBlockWithNewlineText() + { + var result = Parse("```\n\n```"); + + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + // The optional \n? consumes the first newline; second \n is part of the code + Assert.Equal("\n", result[0].Text); + Assert.Null(result[0].Language); + } + + // ── 15. Code block with special characters ──────────────────────────────── + + [Fact] + public void Parse_CodeBlockWithSpecialChars_PreservesContent() + { + var code = "x < 10 && y > 5 || z == 0;\n\n"; + var input = $"```\n{code}```"; + + var result = Parse(input); + + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + Assert.Equal(code, result[0].Text); + } + + [Fact] + public void Parse_CodeBlockWithUnicode_PreservesContent() + { + var input = "```\n日本語テスト 🎉\n```"; + + var result = Parse(input); + + Assert.Single(result); + Assert.Equal(SegmentType.CodeBlock, result[0].Type); + Assert.Contains("日本語テスト", result[0].Text); + Assert.Contains("🎉", result[0].Text); + } + + [Fact] + public void Parse_CodeBlockWithSqlChars_PreservesContent() + { + var input = "```sql\nSELECT * FROM users WHERE id = '1' OR '1'='1';\n```"; + + var result = Parse(input); + + Assert.Single(result); + Assert.Equal("sql", result[0].Language); + Assert.Contains("SELECT * FROM users", result[0].Text); + } + + [Fact] + public void Parse_InlineCodeWithSpecialChars_PreservesContent() + { + var result = Parse("`x < y && z > 0`"); + + Assert.Single(result); + Assert.Equal(SegmentType.InlineCode, result[0].Type); + Assert.Equal("x < y && z > 0", result[0].Text); + } + + // ── Boundary / additional edge cases ───────────────────────────────────── + + [Fact] + public void Parse_BoldWithNoSurroundingText_NoBoundaryTextSegments() + { + var result = Parse("**only bold**"); + + Assert.Single(result); + Assert.Equal(Bold("only bold"), result[0]); + } + + [Fact] + public void Parse_ItalicWithNoSurroundingText_NoBoundaryTextSegments() + { + var result = Parse("*only italic*"); + + Assert.Single(result); + Assert.Equal(Italic("only italic"), result[0]); + } + + [Fact] + public void Parse_DoubleAsterisksAreNotItalic() + { + // ** is consumed by bold regex; the italic lookahead (? s.Type == SegmentType.CodeBlock).ToList(); + Assert.Equal(2, codeBlocks.Count); + Assert.Contains("first", codeBlocks[0].Text); + Assert.Contains("second", codeBlocks[1].Text); + } + + [Fact] + public void Parse_WhitespaceOnlyString_ReturnsSingleTextSegment() + { + // string.IsNullOrEmpty(" ") is false, so whitespace-only goes through parsing + // No formatting marks → AddTextSegment adds it as Text + var result = Parse(" "); + + Assert.Single(result); + Assert.Equal(SegmentType.Text, result[0].Type); + Assert.Equal(" ", result[0].Text); + } + + [Fact] + public void Parse_InlineCodeInsideTextWithBold_InlineCodeTakesPrecedence() + { + // Inline code is processed before bold/italic, so **...** inside inline code + // is NOT parsed as bold — it's raw code content. + var result = Parse("`**not bold**`"); + + Assert.Single(result); + Assert.Equal(SegmentType.InlineCode, result[0].Type); + Assert.Equal("**not bold**", result[0].Text); + } + + [Fact] + public void Parse_SegmentTypesAreNeverNull() + { + var inputs = new[] + { + "plain text", + "**bold**", + "*italic*", + "`code`", + "```\nblock\n```", + "mix **bold** and `code`", + }; + + foreach (var input in inputs) + { + var result = Parse(input); + Assert.All(result, seg => Assert.True( + Enum.IsDefined(typeof(SegmentType), seg.Type), + $"Invalid segment type in: {input}")); + } + } +} diff --git a/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs index 035854a1..f9ed1f67 100644 --- a/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs +++ b/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs @@ -6,8 +6,8 @@ namespace OwnCord.Client.Tests.ViewModels; public sealed class ConnectViewModelTests { - private static ConnectViewModel MakeVm(IProfileService? svc = null, ICredentialService? creds = null) - => new(svc ?? new FakeProfileService(), creds ?? new FakeCredentialService()); + 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() @@ -251,6 +251,33 @@ internal sealed class FakeCredentialService : ICredentialService public void DeletePassword(string host, string username) => _passwords.Remove(Key(host, username)); } +internal sealed class StubApiClient : IApiClient +{ + public HealthResponse? HealthResult { get; set; } = new("ok", "1.0.0"); + public bool HealthThrows { get; set; } + + public Task LoginAsync(string host, string username, string password, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task LogoutAsync(string host, string token, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetMeAsync(string host, string token, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task> GetChannelsAsync(string host, string token, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task HealthCheckAsync(string host, CancellationToken ct = default) + { + if (HealthThrows) throw new Exception("Connection refused"); + return Task.FromResult(HealthResult ?? new HealthResponse("ok", "1.0.0")); + } +} + internal sealed class FakeProfileService : IProfileService { public List Saved = []; diff --git a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs index 286cf9f1..6f51fecf 100644 --- a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs +++ b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs @@ -26,7 +26,7 @@ public sealed class MainViewModelTests => 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, []); + => new(id, channelId, MakeUser(1, "alice"), content, DateTime.UtcNow, null, null, false, [], []); [Fact] public void SendCommand_DisabledWhenInputEmpty() diff --git a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs new file mode 100644 index 00000000..7bbc35df --- /dev/null +++ b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelVoiceTests.cs @@ -0,0 +1,549 @@ +using OwnCord.Client.Models; +using OwnCord.Client.Services; +using OwnCord.Client.Tests.Services; +using OwnCord.Client.ViewModels; + +namespace OwnCord.Client.Tests.ViewModels; + +/// Tests for MainViewModel voice events, channel CRUD, member events, and grouping. +public sealed class MainViewModelVoiceTests +{ + private static readonly ApiUser TestUser = new(1, "alice", null, "online", 1, "2026-01-01T00:00:00Z"); + private static readonly AuthResponse TestAuth = new("tok_abc", TestUser); + + private static MainViewModel MakeVmWithChat(out FakeApiClient api, out FakeWebSocketService ws) + { + api = new FakeApiClient(); + ws = new FakeWebSocketService(); + var chat = new ChatService(api, ws); + var vm = new MainViewModel(); + vm.Initialize(chat); + return vm; + } + + private static async Task<(MainViewModel vm, FakeApiClient api, FakeWebSocketService ws)> MakeLoggedInVm() + { + var api = new FakeApiClient { LoginResult = TestAuth }; + var ws = new FakeWebSocketService(); + var chat = new ChatService(api, ws); + var vm = new MainViewModel(); + vm.Initialize(chat); + await chat.LoginAsync("host:8443", "alice", "pass"); + await chat.ConnectWebSocketAsync("host:8443", "tok_abc"); + return (vm, api, ws); + } + + private static Channel MakeChannel(long id, string name, ChannelType type = ChannelType.Text, string? category = null, int position = 0) + => new(id, name, type, category, position, 0, null); + + private static User MakeUser(long id, string name, long roleId = 1) + => new(id, name, null, roleId, UserStatus.Online); + + private static Message MakeMessage(long id, long channelId, string content, long authorId = 1, string authorName = "alice") + => new(id, channelId, MakeUser(authorId, authorName), content, DateTime.UtcNow, null, null, false, [], []); + + private static string ReadyJson(string channels = "[]", string members = "[]", string voiceStates = "[]", string roles = "[]") + => $@"{{ ""type"": ""ready"", ""payload"": {{ ""channels"": {channels}, ""members"": {members}, ""voice_states"": {voiceStates}, ""roles"": {roles} }} }}"; + + // ── Voice state event ──────────────────────────────────────────────── + + [Fact] + public void VoiceState_AddsNewVoiceUser() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice)]); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); + + Assert.Single(vm.VoiceStates); + Assert.Equal("bob", vm.VoiceStates[0].Username); + Assert.Equal(5, vm.VoiceStates[0].ChannelId); + } + + [Fact] + public void VoiceState_UpdatesExistingUser() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice)]); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": true, "deafened": false } }"""); + + Assert.Single(vm.VoiceStates); + Assert.True(vm.VoiceStates[0].Muted); + } + + [Fact] + public async Task VoiceState_LocalUser_SetsVoiceWidgetState() + { + var (vm, _, ws) = await MakeLoggedInVm(); + + // Fire ready to populate channels and set CurrentUser on ChatService + ws.SimulateMessage(ReadyJson( + channels: @"[{ ""id"": 5, ""name"": ""voice-room"", ""type"": ""voice"", ""category"": ""Voice"", ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", + members: @"[{ ""id"": 1, ""username"": ""alice"", ""avatar"": null, ""status"": ""online"", ""role_id"": 1 }]" + )); + + // Simulate local user (id=1) joining voice + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); + + Assert.True(vm.IsInVoice); + Assert.Equal("voice-room", vm.VoiceChannelName); + Assert.False(vm.IsMuted); + } + + // ── Voice leave event ──────────────────────────────────────────────── + + [Fact] + public void VoiceLeave_RemovesVoiceUser() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); + Assert.Single(vm.VoiceStates); + + ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 2, "channel_id": 5 } }"""); + Assert.Empty(vm.VoiceStates); + } + + [Fact] + public async Task VoiceLeave_LocalUser_ClearsVoiceWidget() + { + var (vm, _, ws) = await MakeLoggedInVm(); + + ws.SimulateMessage(ReadyJson( + channels: @"[{ ""id"": 5, ""name"": ""voice-room"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", + members: @"[{ ""id"": 1, ""username"": ""alice"", ""avatar"": null, ""status"": ""online"", ""role_id"": 1 }]" + )); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); + Assert.True(vm.IsInVoice); + + ws.SimulateMessage("""{ "type": "voice_leave", "payload": { "user_id": 1, "channel_id": 5 } }"""); + Assert.False(vm.IsInVoice); + Assert.Null(vm.VoiceChannelName); + Assert.False(vm.IsMuted); + Assert.False(vm.IsDeafened); + } + + // ── Voice speakers event ───────────────────────────────────────────── + + [Fact] + public void VoiceSpeakers_UpdatesSpeakingState() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 3, "channel_id": 5, "username": "carol", "muted": false, "deafened": false } }"""); + + ws.SimulateMessage("""{ "type": "voice_speakers", "payload": { "channel_id": 5, "speakers": [2], "mode": "sfu" } }"""); + + var bob = vm.VoiceStates.First(vs => vs.UserId == 2); + var carol = vm.VoiceStates.First(vs => vs.UserId == 3); + Assert.True(bob.Speaking); + Assert.False(carol.Speaking); + } + + // ── Channel CRUD events ────────────────────────────────────────────── + + [Fact] + public void ChannelCreated_AddsNewChannel() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general")]); + + ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 2, "name": "random", "type": "text", "category": "Chat", "topic": null, "position": 1 } }"""); + + Assert.Equal(2, vm.Channels.Count); + Assert.Contains(vm.Channels, c => c.Name == "random"); + } + + [Fact] + public void ChannelCreated_DuplicateId_DoesNotAdd() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general")]); + + ws.SimulateMessage("""{ "type": "channel_create", "payload": { "id": 1, "name": "general-dup", "type": "text", "category": null, "topic": null, "position": 0 } }"""); + + Assert.Single(vm.Channels); + } + + [Fact] + public void ChannelUpdated_UpdatesExistingChannel() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general")]); + + ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 1, "name": "general-renamed", "type": "text", "category": "Chat", "topic": "New topic", "position": 0 } }"""); + + Assert.Equal("general-renamed", vm.Channels[0].Name); + Assert.Equal("New topic", vm.Channels[0].Topic); + } + + [Fact] + public void ChannelUpdated_SelectedChannel_NotifiesTopicChanged() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general")]); + vm.SelectedChannel = vm.Channels[0]; + + bool topicNotified = false; + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(vm.SelectedChannelTopic)) + topicNotified = true; + }; + + ws.SimulateMessage("""{ "type": "channel_update", "payload": { "id": 1, "name": "general", "type": "text", "category": null, "topic": "Updated!", "position": 0 } }"""); + + Assert.True(topicNotified); + // The channel in Channels collection has the updated topic + Assert.Equal("Updated!", vm.Channels[0].Topic); + } + + [Fact] + public void ChannelDeleted_RemovesChannel() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); + + ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 2 } }"""); + + Assert.Single(vm.Channels); + Assert.Equal("general", vm.Channels[0].Name); + } + + [Fact] + public void ChannelDeleted_SelectedChannel_SelectsAnother() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); + vm.SelectedChannel = vm.Channels.First(c => c.Id == 2); + + ws.SimulateMessage("""{ "type": "channel_delete", "payload": { "id": 2 } }"""); + + Assert.NotNull(vm.SelectedChannel); + Assert.Equal(1, vm.SelectedChannel!.Id); + } + + // ── Member events ──────────────────────────────────────────────────── + + [Fact] + public void MemberJoined_AddsNewMember() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadMembers([MakeUser(1, "alice")]); + + ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 10, "username": "newuser", "avatar": null, "status": "online", "role_id": 1 } }"""); + + Assert.Equal(2, vm.Members.Count); + Assert.Contains(vm.Members, m => m.Username == "newuser"); + } + + [Fact] + public void MemberJoined_DuplicateId_DoesNotAdd() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadMembers([MakeUser(1, "alice")]); + + ws.SimulateMessage("""{ "type": "member_join", "payload": { "id": 1, "username": "alice-dup", "avatar": null, "status": "online", "role_id": 1 } }"""); + + Assert.Single(vm.Members); + } + + [Fact] + public void Presence_UpdatesMemberStatus() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadMembers([MakeUser(1, "alice")]); + + ws.SimulateMessage("""{ "type": "presence", "payload": { "user_id": 1, "status": "idle" } }"""); + + Assert.Equal(UserStatus.Idle, vm.Members[0].Status); + } + + // ── Channel grouping ───────────────────────────────────────────────── + + [Fact] + public void ChannelGroups_GroupedByCategory() + { + var vm = new MainViewModel(); + vm.LoadChannels([ + MakeChannel(1, "general", ChannelType.Text, "Chat", 0), + MakeChannel(2, "random", ChannelType.Text, "Chat", 1), + MakeChannel(3, "voice", ChannelType.Voice, "Voice", 0), + ]); + + Assert.Equal(2, vm.ChannelGroups.Count); + Assert.Contains(vm.ChannelGroups, g => g.CategoryName == "Chat" && g.Items.Count == 2); + Assert.Contains(vm.ChannelGroups, g => g.CategoryName == "Voice" && g.Items.Count == 1); + } + + [Fact] + public void ChannelGroups_PreservesExpandedState() + { + var vm = new MainViewModel(); + vm.LoadChannels([ + MakeChannel(1, "general", ChannelType.Text, "Chat", 0), + MakeChannel(2, "voice", ChannelType.Voice, "Voice", 0), + ]); + + // Collapse the Chat group + var chatGroup = vm.ChannelGroups.First(g => g.CategoryName == "Chat"); + chatGroup.IsExpanded = false; + + // Reload channels — should preserve collapsed state + vm.LoadChannels([ + MakeChannel(1, "general", ChannelType.Text, "Chat", 0), + MakeChannel(2, "voice", ChannelType.Voice, "Voice", 0), + MakeChannel(3, "random", ChannelType.Text, "Chat", 1), + ]); + + var chatGroupAfter = vm.ChannelGroups.First(g => g.CategoryName == "Chat"); + Assert.False(chatGroupAfter.IsExpanded); + } + + [Fact] + public void ChannelGroups_VoiceChannelsIncludeVoiceUsers() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(5, "voice-room", ChannelType.Voice, "Voice")]); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 5, "username": "bob", "muted": false, "deafened": false } }"""); + + var voiceGroup = vm.ChannelGroups.First(g => g.CategoryName == "Voice"); + var voiceItem = voiceGroup.Items[0]; + Assert.Single(voiceItem.VoiceUsers); + Assert.Equal("bob", voiceItem.VoiceUsers[0].Username); + } + + [Fact] + public void ChannelGroups_NullCategory_SortedFirst() + { + var vm = new MainViewModel(); + vm.LoadChannels([ + MakeChannel(1, "general", ChannelType.Text, null, 0), + MakeChannel(2, "chat", ChannelType.Text, "Chat", 0), + ]); + + Assert.Null(vm.ChannelGroups[0].CategoryName); + Assert.Equal("Chat", vm.ChannelGroups[1].CategoryName); + } + + // ── Member grouping ────────────────────────────────────────────────── + + [Fact] + public void MemberGroups_GroupedByRole() + { + var vm = new MainViewModel(); + vm.Roles.Add(new WsRole(1, "Admin", "#ff0000", 0, 0, false)); + vm.Roles.Add(new WsRole(2, "Member", null, 0, 1, true)); + vm.LoadMembers([ + MakeUser(1, "alice", 1), + MakeUser(2, "bob", 2), + MakeUser(3, "carol", 2), + ]); + + Assert.Equal(2, vm.MemberGroups.Count); + var adminGroup = vm.MemberGroups.First(g => g.RoleName == "Admin"); + Assert.Single(adminGroup.Members); + var memberGroup = vm.MemberGroups.First(g => g.RoleName == "Member"); + Assert.Equal(2, memberGroup.Members.Count); + } + + [Fact] + public void MemberGroups_UnknownRole_DefaultsToMembers() + { + var vm = new MainViewModel(); + vm.LoadMembers([MakeUser(1, "alice", 99)]); + + Assert.Single(vm.MemberGroups); + Assert.Equal("Members", vm.MemberGroups[0].RoleName); + } + + // ── Connection status ──────────────────────────────────────────────── + + [Fact] + public void ConnectionStatus_SetsHasConnectionIssue() + { + var vm = new MainViewModel(); + Assert.False(vm.HasConnectionIssue); + + vm.ConnectionStatus = "Disconnected"; + Assert.True(vm.HasConnectionIssue); + + vm.ConnectionStatus = null; + Assert.False(vm.HasConnectionIssue); + } + + // ── Ready event with voice states ──────────────────────────────────── + + [Fact] + public void Ready_LoadsVoiceStates() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage(ReadyJson( + channels: @"[{ ""id"": 5, ""name"": ""voice"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]", + voiceStates: @"[{ ""user_id"": 2, ""channel_id"": 5, ""username"": ""bob"", ""muted"": true, ""deafened"": false, ""speaking"": false }]" + )); + + Assert.Single(vm.VoiceStates); + Assert.Equal("bob", vm.VoiceStates[0].Username); + Assert.True(vm.VoiceStates[0].Muted); + } + + [Fact] + public void Ready_LoadsRoles() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage(ReadyJson( + roles: @"[{ ""id"": 1, ""name"": ""Admin"", ""color"": ""#ff0000"", ""permissions"": 255, ""position"": 0, ""is_default"": false }, { ""id"": 2, ""name"": ""Member"", ""color"": null, ""permissions"": 1, ""position"": 1, ""is_default"": true }]" + )); + + Assert.Equal(2, vm.Roles.Count); + Assert.Equal("Admin", vm.Roles[0].Name); + } + + [Fact] + public void Ready_SelectsFirstTextChannel() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage(ReadyJson( + channels: @"[{ ""id"": 5, ""name"": ""voice"", ""type"": ""voice"", ""category"": null, ""topic"": """", ""position"": 0, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }, { ""id"": 1, ""name"": ""general"", ""type"": ""text"", ""category"": null, ""topic"": """", ""position"": 1, ""slow_mode"": 0, ""archived"": false, ""created_at"": ""2026-01-01T00:00:00Z"" }]" + )); + + Assert.NotNull(vm.SelectedChannel); + Assert.Equal("general", vm.SelectedChannel!.Name); + } + + // ── Chat message to non-selected channel increments unread ────────── + + [Fact] + public void ChatMessage_OtherChannel_IncrementsUnread() + { + var vm = MakeVmWithChat(out _, out var ws); + vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); + vm.SelectedChannel = vm.Channels[0]; // selected = general (id 1) + + ws.SimulateMessage("""{ "type": "chat_message", "payload": { "id": 50, "channel_id": 2, "user": { "id": 2, "username": "bob", "avatar": null }, "content": "hi", "reply_to": null, "timestamp": "2026-01-01T00:00:00Z" } }"""); + + Assert.Equal(1, vm.Channels.First(c => c.Id == 2).UnreadCount); + } + + // ── Display messages ───────────────────────────────────────────────── + + [Fact] + public void AddMessage_CreatesDisplayMessage() + { + var vm = new MainViewModel(); + vm.AddMessage(MakeMessage(1, 1, "hello")); + + Assert.Single(vm.DisplayMessages); + Assert.Equal("hello", vm.DisplayMessages[0].Content); + } + + [Fact] + public void AddMessage_SecondBySameAuthor_GroupedTogether() + { + var vm = new MainViewModel(); + vm.AddMessage(MakeMessage(1, 1, "hello", 1, "alice")); + vm.AddMessage(MakeMessage(2, 1, "world", 1, "alice")); + + Assert.Equal(2, vm.DisplayMessages.Count); + Assert.False(vm.DisplayMessages[0].IsGrouped); // First message shows header + Assert.True(vm.DisplayMessages[1].IsGrouped); // Second is grouped (no header) + } + + // ── Toggle commands ────────────────────────────────────────────────── + + [Fact] + public void ToggleMemberList_TogglesVisibility() + { + var vm = new MainViewModel(); + Assert.True(vm.IsMemberListVisible); + + vm.ToggleMemberListCommand.Execute(null); + Assert.False(vm.IsMemberListVisible); + + vm.ToggleMemberListCommand.Execute(null); + Assert.True(vm.IsMemberListVisible); + } + + [Fact] + public void ToggleCategory_TogglesExpandedState() + { + var vm = new MainViewModel(); + vm.LoadChannels([MakeChannel(1, "general", ChannelType.Text, "Chat")]); + + var group = vm.ChannelGroups[0]; + Assert.True(group.IsExpanded); + + vm.ToggleCategoryCommand.Execute(group); + Assert.False(group.IsExpanded); + + vm.ToggleCategoryCommand.Execute(group); + Assert.True(group.IsExpanded); + } + + // ── GetVoiceUsersForChannel ────────────────────────────────────────── + + [Fact] + public void GetVoiceUsersForChannel_FiltersCorrectly() + { + var vm = MakeVmWithChat(out _, out var ws); + + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 1, "channel_id": 5, "username": "alice", "muted": false, "deafened": false } }"""); + ws.SimulateMessage("""{ "type": "voice_state", "payload": { "user_id": 2, "channel_id": 6, "username": "bob", "muted": false, "deafened": false } }"""); + + var users5 = vm.GetVoiceUsersForChannel(5).ToList(); + var users6 = vm.GetVoiceUsersForChannel(6).ToList(); + var users7 = vm.GetVoiceUsersForChannel(7).ToList(); + + Assert.Single(users5); + Assert.Single(users6); + Assert.Empty(users7); + } + + // ── CurrentUser properties ─────────────────────────────────────────── + + [Fact] + public void CurrentUsername_DefaultsToUnknown() + { + var vm = new MainViewModel(); + Assert.Equal("Unknown", vm.CurrentUsername); + } + + [Fact] + public void CurrentUserStatusEnum_DefaultsToOffline() + { + var vm = new MainViewModel(); + Assert.Equal(UserStatus.Offline, vm.CurrentUserStatusEnum); + } + + // ── SelectChannelCommand ───────────────────────────────────────────── + + [Fact] + public void SelectChannelCommand_WithChannelItem_SelectsChannel() + { + var vm = new MainViewModel(); + var ch = MakeChannel(1, "general"); + vm.LoadChannels([ch]); + + var item = vm.ChannelGroups[0].Items[0]; + vm.SelectChannelCommand.Execute(item); + + Assert.Equal(ch.Id, vm.SelectedChannel?.Id); + } + + [Fact] + public void SelectChannelCommand_WithNull_DoesNothing() + { + var vm = new MainViewModel(); + vm.SelectChannelCommand.Execute(null); + Assert.Null(vm.SelectedChannel); + } +} diff --git a/Client/OwnCord.Client/App.xaml b/Client/OwnCord.Client/App.xaml index 6ab8337d..e17d3d3c 100644 --- a/Client/OwnCord.Client/App.xaml +++ b/Client/OwnCord.Client/App.xaml @@ -4,10 +4,30 @@ xmlns:conv="clr-namespace:OwnCord.Client.Converters" Startup="Application_Startup"> + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs index 2adfb8dd..449e6bdf 100644 --- a/Client/OwnCord.Client/App.xaml.cs +++ b/Client/OwnCord.Client/App.xaml.cs @@ -22,7 +22,7 @@ public partial class App : Application var apiClient = ApiClient.CreateWithTofuTls(trustService); var chatService = new ChatService(apiClient, wsService); - var connectVm = new ConnectViewModel(profileService, credentialService); + var connectVm = new ConnectViewModel(profileService, credentialService, apiClient); var mainVm = new MainViewModel(); var mainWindow = new MainWindow(connectVm, mainVm, chatService); diff --git a/Client/OwnCord.Client/Controls/AttachmentControl.xaml b/Client/OwnCord.Client/Controls/AttachmentControl.xaml new file mode 100644 index 00000000..8bc105f3 --- /dev/null +++ b/Client/OwnCord.Client/Controls/AttachmentControl.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs b/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs new file mode 100644 index 00000000..13456ad6 --- /dev/null +++ b/Client/OwnCord.Client/Controls/AttachmentControl.xaml.cs @@ -0,0 +1,99 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +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) + { + FilenameText.Text = Filename; + FileSizeText.Text = FormatFileSize(FileSize); + } + } + + private static string FormatFileSize(long bytes) + { + return bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", + _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB" + }; + } +} diff --git a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml new file mode 100644 index 00000000..5581dd8c --- /dev/null +++ b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs new file mode 100644 index 00000000..307b7ab8 --- /dev/null +++ b/Client/OwnCord.Client/Controls/CodeBlockControl.xaml.cs @@ -0,0 +1,55 @@ +using System.Windows; +using System.Windows.Controls; + +namespace OwnCord.Client.Controls; + +public partial class CodeBlockControl : UserControl +{ + public static readonly DependencyProperty CodeProperty = + DependencyProperty.Register( + nameof(Code), + typeof(string), + typeof(CodeBlockControl), + new PropertyMetadata(string.Empty, OnPropertyChanged)); + + public static readonly DependencyProperty CodeLanguageProperty = + DependencyProperty.Register( + nameof(CodeLanguage), + typeof(string), + typeof(CodeBlockControl), + new PropertyMetadata(string.Empty, OnPropertyChanged)); + + public string Code + { + get => (string)GetValue(CodeProperty); + set => SetValue(CodeProperty, value); + } + + public string CodeLanguage + { + get => (string)GetValue(CodeLanguageProperty); + set => SetValue(CodeLanguageProperty, value); + } + + public CodeBlockControl() + { + InitializeComponent(); + } + + private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CodeBlockControl control) + { + control.UpdateDisplay(); + } + } + + private void UpdateDisplay() + { + CodeText.Text = Code; + + var hasLanguage = !string.IsNullOrWhiteSpace(CodeLanguage); + LanguageLabel.Text = hasLanguage ? CodeLanguage : string.Empty; + LanguageLabel.Visibility = hasLanguage ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml new file mode 100644 index 00000000..763a6a23 --- /dev/null +++ b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs new file mode 100644 index 00000000..bda4cf67 --- /dev/null +++ b/Client/OwnCord.Client/Controls/DmSidebarControl.xaml.cs @@ -0,0 +1,79 @@ +using System.Collections; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class DmSidebarControl : UserControl +{ + public static readonly DependencyProperty DirectMessagesProperty = + DependencyProperty.Register( + nameof(DirectMessages), + typeof(IEnumerable), + typeof(DmSidebarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty SelectedDmProperty = + DependencyProperty.Register( + nameof(SelectedDm), + typeof(object), + typeof(DmSidebarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty SelectDmCommandProperty = + DependencyProperty.Register( + nameof(SelectDmCommand), + typeof(ICommand), + typeof(DmSidebarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty FriendsCommandProperty = + DependencyProperty.Register( + nameof(FriendsCommand), + typeof(ICommand), + typeof(DmSidebarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty CloseDmCommandProperty = + DependencyProperty.Register( + nameof(CloseDmCommand), + typeof(ICommand), + typeof(DmSidebarControl), + new PropertyMetadata(null)); + + public DmSidebarControl() + { + InitializeComponent(); + } + + public IEnumerable? DirectMessages + { + get => (IEnumerable?)GetValue(DirectMessagesProperty); + set => SetValue(DirectMessagesProperty, value); + } + + public object? SelectedDm + { + get => GetValue(SelectedDmProperty); + set => SetValue(SelectedDmProperty, value); + } + + public ICommand? SelectDmCommand + { + get => (ICommand?)GetValue(SelectDmCommandProperty); + set => SetValue(SelectDmCommandProperty, value); + } + + public ICommand? FriendsCommand + { + get => (ICommand?)GetValue(FriendsCommandProperty); + set => SetValue(FriendsCommandProperty, value); + } + + public ICommand? CloseDmCommand + { + get => (ICommand?)GetValue(CloseDmCommandProperty); + set => SetValue(CloseDmCommandProperty, value); + } +} diff --git a/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml b/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml new file mode 100644 index 00000000..782490a9 --- /dev/null +++ b/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs b/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs new file mode 100644 index 00000000..079c37ed --- /dev/null +++ b/Client/OwnCord.Client/Controls/FriendsViewControl.xaml.cs @@ -0,0 +1,79 @@ +using System.Collections; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class FriendsViewControl : UserControl +{ + public static readonly DependencyProperty SelectedTabProperty = + DependencyProperty.Register( + nameof(SelectedTab), + typeof(string), + typeof(FriendsViewControl), + new PropertyMetadata("online")); + + public static readonly DependencyProperty FriendsProperty = + DependencyProperty.Register( + nameof(Friends), + typeof(IEnumerable), + typeof(FriendsViewControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty FriendSearchTextProperty = + DependencyProperty.Register( + nameof(FriendSearchText), + typeof(string), + typeof(FriendsViewControl), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty SelectTabCommandProperty = + DependencyProperty.Register( + nameof(SelectTabCommand), + typeof(ICommand), + typeof(FriendsViewControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty MessageFriendCommandProperty = + DependencyProperty.Register( + nameof(MessageFriendCommand), + typeof(ICommand), + typeof(FriendsViewControl), + new PropertyMetadata(null)); + + public FriendsViewControl() + { + InitializeComponent(); + } + + public string SelectedTab + { + get => (string)GetValue(SelectedTabProperty); + set => SetValue(SelectedTabProperty, value); + } + + public IEnumerable? Friends + { + get => (IEnumerable?)GetValue(FriendsProperty); + set => SetValue(FriendsProperty, value); + } + + public string FriendSearchText + { + get => (string)GetValue(FriendSearchTextProperty); + set => SetValue(FriendSearchTextProperty, value); + } + + public ICommand? SelectTabCommand + { + get => (ICommand?)GetValue(SelectTabCommandProperty); + set => SetValue(SelectTabCommandProperty, value); + } + + public ICommand? MessageFriendCommand + { + get => (ICommand?)GetValue(MessageFriendCommandProperty); + set => SetValue(MessageFriendCommandProperty, value); + } +} diff --git a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml new file mode 100644 index 00000000..3b2bffdd --- /dev/null +++ b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs new file mode 100644 index 00000000..b10fdc3b --- /dev/null +++ b/Client/OwnCord.Client/Controls/MessageActionsBar.xaml.cs @@ -0,0 +1,78 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class MessageActionsBar : UserControl +{ + public static readonly DependencyProperty ReplyCommandProperty = + DependencyProperty.Register( + nameof(ReplyCommand), + typeof(ICommand), + typeof(MessageActionsBar), + new PropertyMetadata(null)); + + public static readonly DependencyProperty EditCommandProperty = + DependencyProperty.Register( + nameof(EditCommand), + typeof(ICommand), + typeof(MessageActionsBar), + new PropertyMetadata(null)); + + public static readonly DependencyProperty DeleteCommandProperty = + DependencyProperty.Register( + nameof(DeleteCommand), + typeof(ICommand), + typeof(MessageActionsBar), + new PropertyMetadata(null)); + + public static readonly DependencyProperty IsOwnMessageProperty = + DependencyProperty.Register( + nameof(IsOwnMessage), + typeof(bool), + typeof(MessageActionsBar), + new PropertyMetadata(false)); + + public static readonly DependencyProperty CommandParameterProperty = + DependencyProperty.Register( + nameof(CommandParameter), + typeof(object), + typeof(MessageActionsBar), + new PropertyMetadata(null)); + + public ICommand? ReplyCommand + { + get => (ICommand?)GetValue(ReplyCommandProperty); + set => SetValue(ReplyCommandProperty, value); + } + + public ICommand? EditCommand + { + get => (ICommand?)GetValue(EditCommandProperty); + set => SetValue(EditCommandProperty, value); + } + + public ICommand? DeleteCommand + { + get => (ICommand?)GetValue(DeleteCommandProperty); + set => SetValue(DeleteCommandProperty, value); + } + + public bool IsOwnMessage + { + get => (bool)GetValue(IsOwnMessageProperty); + set => SetValue(IsOwnMessageProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + public MessageActionsBar() + { + InitializeComponent(); + } +} diff --git a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml new file mode 100644 index 00000000..358ab551 --- /dev/null +++ b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs new file mode 100644 index 00000000..9f51d5b1 --- /dev/null +++ b/Client/OwnCord.Client/Controls/ReplyComposeBar.xaml.cs @@ -0,0 +1,39 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class ReplyComposeBar : UserControl +{ + public static readonly DependencyProperty UsernameProperty = + DependencyProperty.Register( + nameof(Username), + typeof(string), + typeof(ReplyComposeBar), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty CancelCommandProperty = + DependencyProperty.Register( + nameof(CancelCommand), + typeof(ICommand), + typeof(ReplyComposeBar), + new PropertyMetadata(null)); + + public string Username + { + get => (string)GetValue(UsernameProperty); + set => SetValue(UsernameProperty, value); + } + + public ICommand? CancelCommand + { + get => (ICommand?)GetValue(CancelCommandProperty); + set => SetValue(CancelCommandProperty, value); + } + + public ReplyComposeBar() + { + InitializeComponent(); + } +} diff --git a/Client/OwnCord.Client/Controls/ServerStripControl.xaml b/Client/OwnCord.Client/Controls/ServerStripControl.xaml new file mode 100644 index 00000000..f142b71b --- /dev/null +++ b/Client/OwnCord.Client/Controls/ServerStripControl.xaml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs b/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs new file mode 100644 index 00000000..94695435 --- /dev/null +++ b/Client/OwnCord.Client/Controls/StatusPickerControl.xaml.cs @@ -0,0 +1,39 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class StatusPickerControl : UserControl +{ + public static readonly DependencyProperty SelectedStatusProperty = + DependencyProperty.Register( + nameof(SelectedStatus), + typeof(string), + typeof(StatusPickerControl), + new PropertyMetadata("online")); + + public static readonly DependencyProperty StatusChangedCommandProperty = + DependencyProperty.Register( + nameof(StatusChangedCommand), + typeof(ICommand), + typeof(StatusPickerControl), + new PropertyMetadata(null)); + + public StatusPickerControl() + { + InitializeComponent(); + } + + public string SelectedStatus + { + get => (string)GetValue(SelectedStatusProperty); + set => SetValue(SelectedStatusProperty, value); + } + + public ICommand StatusChangedCommand + { + get => (ICommand)GetValue(StatusChangedCommandProperty); + set => SetValue(StatusChangedCommandProperty, value); + } +} diff --git a/Client/OwnCord.Client/Controls/ToastControl.xaml b/Client/OwnCord.Client/Controls/ToastControl.xaml new file mode 100644 index 00000000..743db726 --- /dev/null +++ b/Client/OwnCord.Client/Controls/ToastControl.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/ToastControl.xaml.cs b/Client/OwnCord.Client/Controls/ToastControl.xaml.cs new file mode 100644 index 00000000..803776de --- /dev/null +++ b/Client/OwnCord.Client/Controls/ToastControl.xaml.cs @@ -0,0 +1,85 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media.Animation; +using System.Windows.Threading; + +namespace OwnCord.Client.Controls; + +public partial class ToastControl : UserControl +{ + private DispatcherTimer? _autoDismissTimer; + + public static readonly DependencyProperty MessageProperty = + DependencyProperty.Register( + nameof(Message), + typeof(string), + typeof(ToastControl), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty IsOpenProperty = + DependencyProperty.Register( + nameof(IsOpen), + typeof(bool), + typeof(ToastControl), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsOpenChanged)); + + public ToastControl() + { + InitializeComponent(); + } + + public string Message + { + get => (string)GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + public bool IsOpen + { + get => (bool)GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + private static void OnIsOpenChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ToastControl control) + control.HandleIsOpenChanged((bool)e.NewValue); + } + + private void HandleIsOpenChanged(bool isOpen) + { + _autoDismissTimer?.Stop(); + _autoDismissTimer = null; + + if (isOpen) + { + // Fade in + var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } + }; + BeginAnimation(OpacityProperty, fadeIn); + + // Auto-dismiss after 3 seconds + _autoDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; + _autoDismissTimer.Tick += OnAutoDismiss; + _autoDismissTimer.Start(); + } + else + { + // Fade out + var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } + }; + BeginAnimation(OpacityProperty, fadeOut); + } + } + + private void OnAutoDismiss(object? sender, EventArgs e) + { + _autoDismissTimer?.Stop(); + _autoDismissTimer = null; + IsOpen = false; + } +} diff --git a/Client/OwnCord.Client/Controls/UserBarControl.xaml b/Client/OwnCord.Client/Controls/UserBarControl.xaml new file mode 100644 index 00000000..7863cfa8 --- /dev/null +++ b/Client/OwnCord.Client/Controls/UserBarControl.xaml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs b/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs new file mode 100644 index 00000000..22fe0103 --- /dev/null +++ b/Client/OwnCord.Client/Controls/UserBarControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace OwnCord.Client.Controls; + +public partial class UserBarControl : UserControl +{ + public UserBarControl() + { + InitializeComponent(); + } +} diff --git a/Client/OwnCord.Client/Controls/UserPopupControl.xaml b/Client/OwnCord.Client/Controls/UserPopupControl.xaml new file mode 100644 index 00000000..959582c1 --- /dev/null +++ b/Client/OwnCord.Client/Controls/UserPopupControl.xaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs b/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs new file mode 100644 index 00000000..6d07934d --- /dev/null +++ b/Client/OwnCord.Client/Controls/UserPopupControl.xaml.cs @@ -0,0 +1,93 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace OwnCord.Client.Controls; + +public partial class UserPopupControl : UserControl +{ + public static readonly DependencyProperty UsernameProperty = + DependencyProperty.Register(nameof(Username), typeof(string), typeof(UserPopupControl), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty AvatarColorProperty = + DependencyProperty.Register(nameof(AvatarColor), typeof(string), typeof(UserPopupControl), + new PropertyMetadata("#5865f2")); + + public static readonly DependencyProperty RoleNameProperty = + DependencyProperty.Register(nameof(RoleName), typeof(string), typeof(UserPopupControl), + new PropertyMetadata("Member")); + + public static readonly DependencyProperty RoleColorProperty = + DependencyProperty.Register(nameof(RoleColor), typeof(string), typeof(UserPopupControl), + new PropertyMetadata("#949ba4")); + + public static readonly DependencyProperty JoinedDateProperty = + DependencyProperty.Register(nameof(JoinedDate), typeof(string), typeof(UserPopupControl), + new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(UserPopupControl), + new PropertyMetadata("Offline")); + + public static readonly DependencyProperty MessageCommandProperty = + DependencyProperty.Register(nameof(MessageCommand), typeof(ICommand), typeof(UserPopupControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty CloseCommandProperty = + DependencyProperty.Register(nameof(CloseCommand), typeof(ICommand), typeof(UserPopupControl), + new PropertyMetadata(null)); + + public UserPopupControl() + { + InitializeComponent(); + } + + public string Username + { + get => (string)GetValue(UsernameProperty); + set => SetValue(UsernameProperty, value); + } + + public string AvatarColor + { + get => (string)GetValue(AvatarColorProperty); + set => SetValue(AvatarColorProperty, value); + } + + public string RoleName + { + get => (string)GetValue(RoleNameProperty); + set => SetValue(RoleNameProperty, value); + } + + public string RoleColor + { + get => (string)GetValue(RoleColorProperty); + set => SetValue(RoleColorProperty, value); + } + + public string JoinedDate + { + get => (string)GetValue(JoinedDateProperty); + set => SetValue(JoinedDateProperty, value); + } + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public ICommand MessageCommand + { + get => (ICommand)GetValue(MessageCommandProperty); + set => SetValue(MessageCommandProperty, value); + } + + public ICommand CloseCommand + { + get => (ICommand)GetValue(CloseCommandProperty); + set => SetValue(CloseCommandProperty, value); + } +} diff --git a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml new file mode 100644 index 00000000..eef4eb06 --- /dev/null +++ b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs new file mode 100644 index 00000000..4b66a50f --- /dev/null +++ b/Client/OwnCord.Client/Controls/VoiceWidgetControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace OwnCord.Client.Controls; + +public partial class VoiceWidgetControl : UserControl +{ + public VoiceWidgetControl() + { + InitializeComponent(); + } +} diff --git a/Client/OwnCord.Client/Converters/ColorConverters.cs b/Client/OwnCord.Client/Converters/ColorConverters.cs new file mode 100644 index 00000000..d9c877a8 --- /dev/null +++ b/Client/OwnCord.Client/Converters/ColorConverters.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace OwnCord.Client.Converters; + +/// Converts a hex color string (#rrggbb) to a SolidColorBrush. +[ValueConversion(typeof(string), typeof(SolidColorBrush))] +public sealed class HexColorToBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string hex && hex.StartsWith('#') && hex.Length >= 7) + { + try + { + var color = (Color)ColorConverter.ConvertFromString(hex); + return new SolidColorBrush(color); + } + catch + { + // Fall through to default + } + } + + // Default fallback color (muted text) + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#949ba4")); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Converts a UserStatus enum to the corresponding status dot color brush. +[ValueConversion(typeof(string), typeof(SolidColorBrush))] +public sealed class StatusToBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Online = new((Color)ColorConverter.ConvertFromString("#23a55a")); + private static readonly SolidColorBrush Idle = new((Color)ColorConverter.ConvertFromString("#f0b232")); + private static readonly SolidColorBrush Dnd = new((Color)ColorConverter.ConvertFromString("#f23f43")); + private static readonly SolidColorBrush Offline = new((Color)ColorConverter.ConvertFromString("#6d6f78")); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value switch + { + Models.UserStatus.Online => Online, + Models.UserStatus.Idle => Idle, + Models.UserStatus.Dnd => Dnd, + _ => Offline + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Gets the first letter of a string (for avatar circle initials). +[ValueConversion(typeof(string), typeof(string))] +public sealed class FirstLetterConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + => value is string s && s.Length > 0 ? s[0].ToString().ToUpperInvariant() : "?"; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Converts a bool to a Foreground color (red when true, muted when false). +[ValueConversion(typeof(bool), typeof(SolidColorBrush))] +public sealed class BoolToRedBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Red = new((Color)ColorConverter.ConvertFromString("#f23f43")); + private static readonly SolidColorBrush Normal = new((Color)ColorConverter.ConvertFromString("#b5bac1")); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? Red : Normal; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Converts a bool speaking state to a green or transparent stroke brush. +[ValueConversion(typeof(bool), typeof(SolidColorBrush))] +public sealed class SpeakingToStrokeBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Speaking = new((Color)ColorConverter.ConvertFromString("#23a55a")); + private static readonly SolidColorBrush Silent = new(Colors.Transparent); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? Speaking : Silent; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Converts a boolean expand state to an arrow character. +[ValueConversion(typeof(bool), typeof(string))] +public sealed class BoolToArrowConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? "\u25BE" : "\u25B8"; // ▾ or ▸ + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs b/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs new file mode 100644 index 00000000..2c9be9fa --- /dev/null +++ b/Client/OwnCord.Client/Converters/ColorToBrushConverter.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace OwnCord.Client.Converters; + +/// Converts a hex color string like "#5865f2" to a SolidColorBrush. +[ValueConversion(typeof(string), typeof(SolidColorBrush))] +public sealed class ColorToBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush Fallback = new(Color.FromRgb(0x58, 0x65, 0xF2)); + + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not string hex || hex.Length < 7) + return Fallback; + + try + { + var color = (Color)ColorConverter.ConvertFromString(hex); + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + catch + { + return Fallback; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/FirstCharConverter.cs b/Client/OwnCord.Client/Converters/FirstCharConverter.cs new file mode 100644 index 00000000..754cfe20 --- /dev/null +++ b/Client/OwnCord.Client/Converters/FirstCharConverter.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows.Data; + +namespace OwnCord.Client.Converters; + +/// Returns the first character of a string, uppercased. +[ValueConversion(typeof(string), typeof(string))] +public sealed class FirstCharConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not string s || s.Length == 0) + return "?"; + return char.ToUpperInvariant(s[0]).ToString(); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs b/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs new file mode 100644 index 00000000..31232c78 --- /dev/null +++ b/Client/OwnCord.Client/Converters/HealthStatusToBrushConverter.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace OwnCord.Client.Converters; + +/// +/// Converts a health status string to a SolidColorBrush for the status indicator dot. +/// "online" = Green, "checking" = Yellow, "offline" = Red, "unknown"/other = Gray. +/// +public sealed class HealthStatusToBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush OnlineBrush = new(Color.FromRgb(0x23, 0xa5, 0x5a)); + private static readonly SolidColorBrush CheckingBrush = new(Color.FromRgb(0xf0, 0xb2, 0x32)); + private static readonly SolidColorBrush OfflineBrush = new(Color.FromRgb(0xf2, 0x3f, 0x43)); + private static readonly SolidColorBrush UnknownBrush = new(Color.FromRgb(0x6d, 0x6f, 0x78)); + + static HealthStatusToBrushConverter() + { + OnlineBrush.Freeze(); + CheckingBrush.Freeze(); + OfflineBrush.Freeze(); + UnknownBrush.Freeze(); + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as string) switch + { + "online" => OnlineBrush, + "checking" => CheckingBrush, + "offline" => OfflineBrush, + _ => UnknownBrush + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/HostPortConverter.cs b/Client/OwnCord.Client/Converters/HostPortConverter.cs new file mode 100644 index 00000000..18c1896f --- /dev/null +++ b/Client/OwnCord.Client/Converters/HostPortConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using System.Windows.Data; + +namespace OwnCord.Client.Converters; + +/// Combines Host and Port into a display string. Used as a multi-value converter. +public sealed class HostPortConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var host = values[0] as string ?? ""; + var port = values.Length > 1 && values[1] is int p ? p : 8443; + return port == 8443 ? host : $"{host}:{port}"; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs b/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs new file mode 100644 index 00000000..e85e8347 --- /dev/null +++ b/Client/OwnCord.Client/Converters/RelativeTimeConverter.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Windows.Data; + +namespace OwnCord.Client.Converters; + +/// Converts a DateTime? to a human-readable relative time string. +[ValueConversion(typeof(DateTime?), typeof(string))] +public sealed class RelativeTimeConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not DateTime dt) + return "never"; + + var span = DateTime.UtcNow - dt.ToUniversalTime(); + + return span.TotalSeconds switch + { + < 60 => "just now", + < 3600 => $"{(int)span.TotalMinutes}m ago", + < 86400 => $"{(int)span.TotalHours}h ago", + < 172800 => "yesterday", + < 604800 => $"{(int)span.TotalDays}d ago", + _ => dt.ToLocalTime().ToString("MMM d", culture) + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/Converters/StringEqualsConverter.cs b/Client/OwnCord.Client/Converters/StringEqualsConverter.cs new file mode 100644 index 00000000..31038a86 --- /dev/null +++ b/Client/OwnCord.Client/Converters/StringEqualsConverter.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows.Data; + +namespace OwnCord.Client.Converters; + +/// +/// Returns true when the bound string value equals the converter parameter (case-insensitive). +/// Useful for highlighting the active tab in a tab bar. +/// +[ValueConversion(typeof(string), typeof(bool))] +public sealed class StringEqualsConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string s && parameter is string p + && string.Equals(s, p, StringComparison.OrdinalIgnoreCase); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/MainWindow.xaml.cs b/Client/OwnCord.Client/MainWindow.xaml.cs index 877d62b5..5f11eabe 100644 --- a/Client/OwnCord.Client/MainWindow.xaml.cs +++ b/Client/OwnCord.Client/MainWindow.xaml.cs @@ -22,6 +22,7 @@ public partial class MainWindow : Window _mainVm = mainVm; connectVm.ConnectRequested += OnConnectRequested; + connectVm.TotpVerifyRequested += OnTotpVerifyRequested; RootFrame.Navigate(new ConnectPage(connectVm)); } @@ -32,12 +33,20 @@ public partial class MainWindow : Window try { + Models.AuthResponse result; if (isRegister) - await _chat.RegisterAsync(host, username, password, inviteCode ?? ""); + result = await _chat.RegisterAsync(host, username, password, inviteCode ?? ""); else - await _chat.LoginAsync(host, username, password); + 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)); @@ -58,6 +67,38 @@ public partial class MainWindow : Window } } + 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)); + + await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!); + } + catch (ApiException ex) + { + _connectVm.ErrorMessage = ex.Message; + } + catch (Exception ex) + { + _connectVm.ErrorMessage = $"Verification failed: {ex.Message}"; + } + finally + { + _connectVm.IsLoading = false; + } + } + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { base.OnClosing(e); diff --git a/Client/OwnCord.Client/Models/ApiResponses.cs b/Client/OwnCord.Client/Models/ApiResponses.cs index 83c8e16a..0be84111 100644 --- a/Client/OwnCord.Client/Models/ApiResponses.cs +++ b/Client/OwnCord.Client/Models/ApiResponses.cs @@ -5,7 +5,9 @@ namespace OwnCord.Client.Models; /// REST API response for login and register endpoints. public record AuthResponse( [property: JsonPropertyName("token")] string Token, - [property: JsonPropertyName("user")] ApiUser User + [property: JsonPropertyName("user")] ApiUser? User, + [property: JsonPropertyName("requires_2fa")] bool Requires2FA = false, + [property: JsonPropertyName("partial_token")] string? PartialToken = null ); /// User shape returned by auth endpoints. @@ -49,7 +51,17 @@ public record ApiMessage( [property: JsonPropertyName("pinned")] bool Pinned, [property: JsonPropertyName("timestamp")] string Timestamp, [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("avatar")] string? Avatar + [property: JsonPropertyName("avatar")] string? Avatar, + [property: JsonPropertyName("attachments")] IReadOnlyList? Attachments = null +); + +/// Single attachment from the REST API. +public record ApiAttachment( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("filename")] string Filename, + [property: JsonPropertyName("size")] long Size, + [property: JsonPropertyName("mime")] string Mime, + [property: JsonPropertyName("url")] string Url ); /// Error response shape from all REST endpoints. diff --git a/Client/OwnCord.Client/Models/Channel.cs b/Client/OwnCord.Client/Models/Channel.cs index ad6c3451..1171d598 100644 --- a/Client/OwnCord.Client/Models/Channel.cs +++ b/Client/OwnCord.Client/Models/Channel.cs @@ -9,5 +9,6 @@ public record Channel( string? Category, int Position, int UnreadCount, - long? LastMessageId + long? LastMessageId, + string? Topic = null ); diff --git a/Client/OwnCord.Client/Models/ChannelGroup.cs b/Client/OwnCord.Client/Models/ChannelGroup.cs new file mode 100644 index 00000000..a83c13a7 --- /dev/null +++ b/Client/OwnCord.Client/Models/ChannelGroup.cs @@ -0,0 +1,33 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace OwnCord.Client.Models; + +/// +/// Groups channels by category for the sidebar. Supports collapse/expand. +/// +public sealed class ChannelGroup : INotifyPropertyChanged +{ + private bool _isExpanded = true; + + public string? CategoryName { get; init; } + public ObservableCollection Items { get; } = []; + + public bool IsExpanded + { + get => _isExpanded; + set { if (_isExpanded != value) { _isExpanded = value; OnPropertyChanged(); } } + } + + /// Display name: uppercase category or empty for ungrouped. + public string DisplayName => CategoryName?.ToUpperInvariant() ?? string.Empty; + + /// True if this group has a category name (shows header). + public bool HasCategory => CategoryName is not null; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/Client/OwnCord.Client/Models/ChannelItem.cs b/Client/OwnCord.Client/Models/ChannelItem.cs new file mode 100644 index 00000000..eb1b2dbc --- /dev/null +++ b/Client/OwnCord.Client/Models/ChannelItem.cs @@ -0,0 +1,19 @@ +using System.Collections.ObjectModel; + +namespace OwnCord.Client.Models; + +/// +/// Wraps a Channel with its associated voice users for display in the sidebar. +/// +public sealed class ChannelItem +{ + public Channel Channel { get; init; } = null!; + public ObservableCollection VoiceUsers { get; } = []; + + // Convenience pass-through for binding + public long Id => Channel.Id; + public string Name => Channel.Name; + public ChannelType Type => Channel.Type; + public int UnreadCount => Channel.UnreadCount; + public string? Topic => Channel.Topic; +} diff --git a/Client/OwnCord.Client/Models/MemberGroup.cs b/Client/OwnCord.Client/Models/MemberGroup.cs new file mode 100644 index 00000000..9202747f --- /dev/null +++ b/Client/OwnCord.Client/Models/MemberGroup.cs @@ -0,0 +1,15 @@ +using System.Collections.ObjectModel; + +namespace OwnCord.Client.Models; + +/// +/// Groups members by their role for the member list sidebar. +/// +public sealed class MemberGroup +{ + public string RoleName { get; init; } = string.Empty; + public string? RoleColor { get; init; } + public int Position { get; init; } + public ObservableCollection Members { get; } = []; + public int MemberCount => Members.Count; +} diff --git a/Client/OwnCord.Client/Models/Message.cs b/Client/OwnCord.Client/Models/Message.cs index 7eece74f..2ef13f73 100644 --- a/Client/OwnCord.Client/Models/Message.cs +++ b/Client/OwnCord.Client/Models/Message.cs @@ -1,5 +1,13 @@ namespace OwnCord.Client.Models; +public record Attachment( + string Id, + string Filename, + long Size, + string Mime, + string Url +); + public record Message( long Id, long ChannelId, @@ -9,7 +17,8 @@ public record Message( long? ReplyToId, string? EditedAt, bool Deleted, - IReadOnlyList Reactions + IReadOnlyList Reactions, + IReadOnlyList Attachments ); public record Reaction(string Emoji, int Count, bool Me); diff --git a/Client/OwnCord.Client/Models/MessageDisplayItem.cs b/Client/OwnCord.Client/Models/MessageDisplayItem.cs new file mode 100644 index 00000000..f37b2a38 --- /dev/null +++ b/Client/OwnCord.Client/Models/MessageDisplayItem.cs @@ -0,0 +1,70 @@ +namespace OwnCord.Client.Models; + +/// +/// Wraps a Message with computed display properties for the UI. +/// Handles message grouping (consecutive same-author) and day dividers. +/// +public sealed class MessageDisplayItem +{ + public Message Message { get; } + + /// True when this message is from the same author as the previous one + /// and within 7 minutes — avatar and author name should be hidden. + public bool IsGrouped { get; } + + /// True when this message is the first of a new calendar day. + public bool ShowDayDivider { get; } + + /// Formatted day divider text (e.g. "March 15, 2026"). + public string? DayDividerText { get; } + + /// The message this replies to (if any) — set externally by the ViewModel. + public Message? ReplyToMessage { get; init; } + + // ── Pass-through convenience properties ── + + public long Id => Message.Id; + public User Author => Message.Author; + public string Content => Message.Content; + public DateTime Timestamp => Message.Timestamp; + public long? ReplyToId => Message.ReplyToId; + public string? EditedAt => Message.EditedAt; + public bool Deleted => Message.Deleted; + public IReadOnlyList Reactions => Message.Reactions; + public IReadOnlyList Attachments => Message.Attachments; + public bool IsEdited => EditedAt is not null; + public bool HasReactions => Reactions.Count > 0; + public bool HasAttachments => Attachments.Count > 0; + public bool IsReply => ReplyToId is not null && ReplyToMessage is not null; + public bool IsSystemMessage => Message.Author.Username == "System" || Content.StartsWith("["); + + /// True when the current user authored this message (for showing edit/delete actions). + public bool IsOwnMessage { get; init; } + + public MessageDisplayItem(Message message, Message? previousMessage) + { + Message = message; + + // Day divider logic + if (previousMessage is null || + message.Timestamp.Date != previousMessage.Timestamp.Date) + { + ShowDayDivider = true; + DayDividerText = message.Timestamp.Date == DateTime.Today + ? "Today" + : message.Timestamp.Date == DateTime.Today.AddDays(-1) + ? "Yesterday" + : message.Timestamp.ToString("MMMM d, yyyy"); + } + + // Grouping logic: same author, within 7 minutes, no day break, not a reply + if (previousMessage is not null && + !ShowDayDivider && + message.Author.Id == previousMessage.Author.Id && + message.ReplyToId is null && + (message.Timestamp - previousMessage.Timestamp).TotalMinutes <= 7) + { + IsGrouped = true; + } + } +} diff --git a/Client/OwnCord.Client/Models/ServerProfile.cs b/Client/OwnCord.Client/Models/ServerProfile.cs index a696664c..0ca0a199 100644 --- a/Client/OwnCord.Client/Models/ServerProfile.cs +++ b/Client/OwnCord.Client/Models/ServerProfile.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace OwnCord.Client.Models; public record ServerProfile( @@ -5,9 +7,21 @@ public record ServerProfile( string Name, string Host, string? LastUsername, - bool AutoConnect + 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) - => new(Guid.NewGuid().ToString(), name, host, lastUsername, autoConnect); + public static ServerProfile Create( + string name, + string host, + string? lastUsername = null, + bool autoConnect = false, + int port = 8443, + string color = "#5865f2") + => new(Guid.NewGuid().ToString(), name, host, lastUsername, autoConnect, port, color, null); + + /// Returns host:port for display, omitting port if it is the default 8443. + public string HostDisplay => Port == 8443 ? Host : $"{Host}:{Port}"; } diff --git a/Client/OwnCord.Client/Models/VoiceStateInfo.cs b/Client/OwnCord.Client/Models/VoiceStateInfo.cs new file mode 100644 index 00000000..1821af6a --- /dev/null +++ b/Client/OwnCord.Client/Models/VoiceStateInfo.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace OwnCord.Client.Models; + +/// +/// Mutable view-model-friendly class representing a user's voice state. +/// Implements INotifyPropertyChanged so the UI can bind to Speaking, Muted, etc. +/// +public sealed class VoiceStateInfo : INotifyPropertyChanged +{ + private bool _muted; + private bool _deafened; + private bool _speaking; + + public long UserId { get; init; } + public long ChannelId { get; set; } + public string Username { get; init; } = string.Empty; + + public bool Muted + { + get => _muted; + set { if (_muted != value) { _muted = value; OnPropertyChanged(); } } + } + + public bool Deafened + { + get => _deafened; + set { if (_deafened != value) { _deafened = value; OnPropertyChanged(); } } + } + + public bool Speaking + { + get => _speaking; + set { if (_speaking != value) { _speaking = value; OnPropertyChanged(); } } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/Client/OwnCord.Client/Models/WsEnvelope.cs b/Client/OwnCord.Client/Models/WsEnvelope.cs index 121bb4d6..4568ccb5 100644 --- a/Client/OwnCord.Client/Models/WsEnvelope.cs +++ b/Client/OwnCord.Client/Models/WsEnvelope.cs @@ -65,7 +65,8 @@ public record ChatMessagePayload( [property: JsonPropertyName("user")] WsUser User, [property: JsonPropertyName("content")] string Content, [property: JsonPropertyName("reply_to")] long? ReplyTo, - [property: JsonPropertyName("timestamp")] string Timestamp + [property: JsonPropertyName("timestamp")] string Timestamp, + [property: JsonPropertyName("attachments")] IReadOnlyList? Attachments = null ); public record ChatSendOkPayload( @@ -122,3 +123,31 @@ public record ChannelEventPayload( [property: JsonPropertyName("topic")] string? Topic, [property: JsonPropertyName("position")] int Position ); + +// ── Voice payloads ─────────────────────────────────────────────────────────── + +public record VoiceStatePayload( + [property: JsonPropertyName("user_id")] long UserId, + [property: JsonPropertyName("channel_id")] long ChannelId, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("muted")] bool Muted, + [property: JsonPropertyName("deafened")] bool Deafened +); + +public record VoiceLeavePayload( + [property: JsonPropertyName("user_id")] long UserId, + [property: JsonPropertyName("channel_id")] long ChannelId +); + +public record VoiceConfigPayload( + [property: JsonPropertyName("channel_id")] long ChannelId, + [property: JsonPropertyName("quality")] string Quality, + [property: JsonPropertyName("bitrate")] int Bitrate, + [property: JsonPropertyName("mode")] string Mode +); + +public record VoiceSpeakersPayload( + [property: JsonPropertyName("channel_id")] long ChannelId, + [property: JsonPropertyName("speakers")] IReadOnlyList Speakers, + [property: JsonPropertyName("mode")] string Mode +); diff --git a/Client/OwnCord.Client/Services/ApiClient.cs b/Client/OwnCord.Client/Services/ApiClient.cs index fbb1fd1b..ee4c8af8 100644 --- a/Client/OwnCord.Client/Services/ApiClient.cs +++ b/Client/OwnCord.Client/Services/ApiClient.cs @@ -108,6 +108,13 @@ public sealed class ApiClient : IApiClient return await ReadOrThrowAsync(response, ct); } + public async Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) + { + var body = new { partial_token = partialToken, code }; + var response = await PostJsonAsync(host, "/api/v1/auth/verify-totp", body, ct); + return await ReadOrThrowAsync(response, ct); + } + public async Task HealthCheckAsync(string host, CancellationToken ct = default) { TofuHostContext.CurrentHost = NormalizeHost(host); diff --git a/Client/OwnCord.Client/Services/ChatService.cs b/Client/OwnCord.Client/Services/ChatService.cs index 0d17f4a4..3bbbce40 100644 --- a/Client/OwnCord.Client/Services/ChatService.cs +++ b/Client/OwnCord.Client/Services/ChatService.cs @@ -38,6 +38,10 @@ public sealed class ChatService : IChatService public event Action? ChannelUpdated; public event Action? ChannelDeleted; public event Action? ConnectionLost; + public event Action? VoiceStateReceived; + public event Action? VoiceLeaveReceived; + public event Action? VoiceConfigReceived; + public event Action? VoiceSpeakersReceived; public ChatService(IApiClient api, IWebSocketService ws) { @@ -68,6 +72,15 @@ public sealed class ChatService : IChatService return result; } + public async Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default) + { + var result = await _api.VerifyTotpAsync(host, partialToken, code, ct); + _host = ApiClient.NormalizeHost(host); + CurrentToken = result.Token; + CurrentUser = result.User; + return result; + } + public async Task LogoutAsync(CancellationToken ct = default) { _intentionalDisconnect = true; @@ -139,6 +152,28 @@ public sealed class ChatService : IChatService 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 @@ -159,6 +194,54 @@ public sealed class ChatService : IChatService 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) @@ -217,6 +300,18 @@ public sealed class ChatService : IChatService if (delPayload?.TryGetProperty("id", out var idEl) == true) ChannelDeleted?.Invoke(idEl.GetInt64()); break; + case "voice_state": + VoiceStateReceived?.Invoke(Deserialize(envelope)); + break; + case "voice_leave": + VoiceLeaveReceived?.Invoke(Deserialize(envelope)); + break; + case "voice_config": + VoiceConfigReceived?.Invoke(Deserialize(envelope)); + break; + case "voice_speakers": + VoiceSpeakersReceived?.Invoke(Deserialize(envelope)); + break; // Unknown types silently ignored — forward compatibility } } diff --git a/Client/OwnCord.Client/Services/EmojiData.cs b/Client/OwnCord.Client/Services/EmojiData.cs new file mode 100644 index 00000000..a9491b5e --- /dev/null +++ b/Client/OwnCord.Client/Services/EmojiData.cs @@ -0,0 +1,16 @@ +namespace OwnCord.Client.Services; + +public static class EmojiData +{ + public record EmojiCategory(string Name, IReadOnlyList Emojis); + + public static IReadOnlyList Categories { get; } = new[] + { + new EmojiCategory("Smileys", new[] { "\U0001F600", "\U0001F603", "\U0001F604", "\U0001F601", "\U0001F606", "\U0001F605", "\U0001F923", "\U0001F602", "\U0001F642", "\U0001F60A", "\U0001F607", "\U0001F970", "\U0001F60D", "\U0001F929", "\U0001F618", "\U0001F617", "\U0001F61A", "\U0001F619", "\U0001F972", "\U0001F60B", "\U0001F61B", "\U0001F61C", "\U0001F92A", "\U0001F61D", "\U0001F911", "\U0001F917", "\U0001F92D", "\U0001F92B", "\U0001F914", "\U0001FAE1", "\U0001F910", "\U0001F928", "\U0001F610", "\U0001F611", "\U0001F636", "\U0001FAE5", "\U0001F60F", "\U0001F612", "\U0001F644", "\U0001F62C", "\U0001F925", "\U0001F60C", "\U0001F614", "\U0001F62A", "\U0001F924", "\U0001F634", "\U0001F637", "\U0001F912", "\U0001F915" }), + new EmojiCategory("People", new[] { "\U0001F44B", "\U0001F91A", "\U0001F590", "\u270B", "\U0001F596", "\U0001F44C", "\U0001F90C", "\U0001F90F", "\u270C", "\U0001F91E", "\U0001F91F", "\U0001F918", "\U0001F919", "\U0001F448", "\U0001F449", "\U0001F446", "\U0001F595", "\U0001F447", "\u261D", "\U0001F44D", "\U0001F44E", "\u270A", "\U0001F44A", "\U0001F91B", "\U0001F91C", "\U0001F44F", "\U0001F64C", "\U0001F450", "\U0001F932", "\U0001F91D", "\U0001F64F" }), + new EmojiCategory("Nature", new[] { "\U0001F436", "\U0001F431", "\U0001F42D", "\U0001F439", "\U0001F430", "\U0001F98A", "\U0001F43B", "\U0001F43C", "\U0001F43B\u200D\u2744", "\U0001F428", "\U0001F42F", "\U0001F981", "\U0001F42E", "\U0001F437", "\U0001F438", "\U0001F435", "\U0001F338", "\U0001F339", "\U0001F33A", "\U0001F33B", "\U0001F33C", "\U0001F337", "\U0001F331", "\U0001F332", "\U0001F333", "\U0001F334", "\U0001F340", "\U0001F341", "\U0001F342", "\U0001F343" }), + new EmojiCategory("Food", new[] { "\U0001F34E", "\U0001F350", "\U0001F34A", "\U0001F34B", "\U0001F34C", "\U0001F349", "\U0001F347", "\U0001F353", "\U0001FAD0", "\U0001F348", "\U0001F352", "\U0001F351", "\U0001F96D", "\U0001F34D", "\U0001F965", "\U0001F95D", "\U0001F345", "\U0001F346", "\U0001F951", "\U0001F966", "\U0001F96C", "\U0001F336", "\U0001F33D", "\U0001F955", "\U0001F9C4", "\U0001F9C5", "\U0001F954", "\U0001F360", "\U0001F950", "\U0001F355" }), + new EmojiCategory("Objects", new[] { "\u231A", "\U0001F4F1", "\U0001F4BB", "\u2328", "\U0001F5A5", "\U0001F5A8", "\U0001F5B1", "\U0001F4BF", "\U0001F4C0", "\U0001F3AE", "\U0001F579", "\U0001F3A7", "\U0001F3A4", "\U0001F3B5", "\U0001F3B6", "\U0001F3B8", "\U0001F3B9", "\U0001F3BA", "\U0001F3BB", "\U0001F941", "\U0001F4F7", "\U0001F4F8", "\U0001F4F9", "\U0001F3AC", "\U0001F4FA", "\U0001F4FB", "\U0001F514", "\U0001F515", "\U0001F4E3", "\U0001F4A1" }), + new EmojiCategory("Symbols", new[] { "\u2764", "\U0001F9E1", "\U0001F49B", "\U0001F49A", "\U0001F499", "\U0001F49C", "\U0001F5A4", "\U0001F90D", "\U0001F90E", "\U0001F494", "\u2763", "\U0001F495", "\U0001F49E", "\U0001F493", "\U0001F497", "\U0001F496", "\U0001F498", "\U0001F49D", "\u2B50", "\U0001F31F", "\U0001F4AB", "\u2728", "\u26A1", "\U0001F525", "\U0001F4A5", "\U0001F389", "\U0001F38A", "\u2705", "\u274C", "\u26A0" }) + }; +} diff --git a/Client/OwnCord.Client/Services/IApiClient.cs b/Client/OwnCord.Client/Services/IApiClient.cs index f487ca90..b5abc63c 100644 --- a/Client/OwnCord.Client/Services/IApiClient.cs +++ b/Client/OwnCord.Client/Services/IApiClient.cs @@ -12,4 +12,5 @@ public interface IApiClient Task> GetChannelsAsync(string host, string token, CancellationToken ct = default); Task GetMessagesAsync(string host, string token, long channelId, int limit = 50, long? before = null, CancellationToken ct = default); Task HealthCheckAsync(string host, CancellationToken ct = default); + Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default); } diff --git a/Client/OwnCord.Client/Services/IChatService.cs b/Client/OwnCord.Client/Services/IChatService.cs index 48a08d78..44c4d010 100644 --- a/Client/OwnCord.Client/Services/IChatService.cs +++ b/Client/OwnCord.Client/Services/IChatService.cs @@ -19,6 +19,7 @@ public interface IChatService Task LoginAsync(string host, string username, string password, CancellationToken ct = default); Task RegisterAsync(string host, string username, string password, string inviteCode, CancellationToken ct = default); Task LogoutAsync(CancellationToken ct = default); + Task VerifyTotpAsync(string host, string partialToken, string code, CancellationToken ct = default); // ── WebSocket lifecycle ───────────────────────────────────────────────── @@ -33,8 +34,18 @@ public interface IChatService // ── 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) ──────────────────────────────────────────── @@ -54,4 +65,11 @@ public interface IChatService event Action? ChannelUpdated; event Action? ChannelDeleted; event Action? ConnectionLost; + + // ── Voice events ──────────────────────────────────────────────────────── + + event Action? VoiceStateReceived; + event Action? VoiceLeaveReceived; + event Action? VoiceConfigReceived; + event Action? VoiceSpeakersReceived; } diff --git a/Client/OwnCord.Client/Services/MessageContentParser.cs b/Client/OwnCord.Client/Services/MessageContentParser.cs new file mode 100644 index 00000000..d3c8405b --- /dev/null +++ b/Client/OwnCord.Client/Services/MessageContentParser.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; + +namespace OwnCord.Client.Services; + +/// +/// Parses message content into segments for rich rendering. +/// Handles code blocks (```), inline code (`), bold (**), italic (*), and plain text. +/// +public static class MessageContentParser +{ + public enum SegmentType { Text, CodeBlock, InlineCode, Bold, Italic } + + public record ContentSegment(SegmentType Type, string Text, string? Language = null); + + // Matches ```language\n...\n``` (multiline) + private static readonly Regex CodeBlockRegex = new( + @"```(\w*)\n?([\s\S]*?)```", + RegexOptions.Compiled); + + // Matches `...` (single backtick inline code, no newlines) + private static readonly Regex InlineCodeRegex = new( + @"`([^`\n]+)`", + RegexOptions.Compiled); + + // Matches **...** (bold) + private static readonly Regex BoldRegex = new( + @"\*\*(.+?)\*\*", + RegexOptions.Compiled); + + // Matches *...* (italic, but not **) + private static readonly Regex ItalicRegex = new( + @"(? Parse(string content) + { + if (string.IsNullOrEmpty(content)) + return Array.Empty(); + + var segments = new List(); + ParseCodeBlocks(content, segments); + return segments; + } + + private static void ParseCodeBlocks(string text, List segments) + { + var lastIndex = 0; + + foreach (Match match in CodeBlockRegex.Matches(text)) + { + if (match.Index > lastIndex) + { + ParseInlineCode(text[lastIndex..match.Index], segments); + } + + var language = match.Groups[1].Value; + var code = match.Groups[2].Value; + segments.Add(new ContentSegment( + SegmentType.CodeBlock, + code, + string.IsNullOrEmpty(language) ? null : language)); + + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + ParseInlineCode(text[lastIndex..], segments); + } + } + + private static void ParseInlineCode(string text, List segments) + { + var lastIndex = 0; + + foreach (Match match in InlineCodeRegex.Matches(text)) + { + if (match.Index > lastIndex) + { + ParseBoldAndItalic(text[lastIndex..match.Index], segments); + } + + segments.Add(new ContentSegment(SegmentType.InlineCode, match.Groups[1].Value)); + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + ParseBoldAndItalic(text[lastIndex..], segments); + } + } + + private static void ParseBoldAndItalic(string text, List segments) + { + var lastIndex = 0; + + foreach (Match match in BoldRegex.Matches(text)) + { + if (match.Index > lastIndex) + { + ParseItalic(text[lastIndex..match.Index], segments); + } + + segments.Add(new ContentSegment(SegmentType.Bold, match.Groups[1].Value)); + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + ParseItalic(text[lastIndex..], segments); + } + } + + private static void ParseItalic(string text, List segments) + { + var lastIndex = 0; + + foreach (Match match in ItalicRegex.Matches(text)) + { + if (match.Index > lastIndex) + { + AddTextSegment(text[lastIndex..match.Index], segments); + } + + segments.Add(new ContentSegment(SegmentType.Italic, match.Groups[1].Value)); + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + AddTextSegment(text[lastIndex..], segments); + } + } + + private static void AddTextSegment(string text, List segments) + { + if (!string.IsNullOrEmpty(text)) + { + segments.Add(new ContentSegment(SegmentType.Text, text)); + } + } +} diff --git a/Client/OwnCord.Client/Services/ToastService.cs b/Client/OwnCord.Client/Services/ToastService.cs new file mode 100644 index 00000000..f39b5fc0 --- /dev/null +++ b/Client/OwnCord.Client/Services/ToastService.cs @@ -0,0 +1,22 @@ +namespace OwnCord.Client.Services; + +public sealed class ToastService +{ + public string? CurrentMessage { get; private set; } + public bool IsVisible { get; private set; } + + public event Action? ToastChanged; + + public void Show(string message) + { + CurrentMessage = message; + IsVisible = true; + ToastChanged?.Invoke(); + } + + public void Hide() + { + IsVisible = false; + ToastChanged?.Invoke(); + } +} diff --git a/Client/OwnCord.Client/Themes/Colors.xaml b/Client/OwnCord.Client/Themes/Colors.xaml new file mode 100644 index 00000000..ef34b759 --- /dev/null +++ b/Client/OwnCord.Client/Themes/Colors.xaml @@ -0,0 +1,71 @@ + + + + #1e1f22 + #2b2d31 + #313338 + #383a40 + #35373c + #404249 + #B3000000 + + + + + + + + + + + #5865f2 + #4752c4 + #3c45a5 + + + + + + + #dbdee1 + #949ba4 + #80848e + #6d6f78 + #00a8fc + + + + + + + + + #23a55a + #f0b232 + #f23f43 + + + + + + + #3f4147 + #4e5058 + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Themes/Controls.xaml b/Client/OwnCord.Client/Themes/Controls.xaml new file mode 100644 index 00000000..8ef8a2c4 --- /dev/null +++ b/Client/OwnCord.Client/Themes/Controls.xaml @@ -0,0 +1,285 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Themes/Typography.xaml b/Client/OwnCord.Client/Themes/Typography.xaml new file mode 100644 index 00000000..deeb6830 --- /dev/null +++ b/Client/OwnCord.Client/Themes/Typography.xaml @@ -0,0 +1,50 @@ + + + + Segoe UI Variable Display, Segoe UI, Segoe UI Symbol + Segoe UI Variable Text, Segoe UI, Segoe UI Symbol + Cascadia Code, Consolas, Courier New + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs b/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs index 6c44a4cc..038e5f23 100644 --- a/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs +++ b/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs @@ -1,5 +1,8 @@ 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; @@ -9,8 +12,11 @@ public sealed class ConnectViewModel : ViewModelBase { private readonly IProfileService _profiles; private readonly ICredentialService _credentials; + private readonly IApiClient _api; + private readonly Dictionary _healthStatuses = new(); private string _host = string.Empty; + private int _port = 8443; private string _username = string.Empty; private string _password = string.Empty; private string _inviteCode = string.Empty; @@ -19,19 +25,32 @@ public sealed class ConnectViewModel : ViewModelBase 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) + public ConnectViewModel(IProfileService profiles, ICredentialService credentials, IApiClient api) { _profiles = profiles; _credentials = credentials; + _api = api; ConnectCommand = new RelayCommand(OnConnect, CanConnect); SaveProfileCommand = new RelayCommand(OnSaveProfile, CanSaveProfile); DeleteProfileCommand = new RelayCommand(OnDeleteProfile, () => SelectedProfile is not null); + AddProfileCommand = new RelayCommand(OnAddProfile); + EditProfileCommand = new RelayCommand(OnEditProfile); + VerifyTotpCommand = new RelayCommand(OnVerifyTotp, CanVerifyTotp); + ImportProfilesCommand = new RelayCommand(OnImportProfiles); + ExportProfilesCommand = new RelayCommand(OnExportProfiles, () => Profiles.Count > 0); + CancelTotpCommand = new RelayCommand(OnCancelTotp); + RefreshHealthCommand = new RelayCommand(OnRefreshHealth, () => Profiles.Count > 0); Profiles = new ObservableCollection(profiles.LoadProfiles()); Profiles.CollectionChanged += (_, _) => { OnPropertyChanged(nameof(HasProfiles)); OnPropertyChanged(nameof(HasNoProfiles)); + ((RelayCommand)ExportProfilesCommand).RaiseCanExecuteChanged(); + ((RelayCommand)RefreshHealthCommand).RaiseCanExecuteChanged(); }; } @@ -48,6 +67,16 @@ public sealed class ConnectViewModel : ViewModelBase } } + public int Port + { + get => _port; + set + { + if (SetField(ref _port, value)) + RaiseCanExecuteChanged(); + } + } + public string Username { get => _username; @@ -102,6 +131,28 @@ public sealed class ConnectViewModel : ViewModelBase 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; @@ -110,9 +161,10 @@ public sealed class ConnectViewModel : ViewModelBase 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.Host, value.LastUsername ?? ""); + var saved = _credentials.LoadPassword(value.HostDisplay, value.LastUsername ?? ""); if (saved is not null) { Password = saved; @@ -138,24 +190,109 @@ public sealed class ConnectViewModel : ViewModelBase public ICommand ConnectCommand { get; } public ICommand SaveProfileCommand { get; } public ICommand DeleteProfileCommand { get; } + public ICommand AddProfileCommand { get; } + public ICommand EditProfileCommand { get; } + public ICommand VerifyTotpCommand { get; } + public ICommand ImportProfilesCommand { get; } + public ICommand ExportProfilesCommand { get; } + public ICommand CancelTotpCommand { get; } + public ICommand RefreshHealthCommand { get; } + + /// Gets the health status string for a given profile ID. + public string GetHealthStatus(string profileId) + => _healthStatuses.TryGetValue(profileId, out var s) ? s : "unknown"; + + /// Pings every saved server's health endpoint in parallel and updates statuses. + public async Task RefreshHealthAsync() + { + if (Profiles.Count == 0) return; + + var tasks = Profiles.Select(async profile => + { + _healthStatuses[profile.Id] = "checking"; + OnPropertyChanged(nameof(Profiles)); + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var health = await _api.HealthCheckAsync(profile.HostDisplay, cts.Token); + _healthStatuses[profile.Id] = health.Status == "ok" ? "online" : "offline"; + } + catch + { + _healthStatuses[profile.Id] = "offline"; + } + }); + await Task.WhenAll(tasks); + OnPropertyChanged(nameof(Profiles)); + HealthStatusChanged?.Invoke(); + } + + /// Raised when any health status changes so the view can refresh bindings. + public event Action? HealthStatusChanged; /// Args: host, username, password, inviteCode?, isRegister public event Action? ConnectRequested; + /// Args: host, partialToken, totpCode + public event Action? TotpVerifyRequested; + + /// Raised to open the add/edit server profile dialog. Arg: profile to edit (null = add new). + public event Action? EditProfileRequested; + + /// + /// Called when login returns requires_2fa. Sets up the TOTP entry UI state. + /// + public void Enter2FAMode(string partialToken) + { + PartialToken = partialToken; + IsTotpRequired = true; + TotpCode = string.Empty; + ErrorMessage = null; + } + + /// Applies a saved or new profile from the dialog. + public void ApplyProfileFromDialog(ServerProfile profile, bool isNew) + { + if (isNew) + { + var updated = _profiles.AddProfile([.. Profiles], profile); + _profiles.SaveProfiles(updated); + Profiles.Add(profile); + } + else + { + var index = -1; + for (var i = 0; i < Profiles.Count; i++) + { + if (Profiles[i].Id == profile.Id) { index = i; break; } + } + if (index >= 0) + { + Profiles[index] = profile; + var updated = _profiles.UpdateProfile([.. Profiles], profile); + _profiles.SaveProfiles(updated); + } + } + } + private bool CanConnect() => !_isLoading && !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(Username); - private void OnConnect() => - ConnectRequested?.Invoke(Host, Username, Password, IsRegisterMode ? InviteCode : null, IsRegisterMode); + 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); + var profile = ServerProfile.Create(Host, Host, Username, port: Port); var updated = _profiles.AddProfile([.. Profiles], profile); _profiles.SaveProfiles(updated); Profiles.Add(profile); @@ -170,6 +307,88 @@ public sealed class ConnectViewModel : ViewModelBase 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 json = File.ReadAllText(dlg.FileName); + var imported = JsonSerializer.Deserialize>(json); + if (imported is null || imported.Count == 0) return; + + var current = Profiles.ToList(); + var existingHosts = new HashSet(current.Select(p => p.HostDisplay), StringComparer.OrdinalIgnoreCase); + + foreach (var profile in imported) + { + if (!existingHosts.Contains(profile.HostDisplay)) + { + current.Add(profile); + Profiles.Add(profile); + existingHosts.Add(profile.HostDisplay); + } + } + + _profiles.SaveProfiles(current); + } + catch (Exception ex) + { + ErrorMessage = $"Import failed: {ex.Message}"; + } + } + + private void OnExportProfiles() + { + var dlg = new SaveFileDialog + { + Filter = "JSON files (*.json)|*.json", + Title = "Export Server Profiles", + FileName = "owncord-profiles.json" + }; + if (dlg.ShowDialog() != true) return; + + try + { + var json = JsonSerializer.Serialize(Profiles.ToList(), new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(dlg.FileName, json); + } + catch (Exception ex) + { + ErrorMessage = $"Export failed: {ex.Message}"; + } + } + /// Persist or remove the saved password based on the checkbox state. public void PersistPasswordIfRequested(string host, string username, string password) { @@ -179,6 +398,27 @@ public sealed class ConnectViewModel : ViewModelBase _credentials.DeletePassword(host, username); } + /// Updates the LastConnected timestamp on the matching profile. + public void MarkProfileConnected(string host) + { + for (var i = 0; i < Profiles.Count; i++) + { + if (string.Equals(Profiles[i].HostDisplay, host, StringComparison.OrdinalIgnoreCase) || + string.Equals(Profiles[i].Host, host, StringComparison.OrdinalIgnoreCase)) + { + var updated = Profiles[i] with { LastConnected = DateTime.UtcNow, LastUsername = Username }; + Profiles[i] = updated; + _profiles.SaveProfiles([.. Profiles]); + break; + } + } + } + + private async void OnRefreshHealth() + { + await RefreshHealthAsync(); + } + private void RaiseCanExecuteChanged() { ((RelayCommand)ConnectCommand).RaiseCanExecuteChanged(); diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs index 12762d94..8c7fdc8f 100644 --- a/Client/OwnCord.Client/ViewModels/MainViewModel.cs +++ b/Client/OwnCord.Client/ViewModels/MainViewModel.cs @@ -15,13 +15,65 @@ public sealed class MainViewModel : ViewModelBase private bool _isTyping; private string? _connectionStatus; private Timer? _typingTimer; + private bool _isMemberListVisible = true; + private bool _isInVoice; + private string? _voiceChannelName; + private long _voiceChannelId; + private bool _isMuted; + private bool _isDeafened; + private Message? _replyingToMessage; + private long? _editingMessageId; + private ObservableCollection _serverProfiles = []; + private ServerProfile? _activeServer; + private bool _showStatusPicker; + private bool _showSettings; + private bool _showEmojiPicker; + private string _toastMessage = string.Empty; + private bool _showToast; + private User? _popupUser; + private bool _showUserPopup; + private double _userPopupX; + private double _userPopupY; + private bool _isHomeView; + private string _selectedFriendsTab = "online"; + private string _friendSearchText = string.Empty; public MainViewModel() { Channels = []; Members = []; Messages = []; + DisplayMessages = []; + Roles = []; + ChannelGroups = []; + MemberGroups = []; + VoiceStates = []; + SendMessageCommand = new RelayCommand(OnSendMessage, () => !string.IsNullOrWhiteSpace(MessageInput) && SelectedChannel is not null); + ToggleMemberListCommand = new RelayCommand(() => IsMemberListVisible = !IsMemberListVisible); + JoinVoiceCommand = new RelayCommand(OnJoinVoice); + LeaveVoiceCommand = new RelayCommand(OnLeaveVoice); + ToggleMuteCommand = new RelayCommand(OnToggleMute); + ToggleDeafenCommand = new RelayCommand(OnToggleDeafen); + ToggleCategoryCommand = new RelayCommand(OnToggleCategory); + SelectChannelCommand = new RelayCommand(OnSelectChannel); + StartReplyCommand = new RelayCommand(OnStartReply); + CancelReplyCommand = new RelayCommand(OnCancelReply); + DeleteMessageCommand = new RelayCommand(OnDeleteMessage); + StartEditCommand = new RelayCommand(OnStartEdit); + SelectServerCommand = new RelayCommand(p => { ActiveServer = p; IsHomeView = false; }); + AddServerCommand = new RelayCommand(() => { /* placeholder for future dialog */ }); + ToggleStatusPickerCommand = new RelayCommand(() => ShowStatusPicker = !ShowStatusPicker); + ChangeStatusCommand = new RelayCommand(OnChangeStatus); + OpenSettingsCommand = new RelayCommand(() => ShowSettings = true); + CloseSettingsCommand = new RelayCommand(() => ShowSettings = false); + ToggleEmojiPickerCommand = new RelayCommand(() => ShowEmojiPicker = !ShowEmojiPicker); + InsertEmojiCommand = new RelayCommand(OnInsertEmoji); + ShowUserPopupCommand = new RelayCommand(OnShowUserPopup); + CloseUserPopupCommand = new RelayCommand(OnCloseUserPopup); + HomeCommand = new RelayCommand(OnHome); + SelectFriendsTabCommand = new RelayCommand(OnSelectFriendsTab); + MessageFriendCommand = new RelayCommand(OnMessageFriend); } /// Wire up ChatService events. Called once after login succeeds. @@ -40,6 +92,9 @@ public sealed class MainViewModel : ViewModelBase chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p)); chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id)); chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r)); + chat.VoiceStateReceived += p => RunOnUI(() => OnVoiceState(p)); + chat.VoiceLeaveReceived += p => RunOnUI(() => OnVoiceLeave(p)); + chat.VoiceSpeakersReceived += p => RunOnUI(() => OnVoiceSpeakers(p)); } private static void RunOnUI(Action action) @@ -50,6 +105,8 @@ public sealed class MainViewModel : ViewModelBase action(); } + // ── Connection status ──────────────────────────────────────────────────── + public string? ConnectionStatus { get => _connectionStatus; @@ -62,9 +119,18 @@ public sealed class MainViewModel : ViewModelBase public bool HasConnectionIssue => _connectionStatus is not null; + // ── Collections ────────────────────────────────────────────────────────── + public ObservableCollection Channels { get; } public ObservableCollection Members { get; } public ObservableCollection Messages { get; } + public ObservableCollection DisplayMessages { get; } + public ObservableCollection Roles { get; } + public ObservableCollection ChannelGroups { get; } + public ObservableCollection MemberGroups { get; } + public ObservableCollection VoiceStates { get; } + + // ── Selected channel ───────────────────────────────────────────────────── public Channel? SelectedChannel { @@ -73,10 +139,19 @@ public sealed class MainViewModel : ViewModelBase { if (SetField(ref _selectedChannel, value)) { + OnPropertyChanged(nameof(SelectedChannelTopic)); Messages.Clear(); + DisplayMessages.Clear(); ((RelayCommand)SendMessageCommand).RaiseCanExecuteChanged(); if (value is not null) { + // Voice channels join voice instead of loading messages + if (value.Type == ChannelType.Voice) + { + _ = _chat?.JoinVoiceAsync(value.Id); + return; + } + _ = _chat?.SendChannelFocusAsync(value.Id); _ = LoadMessagesForChannelAsync(value.Id); } @@ -84,6 +159,10 @@ public sealed class MainViewModel : ViewModelBase } } + public string? SelectedChannelTopic => _selectedChannel?.Topic; + + // ── Message input ──────────────────────────────────────────────────────── + public string MessageInput { get => _messageInput; @@ -102,23 +181,265 @@ public sealed class MainViewModel : ViewModelBase public string? TypingText { get; private set; } + // ── Member list visibility ─────────────────────────────────────────────── + + public bool IsMemberListVisible + { + get => _isMemberListVisible; + set => SetField(ref _isMemberListVisible, value); + } + + // ── Voice state (local user) ───────────────────────────────────────────── + + public bool IsInVoice + { + get => _isInVoice; + set => SetField(ref _isInVoice, value); + } + + public string? VoiceChannelName + { + get => _voiceChannelName; + set => SetField(ref _voiceChannelName, value); + } + + public bool IsMuted + { + get => _isMuted; + set => SetField(ref _isMuted, value); + } + + public bool IsDeafened + { + get => _isDeafened; + set => SetField(ref _isDeafened, value); + } + + // ── Current user info (for user bar) ───────────────────────────────────── + + public string CurrentUsername => _chat?.CurrentUser?.Username ?? "Unknown"; + public string CurrentUserStatus => _chat?.CurrentUser?.Status ?? "offline"; + + public UserStatus CurrentUserStatusEnum => CurrentUserStatus switch + { + "online" => UserStatus.Online, + "idle" => UserStatus.Idle, + "dnd" => UserStatus.Dnd, + _ => UserStatus.Offline + }; + + // ── Home / Friends view ──────────────────────────────────────────────── + + public bool IsHomeView + { + get => _isHomeView; + set => SetField(ref _isHomeView, value); + } + + public string SelectedFriendsTab + { + get => _selectedFriendsTab; + set => SetField(ref _selectedFriendsTab, value); + } + + public string FriendSearchText + { + get => _friendSearchText; + set => SetField(ref _friendSearchText, value); + } + + // ── Settings overlay ────────────────────────────────────────────────── + + public bool ShowSettings + { + get => _showSettings; + set => SetField(ref _showSettings, value); + } + + // ── Status picker ───────────────────────────────────────────────────── + + public bool ShowStatusPicker + { + get => _showStatusPicker; + set => SetField(ref _showStatusPicker, value); + } + + // ── Emoji picker ────────────────────────────────────────────────────── + + public bool ShowEmojiPicker + { + get => _showEmojiPicker; + set => SetField(ref _showEmojiPicker, value); + } + + // ── Toast notification ────────────────────────────────────────────────── + + public string ToastMessage + { + get => _toastMessage; + set => SetField(ref _toastMessage, value); + } + + public bool ShowToast + { + get => _showToast; + set => SetField(ref _showToast, value); + } + + public void ShowToastMessage(string message) + { + ToastMessage = message; + ShowToast = true; + } + + // ── Commands ───────────────────────────────────────────────────────────── + public ICommand SendMessageCommand { get; } + public ICommand ToggleMemberListCommand { get; } + public ICommand JoinVoiceCommand { get; } + public ICommand LeaveVoiceCommand { get; } + public ICommand ToggleMuteCommand { get; } + public ICommand ToggleDeafenCommand { get; } + public ICommand ToggleCategoryCommand { get; } + public ICommand SelectChannelCommand { get; } + public ICommand StartReplyCommand { get; } + public ICommand CancelReplyCommand { get; } + public ICommand DeleteMessageCommand { get; } + public ICommand StartEditCommand { get; } + public ICommand SelectServerCommand { get; } + public ICommand AddServerCommand { get; } + public ICommand ToggleStatusPickerCommand { get; } + public ICommand ChangeStatusCommand { get; } + public ICommand OpenSettingsCommand { get; } + public ICommand CloseSettingsCommand { get; } + public ICommand ToggleEmojiPickerCommand { get; } + public ICommand InsertEmojiCommand { get; } + public ICommand ShowUserPopupCommand { get; } + public ICommand CloseUserPopupCommand { get; } + public ICommand HomeCommand { get; } + public ICommand SelectFriendsTabCommand { get; } + public ICommand MessageFriendCommand { get; } + + // ── User popup state ──────────────────────────────────────────────────── + + public User? PopupUser + { + get => _popupUser; + set => SetField(ref _popupUser, value); + } + + public bool ShowUserPopup + { + get => _showUserPopup; + set => SetField(ref _showUserPopup, value); + } + + public double UserPopupX + { + get => _userPopupX; + set => SetField(ref _userPopupX, value); + } + + public double UserPopupY + { + get => _userPopupY; + set => SetField(ref _userPopupY, value); + } + + /// Resolved role name for the popup user. + public string PopupUserRoleName + { + get + { + if (_popupUser is null) return "Member"; + var role = Roles.FirstOrDefault(r => r.Id == _popupUser.RoleId); + return role?.Name ?? "Member"; + } + } + + /// Resolved role color for the popup user. + public string PopupUserRoleColor + { + get + { + if (_popupUser is null) return "#949ba4"; + var role = Roles.FirstOrDefault(r => r.Id == _popupUser.RoleId); + return role?.Color ?? "#949ba4"; + } + } + + /// Status text for the popup user. + public string PopupUserStatusText => _popupUser?.Status switch + { + UserStatus.Online => "Online", + UserStatus.Idle => "Idle", + UserStatus.Dnd => "Do Not Disturb", + _ => "Offline" + }; + + // ── Reply state ─────────────────────────────────────────────────────── + + public Message? ReplyingToMessage + { + get => _replyingToMessage; + set + { + if (SetField(ref _replyingToMessage, value)) + OnPropertyChanged(nameof(IsReplying)); + } + } + + public bool IsReplying => _replyingToMessage is not null; + + // ── Edit state ──────────────────────────────────────────────────────── + + public long? EditingMessageId + { + get => _editingMessageId; + set => SetField(ref _editingMessageId, value); + } + + // ── Server strip ────────────────────────────────────────────────────────── + + public ObservableCollection ServerProfiles + { + get => _serverProfiles; + set => SetField(ref _serverProfiles, value); + } + + public ServerProfile? ActiveServer + { + get => _activeServer; + set => SetField(ref _activeServer, value); + } + + // ── Public helpers ─────────────────────────────────────────────────────── + + public void LoadServerProfiles(IReadOnlyList profiles) + { + ServerProfiles = new ObservableCollection(profiles); + if (ActiveServer is null && ServerProfiles.Count > 0) + ActiveServer = ServerProfiles[0]; + } public void LoadChannels(IEnumerable channels) { Channels.Clear(); foreach (var ch in channels) Channels.Add(ch); + RebuildChannelGroups(); } public void LoadMembers(IEnumerable members) { Members.Clear(); foreach (var m in members) Members.Add(m); + RebuildMemberGroups(); } public void AddMessage(Message message) { Messages.Add(message); + AppendDisplayMessage(message); } public void UpdateUnreadCount(long channelId, int count) @@ -127,6 +448,7 @@ public sealed class MainViewModel : ViewModelBase if (idx < 0) return; var updated = Channels[idx] with { UnreadCount = count }; Channels[idx] = updated; + RebuildChannelGroups(); } public void ShowTyping(string username) @@ -143,15 +465,265 @@ public sealed class MainViewModel : ViewModelBase OnPropertyChanged(nameof(TypingText)); } + /// Get voice users for a specific channel. + public IEnumerable GetVoiceUsersForChannel(long channelId) + => VoiceStates.Where(vs => vs.ChannelId == channelId); + + // ── Command handlers ───────────────────────────────────────────────────── + + private void OnInsertEmoji(string? emoji) + { + if (string.IsNullOrEmpty(emoji)) return; + MessageInput += emoji; + ShowEmojiPicker = false; + } + + private void OnChangeStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) return; + ShowStatusPicker = false; + _ = _chat?.SendStatusChangeAsync(status); + OnPropertyChanged(nameof(CurrentUserStatus)); + OnPropertyChanged(nameof(CurrentUserStatusEnum)); + } + private void OnSendMessage() { if (_chat is null || SelectedChannel is null || string.IsNullOrWhiteSpace(MessageInput)) return; var channelId = SelectedChannel.Id; var content = MessageInput; MessageInput = string.Empty; - _ = _chat.SendMessageAsync(channelId, content); + + if (EditingMessageId is { } editId) + { + EditingMessageId = null; + _ = _chat.EditMessageAsync(editId, content); + } + else + { + var replyTo = ReplyingToMessage?.Id; + ReplyingToMessage = null; + _ = _chat.SendMessageAsync(channelId, content, replyTo); + } } + private void OnSelectChannel(object? param) + { + var channel = param switch + { + Channel ch => ch, + ChannelItem ci => ci.Channel, + _ => null + }; + if (channel is null) return; + SelectedChannel = channel; + } + + private void OnJoinVoice(Channel? channel) + { + if (_chat is null || channel is null || channel.Type != ChannelType.Voice) return; + _ = _chat.JoinVoiceAsync(channel.Id); + } + + private void OnLeaveVoice() + { + if (_chat is null) return; + _ = _chat.LeaveVoiceAsync(); + IsInVoice = false; + VoiceChannelName = null; + IsMuted = false; + IsDeafened = false; + } + + private void OnToggleMute() + { + if (_chat is null || !IsInVoice) return; + IsMuted = !IsMuted; + _ = _chat.SendVoiceMuteAsync(IsMuted); + } + + private void OnToggleDeafen() + { + if (_chat is null || !IsInVoice) return; + IsDeafened = !IsDeafened; + if (IsDeafened) IsMuted = true; + _ = _chat.SendVoiceDeafenAsync(IsDeafened); + } + + private static void OnToggleCategory(ChannelGroup? group) + { + if (group is null) return; + group.IsExpanded = !group.IsExpanded; + } + + private void OnStartReply(Message? message) + { + if (message is null) return; + ReplyingToMessage = message; + } + + private void OnCancelReply() + { + ReplyingToMessage = null; + } + + private void OnDeleteMessage(Message? message) + { + if (_chat is null || message is null) return; + _ = _chat.DeleteMessageAsync(message.Id); + } + + private void OnStartEdit(Message? message) + { + if (message is null) return; + EditingMessageId = message.Id; + MessageInput = message.Content; + } + + private void OnShowUserPopup(object? param) + { + var user = param switch + { + User u => u, + MessageDisplayItem di => di.Author, + Message m => m.Author, + _ => null + }; + if (user is null) return; + + PopupUser = user; + ShowUserPopup = true; + OnPropertyChanged(nameof(PopupUserRoleName)); + OnPropertyChanged(nameof(PopupUserRoleColor)); + OnPropertyChanged(nameof(PopupUserStatusText)); + } + + private void OnCloseUserPopup() + { + ShowUserPopup = false; + PopupUser = null; + } + + private void OnHome() + { + IsHomeView = true; + } + + private void OnSelectFriendsTab(string? tab) + { + if (string.IsNullOrWhiteSpace(tab)) return; + SelectedFriendsTab = tab; + } + + private void OnMessageFriend(object? param) + { + // Placeholder: in the future, open or create a DM conversation with the friend + } + + // ── Message display items ────────────────────────────────────────────────── + + private void RebuildDisplayMessages() + { + DisplayMessages.Clear(); + Message? prev = null; + foreach (var msg in Messages) + { + var item = new MessageDisplayItem(msg, prev) + { + ReplyToMessage = msg.ReplyToId is not null + ? Messages.FirstOrDefault(m => m.Id == msg.ReplyToId) + : null, + IsOwnMessage = _chat?.CurrentUser is { } u && msg.Author.Id == u.Id + }; + DisplayMessages.Add(item); + prev = msg; + } + } + + private void AppendDisplayMessage(Message message) + { + var prev = Messages.Count > 1 ? Messages[^2] : null; + var item = new MessageDisplayItem(message, prev) + { + ReplyToMessage = message.ReplyToId is not null + ? Messages.FirstOrDefault(m => m.Id == message.ReplyToId) + : null, + IsOwnMessage = _chat?.CurrentUser is { } u && message.Author.Id == u.Id + }; + DisplayMessages.Add(item); + } + + // ── Channel grouping ───────────────────────────────────────────────────── + + private void RebuildChannelGroups() + { + // Preserve expanded state across rebuilds + var expandedState = ChannelGroups.ToDictionary(g => g.CategoryName ?? "", g => g.IsExpanded); + + ChannelGroups.Clear(); + + var grouped = Channels + .OrderBy(c => c.Position) + .GroupBy(c => c.Category); + + foreach (var g in grouped.OrderBy(g => g.Key is null ? 0 : 1)) + { + var group = new ChannelGroup { CategoryName = g.Key }; + + // Restore expanded state + if (expandedState.TryGetValue(g.Key ?? "", out var wasExpanded)) + group.IsExpanded = wasExpanded; + + foreach (var ch in g) + { + var item = new ChannelItem { Channel = ch }; + + // Populate voice users for voice channels + if (ch.Type == ChannelType.Voice) + { + foreach (var vs in VoiceStates.Where(vs => vs.ChannelId == ch.Id)) + item.VoiceUsers.Add(vs); + } + + group.Items.Add(item); + } + + ChannelGroups.Add(group); + } + } + + // ── Member grouping by role ────────────────────────────────────────────── + + private void RebuildMemberGroups() + { + MemberGroups.Clear(); + + var roleMap = Roles.ToDictionary(r => r.Id); + + var grouped = Members + .GroupBy(m => m.RoleId) + .Select(g => + { + roleMap.TryGetValue(g.Key, out var role); + return new { Role = role, Members = g.ToList() }; + }) + .OrderBy(g => g.Role?.Position ?? int.MaxValue); + + foreach (var g in grouped) + { + var mg = new MemberGroup + { + RoleName = g.Role?.Name ?? "Members", + RoleColor = g.Role?.Color, + Position = g.Role?.Position ?? int.MaxValue + }; + foreach (var m in g.Members) mg.Members.Add(m); + MemberGroups.Add(mg); + } + } + + // ── Message loading ────────────────────────────────────────────────────── + private async Task LoadMessagesForChannelAsync(long channelId) { if (_chat is null) return; @@ -159,8 +731,12 @@ public sealed class MainViewModel : ViewModelBase { var response = await _chat.GetMessagesAsync(channelId); Messages.Clear(); + DisplayMessages.Clear(); foreach (var msg in response.Messages) { + var attachments = msg.Attachments? + .Select(a => new Attachment(a.Id, a.Filename, a.Size, a.Mime, a.Url)) + .ToList() as IReadOnlyList ?? Array.Empty(); Messages.Add(new Message( msg.Id, msg.ChannelId, @@ -170,9 +746,11 @@ public sealed class MainViewModel : ViewModelBase msg.ReplyTo, msg.EditedAt, msg.Deleted, - [] + [], + attachments )); } + RebuildDisplayMessages(); } catch { @@ -185,6 +763,12 @@ public sealed class MainViewModel : ViewModelBase private void OnReady(ReadyPayload payload) { ConnectionStatus = null; + + // Store roles + Roles.Clear(); + foreach (var r in payload.Roles) Roles.Add(r); + + // Load channels Channels.Clear(); foreach (var ch in payload.Channels) { @@ -194,9 +778,11 @@ public sealed class MainViewModel : ViewModelBase "announcement" => ChannelType.Announcement, _ => ChannelType.Text }; - Channels.Add(new Channel(ch.Id, ch.Name, type, ch.Category, ch.Position, 0, null)); + Channels.Add(new Channel(ch.Id, ch.Name, type, ch.Category, ch.Position, 0, null, ch.Topic)); } + RebuildChannelGroups(); + // Load members Members.Clear(); foreach (var m in payload.Members) { @@ -209,8 +795,36 @@ public sealed class MainViewModel : ViewModelBase }; Members.Add(new User(m.Id, m.Username, m.Avatar, m.RoleId, status)); } + RebuildMemberGroups(); - if (Channels.Count > 0) + // Load voice states + VoiceStates.Clear(); + foreach (var vs in payload.VoiceStates) + { + VoiceStates.Add(new VoiceStateInfo + { + UserId = vs.UserId, + ChannelId = vs.ChannelId, + Username = vs.Username, + Muted = vs.Muted, + Deafened = vs.Deafened, + Speaking = vs.Speaking + }); + } + + // Rebuild channel groups now that voice states are loaded + RebuildChannelGroups(); + + // Notify user bar + OnPropertyChanged(nameof(CurrentUsername)); + OnPropertyChanged(nameof(CurrentUserStatus)); + OnPropertyChanged(nameof(CurrentUserStatusEnum)); + + // Select first text channel + var firstText = Channels.FirstOrDefault(c => c.Type == ChannelType.Text); + if (firstText is not null) + SelectedChannel = firstText; + else if (Channels.Count > 0) SelectedChannel = Channels[0]; } @@ -218,6 +832,9 @@ public sealed class MainViewModel : ViewModelBase { if (SelectedChannel is not null && payload.ChannelId == SelectedChannel.Id) { + var attachments = payload.Attachments? + .Select(a => new Attachment(a.Id, a.Filename, a.Size, a.Mime, a.Url)) + .ToList() as IReadOnlyList ?? Array.Empty(); var msg = new Message( payload.Id, payload.ChannelId, @@ -227,13 +844,13 @@ public sealed class MainViewModel : ViewModelBase payload.ReplyTo, null, false, - [] + [], + attachments ); Messages.Add(msg); } else { - // Increment unread for non-active channel UpdateUnreadCount(payload.ChannelId, GetUnreadCount(payload.ChannelId) + 1); } } @@ -250,7 +867,6 @@ public sealed class MainViewModel : ViewModelBase private void OnPresence(PresencePayload payload) { - // Update member status in the member list var idx = Members.ToList().FindIndex(m => m.Id == payload.UserId); if (idx < 0) return; var status = payload.Status switch @@ -261,6 +877,7 @@ public sealed class MainViewModel : ViewModelBase _ => UserStatus.Offline }; Members[idx] = Members[idx] with { Status = status }; + RebuildMemberGroups(); } private void OnChatEdited(ChatEditedPayload payload) @@ -268,6 +885,7 @@ public sealed class MainViewModel : ViewModelBase var idx = Messages.ToList().FindIndex(m => m.Id == payload.MessageId); if (idx < 0) return; Messages[idx] = Messages[idx] with { Content = payload.Content, EditedAt = payload.EditedAt }; + RebuildDisplayMessages(); } private void OnChatDeleted(ChatDeletedPayload payload) @@ -275,11 +893,11 @@ public sealed class MainViewModel : ViewModelBase var idx = Messages.ToList().FindIndex(m => m.Id == payload.MessageId); if (idx < 0) return; Messages[idx] = Messages[idx] with { Deleted = true, Content = "[deleted]" }; + RebuildDisplayMessages(); } private void OnMemberJoined(WsMember payload) { - // Don't add duplicates if (Members.Any(m => m.Id == payload.Id)) return; @@ -291,6 +909,7 @@ public sealed class MainViewModel : ViewModelBase _ => UserStatus.Offline }; Members.Add(new User(payload.Id, payload.Username, payload.Avatar, payload.RoleId, status)); + RebuildMemberGroups(); } private void OnChannelCreated(ChannelEventPayload payload) @@ -302,7 +921,8 @@ public sealed class MainViewModel : ViewModelBase "announcement" => ChannelType.Announcement, _ => ChannelType.Text }; - Channels.Add(new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, 0, null)); + Channels.Add(new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, 0, null, payload.Topic)); + RebuildChannelGroups(); } private void OnChannelUpdated(ChannelEventPayload payload) @@ -315,7 +935,10 @@ public sealed class MainViewModel : ViewModelBase "announcement" => ChannelType.Announcement, _ => ChannelType.Text }; - Channels[idx] = new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, Channels[idx].UnreadCount, Channels[idx].LastMessageId); + Channels[idx] = new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, Channels[idx].UnreadCount, Channels[idx].LastMessageId, payload.Topic); + RebuildChannelGroups(); + if (SelectedChannel?.Id == payload.Id) + OnPropertyChanged(nameof(SelectedChannelTopic)); } private void OnChannelDeleted(long channelId) @@ -324,6 +947,7 @@ public sealed class MainViewModel : ViewModelBase if (ch is not null) { Channels.Remove(ch); + RebuildChannelGroups(); if (SelectedChannel?.Id == channelId) SelectedChannel = Channels.FirstOrDefault(); } @@ -331,7 +955,72 @@ public sealed class MainViewModel : ViewModelBase private void OnConnectionLost(string reason) { - ConnectionStatus = "Disconnected — reconnecting..."; + ConnectionStatus = "Disconnected \u2014 reconnecting..."; + } + + // ── Voice event handlers ───────────────────────────────────────────────── + + private void OnVoiceState(VoiceStatePayload payload) + { + // Update or add voice state + var existing = VoiceStates.FirstOrDefault(vs => vs.UserId == payload.UserId); + if (existing is not null) + { + existing.ChannelId = payload.ChannelId; + existing.Muted = payload.Muted; + existing.Deafened = payload.Deafened; + } + else + { + VoiceStates.Add(new VoiceStateInfo + { + UserId = payload.UserId, + ChannelId = payload.ChannelId, + Username = payload.Username, + Muted = payload.Muted, + Deafened = payload.Deafened + }); + } + + // If this is the local user, update voice widget state + if (_chat?.CurrentUser is not null && payload.UserId == _chat.CurrentUser.Id) + { + IsInVoice = true; + _voiceChannelId = payload.ChannelId; + VoiceChannelName = Channels.FirstOrDefault(c => c.Id == payload.ChannelId)?.Name ?? "Voice"; + IsMuted = payload.Muted; + IsDeafened = payload.Deafened; + } + + RebuildChannelGroups(); + } + + private void OnVoiceLeave(VoiceLeavePayload payload) + { + var existing = VoiceStates.FirstOrDefault(vs => vs.UserId == payload.UserId); + if (existing is not null) + VoiceStates.Remove(existing); + + // If this is the local user, clear voice widget + if (_chat?.CurrentUser is not null && payload.UserId == _chat.CurrentUser.Id) + { + IsInVoice = false; + VoiceChannelName = null; + _voiceChannelId = 0; + IsMuted = false; + IsDeafened = false; + } + + RebuildChannelGroups(); + } + + private void OnVoiceSpeakers(VoiceSpeakersPayload payload) + { + var speakerSet = new HashSet(payload.Speakers); + foreach (var vs in VoiceStates.Where(vs => vs.ChannelId == payload.ChannelId)) + { + vs.Speaking = speakerSet.Contains(vs.UserId); + } } private int GetUnreadCount(long channelId) diff --git a/Client/OwnCord.Client/Views/ConnectPage.xaml b/Client/OwnCord.Client/Views/ConnectPage.xaml index 3a5f4043..8ae55d17 100644 --- a/Client/OwnCord.Client/Views/ConnectPage.xaml +++ b/Client/OwnCord.Client/Views/ConnectPage.xaml @@ -4,201 +4,32 @@ Title="Connect"> - - - - - - - - - - - - - - - - - - - - - + - + - - + + + + + @@ -225,10 +79,20 @@ - + - + Foreground="#80848e" VerticalAlignment="Center"/> + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml new file mode 100644 index 00000000..a0c8fe86 --- /dev/null +++ b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs new file mode 100644 index 00000000..2a6a2e8c --- /dev/null +++ b/Client/OwnCord.Client/Views/ServerProfileDialog.xaml.cs @@ -0,0 +1,90 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using OwnCord.Client.Models; + +namespace OwnCord.Client.Views; + +public partial class ServerProfileDialog : Window +{ + private readonly ServerProfile? _existing; + private string _selectedColor = "#5865f2"; + private readonly Border[] _colorBorders; + + /// The resulting profile after Save, or null if cancelled. + public ServerProfile? ResultProfile { get; private set; } + + /// True when a new profile was created, false when an existing one was edited. + public bool IsNewProfile => _existing is null; + + public ServerProfileDialog(ServerProfile? existing = null) + { + InitializeComponent(); + _existing = existing; + _colorBorders = [Color1, Color2, Color3, Color4, Color5, Color6]; + + if (existing is not null) + { + TitleText.Text = "Edit Server"; + NameBox.Text = existing.Name; + HostBox.Text = existing.Host; + PortBox.Text = existing.Port.ToString(); + AutoConnectBox.IsChecked = existing.AutoConnect; + _selectedColor = existing.Color; + } + + UpdateColorSelection(); + } + + private void UpdateColorSelection() + { + foreach (var border in _colorBorders) + { + var tag = border.Tag as string ?? ""; + border.BorderBrush = string.Equals(tag, _selectedColor, StringComparison.OrdinalIgnoreCase) + ? System.Windows.Media.Brushes.White + : System.Windows.Media.Brushes.Transparent; + } + } + + private void ColorPick_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border border && border.Tag is string color) + { + _selectedColor = color; + UpdateColorSelection(); + } + } + + private void Save_Click(object sender, RoutedEventArgs e) + { + var name = NameBox.Text.Trim(); + var host = HostBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(host)) + return; + + if (!int.TryParse(PortBox.Text.Trim(), out var port) || port < 1 || port > 65535) + port = 8443; + + var autoConnect = AutoConnectBox.IsChecked == true; + + ResultProfile = _existing is not null + ? _existing with { Name = name, Host = host, Port = port, Color = _selectedColor, AutoConnect = autoConnect } + : ServerProfile.Create(name, host, port: port, color: _selectedColor, autoConnect: autoConnect); + + DialogResult = true; + Close(); + } + + private void Cancel_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } + + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left) + DragMove(); + } +} diff --git a/Client/login-mockup.html b/Client/login-mockup.html new file mode 100644 index 00000000..a26c4dbe --- /dev/null +++ b/Client/login-mockup.html @@ -0,0 +1,1967 @@ + + + + + +OwnCord — Connect + + + + + +
+ + + + + +
+ +
+

Your Servers

+
+ +
+
+ +
+ +
+ Tip: Share chatserver://host:port/invite/CODE links to invite friends +
+ + +
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+
+
O
+
+ + + +
+
+
Connected!
+
Logged in as LordJebus
+
+
+
+ Loading server data... +
+
+ + +
+ +
+ +
+
+
+ + + + diff --git a/Client/ui-mockup.html b/Client/ui-mockup.html new file mode 100644 index 00000000..51ee925f --- /dev/null +++ b/Client/ui-mockup.html @@ -0,0 +1,2247 @@ + + + + + +OwnCord — Interactive Prototype + + + + +
+ +
+ + +
+

+
+
+
+ Voice Connected + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+
+ # + + + +
+ + + +
+
+
+
+
+
+ Replying to + +
+
+
+
+ + + + +
+
+
+ + +
+ + + + + + +
+ + +
+
+
+
+
+
+
+
Member Since
+
+
+
+ +
+
+ + +
+
+ +
+
+
+ + +
+ +
+
+
+ + +
+ + + + diff --git a/PROTOCOL.md b/PROTOCOL.md index 36b088b9..fc6e5538 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -1,6 +1,8 @@ # WebSocket Protocol Spec -All client-server communication (except file uploads and admin panel) happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`. +All client-server communication (except file uploads and +admin panel) happens over a single WebSocket connection. +Messages are JSON with a `type` and `payload`. ## Message Format @@ -31,7 +33,17 @@ Server responses to client requests include the same `id` for correlation. ### Server → Client (success) ```json -{ "type": "auth_ok", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "server_name": "My Server", "motd": "Welcome!" } } +{ + "type": "auth_ok", + "payload": { + "user": { + "id": 1, "username": "alex", + "avatar": "uuid.png", "role": "admin" + }, + "server_name": "My Server", + "motd": "Welcome!" + } +} ``` ### Server → Client (failure) @@ -49,31 +61,81 @@ Connection is closed by server after auth_error. ### Send Message (Client → Server) ```json -{ "type": "chat_send", "id": "req-uuid", "payload": { "channel_id": 5, "content": "Hello everyone!", "reply_to": null, "attachments": ["upload-uuid-1"] } } +{ + "type": "chat_send", + "id": "req-uuid", + "payload": { + "channel_id": 5, + "content": "Hello everyone!", + "reply_to": null, + "attachments": ["upload-uuid-1"] + } +} ``` ### Message Broadcast (Server → Client) ```json -{ "type": "chat_message", "payload": { "id": 1042, "channel_id": 5, "user": { "id": 1, "username": "alex", "avatar": "uuid.png" }, "content": "Hello everyone!", "reply_to": null, "attachments": [{ "id": "upload-uuid-1", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/files/upload-uuid-1" }], "timestamp": "2026-03-14T10:30:00Z" } } +{ + "type": "chat_message", + "payload": { + "id": 1042, "channel_id": 5, + "user": { + "id": 1, "username": "alex", + "avatar": "uuid.png" + }, + "content": "Hello everyone!", + "reply_to": null, + "attachments": [{ + "id": "upload-uuid-1", + "filename": "photo.jpg", + "size": 204800, + "mime": "image/jpeg", + "url": "/files/upload-uuid-1" + }], + "timestamp": "2026-03-14T10:30:00Z" + } +} ``` ### Send Ack (Server → Client) ```json -{ "type": "chat_send_ok", "id": "req-uuid", "payload": { "message_id": 1042, "timestamp": "2026-03-14T10:30:00Z" } } +{ + "type": "chat_send_ok", + "id": "req-uuid", + "payload": { + "message_id": 1042, + "timestamp": "2026-03-14T10:30:00Z" + } +} ``` ### Edit Message (Client → Server) ```json -{ "type": "chat_edit", "id": "req-uuid", "payload": { "message_id": 1042, "content": "Hello everyone! (edited)" } } +{ + "type": "chat_edit", + "id": "req-uuid", + "payload": { + "message_id": 1042, + "content": "Hello everyone! (edited)" + } +} ``` ### Edit Broadcast (Server → Client) ```json -{ "type": "chat_edited", "payload": { "message_id": 1042, "channel_id": 5, "content": "Hello everyone! (edited)", "edited_at": "2026-03-14T10:31:00Z" } } +{ + "type": "chat_edited", + "payload": { + "message_id": 1042, + "channel_id": 5, + "content": "Hello everyone! (edited)", + "edited_at": "2026-03-14T10:31:00Z" + } +} ``` ### Delete Message (Client → Server) @@ -98,7 +160,16 @@ Connection is closed by server after auth_error. ### Reaction Broadcast (Server → Client) ```json -{ "type": "reaction_update", "payload": { "message_id": 1042, "channel_id": 5, "emoji": "👍", "user_id": 1, "action": "add" } } +{ + "type": "reaction_update", + "payload": { + "message_id": 1042, + "channel_id": 5, + "emoji": "👍", + "user_id": 1, + "action": "add" + } +} ``` --- @@ -114,7 +185,14 @@ Connection is closed by server after auth_error. ### Server → Client (broadcast to channel members) ```json -{ "type": "typing", "payload": { "channel_id": 5, "user_id": 1, "username": "alex" } } +{ + "type": "typing", + "payload": { + "channel_id": 5, + "user_id": 1, + "username": "alex" + } +} ``` Client-side: show indicator for 5 seconds, reset on new typing event from same user. @@ -123,7 +201,7 @@ Client-side: show indicator for 5 seconds, reset on new typing event from same u ## Presence -### Client → Server +### Presence Client → Server ```json { "type": "presence_update", "payload": { "status": "online" } } @@ -131,7 +209,7 @@ Client-side: show indicator for 5 seconds, reset on new typing event from same u Status values: `online`, `idle`, `dnd`, `offline` -### Server → Client (broadcast) +### Presence Server → Client (broadcast) ```json { "type": "presence", "payload": { "user_id": 1, "status": "online" } } @@ -146,8 +224,21 @@ Server auto-sets `idle` after 10 minutes of no WebSocket activity. ### Server → Client (on channel created/edited/deleted/reordered) ```json -{ "type": "channel_create", "payload": { "id": 8, "name": "gaming", "type": "text", "category": "Hangout", "position": 3 } } -{ "type": "channel_update", "payload": { "id": 8, "name": "gaming-talk", "position": 4 } } +{ + "type": "channel_create", + "payload": { + "id": 8, "name": "gaming", + "type": "text", + "category": "Hangout", "position": 3 + } +} +{ + "type": "channel_update", + "payload": { + "id": 8, "name": "gaming-talk", + "position": 4 + } +} { "type": "channel_delete", "payload": { "id": 8 } } ``` @@ -166,7 +257,16 @@ Channel types: `text`, `voice`, `announcement` ### Server → Client (voice state updates, broadcast to channel) ```json -{ "type": "voice_state", "payload": { "channel_id": 10, "user_id": 1, "username": "alex", "muted": false, "deafened": false, "speaking": false } } +{ + "type": "voice_state", + "payload": { + "channel_id": 10, "user_id": 1, + "username": "alex", + "muted": false, "deafened": false, + "speaking": false, + "camera": false, "screenshare": false + } +} ``` ### Voice User Left (Server → Client) @@ -175,7 +275,29 @@ Channel types: `text`, `voice`, `announcement` { "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } } ``` -### WebRTC Signaling (bidirectional) +### Voice Config (Server → Client, sent after voice_join acceptance) + +```json +{ + "type": "voice_config", + "payload": { + "channel_id": 10, "quality": "medium", "bitrate": 64000, + "threshold_mode": "forwarding", "mixing_threshold": 10, + "top_speakers": 3, "max_users": 50 + } +} +``` + +Client uses `bitrate` to configure the Opus encoder. Other fields are +informational for UI. + +### WebRTC Signaling (Client ↔ Server SFU) + +**Note:** As of the SFU migration, `voice_offer`/`voice_answer`/`voice_ice` +are exchanged between each client and the **server** (not relayed between +clients). The server is the WebRTC peer. + +Clients must include RFC 6464 `ssrc-audio-level` RTP header extension in SDP offers. ```json { "type": "voice_offer", "payload": { "channel_id": 10, "sdp": "..." } } @@ -190,6 +312,33 @@ Channel types: `text`, `voice`, `announcement` { "type": "voice_deafen", "payload": { "deafened": true } } ``` +### Voice Camera / Screenshare (Client → Server) + +```json +{ "type": "voice_camera", "payload": { "enabled": true } } +{ "type": "voice_screenshare", "payload": { "enabled": true } } +``` + +Requires `USE_VIDEO` (bit 11) or `SHARE_SCREEN` (bit 12) permission. +Rate limit: 2/sec per user. + +### Active Speakers (Server → Client) + +```json +{ + "type": "voice_speakers", + "payload": { + "channel_id": 10, "speakers": [1, 5, 12], + "threshold_mode": "forwarding" + } +} +``` + +- `speakers`: Active speaker user IDs (up to top-N) +- `threshold_mode`: `"forwarding"` or `"selective"` +- Sent on speaker list changes or mode transitions +- Rate: at most once per 200ms per channel + ### Soundboard (Client → Server) ```json @@ -203,7 +352,15 @@ Channel types: `text`, `voice`, `announcement` ### Server → Client ```json -{ "type": "member_join", "payload": { "user": { "id": 5, "username": "newuser", "avatar": null, "role": "member" } } } +{ + "type": "member_join", + "payload": { + "user": { + "id": 5, "username": "newuser", + "avatar": null, "role": "member" + } + } +} { "type": "member_leave", "payload": { "user_id": 5 } } { "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } } { "type": "member_ban", "payload": { "user_id": 5 } } @@ -213,7 +370,7 @@ Channel types: `text`, `voice`, `announcement` ## Server Restart -### Server → Client +### Restart Server → Client ```json { @@ -228,32 +385,58 @@ Channel types: `text`, `voice`, `announcement` - `reason` (string): Why the server is restarting. Currently only `"update"`. - `delay_seconds` (integer): How many seconds until the server shuts down. -Client behavior: Display a banner/notification ("Server restarting for update..."), then auto-reconnect using existing reconnection logic after the delay expires. +Client behavior: Display a banner ("Server restarting..."), +then auto-reconnect after the delay expires. --- ## Initial State (sent after auth_ok) -### Server → Client +### Ready Server → Client ```json { "type": "ready", "payload": { "channels": [ - { "id": 1, "name": "general", "type": "text", "category": "Main", "position": 0, "unread_count": 3, "last_message_id": 1040 }, - { "id": 10, "name": "voice-chat", "type": "voice", "category": "Main", "position": 1 } + { + "id": 1, "name": "general", + "type": "text", "category": "Main", + "position": 0, "unread_count": 3, + "last_message_id": 1040 + }, + { + "id": 10, "name": "voice-chat", + "type": "voice", "category": "Main", + "position": 1 + } ], "members": [ - { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin", "status": "online" }, - { "id": 2, "username": "jordan", "avatar": null, "role": "member", "status": "idle" } + { + "id": 1, "username": "alex", + "avatar": "uuid.png", + "role": "admin", "status": "online" + }, + { + "id": 2, "username": "jordan", + "avatar": null, + "role": "member", "status": "idle" + } ], "voice_states": [ { "channel_id": 10, "user_id": 2, "muted": false, "deafened": false } ], "roles": [ - { "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647 }, - { "id": 2, "name": "Admin", "color": "#F39C12", "permissions": 1073741823 }, + { + "id": 1, "name": "Owner", + "color": "#E74C3C", + "permissions": 2147483647 + }, + { + "id": 2, "name": "Admin", + "color": "#F39C12", + "permissions": 1073741823 + }, { "id": 3, "name": "Member", "color": null, "permissions": 1049601 } ] } @@ -266,8 +449,8 @@ Client behavior: Display a banner/notification ("Server restarting for update... Fetched via REST API, not WebSocket, to keep the WS connection lean. -``` -GET /api/channels/{id}/messages?before={message_id}&limit=50 +```text +GET /api/channels/{id}/messages?before={msg_id}&limit=50 ``` --- @@ -277,10 +460,18 @@ GET /api/channels/{id}/messages?before={message_id}&limit=50 Any request that fails returns: ```json -{ "type": "error", "id": "original-req-uuid", "payload": { "code": "FORBIDDEN", "message": "You don't have permission to post in this channel" } } +{ + "type": "error", + "id": "original-req-uuid", + "payload": { + "code": "FORBIDDEN", + "message": "No permission to post here" + } +} ``` -Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`, `SERVER_ERROR` +Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`, +`SERVER_ERROR`, `CHANNEL_FULL`, `INVALID_SDP`, `VOICE_ERROR`, `VIDEO_LIMIT` --- @@ -291,6 +482,7 @@ Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`, `SERVER_ - Presence updates: 1/10sec per user - Reactions: 5/sec per user - Voice signaling: 20/sec per user +- Voice camera/screenshare: 2/sec per user - Soundboard: 1/3sec per user Server sends `rate_limited` error with `retry_after` in seconds. diff --git a/SCHEMA.md b/SCHEMA.md index 90ab21db..89e13c42 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -1,6 +1,7 @@ # Database Schema (SQLite) -Single file: `data/chatserver.db`. WAL mode enabled. Migrations run automatically on server startup. +Single file: `data/chatserver.db`. WAL mode enabled. +Migrations run automatically on startup. --- @@ -62,7 +63,7 @@ CREATE TABLE roles ( ### Permission Bitfield -``` +```text Bit 0: SEND_MESSAGES (0x1) Bit 1: READ_MESSAGES (0x2) Bit 5: ATTACH_FILES (0x20) @@ -88,15 +89,19 @@ Bit 30: ADMINISTRATOR (0x40000000) -- bypasses all checks ```sql CREATE TABLE channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', -- text, voice, announcement - category TEXT, -- category name for grouping - topic TEXT, -- channel description - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, -- seconds between messages, 0 = off - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', -- text, voice, announcement + category TEXT, -- category name for grouping + topic TEXT, -- channel description + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, -- seconds, 0=off + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited + voice_quality TEXT, -- low|medium|high; NULL=default + mixing_threshold INTEGER, -- NULL = server default + voice_max_video INTEGER NOT NULL DEFAULT 10 -- max video streams ); ``` @@ -147,11 +152,13 @@ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN END; CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES('delete', old.id, old.content); END; CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(messages_fts, rowid, content) + VALUES('delete', old.id, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; ``` @@ -218,7 +225,7 @@ CREATE TABLE read_states ( CREATE TABLE audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), - action TEXT NOT NULL, -- e.g. user_ban, channel_create, message_delete, role_update + action TEXT NOT NULL, -- e.g. user_ban, channel_create target_type TEXT, -- user, channel, message, role, invite target_id INTEGER, details TEXT, -- JSON with extra context @@ -282,10 +289,31 @@ CREATE TABLE sounds ( --- +## Voice States + +```sql +CREATE TABLE voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_voice_states_channel ON voice_states(channel_id); +``` + +On startup: `DELETE FROM voice_states;` clears stale state from previous run. + +--- + ## Notes - All datetimes stored as ISO 8601 UTC strings. - Enable WAL mode on connection: `PRAGMA journal_mode=WAL;` - Enable foreign keys: `PRAGMA foreign_keys=ON;` - Use `modernc.org/sqlite` (pure Go, no CGO needed). -- Migrations: store schema version in `settings` table, apply incremental SQL on startup. +- Migrations: schema version in `settings`, apply incremental SQL on startup. diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 17b97314..c64e2699 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -59,15 +59,19 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( diff --git a/Server/admin/static/admin-mockup.html b/Server/admin/static/admin-mockup.html new file mode 100644 index 00000000..14f99569 --- /dev/null +++ b/Server/admin/static/admin-mockup.html @@ -0,0 +1,1300 @@ + + + + + +OwnCord — Admin Panel + + + + +
+ + + + +
+
+ + + + + +
+ + + + diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 88b91690..9aef3a07 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -58,15 +58,19 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/api/router.go b/Server/api/router.go index 8ae05d1f..0d75e317 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" + "log/slog" "net/http" "github.com/go-chi/chi/v5" @@ -54,6 +55,15 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler { // WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here. hub := ws.NewHub(database, limiter) + + // Create SFU if voice config is present; voice is disabled on failure. + sfu, sfuErr := ws.NewSFU(&cfg.Voice) + if sfuErr != nil { + slog.Warn("failed to create SFU, voice disabled", "error", sfuErr) + } else { + hub.SetSFU(sfu) + } + go hub.Run() r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) diff --git a/Server/config/config.go b/Server/config/config.go index 63eab039..2a09878c 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -29,12 +29,18 @@ type GitHubConfig struct { Token string `koanf:"token"` } -// VoiceConfig holds STUN/TURN server settings for WebRTC signaling. +// VoiceConfig holds STUN/TURN server settings and SFU configuration. type VoiceConfig struct { - TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty - STUNPort int `koanf:"stun_port"` // default 3478 - TURNPort int `koanf:"turn_port"` // default 3478 - TURNEnabled bool `koanf:"turn_enabled"` // default true + TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty + STUNPort int `koanf:"stun_port"` // default 3478 + TURNPort int `koanf:"turn_port"` // default 3478 + TURNEnabled bool `koanf:"turn_enabled"` // default true + Quality string `koanf:"quality"` // low | medium | high + MixingThreshold int `koanf:"mixing_threshold"` // selective forwarding threshold + TopSpeakers int `koanf:"top_speakers"` // top-N speakers in selective mode + ExternalIP string `koanf:"external_ip"` // set if behind NAT + MediaPortMin int `koanf:"media_port_min"` // UDP port range start for WebRTC media + MediaPortMax int `koanf:"media_port_max"` // UDP port range end for WebRTC media } // ServerConfig holds HTTP server settings. @@ -90,9 +96,14 @@ func defaults() Config { StorageDir: "data/uploads", }, Voice: VoiceConfig{ - STUNPort: 3478, - TURNPort: 3478, - TURNEnabled: true, + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + Quality: "medium", + MixingThreshold: 10, + TopSpeakers: 3, + MediaPortMin: 10000, + MediaPortMax: 10100, }, GitHub: GitHubConfig{}, } diff --git a/Server/config/config_test.go b/Server/config/config_test.go index 054b78f4..7b34c50c 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -226,6 +226,82 @@ tls: } } +func TestLoadVoiceConfigDefaults(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Voice.Quality", cfg.Voice.Quality, "medium"}, + {"Voice.MixingThreshold", cfg.Voice.MixingThreshold, 10}, + {"Voice.TopSpeakers", cfg.Voice.TopSpeakers, 3}, + {"Voice.ExternalIP", cfg.Voice.ExternalIP, ""}, + {"Voice.MediaPortMin", cfg.Voice.MediaPortMin, 10000}, + {"Voice.MediaPortMax", cfg.Voice.MediaPortMax, 10100}, + {"Voice.STUNPort", cfg.Voice.STUNPort, 3478}, + {"Voice.TURNPort", cfg.Voice.TURNPort, 3478}, + {"Voice.TURNEnabled", cfg.Voice.TURNEnabled, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("got %v, want %v", tc.got, tc.want) + } + }) + } +} + +func TestLoadVoiceConfigFromYAML(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +voice: + quality: high + mixing_threshold: 5 + top_speakers: 4 + external_ip: "1.2.3.4" + media_port_min: 20000 + media_port_max: 20500 +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Voice.Quality != "high" { + t.Errorf("Voice.Quality = %q, want 'high'", cfg.Voice.Quality) + } + if cfg.Voice.MixingThreshold != 5 { + t.Errorf("Voice.MixingThreshold = %d, want 5", cfg.Voice.MixingThreshold) + } + if cfg.Voice.TopSpeakers != 4 { + t.Errorf("Voice.TopSpeakers = %d, want 4", cfg.Voice.TopSpeakers) + } + if cfg.Voice.ExternalIP != "1.2.3.4" { + t.Errorf("Voice.ExternalIP = %q, want '1.2.3.4'", cfg.Voice.ExternalIP) + } + if cfg.Voice.MediaPortMin != 20000 { + t.Errorf("Voice.MediaPortMin = %d, want 20000", cfg.Voice.MediaPortMin) + } + if cfg.Voice.MediaPortMax != 20500 { + t.Errorf("Voice.MediaPortMax = %d, want 20500", cfg.Voice.MediaPortMax) + } +} + func TestLoadUploadBoundaryValues(t *testing.T) { tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, "config.yaml") diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go index 4022e561..af2e024f 100644 --- a/Server/db/admin_queries_test.go +++ b/Server/db/admin_queries_test.go @@ -13,15 +13,19 @@ import ( // adminTestSchema extends testSchema with tables needed for admin queries. var adminTestSchema = append(testSchema, []byte(` CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index c73eefd9..5596da58 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -39,7 +39,11 @@ func (d *DB) ListChannels() ([]Channel, error) { func (d *DB) GetChannel(id int64) (*Channel, error) { row := d.sqlDB.QueryRow( `SELECT id, name, type, COALESCE(category,''), COALESCE(topic,''), - position, slow_mode, archived, created_at + position, slow_mode, archived, created_at, + COALESCE(voice_max_users, 0), + voice_quality, + mixing_threshold, + COALESCE(voice_max_video, 0) FROM channels WHERE id = ?`, id, ) @@ -48,6 +52,7 @@ func (d *DB) GetChannel(id int64) (*Channel, error) { err := row.Scan( &ch.ID, &ch.Name, &ch.Type, &ch.Category, &ch.Topic, &ch.Position, &ch.SlowMode, &archived, &ch.CreatedAt, + &ch.VoiceMaxUsers, &ch.VoiceQuality, &ch.MixingThreshold, &ch.VoiceMaxVideo, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -95,6 +100,15 @@ func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { return nil } +// SetChannelVoiceMaxUsers updates the voice_max_users field for the given channel. +func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { + _, err := d.sqlDB.Exec(`UPDATE channels SET voice_max_users = ? WHERE id = ?`, maxUsers, id) + if err != nil { + return fmt.Errorf("SetChannelVoiceMaxUsers: %w", err) + } + return nil +} + // DeleteChannel removes the channel row (cascades to messages, overrides, etc.). func (d *DB) DeleteChannel(id int64) error { _, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id) diff --git a/Server/db/models.go b/Server/db/models.go index f73197b3..3e68422e 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -54,15 +54,19 @@ type Role struct { // Channel represents a row in the channels table. type Channel struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int `json:"position"` - SlowMode int `json:"slow_mode"` - Archived bool `json:"archived"` - CreatedAt string `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Topic string `json:"topic"` + Position int `json:"position"` + SlowMode int `json:"slow_mode"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` + VoiceMaxUsers int `json:"voice_max_users"` + VoiceQuality *string `json:"voice_quality,omitempty"` + MixingThreshold *int `json:"mixing_threshold,omitempty"` + VoiceMaxVideo int `json:"voice_max_video"` } // Message represents a row in the messages table. @@ -105,12 +109,14 @@ type MessageSearchResult struct { // VoiceState represents a row in the voice_states table. // It tracks which voice channel a user is in and their current audio state. type VoiceState struct { - UserID int64 - ChannelID int64 - Username string - Muted bool - Deafened bool - Speaking bool + UserID int64 + ChannelID int64 + Username string + Muted bool + Deafened bool + Speaking bool + Camera bool + Screenshare bool } // ServerStats contains aggregate counts for the admin dashboard. diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 4cb9ec75..d5b2a097 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -11,14 +11,16 @@ import ( // replaced. Muted, deafened, and speaking are reset to false on join. func (d *DB) JoinVoiceChannel(userID, channelID int64) error { _, err := d.sqlDB.Exec( - `INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking) - VALUES (?, ?, 0, 0, 0) + `INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare) + VALUES (?, ?, 0, 0, 0, 0, 0) ON CONFLICT(user_id) DO UPDATE SET - channel_id = excluded.channel_id, - muted = 0, - deafened = 0, - speaking = 0, - joined_at = datetime('now')`, + channel_id = excluded.channel_id, + muted = 0, + deafened = 0, + speaking = 0, + camera = 0, + screenshare = 0, + joined_at = datetime('now')`, userID, channelID, ) if err != nil { @@ -42,7 +44,8 @@ func (d *DB) LeaveVoiceChannel(userID int64) error { func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { row := d.sqlDB.QueryRow( `SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.user_id = ?`, @@ -56,7 +59,8 @@ func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { rows, err := d.sqlDB.Query( `SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.channel_id = ? @@ -123,16 +127,65 @@ func (d *DB) ClearVoiceState(userID int64) error { return nil } +// ClearAllVoiceStates removes all voice state rows. Called on server startup +// to clear stale state from a previous run. +func (d *DB) ClearAllVoiceStates() error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states`) + if err != nil { + return fmt.Errorf("ClearAllVoiceStates: %w", err) + } + return nil +} + +// UpdateVoiceCamera sets the camera field for the given user's voice state. +func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET camera = ? WHERE user_id = ?`, + boolToInt(camera), userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceCamera: %w", err) + } + return nil +} + +// UpdateVoiceScreenshare sets the screenshare field for the given user's voice state. +func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET screenshare = ? WHERE user_id = ?`, + boolToInt(screenshare), userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceScreenshare: %w", err) + } + return nil +} + +// CountChannelVoiceUsers returns the number of users currently in the given +// voice channel. +func (d *DB) CountChannelVoiceUsers(channelID int64) (int, error) { + var count int + err := d.sqlDB.QueryRow( + `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, + channelID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("CountChannelVoiceUsers: %w", err) + } + return count, nil +} + // ─── helpers ────────────────────────────────────────────────────────────────── // scanVoiceState scans a single *sql.Row into a VoiceState. // Returns nil (not an error) when the row is not found. func scanVoiceState(row *sql.Row) (*VoiceState, error) { vs := &VoiceState{} - var muted, deafened, speaking int + var muted, deafened, speaking, camera, screenshare int err := row.Scan( &vs.UserID, &vs.ChannelID, &vs.Username, &muted, &deafened, &speaking, + &camera, &screenshare, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -143,16 +196,19 @@ func scanVoiceState(row *sql.Row) (*VoiceState, error) { vs.Muted = muted != 0 vs.Deafened = deafened != 0 vs.Speaking = speaking != 0 + vs.Camera = camera != 0 + vs.Screenshare = screenshare != 0 return vs, nil } // scanVoiceStateRow scans a single row from *sql.Rows into a VoiceState. func scanVoiceStateRow(rows *sql.Rows) (VoiceState, error) { vs := VoiceState{} - var muted, deafened, speaking int + var muted, deafened, speaking, camera, screenshare int err := rows.Scan( &vs.UserID, &vs.ChannelID, &vs.Username, &muted, &deafened, &speaking, + &camera, &screenshare, ) if err != nil { return vs, fmt.Errorf("scanVoiceStateRow: %w", err) @@ -160,6 +216,8 @@ func scanVoiceStateRow(rows *sql.Rows) (VoiceState, error) { vs.Muted = muted != 0 vs.Deafened = deafened != 0 vs.Speaking = speaking != 0 + vs.Camera = camera != 0 + vs.Screenshare = screenshare != 0 return vs, nil } diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go index 5822ebfe..2694c8be 100644 --- a/Server/db/voice_queries_test.go +++ b/Server/db/voice_queries_test.go @@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( muted INTEGER NOT NULL DEFAULT 0, deafened INTEGER NOT NULL DEFAULT 0, speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, joined_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); @@ -22,15 +24,19 @@ CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); var channelSchema = []byte(` CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 10 ); `) @@ -44,16 +50,18 @@ func newVoiceTestDB(t *testing.T) *db.DB { t.Cleanup(func() { database.Close() }) migrFS := fstest.MapFS{ - "001_schema.sql": {Data: testSchema}, + "001_schema.sql": {Data: testSchema}, "002_channels.sql": {Data: channelSchema}, "003_voice.sql": {Data: []byte(` CREATE TABLE IF NOT EXISTS voice_states ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - muted INTEGER NOT NULL DEFAULT 0, - deafened INTEGER NOT NULL DEFAULT 0, - speaking INTEGER NOT NULL DEFAULT 0, - joined_at TEXT NOT NULL DEFAULT (datetime('now')) + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); `)}, @@ -421,3 +429,274 @@ func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) { t.Errorf("Username = %q, want %q", states[0].Username, "rachel") } } + +// ─── UpdateVoiceCamera ──────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceCamera_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-on") + chanID := seedVoiceChannel(t, database, "voice-camera") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Camera { + t.Error("Camera = false after UpdateVoiceCamera(true)") + } +} + +func TestVoice_UpdateVoiceCamera_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-off") + chanID := seedVoiceChannel(t, database, "voice-camera-off") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera(true): %v", err) + } + if err := database.UpdateVoiceCamera(userID, false); err != nil { + t.Fatalf("UpdateVoiceCamera(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Camera { + t.Error("Camera = true after UpdateVoiceCamera(false), want false") + } +} + +func TestVoice_UpdateVoiceCamera_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-noop") + + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera for non-member: %v", err) + } +} + +// ─── UpdateVoiceScreenshare ────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceScreenshare_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "share-on") + chanID := seedVoiceChannel(t, database, "voice-screen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Screenshare { + t.Error("Screenshare = false after UpdateVoiceScreenshare(true)") + } +} + +func TestVoice_UpdateVoiceScreenshare_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "share-off") + chanID := seedVoiceChannel(t, database, "voice-screen-off") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare(true): %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, false); err != nil { + t.Fatalf("UpdateVoiceScreenshare(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Screenshare { + t.Error("Screenshare = true after UpdateVoiceScreenshare(false), want false") + } +} + +// ─── CountChannelVoiceUsers ────────────────────────────────────────────────── + +func TestVoice_CountChannelVoiceUsers_Empty(t *testing.T) { + database := newVoiceTestDB(t) + chanID := seedVoiceChannel(t, database, "count-empty") + + count, err := database.CountChannelVoiceUsers(chanID) + if err != nil { + t.Fatalf("CountChannelVoiceUsers: %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0", count) + } +} + +func TestVoice_CountChannelVoiceUsers_Multiple(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "count1") + u2 := seedVoiceUser(t, database, "count2") + u3 := seedVoiceUser(t, database, "count3") + chanID := seedVoiceChannel(t, database, "count-multi") + otherChan := seedVoiceChannel(t, database, "count-other") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chanID); err != nil { + t.Fatalf("join u2: %v", err) + } + // u3 joins a different channel — should not be counted. + if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + t.Fatalf("join u3: %v", err) + } + + count, err := database.CountChannelVoiceUsers(chanID) + if err != nil { + t.Fatalf("CountChannelVoiceUsers: %v", err) + } + if count != 2 { + t.Errorf("count = %d, want 2", count) + } +} + +// ─── ClearAllVoiceStates ───────────────────────────────────────────────────── + +func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "clear1") + u2 := seedVoiceUser(t, database, "clear2") + chan1 := seedVoiceChannel(t, database, "clear-ch1") + chan2 := seedVoiceChannel(t, database, "clear-ch2") + + if err := database.JoinVoiceChannel(u1, chan1); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chan2); err != nil { + t.Fatalf("join u2: %v", err) + } + + if err := database.ClearAllVoiceStates(); err != nil { + t.Fatalf("ClearAllVoiceStates: %v", err) + } + + s1, _ := database.GetVoiceState(u1) + s2, _ := database.GetVoiceState(u2) + if s1 != nil || s2 != nil { + t.Error("voice states still exist after ClearAllVoiceStates") + } +} + +func TestVoice_ClearAllVoiceStates_EmptyTable_NoError(t *testing.T) { + database := newVoiceTestDB(t) + + if err := database.ClearAllVoiceStates(); err != nil { + t.Fatalf("ClearAllVoiceStates on empty table: %v", err) + } +} + +// ─── JoinVoiceChannel resets camera/screenshare ────────────────────────────── + +func TestVoice_JoinVoiceChannel_ResetsCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "reset-av") + chan1 := seedVoiceChannel(t, database, "voice-reset1") + chan2 := seedVoiceChannel(t, database, "voice-reset2") + + // Join, enable camera and screenshare. + if err := database.JoinVoiceChannel(userID, chan1); err != nil { + t.Fatalf("first join: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare: %v", err) + } + + // Join a different channel — camera and screenshare should be reset. + if err := database.JoinVoiceChannel(userID, chan2); err != nil { + t.Fatalf("second join: %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil after re-join") + } + if state.Camera { + t.Error("Camera should be reset to false on re-join") + } + if state.Screenshare { + t.Error("Screenshare should be reset to false on re-join") + } +} + +// ─── Camera/Screenshare in GetVoiceState ───────────────────────────────────── + +func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "av-fields") + chanID := seedVoiceChannel(t, database, "voice-av-fields") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + // Initially both should be false. + state, _ := database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil") + } + if state.Camera { + t.Error("Camera should be false after join") + } + if state.Screenshare { + t.Error("Screenshare should be false after join") + } + + // Enable both. + database.UpdateVoiceCamera(userID, true) + database.UpdateVoiceScreenshare(userID, true) + + state, _ = database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil after update") + } + if !state.Camera { + t.Error("Camera should be true after UpdateVoiceCamera(true)") + } + if !state.Screenshare { + t.Error("Screenshare should be true after UpdateVoiceScreenshare(true)") + } +} + +// ─── Camera/Screenshare in GetChannelVoiceStates ───────────────────────────── + +func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "chan-av") + chanID := seedVoiceChannel(t, database, "voice-chan-av") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + database.UpdateVoiceCamera(userID, true) + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 1 { + t.Fatalf("got %d states, want 1", len(states)) + } + if !states[0].Camera { + t.Error("Camera should be true in GetChannelVoiceStates") + } + if states[0].Screenshare { + t.Error("Screenshare should be false in GetChannelVoiceStates") + } +} diff --git a/Server/go.mod b/Server/go.mod index 4dba34b7..089986ca 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -30,11 +30,29 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.2.1 // indirect + github.com/pion/interceptor v0.1.44 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.1 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pion/webrtc/v4 v4.2.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.10.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/Server/go.sum b/Server/go.sum index 2bbf492f..9f916854 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -46,12 +46,47 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= +github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= +github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE= +github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -69,6 +104,8 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/Server/main.go b/Server/main.go index fdf6e80e..cb3850c8 100644 --- a/Server/main.go +++ b/Server/main.go @@ -74,6 +74,14 @@ func run(log *slog.Logger) error { if err := db.Migrate(database); err != nil { return fmt.Errorf("running migrations: %w", err) } + + // Clear stale voice states from a previous run. + if err := database.ClearAllVoiceStates(); err != nil { + log.Warn("failed to clear stale voice states", "error", err) + } else { + log.Info("cleared stale voice states") + } + // ── 4. TLS ───────────────────────────────────────────────────────────── tlsResult, err := auth.LoadOrGenerate(cfg.TLS) if err != nil { diff --git a/Server/migrations/003_voice_optimization.sql b/Server/migrations/003_voice_optimization.sql new file mode 100644 index 00000000..e1b882b3 --- /dev/null +++ b/Server/migrations/003_voice_optimization.sql @@ -0,0 +1,9 @@ +-- Phase 5b: Voice optimization — add camera/screenshare tracking and +-- per-channel voice configuration for the Pion SFU. +ALTER TABLE voice_states ADD COLUMN camera INTEGER NOT NULL DEFAULT 0; +ALTER TABLE voice_states ADD COLUMN screenshare INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE channels ADD COLUMN voice_max_users INTEGER NOT NULL DEFAULT 0; +ALTER TABLE channels ADD COLUMN voice_quality TEXT; +ALTER TABLE channels ADD COLUMN mixing_threshold INTEGER; +ALTER TABLE channels ADD COLUMN voice_max_video INTEGER NOT NULL DEFAULT 10; diff --git a/Server/ws/client.go b/Server/ws/client.go index 74e98772..e87d5b5a 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -3,6 +3,8 @@ package ws import ( "sync" + "github.com/pion/webrtc/v4" + "github.com/owncord/server/db" ) @@ -21,11 +23,14 @@ type Client struct { userID int64 user *db.User channelID int64 // currently viewed channel for channel-scoped broadcasts + voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu + pc *webrtc.PeerConnection // SFU peer connection; nil when not in voice; guarded by voiceMu tokenHash string // SHA-256 hex of the session token; used for periodic revalidation msgCount int // count of messages processed; resets after session check sendClosed bool // true after the send channel has been closed send chan []byte - mu sync.Mutex + mu sync.Mutex // guards sendClosed, msgCount, channelID + voiceMu sync.Mutex // guards voiceChID and pc } // wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump. @@ -85,6 +90,13 @@ func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan [ } } +// SetClientVoiceChID sets the voiceChID field on a client. For test use only. +func SetClientVoiceChID(c *Client, channelID int64) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = channelID +} + // NewTestClientWithTokenHash creates a test client that carries a session token // hash. Use this when tests need to exercise the periodic session-expiry check. func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { @@ -98,6 +110,40 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann } } +// getVoiceChID returns the voice channel ID under voiceMu. +func (c *Client) getVoiceChID() int64 { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.voiceChID +} + +// getPC returns the PeerConnection under voiceMu. +func (c *Client) getPC() *webrtc.PeerConnection { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.pc +} + +// setVoice sets the voice channel and PeerConnection atomically. +func (c *Client) setVoice(chID int64, pc *webrtc.PeerConnection) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = chID + c.pc = pc +} + +// clearVoice clears voice state and returns the old values for cleanup. +// The caller is responsible for closing the returned PeerConnection. +func (c *Client) clearVoice() (oldChID int64, oldPC *webrtc.PeerConnection) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + oldChID = c.voiceChID + oldPC = c.pc + c.voiceChID = 0 + c.pc = nil + return +} + // sendMsg queues a message to this client's send buffer without blocking. // It is a no-op if the send channel has already been closed. func (c *Client) sendMsg(msg []byte) { diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index e5705eb0..3ff7bd52 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -31,6 +31,14 @@ func (h *Hub) HandleMessageForTest(c *Client, raw []byte) { h.handleMessage(c, raw) } +// HandleVoiceLeaveForTest calls handleVoiceLeave directly, simulating a +// disconnect-triggered cleanup without an explicit voice_leave message. +// Exported for ws_test package use only. +func (h *Hub) HandleVoiceLeaveForTest(c *Client) { + h.handleVoiceLeave(c) +} + + // handleMessage parses the envelope and dispatches to the appropriate handler. func (h *Hub) handleMessage(c *Client, raw []byte) { // Periodic session expiry check: every SessionCheckInterval messages, @@ -85,8 +93,16 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { h.handleVoiceMute(c, env.Payload) case "voice_deafen": h.handleVoiceDeafen(c, env.Payload) - case "voice_offer", "voice_answer", "voice_ice": - h.handleVoiceSignal(c, env.Type, env.Payload) + case "voice_camera": + h.handleVoiceCamera(c, env.Payload) + case "voice_screenshare": + h.handleVoiceScreenshare(c, env.Payload) + case "voice_offer": + h.handleVoiceOffer(c, env.Payload) + case "voice_answer": + h.handleVoiceAnswer(c, env.Payload) + case "voice_ice": + h.handleVoiceICE(c, env.Payload) case "soundboard_play": h.handleSoundboard(c, env.Payload) default: diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 10a3d524..a5c8ba39 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -18,14 +18,17 @@ type broadcastMsg struct { // Hub manages all active WebSocket clients and routes messages between them. // All exported methods are safe to call from multiple goroutines. type Hub struct { - clients map[int64]*Client - mu sync.RWMutex - db *db.DB - limiter *auth.RateLimiter - broadcast chan broadcastMsg - register chan *Client - unregister chan *Client - stop chan struct{} + clients map[int64]*Client + mu sync.RWMutex + db *db.DB + limiter *auth.RateLimiter + broadcast chan broadcastMsg + register chan *Client + unregister chan *Client + stop chan struct{} + sfu *SFU + voiceRooms map[int64]*VoiceRoom + voiceRoomsMu sync.RWMutex } // NewHub creates a Hub ready to be started with Run. @@ -38,12 +41,70 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub { register: make(chan *Client, 32), unregister: make(chan *Client, 32), stop: make(chan struct{}), + voiceRooms: make(map[int64]*VoiceRoom), + } +} + +// SetSFU sets the SFU engine on the hub. Must be called before Run. +func (h *Hub) SetSFU(sfu *SFU) { + h.sfu = sfu +} + +// GetOrCreateVoiceRoom returns the existing room for channelID or creates one. +// cfg provides the room config (from channel settings and server defaults). +func (h *Hub) GetOrCreateVoiceRoom(channelID int64, cfg VoiceRoomConfig) *VoiceRoom { + h.voiceRoomsMu.Lock() + defer h.voiceRoomsMu.Unlock() + + if room, ok := h.voiceRooms[channelID]; ok { + return room + } + room := NewVoiceRoom(cfg) + h.voiceRooms[channelID] = room + return room +} + +// GetVoiceRoom returns the room for channelID, or nil if none exists. +func (h *Hub) GetVoiceRoom(channelID int64) *VoiceRoom { + h.voiceRoomsMu.RLock() + defer h.voiceRoomsMu.RUnlock() + return h.voiceRooms[channelID] +} + +// RemoveVoiceRoom removes and closes the room for channelID. No-op if absent. +func (h *Hub) RemoveVoiceRoom(channelID int64) { + h.voiceRoomsMu.Lock() + room, ok := h.voiceRooms[channelID] + if ok { + delete(h.voiceRooms, channelID) + } + h.voiceRoomsMu.Unlock() + + if ok { + room.Close() + } +} + +// CloseAllVoiceRooms closes all voice rooms. Called during shutdown. +func (h *Hub) CloseAllVoiceRooms() { + h.voiceRoomsMu.Lock() + rooms := make([]*VoiceRoom, 0, len(h.voiceRooms)) + for _, room := range h.voiceRooms { + rooms = append(rooms, room) + } + h.voiceRooms = make(map[int64]*VoiceRoom) + h.voiceRoomsMu.Unlock() + + for _, room := range rooms { + room.Close() } } // Run starts the hub's dispatch loop. It blocks until Stop is called. // Must be called in its own goroutine. func (h *Hub) Run() { + go h.runSpeakerBroadcast(h.stop) + for { select { case <-h.stop: @@ -53,7 +114,23 @@ func (h *Hub) Run() { h.mu.Lock() // If an existing client has the same userID, close its send channel // so writePump exits cleanly before the new client takes over. + // Also clean up any voice state the old client held. if old, ok := h.clients[c.userID]; ok && old != c { + oldChID, oldPC := old.clearVoice() + if oldPC != nil { + _ = oldPC.Close() + } + if oldChID > 0 { + if room := h.GetVoiceRoom(oldChID); room != nil { + room.RemoveParticipant(old.userID) + if room.IsEmpty() { + h.voiceRoomsMu.Lock() + delete(h.voiceRooms, oldChID) + h.voiceRoomsMu.Unlock() + } + } + _ = h.db.LeaveVoiceChannel(old.userID) + } old.closeSend() } h.clients[c.userID] = c @@ -77,6 +154,55 @@ func (h *Hub) Stop() { close(h.stop) } +// GracefulStop closes all PeerConnections, voice rooms, and then stops the hub. +func (h *Hub) GracefulStop() { + // Close all client PeerConnections first (CRIT-2 fix). + h.mu.RLock() + for _, c := range h.clients { + if _, oldPC := c.clearVoice(); oldPC != nil { + _ = oldPC.Close() + } + } + h.mu.RUnlock() + + h.CloseAllVoiceRooms() + close(h.stop) +} + +// CleanupVoiceForChannel removes the voice room for the given channel and +// closes PeerConnections for all participants. Called when a channel is deleted. +func (h *Hub) CleanupVoiceForChannel(channelID int64) { + room := h.GetVoiceRoom(channelID) + if room == nil { + return + } + + // Get participant IDs before removing the room. + participantIDs := room.ParticipantIDs() + + // Remove the room (this also calls room.Close() which clears participants). + h.RemoveVoiceRoom(channelID) + + // Close PeerConnections and clean up DB state for all participants. + // Use RLock for client map read; voice fields are guarded by voiceMu (HIGH-3 fix). + h.mu.RLock() + for _, userID := range participantIDs { + if client, ok := h.clients[userID]; ok { + if _, oldPC := client.clearVoice(); oldPC != nil { + _ = oldPC.Close() + } + } + // Clean up DB voice state (best-effort; ignore error). + _ = h.db.LeaveVoiceChannel(userID) + } + h.mu.RUnlock() + + // Broadcast voice_leave for each participant. + for _, userID := range participantIDs { + h.BroadcastToChannel(channelID, buildVoiceLeave(channelID, userID)) + } +} + // Register queues a client for registration with the hub. func (h *Hub) Register(c *Client) { h.register <- c diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index bbe67d6c..a38fa330 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -425,6 +425,266 @@ func assertNotReceived(t *testing.T, ch <-chan []byte, label string) { } } +// ─── Voice room lifecycle ───────────────────────────────────────────────────── + +func TestHub_SetSFU_NilSafe(t *testing.T) { + hub, _ := newTestHub(t) + // Setting a nil SFU must not panic. + hub.SetSFU(nil) +} + +func TestHub_GetOrCreateVoiceRoom_CreatesNew(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"} + + room := hub.GetOrCreateVoiceRoom(42, cfg) + if room == nil { + t.Fatal("GetOrCreateVoiceRoom returned nil") + } +} + +func TestHub_GetOrCreateVoiceRoom_ReturnsSameRoom(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 99, MaxUsers: 5, Quality: "low"} + + r1 := hub.GetOrCreateVoiceRoom(99, cfg) + r2 := hub.GetOrCreateVoiceRoom(99, cfg) + if r1 != r2 { + t.Error("GetOrCreateVoiceRoom should return the same room on subsequent calls") + } +} + +func TestHub_GetOrCreateVoiceRoom_DifferentChannels(t *testing.T) { + hub, _ := newTestHub(t) + cfg1 := ws.VoiceRoomConfig{ChannelID: 1, Quality: "low"} + cfg2 := ws.VoiceRoomConfig{ChannelID: 2, Quality: "high"} + + r1 := hub.GetOrCreateVoiceRoom(1, cfg1) + r2 := hub.GetOrCreateVoiceRoom(2, cfg2) + if r1 == r2 { + t.Error("different channel IDs must produce distinct rooms") + } +} + +func TestHub_GetVoiceRoom_ReturnsNilWhenAbsent(t *testing.T) { + hub, _ := newTestHub(t) + room := hub.GetVoiceRoom(404) + if room != nil { + t.Errorf("GetVoiceRoom: want nil for absent channel, got %v", room) + } +} + +func TestHub_GetVoiceRoom_ReturnsRoomAfterCreate(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 7, Quality: "medium"} + hub.GetOrCreateVoiceRoom(7, cfg) + + room := hub.GetVoiceRoom(7) + if room == nil { + t.Fatal("GetVoiceRoom: want non-nil after GetOrCreateVoiceRoom, got nil") + } +} + +func TestHub_RemoveVoiceRoom_NoopWhenAbsent(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic on removal of non-existent room. + hub.RemoveVoiceRoom(999) +} + +func TestHub_RemoveVoiceRoom_RemovesRoom(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 55, Quality: "low"} + hub.GetOrCreateVoiceRoom(55, cfg) + + hub.RemoveVoiceRoom(55) + if hub.GetVoiceRoom(55) != nil { + t.Error("GetVoiceRoom: want nil after RemoveVoiceRoom") + } +} + +func TestHub_CloseAllVoiceRooms_ClearsAll(t *testing.T) { + hub, _ := newTestHub(t) + for _, id := range []int64{10, 20, 30} { + hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"}) + } + + hub.CloseAllVoiceRooms() + + for _, id := range []int64{10, 20, 30} { + if hub.GetVoiceRoom(id) != nil { + t.Errorf("GetVoiceRoom(%d): want nil after CloseAllVoiceRooms", id) + } + } +} + +func TestHub_CloseAllVoiceRooms_EmptyIsNoop(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic when no rooms exist. + hub.CloseAllVoiceRooms() +} + +func TestHub_VoiceRooms_ConcurrentAccess(t *testing.T) { + hub, _ := newTestHub(t) + var wg sync.WaitGroup + + // Concurrent creates and reads must not race. + for i := int64(0); i < 20; i++ { + wg.Add(1) + go func(id int64) { + defer wg.Done() + cfg := ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"} + hub.GetOrCreateVoiceRoom(id, cfg) + hub.GetVoiceRoom(id) + hub.RemoveVoiceRoom(id) + }(i) + } + wg.Wait() +} + +// ─── GracefulStop ───────────────────────────────────────────────────────────── + +func TestHub_GracefulStop_StopsHub(t *testing.T) { + hub, _ := newTestHub(t) + done := make(chan struct{}) + go func() { + hub.Run() + close(done) + }() + time.Sleep(10 * time.Millisecond) + + hub.GracefulStop() + + select { + case <-done: + // ok — hub stopped + case <-time.After(2 * time.Second): + t.Error("hub.Run() did not stop after GracefulStop()") + } +} + +func TestHub_GracefulStop_ClosesAllVoiceRooms(t *testing.T) { + hub, _ := newTestHub(t) + for _, id := range []int64{100, 200, 300} { + hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "low"}) + } + go hub.Run() + + hub.GracefulStop() + time.Sleep(20 * time.Millisecond) + + for _, id := range []int64{100, 200, 300} { + if hub.GetVoiceRoom(id) != nil { + t.Errorf("GetVoiceRoom(%d): expected nil after GracefulStop", id) + } + } +} + +func TestHub_GracefulStop_NoRooms_NoPanic(t *testing.T) { + hub, _ := newTestHub(t) + go hub.Run() + // Must not panic with zero voice rooms. + hub.GracefulStop() +} + +// ─── CleanupVoiceForChannel ─────────────────────────────────────────────────── + +func TestHub_CleanupVoiceForChannel_RemovesRoom(t *testing.T) { + hub, _ := newTestHub(t) + chID := int64(55) + hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"}) + + hub.CleanupVoiceForChannel(chID) + + if hub.GetVoiceRoom(chID) != nil { + t.Error("expected room to be nil after CleanupVoiceForChannel") + } +} + +func TestHub_CleanupVoiceForChannel_NoRoom_NoPanic(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic when channel has no voice room. + hub.CleanupVoiceForChannel(9999) +} + +func TestHub_CleanupVoiceForChannel_BroadcastsVoiceLeave(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "cleanup-vc") + u1 := seedTestUser(t, database, "cleanup-user1") + u2 := seedTestUser(t, database, "cleanup-user2") + + send1 := make(chan []byte, 16) + send2 := make(chan []byte, 16) + c1 := ws.NewTestClientWithChannel(hub, u1, chID, send1) + c2 := ws.NewTestClientWithChannel(hub, u2, chID, send2) + hub.Register(c1) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + room := hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"}) + if err := room.AddParticipant(u1); err != nil { + t.Fatalf("AddParticipant u1: %v", err) + } + if err := room.AddParticipant(u2); err != nil { + t.Fatalf("AddParticipant u2: %v", err) + } + + hub.CleanupVoiceForChannel(chID) + time.Sleep(50 * time.Millisecond) + + // At least one of the clients must receive a voice_leave. + allMsgs := append(drainChan(send1), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + var env map[string]interface{} + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "voice_leave" { + found = true + break + } + } + } + if !found { + t.Error("expected voice_leave broadcast after CleanupVoiceForChannel") + } +} + +// ─── Hub.Register voice state cleanup ──────────────────────────────────────── + +func TestHub_Register_CleansUpOldVoiceState(t *testing.T) { + // Use voiceHub (has voice_states table) so handleVoiceJoin succeeds. + hub, database := newVoiceHub(t) + + user := seedVoiceOwner(t, database, "register-voice-user") + chID := seedVoiceChan(t, database, "vc-register-voice") + + // Create the voice room and register the first client. + room := hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"}) + + send1 := make(chan []byte, 16) + c1 := ws.NewTestClientWithUser(hub, user, chID, send1) + if err := room.AddParticipant(user.ID); err != nil { + t.Fatalf("AddParticipant: %v", err) + } + ws.SetClientVoiceChID(c1, chID) + + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + + // Register a second client for the same user — should evict the first. + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user, chID, send2) + hub.Register(c2) + time.Sleep(30 * time.Millisecond) + + // The old client's voice state in the room should be cleaned up. + if room.HasParticipant(user.ID) { + t.Error("expected old client's voice participation to be cleaned up after re-register") + } +} + // hubTestSchema is the minimal schema needed for hub tests. var hubTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( @@ -469,15 +729,19 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 689c8683..017b4030 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -154,12 +154,42 @@ func buildVoiceState(state db.VoiceState) []byte { return buildJSON(map[string]any{ "type": "voice_state", "payload": map[string]any{ - "channel_id": state.ChannelID, - "user_id": state.UserID, - "username": state.Username, - "muted": state.Muted, - "deafened": state.Deafened, - "speaking": state.Speaking, + "channel_id": state.ChannelID, + "user_id": state.UserID, + "username": state.Username, + "muted": state.Muted, + "deafened": state.Deafened, + "speaking": state.Speaking, + "camera": state.Camera, + "screenshare": state.Screenshare, + }, + }) +} + +// buildVoiceConfig constructs a voice_config message sent after voice_join acceptance. +func buildVoiceConfig(channelID int64, quality string, bitrate int, mode string, threshold, topSpeakers, maxUsers int) []byte { + return buildJSON(map[string]any{ + "type": "voice_config", + "payload": map[string]any{ + "channel_id": channelID, + "quality": quality, + "bitrate": bitrate, + "threshold_mode": mode, + "mixing_threshold": threshold, + "top_speakers": topSpeakers, + "max_users": maxUsers, + }, + }) +} + +// buildVoiceSpeakers constructs a voice_speakers broadcast. +func buildVoiceSpeakers(channelID int64, speakers []int64, mode string) []byte { + return buildJSON(map[string]any{ + "type": "voice_speakers", + "payload": map[string]any{ + "channel_id": channelID, + "speakers": speakers, + "threshold_mode": mode, }, }) } @@ -175,13 +205,26 @@ func buildVoiceLeave(channelID, userID int64) []byte { }) } -// buildVoiceSignalRelay relays a signaling message (offer/answer/ice) as-is to -// channel members. The original payload is embedded unchanged. -// channelID is provided for future filtering logic. -func buildVoiceSignalRelay(msgType string, _ int64, data json.RawMessage) []byte { +// buildVoiceAnswer constructs a voice_answer message sent from server to client. +func buildVoiceAnswer(channelID int64, sdp string) []byte { return buildJSON(map[string]any{ - "type": msgType, - "payload": data, + "type": "voice_answer", + "payload": map[string]any{ + "channel_id": channelID, + "sdp": sdp, + }, + }) +} + +// buildVoiceOffer constructs a voice_offer message sent from server to client +// (used during renegotiation when server needs to send a new offer). +func buildVoiceOffer(channelID int64, sdp string) []byte { + return buildJSON(map[string]any{ + "type": "voice_offer", + "payload": map[string]any{ + "channel_id": channelID, + "sdp": sdp, + }, }) } diff --git a/Server/ws/sfu.go b/Server/ws/sfu.go new file mode 100644 index 00000000..54230677 --- /dev/null +++ b/Server/ws/sfu.go @@ -0,0 +1,113 @@ +package ws + +import ( + "fmt" + "strconv" + + "github.com/pion/interceptor" + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/config" +) + +// SFU wraps Pion's WebRTC API with pre-configured MediaEngine, +// InterceptorRegistry, and SettingEngine. +type SFU struct { + api *webrtc.API + config *config.VoiceConfig +} + +// NewSFU creates a new SFU with the given voice configuration. It sets up +// the Pion MediaEngine with default codecs, registers the ssrc-audio-level +// RTP header extension, configures interceptors, and applies NAT/port settings. +func NewSFU(cfg *config.VoiceConfig) (*SFU, error) { + var me webrtc.MediaEngine + if err := me.RegisterDefaultCodecs(); err != nil { + return nil, err + } + + // Register ssrc-audio-level header extension for active speaker detection. + const audioLevelURI = "urn:ietf:params:rtp-hdrext:ssrc-audio-level" + for _, dir := range []webrtc.RTPTransceiverDirection{ + webrtc.RTPTransceiverDirectionSendonly, + webrtc.RTPTransceiverDirectionRecvonly, + } { + if err := me.RegisterHeaderExtension( + webrtc.RTPHeaderExtensionCapability{URI: audioLevelURI}, + webrtc.RTPCodecTypeAudio, + dir, + ); err != nil { + return nil, err + } + } + + var ir interceptor.Registry + if err := webrtc.RegisterDefaultInterceptors(&me, &ir); err != nil { + return nil, err + } + + var se webrtc.SettingEngine + se.SetEphemeralUDPPortRange(uint16(cfg.MediaPortMin), uint16(cfg.MediaPortMax)) + + if cfg.ExternalIP != "" { + if err := se.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{ + External: []string{cfg.ExternalIP}, + AsCandidateType: webrtc.ICECandidateTypeHost, + Mode: webrtc.ICEAddressRewriteReplace, + }); err != nil { + return nil, fmt.Errorf("setting ICE address rewrite rules: %w", err) + } + } + + api := webrtc.NewAPI( + webrtc.WithMediaEngine(&me), + webrtc.WithInterceptorRegistry(&ir), + webrtc.WithSettingEngine(se), + ) + + return &SFU{api: api, config: cfg}, nil +} + +// NewPeerConnection creates a new PeerConnection using the SFU's pre-configured +// WebRTC API and ICE server settings from config. +func (s *SFU) NewPeerConnection() (*webrtc.PeerConnection, error) { + pcConfig := webrtc.Configuration{} + + // Add STUN server if port is configured. + if s.config.STUNPort > 0 { + pcConfig.ICEServers = append(pcConfig.ICEServers, webrtc.ICEServer{ + URLs: []string{"stun:localhost:" + strconv.Itoa(s.config.STUNPort)}, + }) + } + + // Add TURN server if enabled. + if s.config.TURNEnabled && s.config.TURNPort > 0 { + pcConfig.ICEServers = append(pcConfig.ICEServers, webrtc.ICEServer{ + URLs: []string{"turn:localhost:" + strconv.Itoa(s.config.TURNPort)}, + Username: "owncord", + Credential: s.config.TURNSecret, + }) + } + + return s.api.NewPeerConnection(pcConfig) +} + +// Close is a placeholder for SFU cleanup. Future implementations may close +// active peer connections or release resources. +func (s *SFU) Close() { + // Placeholder for cleanup. +} + +// QualityBitrate returns the target audio bitrate in bits/s based on the +// configured quality preset. +func (s *SFU) QualityBitrate() int { + switch s.config.Quality { + case "low": + return 32000 + case "high": + return 128000 + default: + return 64000 + } +} + diff --git a/Server/ws/sfu_test.go b/Server/ws/sfu_test.go new file mode 100644 index 00000000..68252abb --- /dev/null +++ b/Server/ws/sfu_test.go @@ -0,0 +1,106 @@ +package ws_test + +import ( + "testing" + + "github.com/owncord/server/config" + "github.com/owncord/server/ws" +) + +func testVoiceConfig() *config.VoiceConfig { + return &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + } +} + +func TestNewSFU_Success(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + if sfu == nil { + t.Fatal("NewSFU() returned nil SFU") + } + defer sfu.Close() +} + +func TestNewSFU_CreatesValidPeerConnection(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection() returned error: %v", err) + } + if pc == nil { + t.Fatal("NewPeerConnection() returned nil PeerConnection") + } + if err := pc.Close(); err != nil { + t.Fatalf("PeerConnection.Close() returned error: %v", err) + } +} + +func TestSFU_QualityBitrate_Presets(t *testing.T) { + tests := []struct { + quality string + want int + }{ + {"low", 32000}, + {"medium", 64000}, + {"high", 128000}, + {"unknown", 64000}, + {"", 64000}, + } + + for _, tt := range tests { + t.Run(tt.quality, func(t *testing.T) { + cfg := testVoiceConfig() + cfg.Quality = tt.quality + + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + got := sfu.QualityBitrate() + if got != tt.want { + t.Errorf("QualityBitrate() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestSFU_Close(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + + // Close should not panic. + sfu.Close() +} + +func TestNewSFU_WithExternalIP(t *testing.T) { + cfg := testVoiceConfig() + cfg.ExternalIP = "203.0.113.1" + + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection() returned error: %v", err) + } + if err := pc.Close(); err != nil { + t.Fatalf("PeerConnection.Close() returned error: %v", err) + } +} diff --git a/Server/ws/speaker_broadcast.go b/Server/ws/speaker_broadcast.go new file mode 100644 index 00000000..c4d33acd --- /dev/null +++ b/Server/ws/speaker_broadcast.go @@ -0,0 +1,79 @@ +package ws + +import ( + "log/slog" + "strconv" + "time" +) + +const speakerBroadcastInterval = 200 * time.Millisecond + +// runSpeakerBroadcast periodically checks all voice rooms for speaker changes +// and broadcasts voice_speakers to the channel. Runs until stop is closed. +func (h *Hub) runSpeakerBroadcast(stop <-chan struct{}) { + ticker := time.NewTicker(speakerBroadcastInterval) + defer ticker.Stop() + + // Track previous speaker lists to avoid redundant broadcasts. + prevSpeakers := make(map[int64]string) // channelID → comma-joined speaker IDs + + for { + select { + case <-stop: + return + case <-ticker.C: + h.voiceRoomsMu.RLock() + rooms := make(map[int64]*VoiceRoom, len(h.voiceRooms)) + for id, room := range h.voiceRooms { + rooms[id] = room + } + h.voiceRoomsMu.RUnlock() + + for channelID, room := range rooms { + speakers := room.TopSpeakers() + mode := room.Mode() + + // Build a simple key to detect changes. + key := speakerKey(speakers) + if prev, ok := prevSpeakers[channelID]; ok && prev == key { + continue // no change + } + prevSpeakers[channelID] = key + + msg := buildVoiceSpeakers(channelID, speakers, mode) + slog.Debug("speaker broadcast", "channel_id", channelID, "speakers", speakers, "mode", mode) + h.BroadcastToChannel(channelID, msg) + } + + // Clean up stale entries for rooms that no longer exist. + for id := range prevSpeakers { + if _, exists := rooms[id]; !exists { + delete(prevSpeakers, id) + } + } + } + } +} + +// speakerKey builds a simple string key from speaker IDs for change detection. +// Order matters: [1,2,3] and [3,2,1] produce different keys. +func speakerKey(speakers []int64) string { + if len(speakers) == 0 { + return "" + } + // Simple concatenation — order matters for change detection. + b := make([]byte, 0, len(speakers)*4) + for i, id := range speakers { + if i > 0 { + b = append(b, ',') + } + b = append(b, []byte(strconv.FormatInt(id, 10))...) + } + return string(b) +} + +// SpeakerKeyForTest exposes speakerKey for use in external test packages. +// Only call from *_test.go files. +func SpeakerKeyForTest(speakers []int64) string { + return speakerKey(speakers) +} diff --git a/Server/ws/speaker_detector.go b/Server/ws/speaker_detector.go new file mode 100644 index 00000000..632c676c --- /dev/null +++ b/Server/ws/speaker_detector.go @@ -0,0 +1,164 @@ +package ws + +import ( + "sort" + "sync" + "time" +) + +const defaultHoldoff = 500 * time.Millisecond + +// speakerLevel tracks the running audio level average for one user. +type speakerLevel struct { + userID int64 + levels [10]uint8 // ring buffer, 10 samples = 200ms at 20ms frames + pos int + count int // how many samples collected (up to 10) + average float64 + lastActive time.Time // last time this speaker was in top-N +} + +// SpeakerDetector selects the top-N loudest speakers by RFC 6464 audio level. +type SpeakerDetector struct { + speakers map[int64]*speakerLevel + topN int + holdoff time.Duration // how long a speaker stays in top-N after going quiet + mu sync.Mutex +} + +// NewSpeakerDetector creates a detector with the default 500ms holdoff. +func NewSpeakerDetector(topN int) *SpeakerDetector { + return NewSpeakerDetectorWithHoldoff(topN, defaultHoldoff) +} + +// NewSpeakerDetectorWithHoldoff creates a detector with a custom holdoff duration. +func NewSpeakerDetectorWithHoldoff(topN int, holdoff time.Duration) *SpeakerDetector { + return &SpeakerDetector{ + speakers: make(map[int64]*speakerLevel), + topN: topN, + holdoff: holdoff, + } +} + +// UpdateLevel adds an audio level sample to the ring buffer for the given user +// and recalculates the running average. Level is RFC 6464 dBov: 0 = loudest, +// 127 = silence. +func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) { + d.mu.Lock() + defer d.mu.Unlock() + + sl, ok := d.speakers[userID] + if !ok { + sl = &speakerLevel{userID: userID} + d.speakers[userID] = sl + } + + sl.levels[sl.pos] = level + sl.pos = (sl.pos + 1) % len(sl.levels) + if sl.count < len(sl.levels) { + sl.count++ + } + + // Recalculate average over collected samples. + var sum int + for i := 0; i < sl.count; i++ { + sum += int(sl.levels[i]) + } + sl.average = float64(sum) / float64(sl.count) + + // Mark as active if not silent. + if sl.average < 127 { + sl.lastActive = time.Now() + } +} + +// TopSpeakers returns up to top-N user IDs sorted by lowest average level +// (loudest first). Silent speakers (average == 127) are excluded unless they +// are within the holdoff window. +func (d *SpeakerDetector) TopSpeakers() []int64 { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now() + + // Collect candidates: not silent, or within holdoff. + candidates := make([]*speakerLevel, 0, len(d.speakers)) + for _, sl := range d.speakers { + if sl.average < 127 { + candidates = append(candidates, sl) + } else if !sl.lastActive.IsZero() && now.Sub(sl.lastActive) <= d.holdoff { + candidates = append(candidates, sl) + } + } + + // Sort by average level ascending (loudest first). + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].average < candidates[j].average + }) + + n := d.topN + if len(candidates) < n { + n = len(candidates) + } + + result := make([]int64, n) + for i := 0; i < n; i++ { + result[i] = candidates[i].userID + } + return result +} + +// RemoveSpeaker removes a speaker from the detector (e.g., when they leave). +func (d *SpeakerDetector) RemoveSpeaker(userID int64) { + d.mu.Lock() + defer d.mu.Unlock() + + delete(d.speakers, userID) +} + +// ParseAudioLevel parses an RFC 6464 one-byte header extension from raw RTP +// extension data (RFC 5285 one-byte header format). It scans for the given +// extensionID and extracts the voice activity bit and 7-bit level. +// +// Returns ok=false if the extension is not found. +func ParseAudioLevel(buf []byte, extensionID uint8) (level uint8, voice bool, ok bool) { + if len(buf) == 0 { + return 0, false, false + } + + // Walk RFC 5285 one-byte header extensions. + // Each element: 4-bit ID | 4-bit (length-1), followed by (length) data bytes. + // ID=0 is padding, ID=15 terminates. + i := 0 + for i < len(buf) { + id := buf[i] >> 4 + dataLen := int(buf[i]&0x0F) + 1 + + if id == 0 { + // Padding byte — skip. + i++ + continue + } + if id == 15 { + // Terminator. + break + } + + i++ // move past header byte + + if i+dataLen > len(buf) { + break + } + + if id == extensionID && dataLen >= 1 { + b := buf[i] + voice = (b & 0x80) != 0 + level = b & 0x7F + return level, voice, true + } + + i += dataLen + } + + return 0, false, false +} diff --git a/Server/ws/speaker_detector_test.go b/Server/ws/speaker_detector_test.go new file mode 100644 index 00000000..462f1093 --- /dev/null +++ b/Server/ws/speaker_detector_test.go @@ -0,0 +1,234 @@ +package ws_test + +import ( + "testing" + "time" + + "github.com/owncord/server/ws" +) + +func TestNewSpeakerDetector(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + if sd == nil { + t.Fatal("NewSpeakerDetector returned nil") + } + top := sd.TopSpeakers() + if len(top) != 0 { + t.Fatalf("expected empty top speakers, got %v", top) + } +} + +func TestSpeakerDetector_UpdateLevel(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // Feed several level samples for a single user. + for i := 0; i < 5; i++ { + sd.UpdateLevel(1, 30) // relatively loud + } + + top := sd.TopSpeakers() + if len(top) != 1 { + t.Fatalf("expected 1 speaker, got %d", len(top)) + } + if top[0] != int64(1) { + t.Fatalf("expected userID 1, got %d", top[0]) + } +} + +func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // 5 users with different average levels (lower = louder in dBov). + // User 10: level 10 (loudest) + // User 20: level 30 + // User 30: level 50 + // User 40: level 80 + // User 50: level 100 (quietest) + users := []struct { + id int64 + level uint8 + }{ + {10, 10}, + {20, 30}, + {30, 50}, + {40, 80}, + {50, 100}, + } + for _, u := range users { + for i := 0; i < 5; i++ { + sd.UpdateLevel(u.id, u.level) + } + } + + top := sd.TopSpeakers() + if len(top) != 3 { + t.Fatalf("expected 3 top speakers, got %d: %v", len(top), top) + } + // Should be sorted loudest first: 10, 20, 30 + expected := []int64{10, 20, 30} + for i, want := range expected { + if top[i] != want { + t.Errorf("top[%d] = %d, want %d", i, top[i], want) + } + } +} + +func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // User 1: loud + for i := 0; i < 5; i++ { + sd.UpdateLevel(1, 20) + } + // User 2: completely silent (127 = digital silence in RFC 6464) + for i := 0; i < 5; i++ { + sd.UpdateLevel(2, 127) + } + + top := sd.TopSpeakers() + if len(top) != 1 { + t.Fatalf("expected 1 speaker (silent excluded), got %d: %v", len(top), top) + } + if top[0] != int64(1) { + t.Fatalf("expected userID 1, got %d", top[0]) + } +} + +func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) + + // User 1 speaks loudly. + for i := 0; i < 5; i++ { + sd.UpdateLevel(1, 20) + } + + // User 1 goes silent. + for i := 0; i < 10; i++ { + sd.UpdateLevel(1, 127) + } + + // Immediately check — holdoff should keep user 1 in top speakers. + top := sd.TopSpeakers() + found := false + for _, id := range top { + if id == int64(1) { + found = true + break + } + } + if !found { + t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top) + } +} + +func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) + + // User 1 speaks loudly. + for i := 0; i < 5; i++ { + sd.UpdateLevel(1, 20) + } + + // User 1 goes silent — fill ring buffer with silence. + for i := 0; i < 10; i++ { + sd.UpdateLevel(1, 127) + } + + // Wait longer than holdoff. + time.Sleep(80 * time.Millisecond) + + top := sd.TopSpeakers() + for _, id := range top { + if id == int64(1) { + t.Fatalf("expected user 1 to be evicted after holdoff expired, got %v", top) + } + } +} + +func TestSpeakerDetector_RemoveSpeaker(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + for i := 0; i < 5; i++ { + sd.UpdateLevel(1, 20) + sd.UpdateLevel(2, 30) + } + + sd.RemoveSpeaker(1) + + top := sd.TopSpeakers() + for _, id := range top { + if id == int64(1) { + t.Fatalf("removed speaker should not appear in TopSpeakers, got %v", top) + } + } + if len(top) != 1 || top[0] != int64(2) { + t.Fatalf("expected [2], got %v", top) + } +} + +func TestParseAudioLevel_Valid(t *testing.T) { + t.Parallel() + + // Construct a one-byte header extension value: + // V=1, Level=42 → binary: 1_0101010 → 0xAA + extByte := byte(0x80 | 42) // voice=1, level=42 + // RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1 + // For extensionID=1, length=1 byte: header = 0x10 + extensionID := uint8(1) + buf := []byte{(extensionID << 4) | 0x00, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte + + level, voice, ok := ws.ParseAudioLevel(buf, extensionID) + if !ok { + t.Fatal("expected ok=true for valid extension") + } + if level != 42 { + t.Errorf("level = %d, want 42", level) + } + if !voice { + t.Error("expected voice=true") + } + + // Test with voice=false, level=10 → binary: 0_0001010 → 0x0A + extByte2 := byte(10) // voice=0, level=10 + buf2 := []byte{(extensionID << 4) | 0x00, extByte2} + + level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID) + if !ok2 { + t.Fatal("expected ok=true") + } + if level2 != 10 { + t.Errorf("level = %d, want 10", level2) + } + if voice2 { + t.Error("expected voice=false") + } +} + +func TestParseAudioLevel_NotFound(t *testing.T) { + t.Parallel() + + // Empty buffer. + _, _, ok := ws.ParseAudioLevel(nil, 1) + if ok { + t.Error("expected ok=false for nil buffer") + } + + _, _, ok = ws.ParseAudioLevel([]byte{}, 1) + if ok { + t.Error("expected ok=false for empty buffer") + } + + // Wrong extension ID — buffer has ID=2 but we ask for ID=1. + buf := []byte{(2 << 4) | 0x00, 0x80} + _, _, ok = ws.ParseAudioLevel(buf, 1) + if ok { + t.Error("expected ok=false for wrong extension ID") + } +} diff --git a/Server/ws/speaker_integration_test.go b/Server/ws/speaker_integration_test.go new file mode 100644 index 00000000..f50e687a --- /dev/null +++ b/Server/ws/speaker_integration_test.go @@ -0,0 +1,373 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/ws" +) + +// ─── VoiceRoom speaker detection ───────────────────────────────────────────── + +func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 1, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + + // Add participants first. + _ = room.AddParticipant(10) + _ = room.AddParticipant(20) + _ = room.AddParticipant(30) + + // User 10 is loudest (lowest dBov = 10), user 30 quietest (90). + for i := 0; i < 5; i++ { + room.UpdateSpeakerLevel(10, 10) + room.UpdateSpeakerLevel(20, 50) + room.UpdateSpeakerLevel(30, 90) + } + + top := room.TopSpeakers() + if len(top) == 0 { + t.Fatal("TopSpeakers returned empty; expected at least one active speaker") + } + if top[0] != int64(10) { + t.Errorf("top speaker = %d, want 10 (loudest)", top[0]) + } +} + +func TestVoiceRoom_TopSpeakers_EmptyRoom(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 2, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + + top := room.TopSpeakers() + if len(top) != 0 { + t.Errorf("TopSpeakers on empty room = %v, want empty slice", top) + } +} + +func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 3, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + _ = room.AddParticipant(100) + _ = room.AddParticipant(200) + + // Feed audio so both appear in top speakers. + for i := 0; i < 5; i++ { + room.UpdateSpeakerLevel(100, 20) + room.UpdateSpeakerLevel(200, 30) + } + + // Verify both appear before removal. + topBefore := room.TopSpeakers() + if len(topBefore) < 2 { + t.Fatalf("expected 2 speakers before removal, got %v", topBefore) + } + + // Remove user 100 from the room. + room.RemoveParticipant(100) + + // After removal, user 100 must not appear in TopSpeakers. + top := room.TopSpeakers() + for _, id := range top { + if id == int64(100) { + t.Errorf("removed user 100 still appears in TopSpeakers: %v", top) + } + } +} + +func TestVoiceRoom_Config(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 42, + MaxUsers: 10, + Quality: "high", + MixingThreshold: 8, + TopSpeakers: 5, + MaxVideo: 2, + } + room := ws.NewVoiceRoom(cfg) + + got := room.Config() + if got.ChannelID != 42 { + t.Errorf("Config().ChannelID = %d, want 42", got.ChannelID) + } + if got.MaxUsers != 10 { + t.Errorf("Config().MaxUsers = %d, want 10", got.MaxUsers) + } + if got.Quality != "high" { + t.Errorf("Config().Quality = %q, want %q", got.Quality, "high") + } + if got.MixingThreshold != 8 { + t.Errorf("Config().MixingThreshold = %d, want 8", got.MixingThreshold) + } + if got.TopSpeakers != 5 { + t.Errorf("Config().TopSpeakers = %d, want 5", got.TopSpeakers) + } + if got.MaxVideo != 2 { + t.Errorf("Config().MaxVideo = %d, want 2", got.MaxVideo) + } +} + +// ─── speakerKey helper ──────────────────────────────────────────────────────── + +func TestSpeakerKey_Empty(t *testing.T) { + key := ws.SpeakerKeyForTest(nil) + if key != "" { + t.Errorf("SpeakerKeyForTest(nil) = %q, want empty string", key) + } + + key2 := ws.SpeakerKeyForTest([]int64{}) + if key2 != "" { + t.Errorf("SpeakerKeyForTest([]) = %q, want empty string", key2) + } +} + +func TestSpeakerKey_SingleSpeaker(t *testing.T) { + key := ws.SpeakerKeyForTest([]int64{42}) + if key == "" { + t.Error("SpeakerKeyForTest([42]) returned empty string") + } + // Key must contain the speaker ID in some form. + if key != "42" { + t.Errorf("SpeakerKeyForTest([42]) = %q, want %q", key, "42") + } +} + +func TestSpeakerKey_MultipleSpeakers(t *testing.T) { + key1 := ws.SpeakerKeyForTest([]int64{1, 2, 3}) + key2 := ws.SpeakerKeyForTest([]int64{1, 2, 3}) + key3 := ws.SpeakerKeyForTest([]int64{3, 2, 1}) + + // Same order → same key. + if key1 != key2 { + t.Errorf("same speaker lists produced different keys: %q vs %q", key1, key2) + } + // Different order → different key (order matters for change detection). + if key1 == key3 { + t.Errorf("different speaker order should produce different keys but got %q for both", key1) + } +} + +func TestSpeakerKey_DistinctFromDifferentSpeakers(t *testing.T) { + key1 := ws.SpeakerKeyForTest([]int64{1, 2}) + key2 := ws.SpeakerKeyForTest([]int64{1, 3}) + if key1 == key2 { + t.Errorf("different speaker sets should produce different keys, both got %q", key1) + } +} + +// ─── Speaker broadcast integration ─────────────────────────────────────────── + +// TestSpeakerBroadcast_Integration creates a hub with a voice room, feeds +// speaker levels, and verifies a voice_speakers broadcast is sent within the +// ticker interval. +func TestSpeakerBroadcast_Integration(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Create a voice room for channel 99. + cfg := ws.VoiceRoomConfig{ + ChannelID: 99, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(99, cfg) + + // Register a client subscribed to channel 99 to receive the broadcast. + send := make(chan []byte, 16) + c := ws.NewTestClientWithChannel(hub, 1, 99, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed audio levels into the room — make user 1 a speaker. + for i := 0; i < 5; i++ { + room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold + } + + // Wait for at least two ticker intervals (200ms each) so the broadcast fires. + time.Sleep(500 * time.Millisecond) + + // Drain and look for a voice_speakers message. + var found bool +drainLoop: + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + msgType, ok := env["type"] + if !ok { + continue + } + var t2 string + if err := json.Unmarshal(msgType, &t2); err != nil { + continue + } + if t2 == "voice_speakers" { + found = true + break drainLoop + } + default: + break drainLoop + } + } + + if !found { + t.Error("expected voice_speakers broadcast within ticker interval, none received") + } +} + +// TestSpeakerBroadcast_NoBroadcastWhenNoChange verifies that the ticker does +// not repeatedly broadcast when the speaker list has not changed. +func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + cfg := ws.VoiceRoomConfig{ + ChannelID: 100, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(100, cfg) + + send := make(chan []byte, 64) + c := ws.NewTestClientWithChannel(hub, 2, 100, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed levels to produce a stable speaker list. + for i := 0; i < 5; i++ { + room.UpdateSpeakerLevel(2, 20) + } + + // Wait for first broadcast. + time.Sleep(300 * time.Millisecond) + + // Count how many voice_speakers messages arrived after the initial one. + // In a change-detection implementation, subsequent ticks with the same + // speaker list should NOT send more broadcasts. + count := 0 + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + var msgType string + if raw, ok := env["type"]; ok { + _ = json.Unmarshal(raw, &msgType) + } + if msgType == "voice_speakers" { + count++ + } + default: + goto done + } + } +done: + // We allow 1 broadcast (initial detection), but not many repeated ones. + // If every tick sent a message, we'd see ~2-4 in 300ms. We cap at 2. + if count > 2 { + t.Errorf("expected at most 2 voice_speakers broadcasts (dedup), got %d", count) + } +} + +// TestSpeakerBroadcast_RoomCleanup verifies that when a voice room is removed, +// the ticker cleans up its stale prevSpeakers entry so that re-creating the +// room with an active speaker triggers a new broadcast. +func TestSpeakerBroadcast_RoomCleanup(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + const chanID = int64(101) + cfg := ws.VoiceRoomConfig{ + ChannelID: chanID, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(chanID, cfg) + + // Use a large buffer to avoid missing messages due to timing. + send := make(chan []byte, 64) + c := ws.NewTestClientWithChannel(hub, 3, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed levels so the ticker broadcasts at least once. + for i := 0; i < 5; i++ { + room.UpdateSpeakerLevel(3, 20) + } + + // Wait for two ticker intervals to ensure at least one broadcast fires. + time.Sleep(500 * time.Millisecond) + + // Remove the room; this should cause the ticker to clean up prevSpeakers. + hub.RemoveVoiceRoom(chanID) + + // Wait one more tick to let the cleanup run. + time.Sleep(250 * time.Millisecond) + + // Drain all pending messages. + draining: + for { + select { + case <-send: + default: + break draining + } + } + + // Re-create the room and feed a new speaker — the ticker should broadcast + // again because prevSpeakers[chanID] was deleted when the room was removed. + newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg) + for i := 0; i < 5; i++ { + newRoom.UpdateSpeakerLevel(3, 20) + } + + // Wait for the ticker to detect the new room and broadcast. + time.Sleep(500 * time.Millisecond) + + var found bool + collectLoop: + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + var msgType string + if raw, ok := env["type"]; ok { + _ = json.Unmarshal(raw, &msgType) + } + if msgType == "voice_speakers" { + found = true + break collectLoop + } + default: + break collectLoop + } + } + + if !found { + t.Error("expected voice_speakers broadcast after room re-creation, none received") + } +} diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 4608f93c..6759ea3d 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -2,10 +2,14 @@ package ws import ( "encoding/json" + "errors" "fmt" "log/slog" "time" + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) @@ -15,13 +19,51 @@ const ( voiceSignalWindow = time.Second soundboardRateLimit = 1 soundboardWindow = 3 * time.Second + voiceCameraRateLimit = 2 + voiceCameraWindow = time.Second + voiceScreenshareRateLimit = 2 + voiceScreenshareWindow = time.Second ) +// setupICEMonitor monitors ICE connection state changes on the client's +// PeerConnection. On failure/disconnect, it cleans up voice state. +func (h *Hub) setupICEMonitor(c *Client, channelID int64) { + pc := c.getPC() + if pc == nil { + return + } + + pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + slog.Info("ICE state change", "user_id", c.userID, "channel_id", channelID, "state", state.String()) + + switch state { + case webrtc.ICEConnectionStateFailed: + slog.Warn("ICE connection failed, cleaning up voice", "user_id", c.userID, "channel_id", channelID) + h.handleVoiceLeave(c) + case webrtc.ICEConnectionStateDisconnected: + // Disconnected is transient — ICE may recover. + // Log but don't clean up immediately. + slog.Info("ICE disconnected (may recover)", "user_id", c.userID, "channel_id", channelID) + } + }) +} + +// SetupICEMonitorForTest exposes setupICEMonitor for tests. +func (h *Hub) SetupICEMonitorForTest(c *Client, channelID int64) { + h.setupICEMonitor(c, channelID) +} + // handleVoiceJoin processes a voice_join message. -// 1. Checks CONNECT_VOICE permission. -// 2. Persists join in DB. -// 3. Broadcasts voice_state to channel. -// 4. Sends all current voice states in the channel back to the joiner. +// 1. Parses channel_id. +// 2. Checks CONNECT_VOICE permission. +// 3. If already in a different voice channel, leaves it first. +// 4. Gets or creates VoiceRoom with config from channel settings. +// 5. Adds participant to VoiceRoom (checks capacity). +// 6. Persists join in DB. +// 7. Creates PeerConnection if SFU is available. +// 8. Broadcasts voice_state to channel. +// 9. Sends existing voice states to joiner. +// 10. Sends voice_config to joiner. func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { channelID, err := parseChannelID(payload) if err != nil || channelID <= 0 { @@ -34,12 +76,62 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { return } + currentChID := c.getVoiceChID() + + // HIGH-2: If user is already in the same voice channel, no-op. + if currentChID == channelID { + c.sendMsg(buildErrorMsg("ALREADY_JOINED", "already in this voice channel")) + return + } + + // If user is already in a different voice channel, leave it first. + if currentChID > 0 { + h.handleVoiceLeave(c) + } + + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found")) + return + } + + roomCfg := h.buildVoiceRoomConfig(ch) + room := h.GetOrCreateVoiceRoom(channelID, roomCfg) + + if addErr := room.AddParticipant(c.userID); addErr != nil { + if errors.Is(addErr, ErrRoomFull) { + c.sendMsg(buildErrorMsg("CHANNEL_FULL", "voice channel is full")) + } else { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to join voice channel")) + } + return + } + if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + room.RemoveParticipant(c.userID) slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel")) return } + // Create PeerConnection if SFU is available. Non-fatal on failure. + var pc *webrtc.PeerConnection + if h.sfu != nil { + var pcErr error + pc, pcErr = h.sfu.NewPeerConnection() + if pcErr != nil { + slog.Error("ws handleVoiceJoin NewPeerConnection", "err", pcErr, "user_id", c.userID) + } + } + + // Track the voice channel and PC on the client atomically (CRIT-1 fix). + c.setVoice(channelID, pc) + + if pc != nil { + h.setupOnTrack(c, channelID) + h.setupICEMonitor(c, channelID) + } + state, err := h.db.GetVoiceState(c.userID) if err != nil || state == nil { slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) @@ -57,21 +149,74 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { } for _, vs := range existing { if vs.UserID == c.userID { - continue // skip the joiner themselves + continue } c.sendMsg(buildVoiceState(vs)) } + + // Send voice_config to the joiner with room settings. + quality := roomCfg.Quality + bitrate := 64000 // default medium + if h.sfu != nil { + bitrate = h.sfu.QualityBitrate() + } + c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, room.Mode(), roomCfg.MixingThreshold, roomCfg.TopSpeakers, roomCfg.MaxUsers)) + + slog.Info("voice join", "user_id", c.userID, "channel_id", channelID, "participants", room.ParticipantCount(), "mode", room.Mode()) +} + +// buildVoiceRoomConfig constructs a VoiceRoomConfig from channel settings and server defaults. +func (h *Hub) buildVoiceRoomConfig(ch *db.Channel) VoiceRoomConfig { + cfg := VoiceRoomConfig{ + ChannelID: ch.ID, + MaxUsers: ch.VoiceMaxUsers, + Quality: "medium", + MixingThreshold: 10, + TopSpeakers: 3, + MaxVideo: ch.VoiceMaxVideo, + } + if ch.VoiceQuality != nil && *ch.VoiceQuality != "" { + cfg.Quality = *ch.VoiceQuality + } + if ch.MixingThreshold != nil { + cfg.MixingThreshold = *ch.MixingThreshold + } + return cfg } // handleVoiceLeave processes an explicit voice_leave message or a disconnect. -// 1. Removes voice state from DB. -// 2. Broadcasts voice_leave to the channel the user was in. +// 1. Reads current voice state (for broadcast). +// 2. Closes PeerConnection if active. +// 3. Removes participant from VoiceRoom; removes room if empty. +// 4. Removes voice state from DB. +// 5. Broadcasts voice_leave to the channel the user was in. func (h *Hub) handleVoiceLeave(c *Client) { state, err := h.db.GetVoiceState(c.userID) if err != nil { slog.Error("ws handleVoiceLeave GetVoiceState", "err", err, "user_id", c.userID) } + // Atomically clear voice state and get old values for cleanup (CRIT-1 fix). + oldChID, oldPC := c.clearVoice() + + // Close PeerConnection if active. + // This also causes any setupOnTrack goroutine to exit via track.Read error (HIGH-1). + if oldPC != nil { + if closeErr := oldPC.Close(); closeErr != nil { + slog.Error("ws handleVoiceLeave pc.Close", "err", closeErr, "user_id", c.userID) + } + } + + // Remove from VoiceRoom and clean up empty rooms. + if oldChID > 0 { + if room := h.GetVoiceRoom(oldChID); room != nil { + room.RemoveParticipant(c.userID) + if room.IsEmpty() { + h.RemoveVoiceRoom(oldChID) + } + } + } + if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID) } @@ -125,26 +270,214 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { h.broadcastVoiceStateUpdate(c) } -// handleVoiceSignal relays voice_offer, voice_answer, and voice_ice messages. -// 1. Rate limits at 20/sec per user. -// 2. Parses channel_id from payload. -// 3. Relays the message (with original type) to all other channel members. -// SDP/ICE content is not inspected or logged. -func (h *Hub) handleVoiceSignal(c *Client, msgType string, payload json.RawMessage) { +// handleVoiceCamera processes a voice_camera message. +// 1. Rate limits at 2/sec per user. +// 2. Checks USE_VIDEO permission. +// 3. Parses enabled bool. +// 4. Updates DB. +// 5. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_camera:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many camera toggles")) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + if !h.hasChannelPerm(c, voiceChID, permissions.UseVideo) { + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_VIDEO permission")) + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_camera payload")) + return + } + + if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update camera state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceScreenshare processes a voice_screenshare message. +// 1. Rate limits at 2/sec per user. +// 2. Checks SHARE_SCREEN permission. +// 3. Parses enabled bool. +// 4. Updates DB. +// 5. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many screenshare toggles")) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + if !h.hasChannelPerm(c, voiceChID, permissions.ShareScreen) { + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SHARE_SCREEN permission")) + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_screenshare payload")) + return + } + + if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update screenshare state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceOffer processes a voice_offer from the client. +// The client sends an SDP offer; the server sets it as remote description +// on the client's PeerConnection, creates an answer, and sends it back. +func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_signal:%d", c.userID) if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) return } - channelID, err := parseChannelID(payload) - if err != nil || channelID <= 0 { - c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) return } - relayed := buildVoiceSignalRelay(msgType, channelID, payload) - h.broadcastExclude(channelID, c.userID, relayed) + var p struct { + ChannelID json.Number `json:"channel_id"` + SDP string `json:"sdp"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_offer payload")) + return + } + if p.SDP == "" { + c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required")) + return + } + + offer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: p.SDP, + } + + if err := pc.SetRemoteDescription(offer); err != nil { + slog.Error("ws handleVoiceOffer SetRemoteDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description")) + return + } + + answer, err := pc.CreateAnswer(nil) + if err != nil { + slog.Error("ws handleVoiceOffer CreateAnswer", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to create answer")) + return + } + + if err := pc.SetLocalDescription(answer); err != nil { + slog.Error("ws handleVoiceOffer SetLocalDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to set local description")) + return + } + + // Send the answer back to the client. + c.sendMsg(buildVoiceAnswer(c.getVoiceChID(), answer.SDP)) +} + +// handleVoiceAnswer processes a voice_answer from the client. +// This handles the case where the server sent an offer (e.g., renegotiation) +// and the client responds with an answer. +func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_signal:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + return + } + + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + SDP string `json:"sdp"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_answer payload")) + return + } + if p.SDP == "" { + c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required")) + return + } + + answer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: p.SDP, + } + + if err := pc.SetRemoteDescription(answer); err != nil { + slog.Error("ws handleVoiceAnswer SetRemoteDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description")) + return + } +} + +// handleVoiceICE processes a voice_ice (ICE candidate) from the client. +func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_signal:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + return + } + + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + Candidate webrtc.ICECandidateInit `json:"candidate"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_ice payload")) + return + } + + if err := pc.AddICECandidate(p.Candidate); err != nil { + slog.Error("ws handleVoiceICE AddICECandidate", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to add ICE candidate")) + return + } } // handleSoundboard processes a soundboard_play message. @@ -174,6 +507,63 @@ func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) { h.BroadcastToAll(buildSoundboardPlay(p.SoundID, c.userID)) } +// setupOnTrack configures the PeerConnection's OnTrack handler to: +// 1. Read RTP packets from incoming audio tracks. +// 2. Parse the ssrc-audio-level header extension (RFC 6464). +// 3. Feed the audio levels into the VoiceRoom's SpeakerDetector. +// +// Must be called after c.pc is set and before SDP negotiation completes. +func (h *Hub) setupOnTrack(c *Client, channelID int64) { + pc := c.getPC() + if pc == nil { + return + } + + // audioLevelExtID is the negotiated extension ID for ssrc-audio-level. + // In a real deployment this is resolved via SDP; we use ID 1 as the + // conventional default matching the MediaEngine registration in sfu.go. + const audioLevelExtID = 1 + + // The goroutine spawned inside OnTrack exits when track.Read returns an + // error, which happens when the PeerConnection is closed (HIGH-1). + pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + // Only process audio tracks for speaker detection. + if track.Kind() != webrtc.RTPCodecTypeAudio { + return + } + + slog.Info("SFU OnTrack", + "user_id", c.userID, + "channel_id", channelID, + "kind", track.Kind(), + "codec", track.Codec().MimeType, + ) + + go func() { + buf := make([]byte, 1500) + for { + n, _, readErr := track.Read(buf) + if readErr != nil { + return // track closed or connection gone + } + + // Parse audio level from RTP header extension. + level, _, found := ParseAudioLevel(buf[:n], audioLevelExtID) + if !found { + continue + } + + room := h.GetVoiceRoom(channelID) + if room == nil { + return // room has been removed + } + + room.UpdateSpeakerLevel(c.userID, level) + } + }() + }) +} + // broadcastVoiceStateUpdate fetches the current voice state for the client // and broadcasts it to all members of the voice channel they are in. func (h *Hub) broadcastVoiceStateUpdate(c *Client) { diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index dfe0389c..1588ebb4 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -14,12 +14,14 @@ import ( // voiceSchema extends hubTestSchema with the voice_states table. var voiceSchema = append(hubTestSchema, []byte(` CREATE TABLE IF NOT EXISTS voice_states ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - muted INTEGER NOT NULL DEFAULT 0, - deafened INTEGER NOT NULL DEFAULT 0, - speaking INTEGER NOT NULL DEFAULT 0, - joined_at TEXT NOT NULL DEFAULT (datetime('now')) + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); `)...) @@ -148,6 +150,25 @@ func extractType(t *testing.T, msg []byte) string { return typ } +// extractCode parses a JSON error message and returns the payload "code" field. +// Returns an empty string if the message is not an error envelope. +func extractCode(t *testing.T, msg []byte) string { + t.Helper() + var env struct { + Type string `json:"type"` + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + return "" + } + if env.Type != "error" { + return "" + } + return env.Payload.Code +} + // drainChan reads all pending messages from ch into a slice. func drainChan(ch <-chan []byte) [][]byte { var msgs [][]byte @@ -506,132 +527,275 @@ func TestVoice_Deafen_BroadcastsVoiceState(t *testing.T) { } } -// ─── voice signaling relay ──────────────────────────────────────────────────── +// ─── voice signaling (SFU) ──────────────────────────────────────────────────── +// +// The signaling flow changed from P2P relay to SFU: offer/answer/ice are now +// exchanged between client and server, not relayed between clients. +// +// Tests focus on validation and error paths since PeerConnection operations +// require a real WebRTC stack (only exercised in integration tests). -func TestVoice_Signal_RelaysToOtherChannelMembers(t *testing.T) { +// TestVoice_Offer_NoPeerConnection verifies that voice_offer when the client +// has no PeerConnection returns a VOICE_ERROR. +func TestVoice_Offer_NoPeerConnection(t *testing.T) { hub, database := newVoiceHub(t) - chanID := seedVoiceChan(t, database, "vc-signal") + user := seedVoiceOwner(t, database, "offer-nopc") - sender := seedVoiceOwner(t, database, "kate") - receiver := seedVoiceOwner(t, database, "kate2") - outsider := seedVoiceOwner(t, database, "kate3") - - sendR := make(chan []byte, 16) - cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) - hub.Register(cR) - - sendO := make(chan []byte, 16) - cO := ws.NewTestClientWithUser(hub, outsider, 999, sendO) // different channel - hub.Register(cO) - - sendS := make(chan []byte, 16) - cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) - hub.Register(cS) + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) time.Sleep(20 * time.Millisecond) - hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0...")) - time.Sleep(50 * time.Millisecond) + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0 offer...")) + time.Sleep(30 * time.Millisecond) - // Receiver in same channel should get the signal. - receiverMsgs := drainChan(sendR) + msgs := drainChan(send) found := false - for _, msg := range receiverMsgs { - if extractType(t, msg) == "voice_offer" { + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { found = true break } } if !found { - t.Error("receiver in channel did not receive voice_offer relay") - } - - // Outsider in different channel should NOT get it. - outsiderMsgs := drainChan(sendO) - for _, msg := range outsiderMsgs { - if extractType(t, msg) == "voice_offer" { - t.Error("outsider received voice_offer, should not have") - } - } - - // Sender should NOT receive their own signal. - senderMsgs := drainChan(sendS) - for _, msg := range senderMsgs { - if extractType(t, msg) == "voice_offer" { - t.Error("sender received their own voice_offer, should not have") - } + t.Error("expected VOICE_ERROR when sending voice_offer without a PeerConnection") } } -func TestVoice_Signal_ICERelayed(t *testing.T) { +// TestVoice_Offer_EmptySDP verifies that voice_offer with an empty SDP field +// returns INVALID_SDP before touching any PeerConnection. +func TestVoice_Offer_EmptySDP(t *testing.T) { hub, database := newVoiceHub(t) - chanID := seedVoiceChan(t, database, "vc-ice") + user := seedVoiceOwner(t, database, "offer-emptysdp") - sender := seedVoiceOwner(t, database, "leo") - receiver := seedVoiceOwner(t, database, "leo2") - - sendR := make(chan []byte, 16) - cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) - hub.Register(cR) - - sendS := make(chan []byte, 16) - cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) - hub.Register(cS) + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) time.Sleep(20 * time.Millisecond) - hub.HandleMessageForTest(cS, voiceICEMsg(chanID, "candidate:...")) + // Send offer with blank SDP — pc is nil but SDP check comes after pc check, + // so we expect VOICE_ERROR (no pc) before INVALID_SDP would fire. + // To isolate the empty-SDP path we need a client with pc set. Since we + // can't construct a real PC in unit tests, we verify the pc==nil branch + // fires first, which returns VOICE_ERROR. The INVALID_SDP branch is + // separately reachable; we test its message format via the handler directly. + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + if len(msgs) == 0 { + t.Fatal("expected at least one error response for voice_offer with no pc") + } + code := extractCode(t, msgs[0]) + if code != "VOICE_ERROR" && code != "INVALID_SDP" { + t.Errorf("expected VOICE_ERROR or INVALID_SDP, got %q", code) + } +} + +// TestVoice_Offer_RateLimit verifies that sending 25+ voice_offer messages +// rapidly results in at least one RATE_LIMITED error being sent back to the +// client. +func TestVoice_Offer_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-ratelimit") + + send := make(chan []byte, 256) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // 25 offers rapidly — limit is 20/sec. + for i := 0; i < 25; i++ { + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) + } time.Sleep(50 * time.Millisecond) - receiverMsgs := drainChan(sendR) + msgs := drainChan(send) found := false - for _, msg := range receiverMsgs { - if extractType(t, msg) == "voice_ice" { + for _, m := range msgs { + if extractCode(t, m) == "RATE_LIMITED" { found = true break } } if !found { - t.Error("receiver did not receive relayed voice_ice") + t.Error("expected RATE_LIMITED error after 25 rapid voice_offer messages") } } +// TestVoice_Answer_NoPeerConnection verifies that voice_answer when the client +// has no PeerConnection returns VOICE_ERROR. +func TestVoice_Answer_NoPeerConnection(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-nopc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "v=0 answer...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { + found = true + break + } + } + if !found { + t.Error("expected VOICE_ERROR when sending voice_answer without a PeerConnection") + } +} + +// TestVoice_Answer_EmptySDP verifies that voice_answer with blank SDP returns +// an error (VOICE_ERROR from pc==nil check, or INVALID_SDP if pc existed). +func TestVoice_Answer_EmptySDP(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-emptysdp") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + if len(msgs) == 0 { + t.Fatal("expected at least one error response for empty voice_answer") + } + code := extractCode(t, msgs[0]) + if code != "VOICE_ERROR" && code != "INVALID_SDP" { + t.Errorf("expected VOICE_ERROR or INVALID_SDP, got %q", code) + } +} + +// TestVoice_ICE_NoPeerConnection verifies that voice_ice when the client has +// no PeerConnection returns VOICE_ERROR. +func TestVoice_ICE_NoPeerConnection(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-nopc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceICEMsg(1, "candidate:0 1 UDP 123 192.168.1.1 5000 typ host")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { + found = true + break + } + } + if !found { + t.Error("expected VOICE_ERROR when sending voice_ice without a PeerConnection") + } +} + +// TestVoice_HandleMessage_VoiceOffer_Dispatched verifies that voice_offer is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +func TestVoice_HandleMessage_VoiceOffer_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_offer produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_HandleMessage_VoiceAnswer_Dispatched verifies that voice_answer is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +// This replaces the old TestVoice_HandleMessage_VoiceAnswer_Relayed which +// tested the removed P2P relay behavior. +func TestVoice_HandleMessage_VoiceAnswer_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "v=0 answer...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_answer produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_HandleMessage_VoiceICE_Dispatched verifies that voice_ice is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +func TestVoice_HandleMessage_VoiceICE_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceICEMsg(1, "candidate:0 1 UDP 123 192.168.1.1 5000 typ host")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_ice produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_Signal_RateLimit_BlocksExcess verifies that rapid voice_offer +// messages get rate limited (replaces the old relay-counting test). func TestVoice_Signal_RateLimit_BlocksExcess(t *testing.T) { hub, database := newVoiceHub(t) - chanID := seedVoiceChan(t, database, "vc-ratelimit") + user := seedVoiceOwner(t, database, "mia") - sender := seedVoiceOwner(t, database, "mia") - receiver := seedVoiceOwner(t, database, "mia2") - - sendR := make(chan []byte, 256) - cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) - hub.Register(cR) - - sendS := make(chan []byte, 256) - cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) - hub.Register(cS) + send := make(chan []byte, 256) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) time.Sleep(20 * time.Millisecond) - // Send 30 signals rapidly — limit is 20/sec, so some should be dropped. + // Send 30 signals rapidly — limit is 20/sec, so some should be rate-limited. for i := 0; i < 30; i++ { - hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0...")) + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) } time.Sleep(50 * time.Millisecond) - receivedCount := len(drainChan(sendR)) - if receivedCount >= 30 { - t.Errorf("received %d signals, expected fewer due to rate limit", receivedCount) - } - - // Sender should receive at least one RATE_LIMITED error. - senderMsgs := drainChan(sendS) - foundError := false - for _, msg := range senderMsgs { - if extractType(t, msg) == "error" { - foundError = true + msgs := drainChan(send) + foundRateLimit := false + for _, m := range msgs { + if extractCode(t, m) == "RATE_LIMITED" { + foundRateLimit = true break } } - if !foundError { - t.Error("expected RATE_LIMITED error to sender after exceeding signal rate limit") + if !foundRateLimit { + t.Error("expected RATE_LIMITED error after 30 rapid voice_offer messages") } } @@ -732,36 +896,700 @@ func TestVoice_Soundboard_RateLimit(t *testing.T) { } } -// ─── handleMessage dispatch ─────────────────────────────────────────────────── +// ─── voice_camera ───────────────────────────────────────────────────────────── -func TestVoice_HandleMessage_VoiceAnswer_Relayed(t *testing.T) { +// voiceCameraMsg builds a voice_camera WebSocket message. +func voiceCameraMsg(enabled bool) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_camera", + "payload": map[string]interface{}{"enabled": enabled}, + }) + return raw +} + +// TestVoice_Camera_UpdatesState: join voice, send voice_camera {enabled:true}, +// verify voice_state broadcast includes camera:true. +func TestVoice_Camera_UpdatesState(t *testing.T) { hub, database := newVoiceHub(t) - chanID := seedVoiceChan(t, database, "vc-answer") + user := seedVoiceOwner(t, database, "cam-alice") + chanID := seedVoiceChan(t, database, "vc-cam-alice") - sender := seedVoiceOwner(t, database, "pedro") - receiver := seedVoiceOwner(t, database, "pedro2") + user2 := seedVoiceOwner(t, database, "cam-alice2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) - sendR := make(chan []byte, 16) - cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) - hub.Register(cR) - - sendS := make(chan []byte, 16) - cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) - hub.Register(cS) + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) time.Sleep(20 * time.Millisecond) - hub.HandleMessageForTest(cS, voiceSignalMsg("voice_answer", chanID, "v=0 answer...")) + // Join voice channel first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + // Toggle camera on. + hub.HandleMessageForTest(c, voiceCameraMsg(true)) time.Sleep(50 * time.Millisecond) - receiverMsgs := drainChan(sendR) - found := false - for _, msg := range receiverMsgs { - if extractType(t, msg) == "voice_answer" { - found = true + // Verify DB state. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Camera { + t.Error("Camera = false after voice_camera(true)") + } + + // Verify voice_state broadcast received by channel member. + allMsgs := append(drainChan(send), drainChan(send2)...) + foundVoiceState := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + + var env struct { + Type string `json:"type"` + Payload struct { + Camera bool `json:"camera"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_state: %v", err) + } + if !env.Payload.Camera { + t.Error("voice_state broadcast payload.camera = false, want true") + } break } } - if !found { - t.Error("receiver did not receive relayed voice_answer") + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_camera toggle") } } + +// TestVoice_Camera_NoPermission: Member without USE_VIDEO gets FORBIDDEN. +func TestVoice_Camera_NoPermission(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → hasChannelPerm returns false. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 7001, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for camera toggle without USE_VIDEO permission") + } +} + +// TestVoice_Camera_RateLimit: send 3+ camera toggles rapidly, verify rate limit error. +func TestVoice_Camera_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "cam-ratelimit") + chanID := seedVoiceChan(t, database, "vc-cam-ratelimit") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send 5 camera toggles rapidly — limit is 2/sec, so some should be rate-limited. + for i := 0; i < 5; i++ { + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected RATE_LIMITED error after exceeding camera rate limit") + } +} + +// ─── voice_screenshare ──────────────────────────────────────────────────────── + +// voiceScreenshareMsg builds a voice_screenshare WebSocket message. +func voiceScreenshareMsg(enabled bool) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_screenshare", + "payload": map[string]interface{}{"enabled": enabled}, + }) + return raw +} + +// TestVoice_Screenshare_UpdatesState: join voice, send voice_screenshare {enabled:true}, +// verify voice_state broadcast includes screenshare:true. +func TestVoice_Screenshare_UpdatesState(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-alice") + chanID := seedVoiceChan(t, database, "vc-ss-alice") + + user2 := seedVoiceOwner(t, database, "ss-alice2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Join voice channel first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + // Toggle screenshare on. + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(50 * time.Millisecond) + + // Verify DB state. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Screenshare { + t.Error("Screenshare = false after voice_screenshare(true)") + } + + // Verify voice_state broadcast received. + allMsgs := append(drainChan(send), drainChan(send2)...) + foundVoiceState := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + + var env struct { + Type string `json:"type"` + Payload struct { + Screenshare bool `json:"screenshare"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_state: %v", err) + } + if !env.Payload.Screenshare { + t.Error("voice_state broadcast payload.screenshare = false, want true") + } + break + } + } + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_screenshare toggle") + } +} + +// TestVoice_Screenshare_NoPermission: client without SHARE_SCREEN gets FORBIDDEN. +func TestVoice_Screenshare_NoPermission(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → hasChannelPerm returns false. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 7002, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for screenshare toggle without SHARE_SCREEN permission") + } +} + +// TestVoice_Screenshare_RateLimit: send 5+ screenshare toggles rapidly, verify rate limit error. +func TestVoice_Screenshare_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-ratelimit") + chanID := seedVoiceChan(t, database, "vc-ss-ratelimit") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send 5 screenshare toggles rapidly — limit is 2/sec. + for i := 0; i < 5; i++ { + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected RATE_LIMITED error after exceeding screenshare rate limit") + } +} + +// ─── handleMessage dispatch ─────────────────────────────────────────────────── + +// ─── SFU-integrated voice_join / voice_leave ────────────────────────────────── + +// seedVoiceChanMaxUsers creates a voice channel with a custom voice_max_users limit. +func seedVoiceChanMaxUsers(t *testing.T, database *db.DB, name string, maxUsers int) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChanMaxUsers CreateChannel: %v", err) + } + if err := database.SetChannelVoiceMaxUsers(id, maxUsers); err != nil { + t.Fatalf("seedVoiceChanMaxUsers SetChannelVoiceMaxUsers: %v", err) + } + return id +} + +// TestVoice_Join_SFU_SendsVoiceConfig verifies that after voice_join the joiner +// receives a voice_config message with the expected fields. +func TestVoice_Join_SFU_SendsVoiceConfig(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "sfu-alice") + chanID := seedVoiceChan(t, database, "vc-sfu-alice") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + foundConfig := false + for _, msg := range msgs { + if extractType(t, msg) == "voice_config" { + foundConfig = true + var env struct { + Type string `json:"type"` + Payload struct { + ChannelID int64 `json:"channel_id"` + Quality string `json:"quality"` + Bitrate int `json:"bitrate"` + Mode string `json:"threshold_mode"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_config: %v", err) + } + if env.Payload.ChannelID != chanID { + t.Errorf("voice_config channel_id = %d, want %d", env.Payload.ChannelID, chanID) + } + if env.Payload.Quality == "" { + t.Error("voice_config quality is empty") + } + if env.Payload.Bitrate <= 0 { + t.Errorf("voice_config bitrate = %d, want > 0", env.Payload.Bitrate) + } + if env.Payload.Mode == "" { + t.Error("voice_config threshold_mode is empty") + } + break + } + } + if !foundConfig { + t.Error("joiner did not receive voice_config after voice_join") + } +} + +// TestVoice_Join_SFU_ChannelFull verifies that a second join to a max-1 room +// returns a CHANNEL_FULL error and the first participant is unaffected. +func TestVoice_Join_SFU_ChannelFull(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChanMaxUsers(t, database, "vc-full", 1) + + user1 := seedVoiceOwner(t, database, "full-user1") + send1 := make(chan []byte, 32) + c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + + // First user joins — should succeed. + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Verify first user is in DB. + state1, err := database.GetVoiceState(user1.ID) + if err != nil || state1 == nil { + t.Fatalf("user1 voice state missing after join: %v", err) + } + + user2 := seedVoiceOwner(t, database, "full-user2") + send2 := make(chan []byte, 32) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + drainChan(send1) + drainChan(send2) + + // Second user joins — should get CHANNEL_FULL error. + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + msgs2 := drainChan(send2) + foundFull := false + for _, msg := range msgs2 { + if extractType(t, msg) == "error" { + var env struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if errU := json.Unmarshal(msg, &env); errU == nil && env.Payload.Code == "CHANNEL_FULL" { + foundFull = true + break + } + } + } + if !foundFull { + t.Error("expected CHANNEL_FULL error when joining a full voice channel") + } + + // Second user should NOT be in DB voice state. + state2, err := database.GetVoiceState(user2.ID) + if err != nil { + t.Fatalf("GetVoiceState user2: %v", err) + } + if state2 != nil { + t.Error("user2 voice state should be nil after CHANNEL_FULL rejection") + } +} + +// TestVoice_Join_SFU_AddsToVoiceRoom verifies that after voice_join the +// participant is tracked in the Hub's VoiceRoom. +func TestVoice_Join_SFU_AddsToVoiceRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "room-alice") + chanID := seedVoiceChan(t, database, "vc-room-alice") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + room := hub.GetVoiceRoom(chanID) + if room == nil { + t.Fatal("VoiceRoom not created after voice_join") + } + if !room.HasParticipant(user.ID) { + t.Error("user not tracked as participant in VoiceRoom after voice_join") + } + if room.ParticipantCount() != 1 { + t.Errorf("VoiceRoom participant count = %d, want 1", room.ParticipantCount()) + } +} + +// TestVoice_Leave_SFU_RemovesFromRoom verifies that after voice_leave the +// participant is no longer tracked in the VoiceRoom. +func TestVoice_Leave_SFU_RemovesFromRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "leave-bob") + chanID := seedVoiceChan(t, database, "vc-leave-bob") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Confirm in room before leave. + room := hub.GetVoiceRoom(chanID) + if room == nil || !room.HasParticipant(user.ID) { + t.Fatal("precondition: user not in room after join") + } + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + // After leave, participant should be removed (room gone or user absent). + room = hub.GetVoiceRoom(chanID) + if room != nil && room.HasParticipant(user.ID) { + t.Error("user still tracked in VoiceRoom after voice_leave") + } +} + +// TestVoice_Leave_SFU_CleansUpEmptyRoom verifies that when the last participant +// leaves, the VoiceRoom is removed from the Hub entirely. +func TestVoice_Leave_SFU_CleansUpEmptyRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "empty-carol") + chanID := seedVoiceChan(t, database, "vc-empty-carol") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + if hub.GetVoiceRoom(chanID) == nil { + t.Fatal("precondition: VoiceRoom not created after join") + } + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + if hub.GetVoiceRoom(chanID) != nil { + t.Error("VoiceRoom should be removed from Hub after last participant leaves") + } +} + +// TestVoice_Leave_SFU_OnDisconnect verifies that handleVoiceLeave cleans up +// room state when triggered by a disconnect without an explicit voice_leave message. +func TestVoice_Leave_SFU_OnDisconnect(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "disco-dave") + chanID := seedVoiceChan(t, database, "vc-disco-dave") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + room := hub.GetVoiceRoom(chanID) + if room == nil || !room.HasParticipant(user.ID) { + t.Fatal("precondition: user not in VoiceRoom after join") + } + + // Simulate disconnect by calling the exported test hook. + hub.HandleVoiceLeaveForTest(c) + time.Sleep(30 * time.Millisecond) + + // DB state should be cleared. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState after disconnect: %v", err) + } + if state != nil { + t.Error("voice state still in DB after simulated disconnect") + } + + // VoiceRoom should be gone or user removed from it. + room = hub.GetVoiceRoom(chanID) + if room != nil && room.HasParticipant(user.ID) { + t.Error("user still in VoiceRoom after simulated disconnect") + } +} + +// ─── handleMessage dispatch ─────────────────────────────────────────────────── + +func TestVoice_HandleMessage_VoiceCamera_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "cam-dispatch") + chanID := seedVoiceChan(t, database, "vc-cam-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractType(t, m) == "error" { + var errEnv struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(m, &errEnv); err == nil { + if errEnv.Payload.Code == "UNKNOWN_TYPE" { + t.Error("voice_camera was not dispatched: got UNKNOWN_TYPE error") + } + } + } + } +} + +func TestVoice_HandleMessage_VoiceScreenshare_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-dispatch") + chanID := seedVoiceChan(t, database, "vc-ss-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractType(t, m) == "error" { + var errEnv struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(m, &errEnv); err == nil { + if errEnv.Payload.Code == "UNKNOWN_TYPE" { + t.Error("voice_screenshare was not dispatched: got UNKNOWN_TYPE error") + } + } + } + } +} + +// ─── ICE monitor / setupICEMonitor ──────────────────────────────────────────── + +// TestVoice_SetupICEMonitor_NilPC_NoPanic verifies that setupICEMonitor does +// not panic when the client has a nil PeerConnection. +func TestVoice_SetupICEMonitor_NilPC_NoPanic(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-monitor-nil") + chanID := seedVoiceChan(t, database, "vc-ice-nil") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + + // SetupICEMonitorForTest should not panic when c.pc is nil. + hub.SetupICEMonitorForTest(c, chanID) +} + +// ─── duplicate voice_join (channel switch) ──────────────────────────────────── + +// TestVoice_Join_SwitchChannel_LeavesOldChannel verifies that joining channel B +// while already in channel A results in the user leaving channel A first. +func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { + hub, database := newVoiceHub(t) + userA := seedVoiceOwner(t, database, "switch-alice") + chanA := seedVoiceChan(t, database, "vc-switch-a") + chanB := seedVoiceChan(t, database, "vc-switch-b") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, userA, chanA, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Join channel A. + hub.HandleMessageForTest(c, voiceJoinMsg(chanA)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Verify in channel A. + roomA := hub.GetVoiceRoom(chanA) + if roomA == nil { + t.Fatal("room A should exist after joining") + } + if !roomA.HasParticipant(userA.ID) { + t.Fatal("user should be participant in room A") + } + + // Join channel B — should leave A first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanB)) + time.Sleep(50 * time.Millisecond) + + // Room A should no longer have the user. + roomA = hub.GetVoiceRoom(chanA) + if roomA != nil && roomA.HasParticipant(userA.ID) { + t.Error("user should have been removed from room A after joining room B") + } + + // Room B should have the user. + roomB := hub.GetVoiceRoom(chanB) + if roomB == nil { + t.Fatal("room B should exist after joining") + } + if !roomB.HasParticipant(userA.ID) { + t.Error("user should be participant in room B after switching") + } +} + +// TestVoice_Join_SameChannel_IsIdempotent verifies that joining the same channel +// twice does not result in errors or duplicate participation. +func TestVoice_Join_SameChannel_IsIdempotent(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "idempotent-join") + chanID := seedVoiceChan(t, database, "vc-idempotent") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Join same channel again. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + // Should not receive an error for the second join. + msgs := drainChan(send) + for _, m := range msgs { + if code := extractCode(t, m); code == "CHANNEL_FULL" || code == "VOICE_ERROR" { + t.Errorf("unexpected error %q on re-join of same channel", code) + } + } + + // Participant count should remain 1. + room := hub.GetVoiceRoom(chanID) + if room == nil { + t.Fatal("room should exist") + } + if count := room.ParticipantCount(); count != 1 { + t.Errorf("ParticipantCount = %d, want 1 after idempotent join", count) + } +} + diff --git a/Server/ws/voice_room.go b/Server/ws/voice_room.go new file mode 100644 index 00000000..7404c170 --- /dev/null +++ b/Server/ws/voice_room.go @@ -0,0 +1,177 @@ +package ws + +import ( + "errors" + "sync" + "time" +) + +// ErrRoomFull is returned when attempting to add a participant to a full voice room. +var ErrRoomFull = errors.New("voice room is full") + +// VoiceParticipant represents one user in a voice room. +type VoiceParticipant struct { + UserID int64 + JoinedAt time.Time +} + +// VoiceRoomConfig holds per-room configuration derived from channel settings and server defaults. +type VoiceRoomConfig struct { + ChannelID int64 + MaxUsers int // 0 = unlimited + Quality string // low|medium|high + MixingThreshold int // forwarding → selective threshold + TopSpeakers int // N for top-N selection + MaxVideo int // max simultaneous video streams +} + +// VoiceRoom manages voice participants for a single channel. +// It does NOT hold PeerConnections yet — those come in Phase 3/4. +type VoiceRoom struct { + config VoiceRoomConfig + participants map[int64]*VoiceParticipant + mode string // "forwarding" or "selective" + detector *SpeakerDetector + mu sync.RWMutex +} + +// NewVoiceRoom creates a new voice room in "forwarding" mode. +func NewVoiceRoom(cfg VoiceRoomConfig) *VoiceRoom { + topN := cfg.TopSpeakers + if topN <= 0 { + topN = 3 + } + return &VoiceRoom{ + config: cfg, + participants: make(map[int64]*VoiceParticipant), + mode: "forwarding", + detector: NewSpeakerDetector(topN), + } +} + +// AddParticipant adds a user to the voice room. Returns ErrRoomFull if +// MaxUsers > 0 and the room is already at capacity. Adding a duplicate +// user ID is a no-op. +func (r *VoiceRoom) AddParticipant(userID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Duplicate check — already present, nothing to do. + if _, exists := r.participants[userID]; exists { + return nil + } + + if r.config.MaxUsers > 0 && len(r.participants) >= r.config.MaxUsers { + return ErrRoomFull + } + + r.participants[userID] = &VoiceParticipant{ + UserID: userID, + JoinedAt: time.Now(), + } + + r.updateMode() + return nil +} + +// RemoveParticipant removes a user from the voice room. No-op if the user +// is not present. +func (r *VoiceRoom) RemoveParticipant(userID int64) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.participants[userID]; !exists { + return + } + + delete(r.participants, userID) + r.detector.RemoveSpeaker(userID) + r.updateMode() +} + +// ParticipantCount returns the number of participants (thread-safe). +func (r *VoiceRoom) ParticipantCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.participants) +} + +// IsEmpty returns true if the room has no participants. +func (r *VoiceRoom) IsEmpty() bool { + return r.ParticipantCount() == 0 +} + +// Mode returns the current mixing mode ("forwarding" or "selective"). +func (r *VoiceRoom) Mode() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode +} + +// ParticipantIDs returns a slice of all participant user IDs. +func (r *VoiceRoom) ParticipantIDs() []int64 { + r.mu.RLock() + defer r.mu.RUnlock() + + ids := make([]int64, 0, len(r.participants)) + for id := range r.participants { + ids = append(ids, id) + } + return ids +} + +// HasParticipant checks whether the given user is in the room. +func (r *VoiceRoom) HasParticipant(userID int64) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, exists := r.participants[userID] + return exists +} + +// Close clears all participants from the room. +func (r *VoiceRoom) Close() { + r.mu.Lock() + defer r.mu.Unlock() + r.participants = make(map[int64]*VoiceParticipant) + r.mode = "forwarding" +} + +// UpdateSpeakerLevel updates the audio level for a user in this room's detector. +// level is the raw RFC 6464 dBov value: 0 = loudest, 127 = silence. +func (r *VoiceRoom) UpdateSpeakerLevel(userID int64, level uint8) { + r.detector.UpdateLevel(userID, level) +} + +// TopSpeakers returns the current top-N active speakers for this room. +func (r *VoiceRoom) TopSpeakers() []int64 { + return r.detector.TopSpeakers() +} + +// Config returns a copy of the room's configuration. +func (r *VoiceRoom) Config() VoiceRoomConfig { + r.mu.RLock() + defer r.mu.RUnlock() + return r.config +} + +// updateMode checks participant count vs threshold with ±2 hysteresis. +// Must be called with r.mu held. +func (r *VoiceRoom) updateMode() { + count := len(r.participants) + threshold := r.config.MixingThreshold + + if threshold <= 0 { + return + } + + switch r.mode { + case "forwarding": + if count >= threshold { + r.mode = "selective" + } + case "selective": + if count <= threshold-2 { + r.mode = "forwarding" + } + } +} diff --git a/Server/ws/voice_room_test.go b/Server/ws/voice_room_test.go new file mode 100644 index 00000000..035ef303 --- /dev/null +++ b/Server/ws/voice_room_test.go @@ -0,0 +1,278 @@ +package ws_test + +import ( + "errors" + "sort" + "sync" + "testing" + + "github.com/owncord/server/ws" +) + +func defaultRoomConfig() ws.VoiceRoomConfig { + return ws.VoiceRoomConfig{ + ChannelID: 1, + MaxUsers: 0, + Quality: "medium", + MixingThreshold: 5, + TopSpeakers: 3, + MaxVideo: 4, + } +} + +func TestNewVoiceRoom(t *testing.T) { + cfg := defaultRoomConfig() + room := ws.NewVoiceRoom(cfg) + + if room.Mode() != "forwarding" { + t.Errorf("NewVoiceRoom() mode = %q, want %q", room.Mode(), "forwarding") + } + if !room.IsEmpty() { + t.Error("NewVoiceRoom() should be empty") + } + if room.ParticipantCount() != 0 { + t.Errorf("NewVoiceRoom() count = %d, want 0", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + + if err := room.AddParticipant(100); err != nil { + t.Fatalf("AddParticipant(100) returned error: %v", err) + } + if err := room.AddParticipant(200); err != nil { + t.Fatalf("AddParticipant(200) returned error: %v", err) + } + + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount()) + } + if room.IsEmpty() { + t.Error("room should not be empty after adding participants") + } +} + +func TestVoiceRoom_AddParticipant_Full(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 2 + room := ws.NewVoiceRoom(cfg) + + if err := room.AddParticipant(1); err != nil { + t.Fatalf("AddParticipant(1) returned error: %v", err) + } + if err := room.AddParticipant(2); err != nil { + t.Fatalf("AddParticipant(2) returned error: %v", err) + } + + err := room.AddParticipant(3) + if err == nil { + t.Fatal("AddParticipant(3) should return error when room is full") + } + if !errors.Is(err, ws.ErrRoomFull) { + t.Errorf("error = %v, want ErrRoomFull", err) + } + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2 (third should not be added)", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant_Unlimited(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 0 + room := ws.NewVoiceRoom(cfg) + + for i := int64(1); i <= 50; i++ { + if err := room.AddParticipant(i); err != nil { + t.Fatalf("AddParticipant(%d) returned error: %v", i, err) + } + } + if room.ParticipantCount() != 50 { + t.Errorf("ParticipantCount() = %d, want 50", room.ParticipantCount()) + } +} + +func TestVoiceRoom_RemoveParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + _ = room.AddParticipant(3) + + room.RemoveParticipant(2) + + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount()) + } + if room.HasParticipant(2) { + t.Error("HasParticipant(2) = true after removal") + } +} + +func TestVoiceRoom_RemoveParticipant_NotPresent(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + + // Should not panic. + room.RemoveParticipant(999) + + if room.ParticipantCount() != 1 { + t.Errorf("ParticipantCount() = %d, want 1", room.ParticipantCount()) + } +} + +func TestVoiceRoom_HasParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(42) + + if !room.HasParticipant(42) { + t.Error("HasParticipant(42) = false, want true") + } + if room.HasParticipant(99) { + t.Error("HasParticipant(99) = true, want false") + } +} + +func TestVoiceRoom_ParticipantIDs(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(10) + _ = room.AddParticipant(20) + _ = room.AddParticipant(30) + + ids := room.ParticipantIDs() + if len(ids) != 3 { + t.Fatalf("ParticipantIDs() returned %d IDs, want 3", len(ids)) + } + + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + want := []int64{10, 20, 30} + for i, id := range ids { + if id != want[i] { + t.Errorf("ParticipantIDs()[%d] = %d, want %d", i, id, want[i]) + } + } +} + +func TestVoiceRoom_Mode_ForwardingToSelective(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MixingThreshold = 3 + room := ws.NewVoiceRoom(cfg) + + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + if room.Mode() != "forwarding" { + t.Errorf("mode after 2 users = %q, want %q", room.Mode(), "forwarding") + } + + _ = room.AddParticipant(3) + if room.Mode() != "selective" { + t.Errorf("mode after 3 users (threshold=3) = %q, want %q", room.Mode(), "selective") + } +} + +func TestVoiceRoom_Mode_SelectiveToForwarding_Hysteresis(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MixingThreshold = 5 + room := ws.NewVoiceRoom(cfg) + + // Add 5 participants to trigger selective mode. + for i := int64(1); i <= 5; i++ { + _ = room.AddParticipant(i) + } + if room.Mode() != "selective" { + t.Fatalf("mode after 5 users (threshold=5) = %q, want %q", room.Mode(), "selective") + } + + // Remove 1: count=4, still selective (4 > 5-2=3). + room.RemoveParticipant(5) + if room.Mode() != "selective" { + t.Errorf("mode at count=4 should still be %q (hysteresis), got %q", "selective", room.Mode()) + } + + // Remove 1 more: count=3, 3 <= 5-2=3 → switch to forwarding. + room.RemoveParticipant(4) + if room.Mode() != "forwarding" { + t.Errorf("mode at count=3 should be %q (3 <= threshold-2=3), got %q", "forwarding", room.Mode()) + } +} + +func TestVoiceRoom_Close(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + + room.Close() + + if !room.IsEmpty() { + t.Error("room should be empty after Close()") + } + if room.ParticipantCount() != 0 { + t.Errorf("ParticipantCount() = %d after Close(), want 0", room.ParticipantCount()) + } +} + +func TestVoiceRoom_Concurrent(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 0 + room := ws.NewVoiceRoom(cfg) + + var wg sync.WaitGroup + const goroutines = 50 + + // Add participants concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(1) + go func(id int64) { + defer wg.Done() + _ = room.AddParticipant(id) + }(i) + } + wg.Wait() + + if room.ParticipantCount() != goroutines { + t.Errorf("ParticipantCount() = %d after concurrent adds, want %d", room.ParticipantCount(), goroutines) + } + + // Remove participants concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(1) + go func(id int64) { + defer wg.Done() + room.RemoveParticipant(id) + }(i) + } + wg.Wait() + + if !room.IsEmpty() { + t.Errorf("room should be empty after concurrent removes, count = %d", room.ParticipantCount()) + } + + // Mix add/remove concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(2) + go func(id int64) { + defer wg.Done() + _ = room.AddParticipant(id) + }(i) + go func(id int64) { + defer wg.Done() + room.RemoveParticipant(id) + }(i) + } + wg.Wait() + + // Just verify no panic and count is non-negative. + if room.ParticipantCount() < 0 { + t.Errorf("ParticipantCount() = %d, should not be negative", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant_Duplicate(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(1) // duplicate + + // Should not double-count. + if room.ParticipantCount() != 1 { + t.Errorf("ParticipantCount() = %d after duplicate add, want 1", room.ParticipantCount()) + } +} diff --git a/Server/ws_cov.out b/Server/ws_cov.out new file mode 100644 index 00000000..92d2d063 --- /dev/null +++ b/Server/ws_cov.out @@ -0,0 +1,571 @@ +mode: set +github.com/owncord/server/ws/client.go:43.80,52.2 1 0 +github.com/owncord/server/ws/client.go:56.40,58.2 1 1 +github.com/owncord/server/ws/client.go:62.70,68.2 1 1 +github.com/owncord/server/ws/client.go:71.92,78.2 1 1 +github.com/owncord/server/ws/client.go:82.96,90.2 1 1 +github.com/owncord/server/ws/client.go:93.53,95.2 1 1 +github.com/owncord/server/ws/client.go:99.119,108.2 1 1 +github.com/owncord/server/ws/client.go:112.38,115.18 3 1 +github.com/owncord/server/ws/client.go:115.18,117.3 1 1 +github.com/owncord/server/ws/client.go:118.2,118.9 1 1 +github.com/owncord/server/ws/client.go:119.21,119.21 0 1 +github.com/owncord/server/ws/client.go:120.10,120.10 0 0 +github.com/owncord/server/ws/client.go:127.30,130.19 3 1 +github.com/owncord/server/ws/client.go:130.19,133.3 2 1 +github.com/owncord/server/ws/handlers.go:30.59,32.2 1 1 +github.com/owncord/server/ws/handlers.go:37.50,39.2 1 1 +github.com/owncord/server/ws/handlers.go:43.52,50.17 4 1 +github.com/owncord/server/ws/handlers.go:50.17,52.3 1 1 +github.com/owncord/server/ws/handlers.go:53.2,55.38 2 1 +github.com/owncord/server/ws/handlers.go:55.38,57.75 2 1 +github.com/owncord/server/ws/handlers.go:57.75,61.4 3 1 +github.com/owncord/server/ws/handlers.go:64.2,65.50 2 1 +github.com/owncord/server/ws/handlers.go:65.50,69.3 3 1 +github.com/owncord/server/ws/handlers.go:71.2,71.18 1 1 +github.com/owncord/server/ws/handlers.go:72.19,73.43 1 1 +github.com/owncord/server/ws/handlers.go:74.19,75.43 1 0 +github.com/owncord/server/ws/handlers.go:76.21,77.45 1 0 +github.com/owncord/server/ws/handlers.go:78.22,79.41 1 0 +github.com/owncord/server/ws/handlers.go:80.25,81.42 1 0 +github.com/owncord/server/ws/handlers.go:82.22,83.33 1 0 +github.com/owncord/server/ws/handlers.go:84.25,85.35 1 1 +github.com/owncord/server/ws/handlers.go:86.23,87.39 1 0 +github.com/owncord/server/ws/handlers.go:88.20,89.36 1 1 +github.com/owncord/server/ws/handlers.go:90.21,91.24 1 1 +github.com/owncord/server/ws/handlers.go:92.20,93.36 1 1 +github.com/owncord/server/ws/handlers.go:94.22,95.38 1 1 +github.com/owncord/server/ws/handlers.go:96.22,97.38 1 1 +github.com/owncord/server/ws/handlers.go:98.27,99.43 1 1 +github.com/owncord/server/ws/handlers.go:100.21,101.37 1 1 +github.com/owncord/server/ws/handlers.go:102.22,103.38 1 1 +github.com/owncord/server/ws/handlers.go:104.19,105.35 1 1 +github.com/owncord/server/ws/handlers.go:106.25,107.37 1 1 +github.com/owncord/server/ws/handlers.go:108.10,110.94 2 1 +github.com/owncord/server/ws/handlers.go:115.80,118.57 2 1 +github.com/owncord/server/ws/handlers.go:118.57,121.3 2 1 +github.com/owncord/server/ws/handlers.go:123.2,129.52 2 1 +github.com/owncord/server/ws/handlers.go:129.52,132.3 2 0 +github.com/owncord/server/ws/handlers.go:133.2,134.34 2 1 +github.com/owncord/server/ws/handlers.go:134.34,137.3 2 0 +github.com/owncord/server/ws/handlers.go:140.2,141.29 2 1 +github.com/owncord/server/ws/handlers.go:141.29,144.3 2 0 +github.com/owncord/server/ws/handlers.go:147.2,147.88 1 1 +github.com/owncord/server/ws/handlers.go:147.88,150.3 2 0 +github.com/owncord/server/ws/handlers.go:153.2,153.84 1 1 +github.com/owncord/server/ws/handlers.go:153.84,155.75 2 1 +github.com/owncord/server/ws/handlers.go:155.75,158.4 2 1 +github.com/owncord/server/ws/handlers.go:162.2,163.19 2 1 +github.com/owncord/server/ws/handlers.go:163.19,166.3 2 0 +github.com/owncord/server/ws/handlers.go:167.2,167.33 1 1 +github.com/owncord/server/ws/handlers.go:167.33,170.3 2 0 +github.com/owncord/server/ws/handlers.go:173.2,174.16 2 1 +github.com/owncord/server/ws/handlers.go:174.16,178.3 3 0 +github.com/owncord/server/ws/handlers.go:181.2,182.30 2 1 +github.com/owncord/server/ws/handlers.go:182.30,186.3 3 0 +github.com/owncord/server/ws/handlers.go:188.2,190.19 3 1 +github.com/owncord/server/ws/handlers.go:190.19,193.3 2 1 +github.com/owncord/server/ws/handlers.go:195.2,202.44 4 1 +github.com/owncord/server/ws/handlers.go:206.76,211.52 2 0 +github.com/owncord/server/ws/handlers.go:211.52,214.3 2 0 +github.com/owncord/server/ws/handlers.go:215.2,216.30 2 0 +github.com/owncord/server/ws/handlers.go:216.30,219.3 2 0 +github.com/owncord/server/ws/handlers.go:221.2,222.19 2 0 +github.com/owncord/server/ws/handlers.go:222.19,225.3 2 0 +github.com/owncord/server/ws/handlers.go:228.2,228.67 1 0 +github.com/owncord/server/ws/handlers.go:228.67,231.3 2 0 +github.com/owncord/server/ws/handlers.go:233.2,234.30 2 0 +github.com/owncord/server/ws/handlers.go:234.30,237.3 2 0 +github.com/owncord/server/ws/handlers.go:239.2,240.25 2 0 +github.com/owncord/server/ws/handlers.go:240.25,242.3 1 0 +github.com/owncord/server/ws/handlers.go:243.2,244.95 2 0 +github.com/owncord/server/ws/handlers.go:248.78,252.52 2 0 +github.com/owncord/server/ws/handlers.go:252.52,255.3 2 0 +github.com/owncord/server/ws/handlers.go:256.2,257.30 2 0 +github.com/owncord/server/ws/handlers.go:257.30,260.3 2 0 +github.com/owncord/server/ws/handlers.go:262.2,263.30 2 0 +github.com/owncord/server/ws/handlers.go:263.30,266.3 2 0 +github.com/owncord/server/ws/handlers.go:268.2,269.67 2 0 +github.com/owncord/server/ws/handlers.go:269.67,272.3 2 0 +github.com/owncord/server/ws/handlers.go:274.2,277.77 3 0 +github.com/owncord/server/ws/handlers.go:281.76,283.65 2 0 +github.com/owncord/server/ws/handlers.go:283.65,286.3 2 0 +github.com/owncord/server/ws/handlers.go:288.2,292.52 2 0 +github.com/owncord/server/ws/handlers.go:292.52,295.3 2 0 +github.com/owncord/server/ws/handlers.go:296.2,297.30 2 0 +github.com/owncord/server/ws/handlers.go:297.30,300.3 2 0 +github.com/owncord/server/ws/handlers.go:301.2,301.19 1 0 +github.com/owncord/server/ws/handlers.go:301.19,304.3 2 0 +github.com/owncord/server/ws/handlers.go:305.2,305.23 1 0 +github.com/owncord/server/ws/handlers.go:305.23,308.3 2 0 +github.com/owncord/server/ws/handlers.go:310.2,311.30 2 0 +github.com/owncord/server/ws/handlers.go:311.30,314.3 2 0 +github.com/owncord/server/ws/handlers.go:316.2,316.67 1 0 +github.com/owncord/server/ws/handlers.go:316.67,319.3 2 0 +github.com/owncord/server/ws/handlers.go:321.2,322.9 2 0 +github.com/owncord/server/ws/handlers.go:322.9,324.3 1 0 +github.com/owncord/server/ws/handlers.go:324.8,327.3 2 0 +github.com/owncord/server/ws/handlers.go:328.2,328.16 1 0 +github.com/owncord/server/ws/handlers.go:328.16,331.3 2 0 +github.com/owncord/server/ws/handlers.go:333.2,333.107 1 0 +github.com/owncord/server/ws/handlers.go:337.64,339.34 2 0 +github.com/owncord/server/ws/handlers.go:339.34,342.3 2 0 +github.com/owncord/server/ws/handlers.go:344.2,345.61 2 0 +github.com/owncord/server/ws/handlers.go:345.61,347.3 1 0 +github.com/owncord/server/ws/handlers.go:349.2,350.19 2 0 +github.com/owncord/server/ws/handlers.go:350.19,352.3 1 0 +github.com/owncord/server/ws/handlers.go:355.2,355.88 1 0 +github.com/owncord/server/ws/handlers.go:359.66,361.65 2 1 +github.com/owncord/server/ws/handlers.go:361.65,364.3 2 1 +github.com/owncord/server/ws/handlers.go:366.2,369.52 2 1 +github.com/owncord/server/ws/handlers.go:369.52,372.3 2 0 +github.com/owncord/server/ws/handlers.go:373.2,374.30 2 1 +github.com/owncord/server/ws/handlers.go:374.30,377.3 2 0 +github.com/owncord/server/ws/handlers.go:379.2,379.66 1 1 +github.com/owncord/server/ws/handlers.go:379.66,381.3 1 0 +github.com/owncord/server/ws/handlers.go:383.2,383.56 1 1 +github.com/owncord/server/ws/handlers.go:388.75,389.19 1 1 +github.com/owncord/server/ws/handlers.go:389.19,391.3 1 1 +github.com/owncord/server/ws/handlers.go:392.2,393.31 2 1 +github.com/owncord/server/ws/handlers.go:393.31,395.3 1 0 +github.com/owncord/server/ws/handlers.go:396.2,396.53 1 1 +github.com/owncord/server/ws/handlers.go:396.53,398.3 1 1 +github.com/owncord/server/ws/handlers.go:400.2,401.16 2 1 +github.com/owncord/server/ws/handlers.go:401.16,403.3 1 0 +github.com/owncord/server/ws/handlers.go:404.2,405.31 2 1 +github.com/owncord/server/ws/handlers.go:409.76,412.32 3 0 +github.com/owncord/server/ws/handlers.go:412.32,413.27 1 0 +github.com/owncord/server/ws/handlers.go:413.27,414.12 1 0 +github.com/owncord/server/ws/handlers.go:416.3,416.49 1 0 +github.com/owncord/server/ws/handlers.go:416.49,417.12 1 0 +github.com/owncord/server/ws/handlers.go:419.3,419.10 1 0 +github.com/owncord/server/ws/handlers.go:420.22,420.22 0 0 +github.com/owncord/server/ws/handlers.go:421.11,421.11 0 0 +github.com/owncord/server/ws/handlers.go:428.70,430.29 2 0 +github.com/owncord/server/ws/handlers.go:430.29,432.3 1 0 +github.com/owncord/server/ws/handlers.go:433.2,435.15 3 0 +github.com/owncord/server/ws/hub.go:35.62,46.2 1 1 +github.com/owncord/server/ws/hub.go:49.32,51.2 1 1 +github.com/owncord/server/ws/hub.go:55.85,59.45 3 1 +github.com/owncord/server/ws/hub.go:59.45,61.3 1 1 +github.com/owncord/server/ws/hub.go:62.2,64.13 3 1 +github.com/owncord/server/ws/hub.go:68.56,72.2 3 1 +github.com/owncord/server/ws/hub.go:75.48,78.8 3 1 +github.com/owncord/server/ws/hub.go:78.8,80.3 1 1 +github.com/owncord/server/ws/hub.go:81.2,83.8 2 1 +github.com/owncord/server/ws/hub.go:83.8,85.3 1 1 +github.com/owncord/server/ws/hub.go:89.36,92.36 3 1 +github.com/owncord/server/ws/hub.go:92.36,94.3 1 1 +github.com/owncord/server/ws/hub.go:95.2,98.29 3 1 +github.com/owncord/server/ws/hub.go:98.29,100.3 1 1 +github.com/owncord/server/ws/hub.go:105.21,108.6 2 1 +github.com/owncord/server/ws/hub.go:108.6,109.10 1 1 +github.com/owncord/server/ws/hub.go:110.17,111.10 1 1 +github.com/owncord/server/ws/hub.go:113.26,118.54 2 1 +github.com/owncord/server/ws/hub.go:118.54,119.22 1 1 +github.com/owncord/server/ws/hub.go:119.22,122.6 2 0 +github.com/owncord/server/ws/hub.go:123.5,123.26 1 1 +github.com/owncord/server/ws/hub.go:123.26,124.60 1 1 +github.com/owncord/server/ws/hub.go:124.60,126.25 2 1 +github.com/owncord/server/ws/hub.go:126.25,132.8 3 1 +github.com/owncord/server/ws/hub.go:134.6,135.23 2 1 +github.com/owncord/server/ws/hub.go:137.5,137.20 1 1 +github.com/owncord/server/ws/hub.go:139.4,140.17 2 1 +github.com/owncord/server/ws/hub.go:142.28,144.62 2 1 +github.com/owncord/server/ws/hub.go:144.62,146.5 1 1 +github.com/owncord/server/ws/hub.go:147.4,147.17 1 1 +github.com/owncord/server/ws/hub.go:149.28,150.26 1 1 +github.com/owncord/server/ws/hub.go:156.22,158.2 1 1 +github.com/owncord/server/ws/hub.go:161.30,164.2 2 1 +github.com/owncord/server/ws/hub.go:168.55,170.17 2 1 +github.com/owncord/server/ws/hub.go:170.17,172.3 1 1 +github.com/owncord/server/ws/hub.go:175.2,182.40 4 1 +github.com/owncord/server/ws/hub.go:182.40,183.42 1 1 +github.com/owncord/server/ws/hub.go:183.42,184.24 1 1 +github.com/owncord/server/ws/hub.go:184.24,187.5 2 0 +github.com/owncord/server/ws/hub.go:188.4,188.24 1 1 +github.com/owncord/server/ws/hub.go:191.3,191.37 1 1 +github.com/owncord/server/ws/hub.go:193.2,196.40 2 1 +github.com/owncord/server/ws/hub.go:196.40,198.3 1 1 +github.com/owncord/server/ws/hub.go:202.35,204.2 1 1 +github.com/owncord/server/ws/hub.go:207.37,209.2 1 1 +github.com/owncord/server/ws/hub.go:213.63,215.2 1 1 +github.com/owncord/server/ws/hub.go:218.42,220.2 1 1 +github.com/owncord/server/ws/hub.go:225.71,227.2 1 0 +github.com/owncord/server/ws/hub.go:230.54,232.2 1 0 +github.com/owncord/server/ws/hub.go:235.54,237.2 1 0 +github.com/owncord/server/ws/hub.go:240.55,242.2 1 0 +github.com/owncord/server/ws/hub.go:246.57,250.9 4 1 +github.com/owncord/server/ws/hub.go:250.9,252.3 1 1 +github.com/owncord/server/ws/hub.go:253.2,253.9 1 1 +github.com/owncord/server/ws/hub.go:254.21,255.14 1 1 +github.com/owncord/server/ws/hub.go:256.10,258.15 1 0 +github.com/owncord/server/ws/hub.go:263.33,267.2 3 1 +github.com/owncord/server/ws/hub.go:272.37,274.60 2 1 +github.com/owncord/server/ws/hub.go:274.60,276.3 1 1 +github.com/owncord/server/ws/hub.go:277.2,278.15 2 1 +github.com/owncord/server/ws/hub.go:282.49,286.30 3 1 +github.com/owncord/server/ws/hub.go:286.30,288.55 1 1 +github.com/owncord/server/ws/hub.go:288.55,289.12 1 1 +github.com/owncord/server/ws/hub.go:291.3,291.10 1 1 +github.com/owncord/server/ws/hub.go:292.25,292.25 0 1 +github.com/owncord/server/ws/hub.go:293.11,293.11 0 0 +github.com/owncord/server/ws/messages.go:18.30,20.16 2 1 +github.com/owncord/server/ws/messages.go:20.16,23.3 1 0 +github.com/owncord/server/ws/messages.go:24.2,24.10 1 1 +github.com/owncord/server/ws/messages.go:28.49,36.2 1 1 +github.com/owncord/server/ws/messages.go:39.59,47.2 1 1 +github.com/owncord/server/ws/messages.go:50.44,52.24 2 0 +github.com/owncord/server/ws/messages.go:52.24,54.3 1 0 +github.com/owncord/server/ws/messages.go:55.2,64.4 1 0 +github.com/owncord/server/ws/messages.go:68.145,70.19 2 1 +github.com/owncord/server/ws/messages.go:70.19,72.3 1 0 +github.com/owncord/server/ws/messages.go:73.2,87.4 1 1 +github.com/owncord/server/ws/messages.go:91.78,100.2 1 1 +github.com/owncord/server/ws/messages.go:103.79,113.2 1 0 +github.com/owncord/server/ws/messages.go:116.54,124.2 1 0 +github.com/owncord/server/ws/messages.go:127.87,138.2 1 0 +github.com/owncord/server/ws/messages.go:141.70,150.2 1 0 +github.com/owncord/server/ws/messages.go:153.50,167.2 1 1 +github.com/owncord/server/ws/messages.go:170.127,183.2 1 1 +github.com/owncord/server/ws/messages.go:186.80,195.2 1 1 +github.com/owncord/server/ws/messages.go:198.54,206.2 1 1 +github.com/owncord/server/ws/messages.go:209.59,217.2 1 0 +github.com/owncord/server/ws/messages.go:221.58,229.2 1 0 +github.com/owncord/server/ws/messages.go:232.63,240.2 1 1 +github.com/owncord/server/ws/messages.go:243.48,255.2 1 1 +github.com/owncord/server/ws/messages.go:258.48,270.2 1 1 +github.com/owncord/server/ws/messages.go:273.49,280.2 1 1 +github.com/owncord/server/ws/messages.go:283.68,291.2 1 1 +github.com/owncord/server/ws/messages.go:294.61,298.52 2 1 +github.com/owncord/server/ws/messages.go:298.52,300.3 1 0 +github.com/owncord/server/ws/messages.go:301.2,302.16 2 1 +github.com/owncord/server/ws/messages.go:302.16,304.3 1 0 +github.com/owncord/server/ws/messages.go:305.2,305.16 1 1 +github.com/owncord/server/ws/origin.go:15.76,16.30 1 1 +github.com/owncord/server/ws/origin.go:16.30,18.3 1 1 +github.com/owncord/server/ws/origin.go:20.2,20.35 1 1 +github.com/owncord/server/ws/origin.go:20.35,21.15 1 1 +github.com/owncord/server/ws/origin.go:21.15,23.4 1 1 +github.com/owncord/server/ws/origin.go:26.2,28.3 1 1 +github.com/owncord/server/ws/serve.go:27.83,29.54 2 0 +github.com/owncord/server/ws/serve.go:29.54,31.17 2 0 +github.com/owncord/server/ws/serve.go:31.17,34.4 2 0 +github.com/owncord/server/ws/serve.go:36.3,37.17 2 0 +github.com/owncord/server/ws/serve.go:37.17,41.4 3 0 +github.com/owncord/server/ws/serve.go:43.3,50.82 5 0 +github.com/owncord/server/ws/serve.go:50.82,52.4 1 0 +github.com/owncord/server/ws/serve.go:55.3,57.63 3 0 +github.com/owncord/server/ws/serve.go:57.63,59.4 1 0 +github.com/owncord/server/ws/serve.go:61.3,68.16 6 0 +github.com/owncord/server/ws/serve.go:73.70,74.6 1 0 +github.com/owncord/server/ws/serve.go:74.6,75.10 1 0 +github.com/owncord/server/ws/serve.go:76.28,77.11 1 0 +github.com/owncord/server/ws/serve.go:77.11,80.5 2 0 +github.com/owncord/server/ws/serve.go:81.4,84.18 4 0 +github.com/owncord/server/ws/serve.go:84.18,87.5 2 0 +github.com/owncord/server/ws/serve.go:88.21,89.10 1 0 +github.com/owncord/server/ws/serve.go:95.79,96.15 1 0 +github.com/owncord/server/ws/serve.go:96.15,99.20 3 0 +github.com/owncord/server/ws/serve.go:99.20,103.4 3 0 +github.com/owncord/server/ws/serve.go:106.2,106.6 1 0 +github.com/owncord/server/ws/serve.go:106.6,108.17 2 0 +github.com/owncord/server/ws/serve.go:108.17,110.4 1 0 +github.com/owncord/server/ws/serve.go:111.3,111.28 1 0 +github.com/owncord/server/ws/serve.go:118.88,123.16 4 0 +github.com/owncord/server/ws/serve.go:123.16,125.3 1 0 +github.com/owncord/server/ws/serve.go:127.2,128.50 2 0 +github.com/owncord/server/ws/serve.go:128.50,131.3 2 0 +github.com/owncord/server/ws/serve.go:132.2,132.24 1 0 +github.com/owncord/server/ws/serve.go:132.24,135.3 2 0 +github.com/owncord/server/ws/serve.go:137.2,140.73 2 0 +github.com/owncord/server/ws/serve.go:140.73,143.3 2 0 +github.com/owncord/server/ws/serve.go:145.2,147.31 3 0 +github.com/owncord/server/ws/serve.go:147.31,150.3 2 0 +github.com/owncord/server/ws/serve.go:152.2,152.43 1 0 +github.com/owncord/server/ws/serve.go:152.43,155.3 2 0 +github.com/owncord/server/ws/serve.go:157.2,158.31 2 0 +github.com/owncord/server/ws/serve.go:158.31,161.3 2 0 +github.com/owncord/server/ws/serve.go:163.2,163.36 1 0 +github.com/owncord/server/ws/serve.go:163.36,166.3 2 0 +github.com/owncord/server/ws/serve.go:168.2,168.24 1 0 +github.com/owncord/server/ws/serve.go:172.57,179.24 6 0 +github.com/owncord/server/ws/serve.go:179.24,181.3 1 0 +github.com/owncord/server/ws/serve.go:183.2,195.4 1 0 +github.com/owncord/server/ws/serve.go:199.50,201.16 2 0 +github.com/owncord/server/ws/serve.go:201.16,203.3 1 0 +github.com/owncord/server/ws/serve.go:204.2,205.16 2 0 +github.com/owncord/server/ws/serve.go:205.16,207.3 1 0 +github.com/owncord/server/ws/serve.go:209.2,210.16 2 0 +github.com/owncord/server/ws/serve.go:210.16,213.3 2 0 +github.com/owncord/server/ws/serve.go:216.2,217.16 2 0 +github.com/owncord/server/ws/serve.go:217.16,221.3 2 0 +github.com/owncord/server/ws/serve.go:223.2,231.9 1 0 +github.com/owncord/server/ws/serve.go:235.93,237.30 2 0 +github.com/owncord/server/ws/serve.go:237.30,238.25 1 0 +github.com/owncord/server/ws/serve.go:238.25,239.12 1 0 +github.com/owncord/server/ws/serve.go:241.3,242.17 2 0 +github.com/owncord/server/ws/serve.go:242.17,244.4 1 0 +github.com/owncord/server/ws/serve.go:245.3,245.31 1 0 +github.com/owncord/server/ws/serve.go:247.2,247.16 1 0 +github.com/owncord/server/ws/serve.go:247.16,249.3 1 0 +github.com/owncord/server/ws/serve.go:250.2,250.17 1 0 +github.com/owncord/server/ws/sfu.go:22.52,24.51 2 1 +github.com/owncord/server/ws/sfu.go:24.51,26.3 1 0 +github.com/owncord/server/ws/sfu.go:29.2,33.4 2 1 +github.com/owncord/server/ws/sfu.go:33.4,38.17 1 1 +github.com/owncord/server/ws/sfu.go:38.17,40.4 1 0 +github.com/owncord/server/ws/sfu.go:43.2,44.69 2 1 +github.com/owncord/server/ws/sfu.go:44.69,46.3 1 0 +github.com/owncord/server/ws/sfu.go:48.2,51.26 3 1 +github.com/owncord/server/ws/sfu.go:51.26,56.18 1 1 +github.com/owncord/server/ws/sfu.go:56.18,58.4 1 0 +github.com/owncord/server/ws/sfu.go:61.2,67.41 2 1 +github.com/owncord/server/ws/sfu.go:72.67,76.27 2 1 +github.com/owncord/server/ws/sfu.go:76.27,80.3 1 0 +github.com/owncord/server/ws/sfu.go:83.2,83.51 1 1 +github.com/owncord/server/ws/sfu.go:83.51,89.3 1 0 +github.com/owncord/server/ws/sfu.go:91.2,91.42 1 1 +github.com/owncord/server/ws/sfu.go:96.24,98.2 0 1 +github.com/owncord/server/ws/sfu.go:102.36,103.26 1 1 +github.com/owncord/server/ws/sfu.go:104.13,105.15 1 1 +github.com/owncord/server/ws/sfu.go:106.14,107.16 1 1 +github.com/owncord/server/ws/sfu.go:108.10,109.15 1 1 +github.com/owncord/server/ws/sfu.go:114.25,115.12 1 1 +github.com/owncord/server/ws/sfu.go:115.12,117.3 1 0 +github.com/owncord/server/ws/sfu.go:118.2,121.9 4 1 +github.com/owncord/server/ws/sfu.go:121.9,123.3 1 0 +github.com/owncord/server/ws/sfu.go:124.2,124.12 1 1 +github.com/owncord/server/ws/sfu.go:124.12,128.3 3 1 +github.com/owncord/server/ws/sfu.go:129.2,129.9 1 1 +github.com/owncord/server/ws/sfu.go:129.9,132.3 2 0 +github.com/owncord/server/ws/sfu.go:133.2,133.24 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:12.57,19.6 4 1 +github.com/owncord/server/ws/speaker_broadcast.go:19.6,20.10 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:21.15,22.10 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:23.19,26.39 3 1 +github.com/owncord/server/ws/speaker_broadcast.go:26.39,28.5 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:29.4,31.39 2 1 +github.com/owncord/server/ws/speaker_broadcast.go:31.39,37.63 4 1 +github.com/owncord/server/ws/speaker_broadcast.go:37.63,38.14 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:40.5,44.41 4 1 +github.com/owncord/server/ws/speaker_broadcast.go:48.4,48.33 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:48.33,49.40 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:49.40,51.6 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:59.42,60.24 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:60.24,62.3 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:64.2,65.30 2 1 +github.com/owncord/server/ws/speaker_broadcast.go:65.30,66.12 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:66.12,68.4 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:69.3,69.42 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:71.2,71.18 1 1 +github.com/owncord/server/ws/speaker_broadcast.go:76.49,78.2 1 1 +github.com/owncord/server/ws/speaker_detector.go:30.52,32.2 1 1 +github.com/owncord/server/ws/speaker_detector.go:35.86,41.2 1 1 +github.com/owncord/server/ws/speaker_detector.go:46.66,51.9 4 1 +github.com/owncord/server/ws/speaker_detector.go:51.9,54.3 2 1 +github.com/owncord/server/ws/speaker_detector.go:56.2,58.31 3 1 +github.com/owncord/server/ws/speaker_detector.go:58.31,60.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:63.2,64.32 2 1 +github.com/owncord/server/ws/speaker_detector.go:64.32,66.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:67.2,70.22 2 1 +github.com/owncord/server/ws/speaker_detector.go:70.22,72.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:78.49,86.32 5 1 +github.com/owncord/server/ws/speaker_detector.go:86.32,87.23 1 1 +github.com/owncord/server/ws/speaker_detector.go:87.23,89.4 1 1 +github.com/owncord/server/ws/speaker_detector.go:89.9,89.76 1 1 +github.com/owncord/server/ws/speaker_detector.go:89.76,91.4 1 1 +github.com/owncord/server/ws/speaker_detector.go:95.2,95.45 1 1 +github.com/owncord/server/ws/speaker_detector.go:95.45,97.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:99.2,100.25 2 1 +github.com/owncord/server/ws/speaker_detector.go:100.25,102.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:104.2,105.25 2 1 +github.com/owncord/server/ws/speaker_detector.go:105.25,107.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:108.2,108.15 1 1 +github.com/owncord/server/ws/speaker_detector.go:112.55,117.2 3 1 +github.com/owncord/server/ws/speaker_detector.go:124.88,125.19 1 1 +github.com/owncord/server/ws/speaker_detector.go:125.19,127.3 1 1 +github.com/owncord/server/ws/speaker_detector.go:132.2,133.19 2 1 +github.com/owncord/server/ws/speaker_detector.go:133.19,137.14 3 1 +github.com/owncord/server/ws/speaker_detector.go:137.14,140.12 2 0 +github.com/owncord/server/ws/speaker_detector.go:142.3,142.15 1 1 +github.com/owncord/server/ws/speaker_detector.go:142.15,144.9 1 0 +github.com/owncord/server/ws/speaker_detector.go:147.3,149.27 2 1 +github.com/owncord/server/ws/speaker_detector.go:149.27,150.9 1 0 +github.com/owncord/server/ws/speaker_detector.go:153.3,153.40 1 1 +github.com/owncord/server/ws/speaker_detector.go:153.40,158.4 4 1 +github.com/owncord/server/ws/speaker_detector.go:160.3,160.15 1 1 +github.com/owncord/server/ws/speaker_detector.go:163.2,163.24 1 1 +github.com/owncord/server/ws/voice_handlers.go:30.59,31.17 1 1 +github.com/owncord/server/ws/voice_handlers.go:31.17,33.3 1 1 +github.com/owncord/server/ws/voice_handlers.go:35.2,35.72 1 0 +github.com/owncord/server/ws/voice_handlers.go:35.72,38.16 2 0 +github.com/owncord/server/ws/voice_handlers.go:39.40,41.25 2 0 +github.com/owncord/server/ws/voice_handlers.go:42.46,45.93 1 0 +github.com/owncord/server/ws/voice_handlers.go:51.66,53.2 1 1 +github.com/owncord/server/ws/voice_handlers.go:66.67,68.34 2 1 +github.com/owncord/server/ws/voice_handlers.go:68.34,71.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:73.2,73.63 1 1 +github.com/owncord/server/ws/voice_handlers.go:73.63,76.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:79.2,79.49 1 1 +github.com/owncord/server/ws/voice_handlers.go:79.49,81.3 1 1 +github.com/owncord/server/ws/voice_handlers.go:83.2,84.29 2 1 +github.com/owncord/server/ws/voice_handlers.go:84.29,87.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:89.2,92.60 3 1 +github.com/owncord/server/ws/voice_handlers.go:92.60,93.37 1 1 +github.com/owncord/server/ws/voice_handlers.go:93.37,95.4 1 1 +github.com/owncord/server/ws/voice_handlers.go:95.9,97.4 1 0 +github.com/owncord/server/ws/voice_handlers.go:98.3,98.9 1 1 +github.com/owncord/server/ws/voice_handlers.go:101.2,101.67 1 1 +github.com/owncord/server/ws/voice_handlers.go:101.67,106.3 4 0 +github.com/owncord/server/ws/voice_handlers.go:109.2,112.18 2 1 +github.com/owncord/server/ws/voice_handlers.go:112.18,114.19 2 0 +github.com/owncord/server/ws/voice_handlers.go:114.19,116.4 1 0 +github.com/owncord/server/ws/voice_handlers.go:116.9,120.4 3 0 +github.com/owncord/server/ws/voice_handlers.go:123.2,124.32 2 1 +github.com/owncord/server/ws/voice_handlers.go:124.32,127.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:130.2,134.16 3 1 +github.com/owncord/server/ws/voice_handlers.go:134.16,137.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:138.2,138.30 1 1 +github.com/owncord/server/ws/voice_handlers.go:138.30,139.28 1 1 +github.com/owncord/server/ws/voice_handlers.go:139.28,140.12 1 1 +github.com/owncord/server/ws/voice_handlers.go:142.3,142.33 1 1 +github.com/owncord/server/ws/voice_handlers.go:146.2,148.18 3 1 +github.com/owncord/server/ws/voice_handlers.go:148.18,150.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:151.2,153.133 2 1 +github.com/owncord/server/ws/voice_handlers.go:157.68,166.54 2 1 +github.com/owncord/server/ws/voice_handlers.go:166.54,168.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:169.2,169.31 1 1 +github.com/owncord/server/ws/voice_handlers.go:169.31,171.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:172.2,172.12 1 1 +github.com/owncord/server/ws/voice_handlers.go:181.43,183.16 2 1 +github.com/owncord/server/ws/voice_handlers.go:183.16,185.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:188.2,188.17 1 1 +github.com/owncord/server/ws/voice_handlers.go:188.17,189.48 1 0 +github.com/owncord/server/ws/voice_handlers.go:189.48,191.4 1 0 +github.com/owncord/server/ws/voice_handlers.go:192.3,192.13 1 0 +github.com/owncord/server/ws/voice_handlers.go:196.2,196.21 1 1 +github.com/owncord/server/ws/voice_handlers.go:196.21,197.55 1 1 +github.com/owncord/server/ws/voice_handlers.go:197.55,199.22 2 1 +github.com/owncord/server/ws/voice_handlers.go:199.22,201.5 1 1 +github.com/owncord/server/ws/voice_handlers.go:203.3,203.18 1 1 +github.com/owncord/server/ws/voice_handlers.go:206.2,206.67 1 1 +github.com/owncord/server/ws/voice_handlers.go:206.67,208.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:210.2,210.18 1 1 +github.com/owncord/server/ws/voice_handlers.go:210.18,212.3 1 1 +github.com/owncord/server/ws/voice_handlers.go:219.67,223.52 2 1 +github.com/owncord/server/ws/voice_handlers.go:223.52,226.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:228.2,228.64 1 1 +github.com/owncord/server/ws/voice_handlers.go:228.64,232.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:234.2,234.32 1 1 +github.com/owncord/server/ws/voice_handlers.go:241.69,245.52 2 1 +github.com/owncord/server/ws/voice_handlers.go:245.52,248.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:250.2,250.69 1 1 +github.com/owncord/server/ws/voice_handlers.go:250.69,254.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:256.2,256.32 1 1 +github.com/owncord/server/ws/voice_handlers.go:265.69,267.71 2 1 +github.com/owncord/server/ws/voice_handlers.go:267.71,270.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:272.2,272.51 1 1 +github.com/owncord/server/ws/voice_handlers.go:272.51,275.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:277.2,280.52 2 1 +github.com/owncord/server/ws/voice_handlers.go:280.52,283.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:285.2,285.68 1 1 +github.com/owncord/server/ws/voice_handlers.go:285.68,289.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:291.2,291.32 1 1 +github.com/owncord/server/ws/voice_handlers.go:300.74,302.81 2 1 +github.com/owncord/server/ws/voice_handlers.go:302.81,305.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:307.2,307.54 1 1 +github.com/owncord/server/ws/voice_handlers.go:307.54,310.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:312.2,315.52 2 1 +github.com/owncord/server/ws/voice_handlers.go:315.52,318.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:320.2,320.73 1 1 +github.com/owncord/server/ws/voice_handlers.go:320.73,324.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:326.2,326.32 1 1 +github.com/owncord/server/ws/voice_handlers.go:332.68,334.71 2 1 +github.com/owncord/server/ws/voice_handlers.go:334.71,337.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:339.2,339.17 1 1 +github.com/owncord/server/ws/voice_handlers.go:339.17,342.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:344.2,348.52 2 0 +github.com/owncord/server/ws/voice_handlers.go:348.52,351.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:352.2,352.17 1 0 +github.com/owncord/server/ws/voice_handlers.go:352.17,355.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:357.2,362.57 2 0 +github.com/owncord/server/ws/voice_handlers.go:362.57,366.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:368.2,369.16 2 0 +github.com/owncord/server/ws/voice_handlers.go:369.16,373.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:375.2,375.57 1 0 +github.com/owncord/server/ws/voice_handlers.go:375.57,379.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:382.2,382.54 1 0 +github.com/owncord/server/ws/voice_handlers.go:388.69,390.71 2 1 +github.com/owncord/server/ws/voice_handlers.go:390.71,393.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:395.2,395.17 1 1 +github.com/owncord/server/ws/voice_handlers.go:395.17,398.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:400.2,404.52 2 0 +github.com/owncord/server/ws/voice_handlers.go:404.52,407.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:408.2,408.17 1 0 +github.com/owncord/server/ws/voice_handlers.go:408.17,411.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:413.2,418.58 2 0 +github.com/owncord/server/ws/voice_handlers.go:418.58,422.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:426.66,428.71 2 1 +github.com/owncord/server/ws/voice_handlers.go:428.71,431.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:433.2,433.17 1 1 +github.com/owncord/server/ws/voice_handlers.go:433.17,436.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:438.2,442.52 2 0 +github.com/owncord/server/ws/voice_handlers.go:442.52,445.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:447.2,447.58 1 0 +github.com/owncord/server/ws/voice_handlers.go:447.58,451.3 3 0 +github.com/owncord/server/ws/voice_handlers.go:458.68,460.69 2 1 +github.com/owncord/server/ws/voice_handlers.go:460.69,463.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:465.2,465.56 1 1 +github.com/owncord/server/ws/voice_handlers.go:465.56,468.3 2 1 +github.com/owncord/server/ws/voice_handlers.go:470.2,473.71 2 1 +github.com/owncord/server/ws/voice_handlers.go:473.71,476.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:478.2,478.60 1 1 +github.com/owncord/server/ws/voice_handlers.go:487.56,488.17 1 0 +github.com/owncord/server/ws/voice_handlers.go:488.17,490.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:495.2,497.77 2 0 +github.com/owncord/server/ws/voice_handlers.go:497.77,499.47 1 0 +github.com/owncord/server/ws/voice_handlers.go:499.47,501.4 1 0 +github.com/owncord/server/ws/voice_handlers.go:503.3,510.13 2 0 +github.com/owncord/server/ws/voice_handlers.go:510.13,512.8 2 0 +github.com/owncord/server/ws/voice_handlers.go:512.8,514.23 2 0 +github.com/owncord/server/ws/voice_handlers.go:514.23,516.6 1 0 +github.com/owncord/server/ws/voice_handlers.go:519.5,520.15 2 0 +github.com/owncord/server/ws/voice_handlers.go:520.15,521.14 1 0 +github.com/owncord/server/ws/voice_handlers.go:524.5,525.20 2 0 +github.com/owncord/server/ws/voice_handlers.go:525.20,527.6 1 0 +github.com/owncord/server/ws/voice_handlers.go:529.5,529.45 1 0 +github.com/owncord/server/ws/voice_handlers.go:537.52,539.16 2 1 +github.com/owncord/server/ws/voice_handlers.go:539.16,542.3 2 0 +github.com/owncord/server/ws/voice_handlers.go:543.2,543.18 1 1 +github.com/owncord/server/ws/voice_handlers.go:543.18,545.3 1 0 +github.com/owncord/server/ws/voice_handlers.go:546.2,546.64 1 1 +github.com/owncord/server/ws/voice_room.go:39.51,41.15 2 1 +github.com/owncord/server/ws/voice_room.go:41.15,43.3 1 1 +github.com/owncord/server/ws/voice_room.go:44.2,49.3 1 1 +github.com/owncord/server/ws/voice_room.go:55.56,60.49 3 1 +github.com/owncord/server/ws/voice_room.go:60.49,62.3 1 1 +github.com/owncord/server/ws/voice_room.go:64.2,64.71 1 1 +github.com/owncord/server/ws/voice_room.go:64.71,66.3 1 1 +github.com/owncord/server/ws/voice_room.go:68.2,74.12 3 1 +github.com/owncord/server/ws/voice_room.go:79.53,83.50 3 1 +github.com/owncord/server/ws/voice_room.go:83.50,85.3 1 1 +github.com/owncord/server/ws/voice_room.go:87.2,89.16 3 1 +github.com/owncord/server/ws/voice_room.go:93.44,97.2 3 1 +github.com/owncord/server/ws/voice_room.go:100.36,102.2 1 1 +github.com/owncord/server/ws/voice_room.go:105.35,109.2 3 1 +github.com/owncord/server/ws/voice_room.go:112.46,117.33 4 1 +github.com/owncord/server/ws/voice_room.go:117.33,119.3 1 1 +github.com/owncord/server/ws/voice_room.go:120.2,120.12 1 1 +github.com/owncord/server/ws/voice_room.go:124.55,129.2 4 1 +github.com/owncord/server/ws/voice_room.go:132.29,137.2 4 1 +github.com/owncord/server/ws/voice_room.go:141.67,143.2 1 1 +github.com/owncord/server/ws/voice_room.go:146.43,148.2 1 1 +github.com/owncord/server/ws/voice_room.go:151.46,155.2 3 1 +github.com/owncord/server/ws/voice_room.go:159.34,163.20 3 1 +github.com/owncord/server/ws/voice_room.go:163.20,165.3 1 1 +github.com/owncord/server/ws/voice_room.go:167.2,167.16 1 1 +github.com/owncord/server/ws/voice_room.go:168.20,169.25 1 1 +github.com/owncord/server/ws/voice_room.go:169.25,171.4 1 1 +github.com/owncord/server/ws/voice_room.go:172.19,173.27 1 1 +github.com/owncord/server/ws/voice_room.go:173.27,175.4 1 1