Update: Mutli Channel Support

This commit is contained in:
2026-03-22 01:54:52 -04:00
parent 69a4951579
commit caf020c393
10 changed files with 283 additions and 69 deletions

View File

@@ -5,26 +5,60 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
Title="Relay Client"> Title="Relay Client">
<Grid RowDefinitions="Auto,*,Auto" Padding="12" RowSpacing="10"> <Grid RowDefinitions="Auto,*,Auto"
ColumnDefinitions="220,*"
Padding="12"
RowSpacing="10"
ColumnSpacing="10">
<Border Grid.Row="0" StrokeThickness="1" Padding="10"> <!-- Header -->
<Border Grid.Row="0"
Grid.ColumnSpan="2"
StrokeThickness="1"
Padding="10">
<VerticalStackLayout Spacing="4"> <VerticalStackLayout Spacing="4">
<Label x:Name="UserLabel" <Label x:Name="UserLabel"
Text="Logged in as: Unknown" Text="Logged in as: Unknown"
FontAttributes="Bold" FontAttributes="Bold"
FontSize="18" /> FontSize="18" />
<Label Text="#general" <Label x:Name="ChannelLabel"
Text="No channel selected"
FontSize="14" /> FontSize="14" />
</VerticalStackLayout> </VerticalStackLayout>
</Border> </Border>
<Border Grid.Row="1" StrokeThickness="1" Padding="10"> <!-- Sidebar -->
<ScrollView x:Name="MessagesScrollView"> <Border Grid.Row="1"
<VerticalStackLayout x:Name="MessagesLayout" Spacing="8" /> Grid.Column="0"
StrokeThickness="1"
Padding="10">
<ScrollView>
<VerticalStackLayout Spacing="8">
<Label Text="Channels"
FontAttributes="Bold"
FontSize="16" />
<VerticalStackLayout x:Name="SidebarList"
Spacing="6" />
</VerticalStackLayout>
</ScrollView> </ScrollView>
</Border> </Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" ColumnSpacing="10"> <!-- Messages -->
<Border Grid.Row="1"
Grid.Column="1"
StrokeThickness="1"
Padding="10">
<ScrollView x:Name="MessagesScrollView">
<VerticalStackLayout x:Name="MessagesLayout"
Spacing="8" />
</ScrollView>
</Border>
<!-- Input -->
<Grid Grid.Row="2"
Grid.Column="1"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<Entry x:Name="MessageEntry" <Entry x:Name="MessageEntry"
Grid.Column="0" Grid.Column="0"
Placeholder="Type a message..." Placeholder="Type a message..."

View File

