mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-07 23:34:17 +02:00
feat: Add avatar upload functionality and refactor profile handling in AppOrchestrator
This commit is contained in:
@@ -144,9 +144,54 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnOpenProfile += () =>
|
_commandHandler.OnSetAvatar += async (target) =>
|
||||||
{
|
{
|
||||||
InvokeUI(HandleProfileRequested);
|
if (!IsAuthenticated) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var bytes = await http.GetByteArrayAsync(uri);
|
||||||
|
stream = new MemoryStream(bytes);
|
||||||
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
fileName = "avatar.png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!File.Exists(target))
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"File not found: {target}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream = File.OpenRead(target);
|
||||||
|
fileName = Path.GetFileName(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
var ascii = await _apiClient!.UploadAvatarAsync(stream, fileName);
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Avatar upload failed for {Target}", target);
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnOpenProfile += (username) =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => HandleViewProfile(username));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,35 +445,55 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private void HandleProfileRequested()
|
private void HandleProfileRequested()
|
||||||
{
|
{
|
||||||
|
HandleViewProfile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleViewProfile(string? username)
|
||||||
|
{
|
||||||
|
// If no username or it's our own, show the full user panel
|
||||||
|
var isOwnProfile = string.IsNullOrWhiteSpace(username)
|
||||||
|
|| username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
UserProfileDto? profile = null;
|
UserProfileDto? profile = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsAuthenticated && !string.IsNullOrEmpty(_currentUsername))
|
if (IsAuthenticated)
|
||||||
profile = await _apiClient!.GetUserProfileAsync(_currentUsername);
|
{
|
||||||
|
var target = isOwnProfile ? _currentUsername : username!;
|
||||||
|
if (!string.IsNullOrEmpty(target))
|
||||||
|
profile = await _apiClient!.GetUserProfileAsync(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Profile may not be available; continue with null
|
InvokeUI(() => _mainWindow.ShowError($"Failed to load profile: {ex.Message}"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
var action = UserPanelDialog.Show(_app,
|
if (isOwnProfile)
|
||||||
profile,
|
|
||||||
_config.SavedServers,
|
|
||||||
_currentStatus,
|
|
||||||
_currentStatusMessage);
|
|
||||||
|
|
||||||
switch (action)
|
|
||||||
{
|
{
|
||||||
case UserPanelAction.EditProfile:
|
var action = ProfileViewDialog.ShowOwn(_app,
|
||||||
HandleEditProfile(profile);
|
profile,
|
||||||
break;
|
_currentStatus,
|
||||||
case UserPanelAction.SetStatus:
|
_currentStatusMessage);
|
||||||
HandleStatusRequested();
|
|
||||||
break;
|
switch (action)
|
||||||
|
{
|
||||||
|
case ProfileAction.EditProfile:
|
||||||
|
HandleEditProfile(profile);
|
||||||
|
break;
|
||||||
|
case ProfileAction.SetStatus:
|
||||||
|
HandleStatusRequested();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ProfileViewDialog.Show(_app, profile);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
public event Func<string, Task>? OnSendFile;
|
public event Func<string, Task>? OnSendFile;
|
||||||
public event Func<Task>? OnOpenProfile;
|
public event Func<string?, Task>? OnOpenProfile;
|
||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, Task>? OnJoinChannel;
|
public event Func<string, Task>? OnJoinChannel;
|
||||||
public event Func<Task>? OnLeaveChannel;
|
public event Func<Task>? OnLeaveChannel;
|
||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ public class CommandHandler
|
|||||||
"color" => await HandleColor(args),
|
"color" => await HandleColor(args),
|
||||||
"theme" => await HandleTheme(args),
|
"theme" => await HandleTheme(args),
|
||||||
"send" => await HandleSend(args),
|
"send" => await HandleSend(args),
|
||||||
"profile" => await HandleProfile(),
|
"profile" => await HandleProfile(args),
|
||||||
|
"avatar" => await HandleAvatar(args),
|
||||||
"servers" => await HandleServers(),
|
"servers" => await HandleServers(),
|
||||||
"join" => await HandleJoin(args),
|
"join" => await HandleJoin(args),
|
||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
@@ -141,13 +143,26 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleProfile()
|
private async Task<CommandResult> HandleProfile(string args)
|
||||||
{
|
{
|
||||||
|
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
||||||
if (OnOpenProfile is not null)
|
if (OnOpenProfile is not null)
|
||||||
await OnOpenProfile();
|
await OnOpenProfile(username);
|
||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleAvatar(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
||||||
|
|
||||||
|
var target = args.Trim().Trim('"');
|
||||||
|
|
||||||
|
if (OnSetAvatar is not null)
|
||||||
|
await OnSetAvatar(target);
|
||||||
|
return new CommandResult(true, "Uploading avatar...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleServers()
|
private async Task<CommandResult> HandleServers()
|
||||||
{
|
{
|
||||||
if (OnOpenServers is not null)
|
if (OnOpenServers is not null)
|
||||||
@@ -209,7 +224,8 @@ public class CommandHandler
|
|||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> - Send a file or image
|
/send <filepath or URL> - Send a file or image
|
||||||
/profile - Open your profile
|
/avatar <URL or filepath> - Set your avatar
|
||||||
|
/profile [username] - View a profile (yours if no name given)
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> - Join a channel
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings.json">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<EmbeddedResource Include="appsettings.json">
|
<EmbeddedResource Include="appsettings.example.json">
|
||||||
<LogicalName>EchoHub.Client.appsettings.json</LogicalName>
|
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json")
|
|||||||
if (!File.Exists(appSettingsPath))
|
if (!File.Exists(appSettingsPath))
|
||||||
{
|
{
|
||||||
using var stream = typeof(AppOrchestrator).Assembly
|
using var stream = typeof(AppOrchestrator).Assembly
|
||||||
.GetManifestResourceStream("EchoHub.Client.appsettings.json");
|
.GetManifestResourceStream("EchoHub.Client.appsettings.example.json");
|
||||||
|
|
||||||
if (stream is not null)
|
if (stream is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
||||||
private static readonly Key EnterKey = Key.Enter;
|
private static readonly Key EnterKey = Key.Enter;
|
||||||
private static readonly Key AltEnterKey = Key.Enter.WithAlt;
|
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||||
private static readonly Key TabKey = Key.Tab;
|
private static readonly Key TabKey = Key.Tab;
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ public sealed class MainWindow : Runnable
|
|||||||
private static readonly string[] SlashCommands =
|
private static readonly string[] SlashCommands =
|
||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/profile", "/servers", "/join", "/leave", "/topic",
|
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||||
"/users", "/quit", "/help"
|
"/topic", "/users", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
@@ -159,7 +159,7 @@ public sealed class MainWindow : Runnable
|
|||||||
// Bottom input area
|
// Bottom input area
|
||||||
var inputFrame = new FrameView
|
var inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message (Enter=send, Tab=autocomplete)",
|
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
|
||||||
X = 25,
|
X = 25,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
@@ -194,7 +194,9 @@ public sealed class MainWindow : Runnable
|
|||||||
ApplyColorSchemes();
|
ApplyColorSchemes();
|
||||||
|
|
||||||
// Re-wrap messages when the chat area is resized
|
// Re-wrap messages when the chat area is resized
|
||||||
|
// Subscribe to both ListView and FrameView viewport changes for reliable resize detection
|
||||||
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
|
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
|
|
||||||
// Window-level key handling for Ctrl+C (quit)
|
// Window-level key handling for Ctrl+C (quit)
|
||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
@@ -316,7 +318,7 @@ public sealed class MainWindow : Runnable
|
|||||||
TryAutocompleteCommand();
|
TryAutocompleteCommand();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
else if (e.KeyCode == AltEnterKey.KeyCode)
|
else if (e.KeyCode == NewlineKey.KeyCode)
|
||||||
{
|
{
|
||||||
_inputField.InsertText("\n");
|
_inputField.InsertText("\n");
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -603,6 +605,14 @@ public sealed class MainWindow : Runnable
|
|||||||
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
|
// Update cached width when viewport reports a valid value;
|
||||||
|
// fall back to last known width if viewport hasn't been laid out yet.
|
||||||
|
if (width > 0)
|
||||||
|
_lastChatWidth = width;
|
||||||
|
else
|
||||||
|
width = _lastChatWidth;
|
||||||
|
|
||||||
var source = new ChatListSource();
|
var source = new ChatListSource();
|
||||||
|
|
||||||
if (width > 0)
|
if (width > 0)
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Action selected by the user in their own profile dialog.
|
||||||
|
/// </summary>
|
||||||
|
public enum ProfileAction
|
||||||
|
{
|
||||||
|
Close,
|
||||||
|
EditProfile,
|
||||||
|
SetStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dialog for viewing a user's server profile.
|
||||||
|
/// Shows Edit Profile / Set Status buttons when viewing own profile.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProfileViewDialog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Show a read-only profile view for another user.
|
||||||
|
/// </summary>
|
||||||
|
public static void Show(IApplication app, UserProfileDto? profile)
|
||||||
|
{
|
||||||
|
ShowInternal(app, profile, isOwnProfile: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show the profile view for the current user with action buttons.
|
||||||
|
/// Returns the action the user selected.
|
||||||
|
/// </summary>
|
||||||
|
public static ProfileAction ShowOwn(
|
||||||
|
IApplication app,
|
||||||
|
UserProfileDto? profile,
|
||||||
|
UserStatus currentStatus,
|
||||||
|
string? currentStatusMessage)
|
||||||
|
{
|
||||||
|
return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileAction ShowInternal(
|
||||||
|
IApplication app,
|
||||||
|
UserProfileDto? profile,
|
||||||
|
bool isOwnProfile,
|
||||||
|
UserStatus? currentStatus = null,
|
||||||
|
string? currentStatusMessage = null)
|
||||||
|
{
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK");
|
||||||
|
return ProfileAction.Close;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = ProfileAction.Close;
|
||||||
|
|
||||||
|
var dialog = new Dialog
|
||||||
|
{
|
||||||
|
Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}",
|
||||||
|
Width = 50,
|
||||||
|
Height = 20
|
||||||
|
};
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
|
||||||
|
// Username
|
||||||
|
var usernameLabel = new Label { Text = "Username:", X = 1, Y = row };
|
||||||
|
var usernameValue = new Label { Text = profile.Username, X = 14, Y = row };
|
||||||
|
usernameValue.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.BrightYellow, Color.Blue)
|
||||||
|
});
|
||||||
|
dialog.Add(usernameLabel, usernameValue);
|
||||||
|
row++;
|
||||||
|
|
||||||
|
// Display Name
|
||||||
|
var nameLabel = new Label { Text = "Name:", X = 1, Y = row };
|
||||||
|
var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row };
|
||||||
|
dialog.Add(nameLabel, nameValue);
|
||||||
|
row++;
|
||||||
|
|
||||||
|
// Status — use live status for own profile, stored status for others
|
||||||
|
var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status;
|
||||||
|
var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage;
|
||||||
|
|
||||||
|
var statusLabel = new Label { Text = "Status:", X = 1, Y = row };
|
||||||
|
var statusText = FormatStatus(displayStatus);
|
||||||
|
var statusValue = new Label { Text = statusText, X = 14, Y = row };
|
||||||
|
statusValue.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue)
|
||||||
|
});
|
||||||
|
dialog.Add(statusLabel, statusValue);
|
||||||
|
row++;
|
||||||
|
|
||||||
|
// Status Message
|
||||||
|
if (!string.IsNullOrWhiteSpace(displayStatusMsg))
|
||||||
|
{
|
||||||
|
var msgLabel = new Label { Text = "Message:", X = 1, Y = row };
|
||||||
|
var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) };
|
||||||
|
dialog.Add(msgLabel, msgValue);
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color
|
||||||
|
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
||||||
|
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
||||||
|
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
||||||
|
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
||||||
|
dialog.Add(colorLabel, colorValue);
|
||||||
|
row++;
|
||||||
|
|
||||||
|
// Bio
|
||||||
|
row++;
|
||||||
|
var bioLabel = new Label { Text = "Bio:", X = 1, Y = row };
|
||||||
|
dialog.Add(bioLabel);
|
||||||
|
row++;
|
||||||
|
|
||||||
|
var bioView = new TextView
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = row,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 3,
|
||||||
|
Text = profile.Bio ?? "-",
|
||||||
|
ReadOnly = true,
|
||||||
|
WordWrap = true
|
||||||
|
};
|
||||||
|
bioView.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.White, Color.DarkGray),
|
||||||
|
Focus = new Attribute(Color.White, Color.DarkGray)
|
||||||
|
});
|
||||||
|
dialog.Add(bioView);
|
||||||
|
row += 3;
|
||||||
|
|
||||||
|
// ASCII Avatar
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
||||||
|
{
|
||||||
|
row++;
|
||||||
|
var avatarLines = profile.AvatarAscii.Split('\n').Length;
|
||||||
|
var avatarHeight = Math.Min(avatarLines + 2, 6);
|
||||||
|
var avatarFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Avatar",
|
||||||
|
X = 1,
|
||||||
|
Y = row,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = avatarHeight
|
||||||
|
};
|
||||||
|
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
|
||||||
|
dialog.Add(avatarFrame);
|
||||||
|
// Grow dialog to fit avatar
|
||||||
|
dialog.Height = row + avatarHeight + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
if (isOwnProfile)
|
||||||
|
{
|
||||||
|
var editButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Edit Profile",
|
||||||
|
X = Pos.Center() - 20,
|
||||||
|
Y = Pos.AnchorEnd(2)
|
||||||
|
};
|
||||||
|
editButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
action = ProfileAction.EditProfile;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
var statusButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Set Status",
|
||||||
|
X = Pos.Center() - 4,
|
||||||
|
Y = Pos.AnchorEnd(2)
|
||||||
|
};
|
||||||
|
statusButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
action = ProfileAction.SetStatus;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
var closeButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Close",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() + 13,
|
||||||
|
Y = Pos.AnchorEnd(2)
|
||||||
|
};
|
||||||
|
closeButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
action = ProfileAction.Close;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(editButton, statusButton, closeButton);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var closeButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Close",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center(),
|
||||||
|
Y = Pos.AnchorEnd(2)
|
||||||
|
};
|
||||||
|
closeButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
dialog.Add(closeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run(dialog);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatStatus(UserStatus status) => status switch
|
||||||
|
{
|
||||||
|
UserStatus.Online => "\u25cf Online",
|
||||||
|
UserStatus.Away => "\u25cf Away",
|
||||||
|
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
|
||||||
|
UserStatus.Invisible => "\u25cb Invisible",
|
||||||
|
_ => "\u25cf Unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Color GetStatusColor(UserStatus status) => status switch
|
||||||
|
{
|
||||||
|
UserStatus.Online => Color.BrightGreen,
|
||||||
|
UserStatus.Away => Color.BrightYellow,
|
||||||
|
UserStatus.DoNotDisturb => Color.BrightRed,
|
||||||
|
UserStatus.Invisible => Color.Gray,
|
||||||
|
_ => Color.White
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Terminal.Gui.App;
|
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using EchoHub.Core.DTOs;
|
|
||||||
using EchoHub.Core.Models;
|
|
||||||
using EchoHub.Client.Config;
|
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Action selected by the user in the user panel dialog.
|
|
||||||
/// </summary>
|
|
||||||
public enum UserPanelAction
|
|
||||||
{
|
|
||||||
Close,
|
|
||||||
EditProfile,
|
|
||||||
SetStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A Terminal.Gui dialog for viewing the user panel -- profile info, saved servers, and status.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UserPanelDialog
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Shows the user panel dialog and returns the action the user selected.
|
|
||||||
/// </summary>
|
|
||||||
public static UserPanelAction Show(
|
|
||||||
IApplication app,
|
|
||||||
UserProfileDto? profile,
|
|
||||||
List<SavedServer> savedServers,
|
|
||||||
UserStatus currentStatus,
|
|
||||||
string? currentStatusMessage)
|
|
||||||
{
|
|
||||||
var action = UserPanelAction.Close;
|
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "User Panel", Width = 70, Height = 24 };
|
|
||||||
|
|
||||||
// -- Left side: Profile info ------------------------------------------
|
|
||||||
var profileFrame = new FrameView
|
|
||||||
{
|
|
||||||
Title = "Profile",
|
|
||||||
X = 0,
|
|
||||||
Y = 0,
|
|
||||||
Width = 35,
|
|
||||||
Height = Dim.Fill(3)
|
|
||||||
};
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
|
|
||||||
// Username
|
|
||||||
var usernameLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Username:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
var usernameValue = new Label
|
|
||||||
{
|
|
||||||
Text = profile?.Username ?? "N/A",
|
|
||||||
X = 12,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
usernameValue.SetScheme(new Scheme
|
|
||||||
{
|
|
||||||
Normal = new Attribute(Color.BrightYellow, Color.Blue)
|
|
||||||
});
|
|
||||||
profileFrame.Add(usernameLabel, usernameValue);
|
|
||||||
row += 1;
|
|
||||||
|
|
||||||
// Display Name
|
|
||||||
var displayLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Name:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
var displayValue = new Label
|
|
||||||
{
|
|
||||||
Text = profile?.DisplayName ?? "-",
|
|
||||||
X = 12,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
profileFrame.Add(displayLabel, displayValue);
|
|
||||||
row += 1;
|
|
||||||
|
|
||||||
// Status
|
|
||||||
var statusLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Status:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
var statusText = FormatStatus(currentStatus);
|
|
||||||
var statusValue = new Label
|
|
||||||
{
|
|
||||||
Text = statusText,
|
|
||||||
X = 12,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
statusValue.SetScheme(new Scheme
|
|
||||||
{
|
|
||||||
Normal = new Attribute(GetStatusColor(currentStatus), Color.Blue)
|
|
||||||
});
|
|
||||||
profileFrame.Add(statusLabel, statusValue);
|
|
||||||
row += 1;
|
|
||||||
|
|
||||||
// Status Message
|
|
||||||
if (!string.IsNullOrWhiteSpace(currentStatusMessage))
|
|
||||||
{
|
|
||||||
var msgLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Message:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
var msgValue = new Label
|
|
||||||
{
|
|
||||||
Text = Truncate(currentStatusMessage, 20),
|
|
||||||
X = 12,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
profileFrame.Add(msgLabel, msgValue);
|
|
||||||
row += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bio
|
|
||||||
row += 1;
|
|
||||||
var bioLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Bio:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
profileFrame.Add(bioLabel);
|
|
||||||
row += 1;
|
|
||||||
|
|
||||||
var bioText = profile?.Bio ?? "-";
|
|
||||||
var bioView = new TextView()
|
|
||||||
{
|
|
||||||
X = 1,
|
|
||||||
Y = row,
|
|
||||||
Width = Dim.Fill(1),
|
|
||||||
Height = 3,
|
|
||||||
Text = bioText,
|
|
||||||
ReadOnly = true,
|
|
||||||
WordWrap = true
|
|
||||||
};
|
|
||||||
bioView.SetScheme(new Scheme
|
|
||||||
{
|
|
||||||
Normal = new Attribute(Color.White, Color.DarkGray),
|
|
||||||
Focus = new Attribute(Color.White, Color.DarkGray)
|
|
||||||
});
|
|
||||||
profileFrame.Add(bioView);
|
|
||||||
row += 3;
|
|
||||||
|
|
||||||
// Color
|
|
||||||
var colorLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Color:",
|
|
||||||
X = 1,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
var colorValue = new Label
|
|
||||||
{
|
|
||||||
Text = profile?.NicknameColor ?? "-",
|
|
||||||
X = 12,
|
|
||||||
Y = row
|
|
||||||
};
|
|
||||||
profileFrame.Add(colorLabel, colorValue);
|
|
||||||
row += 1;
|
|
||||||
|
|
||||||
// ASCII Avatar
|
|
||||||
if (!string.IsNullOrWhiteSpace(profile?.AvatarAscii))
|
|
||||||
{
|
|
||||||
row += 1;
|
|
||||||
var avatarFrame = new FrameView
|
|
||||||
{
|
|
||||||
Title = "Avatar",
|
|
||||||
X = 1,
|
|
||||||
Y = row,
|
|
||||||
Width = Dim.Fill(1),
|
|
||||||
Height = 4
|
|
||||||
};
|
|
||||||
var avatarLabel = new Label
|
|
||||||
{
|
|
||||||
Text = profile.AvatarAscii,
|
|
||||||
X = 0,
|
|
||||||
Y = 0
|
|
||||||
};
|
|
||||||
avatarFrame.Add(avatarLabel);
|
|
||||||
profileFrame.Add(avatarFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.Add(profileFrame);
|
|
||||||
|
|
||||||
// -- Right side: Saved Servers ----------------------------------------
|
|
||||||
var serversFrame = new FrameView
|
|
||||||
{
|
|
||||||
Title = "Saved Servers",
|
|
||||||
X = 36,
|
|
||||||
Y = 0,
|
|
||||||
Width = Dim.Fill(1),
|
|
||||||
Height = Dim.Fill(3)
|
|
||||||
};
|
|
||||||
|
|
||||||
var serverNames = savedServers.Select(s => s.Name).ToList();
|
|
||||||
var serverList = new ListView
|
|
||||||
{
|
|
||||||
Source = new ListWrapper<string>(new ObservableCollection<string>(serverNames)),
|
|
||||||
X = 0,
|
|
||||||
Y = 0,
|
|
||||||
Width = Dim.Fill(0),
|
|
||||||
Height = Dim.Fill(4)
|
|
||||||
};
|
|
||||||
|
|
||||||
var serverUrlLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "URL: -",
|
|
||||||
X = 0,
|
|
||||||
Y = Pos.AnchorEnd(3),
|
|
||||||
Width = Dim.Fill(0)
|
|
||||||
};
|
|
||||||
var serverLastLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "Last: -",
|
|
||||||
X = 0,
|
|
||||||
Y = Pos.AnchorEnd(2),
|
|
||||||
Width = Dim.Fill(0)
|
|
||||||
};
|
|
||||||
var serverUserLabel = new Label
|
|
||||||
{
|
|
||||||
Text = "User: -",
|
|
||||||
X = 0,
|
|
||||||
Y = Pos.AnchorEnd(1),
|
|
||||||
Width = Dim.Fill(0)
|
|
||||||
};
|
|
||||||
|
|
||||||
serverList.ValueChanged += (sender, e) =>
|
|
||||||
{
|
|
||||||
var index = e.NewValue;
|
|
||||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
|
||||||
{
|
|
||||||
var server = savedServers[index.Value];
|
|
||||||
serverUrlLabel.Text = $"URL: {Truncate(server.Url, 25)}";
|
|
||||||
serverLastLabel.Text = $"Last: {server.LastConnected:yyyy-MM-dd HH:mm}";
|
|
||||||
serverUserLabel.Text = $"User: {server.Username ?? "-"}";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show initial details if there are servers
|
|
||||||
if (savedServers.Count > 0)
|
|
||||||
{
|
|
||||||
var first = savedServers[0];
|
|
||||||
serverUrlLabel.Text = $"URL: {Truncate(first.Url, 25)}";
|
|
||||||
serverLastLabel.Text = $"Last: {first.LastConnected:yyyy-MM-dd HH:mm}";
|
|
||||||
serverUserLabel.Text = $"User: {first.Username ?? "-"}";
|
|
||||||
}
|
|
||||||
|
|
||||||
serversFrame.Add(serverList, serverUrlLabel, serverLastLabel, serverUserLabel);
|
|
||||||
dialog.Add(serversFrame);
|
|
||||||
|
|
||||||
// -- Bottom buttons ---------------------------------------------------
|
|
||||||
var editProfileButton = new Button
|
|
||||||
{
|
|
||||||
Text = "Edit Profile",
|
|
||||||
X = Pos.Center() - 22,
|
|
||||||
Y = Pos.AnchorEnd(2)
|
|
||||||
};
|
|
||||||
|
|
||||||
var setStatusButton = new Button
|
|
||||||
{
|
|
||||||
Text = "Set Status",
|
|
||||||
X = Pos.Center() - 5,
|
|
||||||
Y = Pos.AnchorEnd(2)
|
|
||||||
};
|
|
||||||
|
|
||||||
var closeButton = new Button
|
|
||||||
{
|
|
||||||
Text = "Close",
|
|
||||||
IsDefault = true,
|
|
||||||
X = Pos.Center() + 12,
|
|
||||||
Y = Pos.AnchorEnd(2)
|
|
||||||
};
|
|
||||||
|
|
||||||
editProfileButton.Accepting += (s, e) =>
|
|
||||||
{
|
|
||||||
action = UserPanelAction.EditProfile;
|
|
||||||
e.Handled = true;
|
|
||||||
app.RequestStop();
|
|
||||||
};
|
|
||||||
|
|
||||||
setStatusButton.Accepting += (s, e) =>
|
|
||||||
{
|
|
||||||
action = UserPanelAction.SetStatus;
|
|
||||||
e.Handled = true;
|
|
||||||
app.RequestStop();
|
|
||||||
};
|
|
||||||
|
|
||||||
closeButton.Accepting += (s, e) =>
|
|
||||||
{
|
|
||||||
action = UserPanelAction.Close;
|
|
||||||
e.Handled = true;
|
|
||||||
app.RequestStop();
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.Add(editProfileButton, setStatusButton, closeButton);
|
|
||||||
|
|
||||||
app.Run(dialog);
|
|
||||||
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatStatus(UserStatus status) => status switch
|
|
||||||
{
|
|
||||||
UserStatus.Online => "\u25cf Online",
|
|
||||||
UserStatus.Away => "\u25cf Away",
|
|
||||||
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
|
|
||||||
UserStatus.Invisible => "\u25cb Invisible",
|
|
||||||
_ => "\u25cf Unknown"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Color GetStatusColor(UserStatus status) => status switch
|
|
||||||
{
|
|
||||||
UserStatus.Online => Color.BrightGreen,
|
|
||||||
UserStatus.Away => Color.BrightYellow,
|
|
||||||
UserStatus.DoNotDisturb => Color.BrightRed,
|
|
||||||
UserStatus.Invisible => Color.Gray,
|
|
||||||
_ => Color.White
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
|
||||||
value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength - 3), "...");
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user