feat: add /meta command for channel metadata retrieval

- Implemented the `/meta` command to fetch and display channel metadata including room ID, topic, message count, unique user count, estimated size, and protection level.
- Added `ChannelMetaDto` to encapsulate channel metadata.
- Updated `ChannelsController` to handle the new `/meta` endpoint.
- Introduced `UploadLimits` configuration for admin-defined upload size limits for files, images, audio, and avatars.
- Enhanced error handling and user feedback for metadata retrieval.
- Updated documentation to reflect changes in encryption and room metadata.
- Added tests for the new functionality and upload limits.
This commit is contained in:
HueByte
2026-07-16 07:04:58 +02:00
parent dbf6565d18
commit e797ec2542
22 changed files with 526 additions and 93 deletions
+41
View File
@@ -127,6 +127,7 @@ public sealed class AppOrchestrator : IDisposable
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
_commandHandler.OnSetTopic += HandleCmdSetTopic;
_commandHandler.OnListUsers += HandleCmdListUsers;
_commandHandler.OnRoomInfo += HandleCmdMeta;
_commandHandler.OnKickUser += HandleCmdKickUser;
_commandHandler.OnBanUser += HandleCmdBanUser;
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
@@ -611,6 +612,46 @@ public sealed class AppOrchestrator : IDisposable
}
}
private async Task HandleCmdMeta()
{
if (!_conn.IsConnected || _conn.Api is null) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
try
{
var meta = await _conn.Api.GetChannelMetaAsync(channel);
if (meta is null)
{
InvokeUI(() => _mainWindow.ShowError($"Channel #{channel} not found."));
return;
}
var size = meta.EstimatedSizeBytes <= 0 ? "0 B" : ChatMessageManager.FormatFileSize(meta.EstimatedSizeBytes);
var protection = meta.IsEncrypted ? "end-to-end encrypted"
: meta.IsProtected ? "password-protected"
: "open";
InvokeUI(() =>
{
_messageManager.AddSystemMessage(channel, $"Room info for #{meta.Name}:");
if (!string.IsNullOrWhiteSpace(meta.Topic))
_messageManager.AddSystemMessage(channel, $" Topic {meta.Topic}");
_messageManager.AddSystemMessage(channel, $" Room ID {meta.Id}");
_messageManager.AddSystemMessage(channel, $" Created {meta.CreatedAt.ToLocalTime():g}");
_messageManager.AddSystemMessage(channel, $" Messages {meta.MessageCount}");
_messageManager.AddSystemMessage(channel, $" Unique users {meta.UniqueUserCount}");
_messageManager.AddSystemMessage(channel, $" Est. size {size}");
_messageManager.AddSystemMessage(channel, $" Protection {protection}");
});
}
catch (Exception ex)
{
InvokeUI(() => _mainWindow.ShowError($"Failed to fetch room info: {ex.Message}"));
}
}
private async Task HandleCmdKickUser(string username, string? reason)
{
if (!_conn.IsAuthenticated) return;
@@ -21,6 +21,7 @@ public class CommandHandler
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
public event Func<Task>? OnRoomInfo;
public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser;
@@ -62,6 +63,7 @@ public class CommandHandler
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
"meta" or "info" => await HandleMeta(),
"kick" => await HandleKick(args),
"ban" => await HandleBan(args),
"unban" => await HandleUnban(args),
@@ -273,6 +275,13 @@ public class CommandHandler
return new CommandResult(true);
}
private async Task<CommandResult> HandleMeta()
{
if (OnRoomInfo is not null)
await OnRoomInfo();
return new CommandResult(true);
}
private async Task<CommandResult> HandleQuit()
{
if (OnQuit is not null)
@@ -403,6 +412,7 @@ public class CommandHandler
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
/meta - Show room info (size, messages, users, created, id)
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/ban <user> [reason] - Ban a user (Admin+)
+14
View File
@@ -271,6 +271,20 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
}
/// <summary>
/// Fetches a channel's human-facing metadata (message count, unique posters, estimated
/// size, created date, room id) for the <c>/meta</c> command. Returns null if it doesn't exist.
/// </summary>
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/meta");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelMetaDto>();
}
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
{
EnsureAuthenticated();
+36 -4
View File
@@ -1,3 +1,5 @@
using System.Text;
using AlwaysUpToDate;
using EchoHub.Client.UI.Dialogs;
@@ -88,6 +90,10 @@ public sealed class UpdateChecker : IDisposable
private async Task ApplyUpdateAsync()
{
_applying = true;
// The TUI restored the console on shutdown; make sure the block-glyph bar renders.
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* redirected/non-interactive */ }
Console.WriteLine();
Console.WriteLine($"Updating EchoHub to v{_pendingVersion}...");
@@ -106,23 +112,49 @@ public sealed class UpdateChecker : IDisposable
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
}
private const int BarWidth = 28;
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
{
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the
// real work happens headless after shutdown, so report it on the console.
// real work happens headless after shutdown, so draw a progress bar on the console.
if (!_applying)
return;
// Finish the previous step's line so each step keeps its completed bar.
if (step != _lastStep)
{
Console.WriteLine();
if (_lastStep != (UpdateStep)(-1))
Console.WriteLine();
_lastStep = step;
}
var pct = progressPercentage ?? 0;
Console.Write($"\r {step}: {itemsProcessed}/{totalItems ?? 0} ({pct:F0}%) ");
var label = Humanize(step);
if (progressPercentage is { } percent)
{
var pct = (int)Math.Clamp(Math.Round(percent), 0, 100);
var filled = pct * BarWidth / 100;
var bar = new string('█', filled) + new string('░', BarWidth - filled); // █ / ░
Console.Write($"\r {label,-13} [{bar}] {pct,3}% ");
}
else
{
// Steps with no measurable total (verifying, restarting): show an indeterminate marker.
Console.Write($"\r {label,-13} working... ");
}
}
private static string Humanize(UpdateStep step) => step switch
{
UpdateStep.Downloading => "Downloading",
UpdateStep.VerifyingChecksum => "Verifying",
UpdateStep.Extracting => "Extracting",
UpdateStep.CleaningUp => "Cleaning up",
UpdateStep.Restarting => "Restarting",
_ => step.ToString(),
};
private void OnUpdateStarted(string version)
{
Log.Information("Update started: v{Version}", version);
@@ -451,10 +451,17 @@ public sealed class ChatMessageManager
private static string FormatDateTime(DateTimeOffset timestamp)
{
if (timestamp.Date == DateTimeOffset.Now.Date)
return timestamp.ToLocalTime().ToString("t");
else
return timestamp.ToLocalTime().ToString("g");
// Server timestamps arrive in UTC; convert to local before deciding the calendar day,
// otherwise a "today" message near midnight is misclassified against the local date.
var local = timestamp.ToLocalTime();
// Today's messages show a compact date + short time; older messages fall back to the
// culture's general short date/time. Both always include the date so new messages
// are never left date-less.
if (local.Date == DateTimeOffset.Now.Date)
return $"{local:d} {local:t}";
return local.ToString("g");
}
internal static string FormatFileSize(long? bytes)
@@ -1,61 +0,0 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI.Dialogs;
public sealed class UpdateProgressDialog
{
private readonly Dialog _dialog;
private readonly ProgressBar _progressBar;
private readonly Label _infoLabel;
private readonly IApplication _app;
public UpdateProgressDialog(IApplication app, string newVersion)
{
_app = app;
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
_infoLabel = new Label
{
Text = "Preparing update...",
X = 1,
Y = 1,
Width = Dim.Fill(2)
};
_progressBar = new ProgressBar
{
X = 1,
Y = 3,
Width = Dim.Fill(2),
Fraction = 0f
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = 6
};
_dialog.Add(_infoLabel, _progressBar);
}
public void UpdateProgress(float fraction, string statusText)
{
_progressBar.Fraction = fraction;
_infoLabel.Text = statusText;
}
public void Show()
{
_app.Run(_dialog);
}
public void Close()
{
_app.RequestStop();
}
}