@@ -8,8 +8,13 @@ namespace RelayClient;
public partial class MainPage : ContentPage public partial class MainPage : ContentPage
{ {
private readonly string _username; private readonly string _username;
private readonly WebSocket wsc; private readonly WebSocket _wsc;
private string? _serverPublicKey; private string? _serverPublicKey;
private string? _currentChannelId;
private string? _currentChannelName;
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
private readonly List<ChannelItem> _channels = new();
public MainPage(string username) public MainPage(string username)
{ {
@@ -25,15 +30,15 @@ public partial class MainPage : ContentPage
KeyStorage.SavePublicKey(_username, keys.publicKey); KeyStorage.SavePublicKey(_username, keys.publicKey);
} }
wsc = new WebSocket("ws://localhost:1337/"); _wsc = new WebSocket("ws://localhost:1337/");
wsc.OnMessage += WscOnMessage; _wsc.OnMessage += WscOnMessage;
wsc.Connect(); _wsc.Connect();
var publicKey = KeyStorage.LoadPublicKey(_username); var publicKey = KeyStorage.LoadPublicKey(_username);
wsc.Send($"REGISTER_KEY|{_username}|{publicKey}"); _wsc.Send($"REGISTER_KEY|{_username}|{publicKey}");
wsc.Send("GET_SERVER_KEY"); _wsc.Send("GET_SERVER_KEY");
wsc.Send($"GET_HISTORY|{_username}"); _wsc.Send("GET_CHANNELS");
} }
private void SendButton_OnClicked(object? sender, EventArgs e) private void SendButton_OnClicked(object? sender, EventArgs e)
@@ -63,6 +68,7 @@ public partial class MainPage : ContentPage
var payload = new SocketEncryptedMessage var payload = new SocketEncryptedMessage
{ {
ChannelId = _currentChannelId!,
Type = "client_encrypted_chat", Type = "client_encrypted_chat",
SenderUsername = _username, SenderUsername = _username,
CipherText = encrypted.CipherText, CipherText = encrypted.CipherText,
@@ -72,7 +78,7 @@ public partial class MainPage : ContentPage
}; };
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
wsc.Send(json); _wsc.Send(json);
Console.WriteLine($"[{_username}] sent encrypted message."); Console.WriteLine($"[{_username}] sent encrypted message.");
@@ -100,6 +106,38 @@ public partial class MainPage : ContentPage
var type = typeElement.GetString(); var type = typeElement.GetString();
if (type == "channel_list")
{
var channelList = JsonSerializer.Deserialize<SocketChannelList>(e.Data);
if (channelList is null)
return;
_channels.Clear();
_channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt));
var defaultChannel = _channels
.Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c.CreatedAt)
.FirstOrDefault()
?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault();
if (defaultChannel is not null)
{
_currentChannelId = defaultChannel.ChannelId;
_currentChannelName = defaultChannel.Name;
MainThread.BeginInvokeOnMainThread(() =>
{
ChannelLabel.Text = $"#{_currentChannelName}";
RenderChannelList();
});
_wsc.Send($"GET_HISTORY|{_username}|{_currentChannelId}");
}
return;
}
if (type == "server_public_key") if (type == "server_public_key")
{ {
var serverKeyMessage = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data); var serverKeyMessage = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data);
@@ -146,7 +184,80 @@ public partial class MainPage : ContentPage
Timestamp = DateTime.Now Timestamp = DateTime.Now
}; };
MainThread.BeginInvokeOnMainThread(async () => if (!_messagesByChannel.ContainsKey(payload.ChannelId))
{
_messagesByChannel[payload.ChannelId] = [];
}
_messagesByChannel[payload.ChannelId].Add(message);
if (payload.ChannelId == _currentChannelId)
{
MainThread.BeginInvokeOnMainThread(() =>
{
RenderSingleMessage(message);
});
}
}
catch (Exception ex)
{
Console.WriteLine($"[{_username}] failed to process websocket message: {ex.Message}");
}
}
protected override void OnDisappearing()
{
_wsc.OnMessage -= WscOnMessage;
_wsc.Close();
base.OnDisappearing();
}
private void RenderChannelList()
{
SidebarList.Children.Clear();
foreach (var channel in _channels.OrderBy(c => c.CreatedAt))
{
var button = new Button
{
Text = $"#{channel.Name}"
};
button.Clicked += (_, _) =>
{
_currentChannelId = channel.ChannelId;
_currentChannelName = channel.Name;
ChannelLabel.Text = $"#{_currentChannelName}";
RenderCurrentChannelMessages();
if (!_messagesByChannel.ContainsKey(channel.ChannelId))
{
_wsc.Send($"GET_HISTORY|{_username}|{channel.ChannelId}");
}
};
SidebarList.Children.Add(button);
}
}
private void RenderCurrentChannelMessages()
{
MessagesLayout.Children.Clear();
if (_currentChannelId is null)
return;
if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages))
return;
foreach (var message in messages.OrderBy(m => m.Timestamp))
{
RenderSingleMessage(message);
}
}
private async void RenderSingleMessage(ChatMessage message)
{ {
bool isOwnMessage = message.SenderUsername == _username; bool isOwnMessage = message.SenderUsername == _username;
@@ -165,40 +276,14 @@ public partial class MainPage : ContentPage
Spacing = 2, Spacing = 2,
Children = Children =
{ {
new Label new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 },
{ new Label { Text = message.Text, FontSize = 14 },
Text = message.SenderUsername, new Label { Text = message.Timestamp.ToString("h:mm tt"), FontSize = 10 }
FontAttributes = FontAttributes.Bold,
FontSize = 12
},
new Label
{
Text = message.Text,
FontSize = 14
},
new Label
{
Text = message.Timestamp.ToString("h:mm tt"),
FontSize = 10
}
} }
} }
}; };
MessagesLayout.Children.Add(bubble); MessagesLayout.Children.Add(bubble);
await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true);
});
}
catch (Exception ex)
{
Console.WriteLine($"[{_username}] failed to process websocket message: {ex.Message}");
}
}
protected override void OnDisappearing()
{
wsc.OnMessage -= WscOnMessage;
wsc.Close();
base.OnDisappearing();
} }
} }

View File

@@ -0,0 +1,8 @@
namespace RelayClient.Models;
public class ChannelItem
{
public required string ChannelId { get; set; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace RelayClient.Models;
public class SocketChannelList
{
public required string Type { get; set; }
public required List<ChannelItem> Channels { get; set; }
}

View File

@@ -5,6 +5,7 @@ public class SocketEncryptedMessage
public required string Type { get; set; } public required string Type { get; set; }
public required string SenderUsername { get; set; } public required string SenderUsername { get; set; }
public string? RecipientUsername { get; set; } public string? RecipientUsername { get; set; }
public required string ChannelId { get; set; }
public required string CipherText { get; set; } public required string CipherText { get; set; }
public required string Nonce { get; set; } public required string Nonce { get; set; }
public required string Tag { get; set; } public required string Tag { get; set; }

View File

@@ -0,0 +1,8 @@
namespace RelayServer.Models;
public class SocketChannelInfo
{
public required string ChannelId { get; set; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace RelayServer.Models;
public class SocketChannelList
{
public required string Type { get; set; }
public required List<SocketChannelInfo> Channels { get; set; }
}

View File

@@ -4,7 +4,8 @@ public class SocketEncryptedMessage
{ {
public required string Type { get; set; } public required string Type { get; set; }
public required string SenderUsername { get; set; } public required string SenderUsername { get; set; }
public required string RecipientUsername { get; set; } public string? RecipientUsername { get; set; }
public required string ChannelId { get; set; }
public required string CipherText { get; set; } public required string CipherText { get; set; }
public required string Nonce { get; set; } public required string Nonce { get; set; }
public required string Tag { get; set; } public required string Tag { get; set; }

View File

@@ -75,18 +75,29 @@ var channel = await db.Create("channels", new Channels
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
var channel2 = await db.Create("files", new Channels var channel2 = await db.Create("channels", new Channels
{ {
Name = "files", Name = "files",
CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0)) CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0))
}); });
var channel3 = await db.Create("channels", new Channels
{
Name = "welcome",
CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4))
});
Console.WriteLine($"Channel created: {ToJsonString(channel)}");
Console.WriteLine($"Channel created: {ToJsonString(channel2)}"); Console.WriteLine($"Channel created: {ToJsonString(channel2)}");
Console.WriteLine($"Channel created: {ToJsonString(channel3)}");
var channelId = GetRecordId(channel.Id); var channelId = GetRecordId(channel.Id);
var channelId2 = GetRecordId(channel2.Id);
var channelId3 = GetRecordId(channel3.Id);
Console.WriteLine($"Resolved channelId: {channelId}"); Console.WriteLine($"Resolved channelId: {channelId}");
ChatTest.DefaultChannelId = channelId; Console.WriteLine($"Resolved channelId: {channelId2}");
Console.WriteLine($"Resolved channelId: {channelId3}");
var keyBase64 = cryptoService.GenerateKey(); var keyBase64 = cryptoService.GenerateKey();
var serverKeys = E2EeHelper.GenerateRsaKeyPair(); var serverKeys = E2EeHelper.GenerateRsaKeyPair();

View File

@@ -12,7 +12,6 @@ public class ChatTest : WebSocketBehavior
public static string? ServerPrivateKey { get; set; } public static string? ServerPrivateKey { get; set; }
public static string? ChannelDbKey { get; set; } public static string? ChannelDbKey { get; set; }
public static SurrealDb.Net.SurrealDbClient? Db { get; set; } public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
public static string? DefaultChannelId { get; set; }
protected override void OnMessage(MessageEventArgs e) protected override void OnMessage(MessageEventArgs e)
{ {
@@ -31,6 +30,12 @@ public class ChatTest : WebSocketBehavior
return; return;
} }
if (msg == "GET_CHANNELS")
{
HandleGetChannels();
return;
}
if (msg.StartsWith("GET_HISTORY|")) if (msg.StartsWith("GET_HISTORY|"))
{ {
HandleGetHistory(msg); HandleGetHistory(msg);
@@ -76,6 +81,35 @@ public class ChatTest : WebSocketBehavior
Send($"SERVER:REGISTERED_KEY:{username}"); Send($"SERVER:REGISTERED_KEY:{username}");
} }
private void HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
var channels = Task.Run(async () => await Db.Select<Channels>("channels"))
.GetAwaiter()
.GetResult()
.OrderBy(c => c.CreatedAt)
.Select(c => new SocketChannelInfo
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = "channel_list",
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
private void HandleGetServerKey() private void HandleGetServerKey()
{ {
if (string.IsNullOrWhiteSpace(ServerPublicKey)) if (string.IsNullOrWhiteSpace(ServerPublicKey))
@@ -113,8 +147,7 @@ public class ChatTest : WebSocketBehavior
if (ClientKeyService is null || if (ClientKeyService is null ||
Db is null || Db is null ||
string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ServerPrivateKey) ||
string.IsNullOrWhiteSpace(ChannelDbKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
string.IsNullOrWhiteSpace(DefaultChannelId))
{ {
Console.WriteLine("Server crypto/database dependencies are not initialized."); Console.WriteLine("Server crypto/database dependencies are not initialized.");
return; return;
@@ -150,7 +183,7 @@ public class ChatTest : WebSocketBehavior
var savedMessage = Task.Run(async () => var savedMessage = Task.Run(async () =>
await Db.Create("channel_messages", new ChannelMessages await Db.Create("channel_messages", new ChannelMessages
{ {
ChannelId = DefaultChannelId, ChannelId = clientPayload.ChannelId,
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}", SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
CipherText = dbEncrypted.cipherText, CipherText = dbEncrypted.cipherText,
Nonce = dbEncrypted.nonce, Nonce = dbEncrypted.nonce,
@@ -182,6 +215,7 @@ public class ChatTest : WebSocketBehavior
Type = "encrypted_chat", Type = "encrypted_chat",
SenderUsername = clientPayload.SenderUsername, SenderUsername = clientPayload.SenderUsername,
RecipientUsername = client.Username, RecipientUsername = client.Username,
ChannelId = clientPayload.ChannelId,
CipherText = encrypted.CipherText, CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce, Nonce = encrypted.Nonce,
Tag = encrypted.Tag, Tag = encrypted.Tag,
@@ -194,20 +228,20 @@ public class ChatTest : WebSocketBehavior
private void HandleGetHistory(string msg) private void HandleGetHistory(string msg)
{ {
var parts = msg.Split('|', 2); var parts = msg.Split('|', 3);
if (parts.Length < 2) if (parts.Length < 3)
{ {
Console.WriteLine("Invalid GET_HISTORY payload."); Console.WriteLine("Invalid GET_HISTORY payload.");
return; return;
} }
var username = parts[1]; var username = parts[1];
var channelId = parts[2];
if (ClientKeyService is null || if (ClientKeyService is null ||
Db is null || Db is null ||
string.IsNullOrWhiteSpace(ChannelDbKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
string.IsNullOrWhiteSpace(DefaultChannelId))
{ {
Console.WriteLine("History dependencies are not initialized."); Console.WriteLine("History dependencies are not initialized.");
return; return;
@@ -228,7 +262,7 @@ public class ChatTest : WebSocketBehavior
.GetResult(); .GetResult();
var channelMessages = allMessages var channelMessages = allMessages
.Where(m => m.ChannelId == DefaultChannelId) .Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt) .OrderBy(m => m.CreatedAt)
.ToList(); .ToList();
@@ -262,6 +296,7 @@ public class ChatTest : WebSocketBehavior
Type = "encrypted_chat", Type = "encrypted_chat",
SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId), SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
RecipientUsername = username, RecipientUsername = username,
ChannelId = channelId,
CipherText = encrypted.CipherText, CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce, Nonce = encrypted.Nonce,
Tag = encrypted.Tag, Tag = encrypted.Tag,
@@ -271,4 +306,21 @@ public class ChatTest : WebSocketBehavior
Send(JsonSerializer.Serialize(outbound)); Send(JsonSerializer.Serialize(outbound));
} }
} }
private static string GetRecordId(object? id)
{
if (id is null)
return string.Empty;
var json = JsonSerializer.Serialize(id);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
} }