mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Add core models, DTOs, and services for EchoHub chat application
- Created User, Channel, Message, and ServerInfo models with necessary properties. - Added DTOs for user profiles, server information, and message handling. - Implemented JWT authentication service for user login and token generation. - Developed Entity Framework Core DbContext for database interactions. - Introduced SignalR ChatHub for real-time messaging and presence tracking. - Implemented file storage and image to ASCII conversion services. - Set up CORS and middleware for API endpoints. - Created launch settings and development configuration for server and web projects.
This commit is contained in:
@@ -410,9 +410,18 @@ FodyWeavers.xsd
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
# Dev folder
|
||||||
|
.dev/
|
||||||
|
|
||||||
# Windows Installer files from build outputs
|
# Windows Installer files from build outputs
|
||||||
*.cab
|
*.cab
|
||||||
*.msi
|
*.msi
|
||||||
*.msix
|
*.msix
|
||||||
*.msm
|
*.msm
|
||||||
*.msp
|
*.msp
|
||||||
|
|
||||||
|
.dev/*
|
||||||
|
appsettings.json
|
||||||
|
CLAUDE.md
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/EchoHub.Api/EchoHub.Api.csproj" />
|
||||||
|
<Project Path="src/EchoHub.Client/EchoHub.Client.csproj" />
|
||||||
|
<Project Path="src/EchoHub.Core/EchoHub.Core.csproj" />
|
||||||
|
<Project Path="src/EchoHub.Server/EchoHub.Server.csproj" />
|
||||||
|
<Project Path="src/EchoHub.Web/EchoHub.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Api.Data;
|
||||||
|
|
||||||
|
public class ServerDirectoryDbContext(DbContextOptions<ServerDirectoryDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<ServerInfo> Servers => Set<ServerInfo>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ServerInfo>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Url).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using EchoHub.Api.Data;
|
||||||
|
using EchoHub.Api.Services;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace EchoHub.Api.Extensions;
|
||||||
|
|
||||||
|
public static class ServerDirectoryExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddServerDirectory(this IServiceCollection services, string connectionString)
|
||||||
|
{
|
||||||
|
services.AddDbContext<ServerDirectoryDbContext>(options =>
|
||||||
|
options.UseSqlite(connectionString));
|
||||||
|
|
||||||
|
services.AddScoped<ServerDirectoryService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication MapServerDirectoryApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/servers");
|
||||||
|
|
||||||
|
group.MapGet("/", async (ServerDirectoryService service) =>
|
||||||
|
{
|
||||||
|
var servers = await service.GetAllServersAsync();
|
||||||
|
return Results.Ok(servers);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/search", async (string q, ServerDirectoryService service) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
return Results.BadRequest("Query parameter 'q' is required.");
|
||||||
|
|
||||||
|
var servers = await service.SearchServersAsync(q);
|
||||||
|
return Results.Ok(servers);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}", async (Guid id, ServerDirectoryService service) =>
|
||||||
|
{
|
||||||
|
var server = await service.GetServerByIdAsync(id);
|
||||||
|
return server is null ? Results.NotFound() : Results.Ok(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (RegisterServerRequest request, ServerDirectoryService service) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var server = await service.RegisterServerAsync(request);
|
||||||
|
return Results.Created($"/api/servers/{server.Id}", server);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}/status", async (Guid id, ServerStatusDto status, ServerDirectoryService service) =>
|
||||||
|
{
|
||||||
|
var server = await service.UpdateServerStatusAsync(id, status.OnlineUsers, status.TotalChannels);
|
||||||
|
return server is null ? Results.NotFound() : Results.Ok(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using EchoHub.Api.Data;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Api.Services;
|
||||||
|
|
||||||
|
public class ServerDirectoryService(ServerDirectoryDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<List<ServerInfoDto>> GetAllServersAsync()
|
||||||
|
{
|
||||||
|
return await db.Servers
|
||||||
|
.OrderByDescending(s => s.OnlineUsers)
|
||||||
|
.Select(s => new ServerInfoDto(
|
||||||
|
s.Id, s.Name, s.Description, s.Url,
|
||||||
|
s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ServerInfoDto?> GetServerByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var server = await db.Servers.FindAsync(id);
|
||||||
|
return server is null ? null : MapToDto(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ServerInfoDto> RegisterServerAsync(RegisterServerRequest request)
|
||||||
|
{
|
||||||
|
var exists = await db.Servers.AnyAsync(s => s.Url == request.Url);
|
||||||
|
if (exists)
|
||||||
|
throw new InvalidOperationException($"A server with URL '{request.Url}' is already registered.");
|
||||||
|
|
||||||
|
var server = new ServerInfo
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = request.Name,
|
||||||
|
Description = request.Description,
|
||||||
|
Url = request.Url,
|
||||||
|
OnlineUsers = 0,
|
||||||
|
TotalChannels = 0,
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow,
|
||||||
|
LastPingAt = DateTimeOffset.UtcNow,
|
||||||
|
IsOnline = true
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Servers.Add(server);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return MapToDto(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ServerInfoDto?> UpdateServerStatusAsync(Guid id, int onlineUsers, int totalChannels)
|
||||||
|
{
|
||||||
|
var server = await db.Servers.FindAsync(id);
|
||||||
|
if (server is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
server.OnlineUsers = onlineUsers;
|
||||||
|
server.TotalChannels = totalChannels;
|
||||||
|
server.LastPingAt = DateTimeOffset.UtcNow;
|
||||||
|
server.IsOnline = true;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return MapToDto(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ServerInfoDto>> SearchServersAsync(string query)
|
||||||
|
{
|
||||||
|
var lowerQuery = query.ToLowerInvariant();
|
||||||
|
|
||||||
|
return await db.Servers
|
||||||
|
.Where(s => s.Name.ToLower().Contains(lowerQuery)
|
||||||
|
|| (s.Description != null && s.Description.ToLower().Contains(lowerQuery)))
|
||||||
|
.OrderByDescending(s => s.OnlineUsers)
|
||||||
|
.Select(s => new ServerInfoDto(
|
||||||
|
s.Id, s.Name, s.Description, s.Url,
|
||||||
|
s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerInfoDto MapToDto(ServerInfo server) => new(
|
||||||
|
server.Id,
|
||||||
|
server.Name,
|
||||||
|
server.Description,
|
||||||
|
server.Url,
|
||||||
|
server.OnlineUsers,
|
||||||
|
server.TotalChannels,
|
||||||
|
server.IsOnline,
|
||||||
|
server.LastPingAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Commands;
|
||||||
|
|
||||||
|
public record CommandResult(bool Handled, string? Message = null, bool IsError = false);
|
||||||
|
|
||||||
|
public class CommandHandler
|
||||||
|
{
|
||||||
|
public event Func<UserStatus, string?, Task>? OnSetStatus;
|
||||||
|
public event Func<string, Task>? OnSetNick;
|
||||||
|
public event Func<string, Task>? OnSetColor;
|
||||||
|
public event Func<string, Task>? OnSetTheme;
|
||||||
|
public event Func<string, Task>? OnSendFile;
|
||||||
|
public event Func<Task>? OnOpenProfile;
|
||||||
|
public event Func<Task>? OnOpenServers;
|
||||||
|
public event Func<string, Task>? OnJoinChannel;
|
||||||
|
public event Func<Task>? OnLeaveChannel;
|
||||||
|
public event Func<string, Task>? OnSetTopic;
|
||||||
|
public event Func<Task>? OnListUsers;
|
||||||
|
public event Func<Task>? OnQuit;
|
||||||
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
|
public bool IsCommand(string input) => input.StartsWith('/');
|
||||||
|
|
||||||
|
public async Task<CommandResult> HandleAsync(string input)
|
||||||
|
{
|
||||||
|
if (!IsCommand(input))
|
||||||
|
return new CommandResult(false);
|
||||||
|
|
||||||
|
var parts = input[1..].Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var command = parts[0].ToLowerInvariant();
|
||||||
|
var args = parts.Length > 1 ? parts[1] : string.Empty;
|
||||||
|
|
||||||
|
return command switch
|
||||||
|
{
|
||||||
|
"status" => await HandleStatus(args),
|
||||||
|
"nick" => await HandleNick(args),
|
||||||
|
"color" => await HandleColor(args),
|
||||||
|
"theme" => await HandleTheme(args),
|
||||||
|
"send" => await HandleSend(args),
|
||||||
|
"profile" => await HandleProfile(),
|
||||||
|
"servers" => await HandleServers(),
|
||||||
|
"join" => await HandleJoin(args),
|
||||||
|
"leave" => await HandleLeave(),
|
||||||
|
"topic" => await HandleTopic(args),
|
||||||
|
"users" => await HandleUsers(),
|
||||||
|
"quit" or "exit" => await HandleQuit(),
|
||||||
|
"help" or "?" => await HandleHelp(),
|
||||||
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleStatus(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
|
||||||
|
|
||||||
|
var statusArg = args.ToLowerInvariant().Trim();
|
||||||
|
UserStatus? status = statusArg switch
|
||||||
|
{
|
||||||
|
"online" => UserStatus.Online,
|
||||||
|
"away" => UserStatus.Away,
|
||||||
|
"dnd" or "donotdisturb" => UserStatus.DoNotDisturb,
|
||||||
|
"invisible" => UserStatus.Invisible,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
{
|
||||||
|
if (OnSetStatus is not null)
|
||||||
|
await OnSetStatus(status.Value, null);
|
||||||
|
return new CommandResult(true, $"Status set to {status.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as custom status message (keep current status)
|
||||||
|
if (OnSetStatus is not null)
|
||||||
|
await OnSetStatus(UserStatus.Online, args);
|
||||||
|
return new CommandResult(true, $"Status message set: {args}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleNick(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /nick <display name>", IsError: true);
|
||||||
|
|
||||||
|
if (OnSetNick is not null)
|
||||||
|
await OnSetNick(args.Trim());
|
||||||
|
return new CommandResult(true, $"Display name set to: {args.Trim()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleColor(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /color <hex> (e.g., /color #FF5733)", IsError: true);
|
||||||
|
|
||||||
|
var color = args.Trim();
|
||||||
|
if (!color.StartsWith('#'))
|
||||||
|
color = "#" + color;
|
||||||
|
|
||||||
|
if (color.Length != 7 || !IsValidHex(color[1..]))
|
||||||
|
return new CommandResult(true, "Invalid color. Use hex format: #RRGGBB", IsError: true);
|
||||||
|
|
||||||
|
if (OnSetColor is not null)
|
||||||
|
await OnSetColor(color);
|
||||||
|
return new CommandResult(true, $"Nickname color set to: {color}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTheme(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
|
||||||
|
|
||||||
|
if (OnSetTheme is not null)
|
||||||
|
await OnSetTheme(args.Trim());
|
||||||
|
return new CommandResult(true, $"Theme switched to: {args.Trim()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleSend(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
|
||||||
|
|
||||||
|
var target = args.Trim().Trim('"');
|
||||||
|
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
if (OnSendFile is not null)
|
||||||
|
await OnSendFile(target);
|
||||||
|
var fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
fileName = "download";
|
||||||
|
return new CommandResult(true, $"Downloading & uploading: {fileName}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(target))
|
||||||
|
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
||||||
|
|
||||||
|
if (OnSendFile is not null)
|
||||||
|
await OnSendFile(target);
|
||||||
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleProfile()
|
||||||
|
{
|
||||||
|
if (OnOpenProfile is not null)
|
||||||
|
await OnOpenProfile();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleServers()
|
||||||
|
{
|
||||||
|
if (OnOpenServers is not null)
|
||||||
|
await OnOpenServers();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleJoin(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
|
||||||
|
|
||||||
|
var channel = args.Trim().TrimStart('#');
|
||||||
|
if (OnJoinChannel is not null)
|
||||||
|
await OnJoinChannel(channel);
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleLeave()
|
||||||
|
{
|
||||||
|
if (OnLeaveChannel is not null)
|
||||||
|
await OnLeaveChannel();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTopic(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /topic <text>", IsError: true);
|
||||||
|
|
||||||
|
if (OnSetTopic is not null)
|
||||||
|
await OnSetTopic(args.Trim());
|
||||||
|
return new CommandResult(true, $"Topic set to: {args.Trim()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUsers()
|
||||||
|
{
|
||||||
|
if (OnListUsers is not null)
|
||||||
|
await OnListUsers();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleQuit()
|
||||||
|
{
|
||||||
|
if (OnQuit is not null)
|
||||||
|
await OnQuit();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleHelp()
|
||||||
|
{
|
||||||
|
if (OnHelp is not null)
|
||||||
|
await OnHelp();
|
||||||
|
return new CommandResult(true, """
|
||||||
|
Available commands:
|
||||||
|
/status <online|away|dnd|invisible> - Set your status
|
||||||
|
/status <message> - Set status message
|
||||||
|
/nick <name> - Set display name
|
||||||
|
/color <#hex> - Set nickname color
|
||||||
|
/theme <name> - Switch theme
|
||||||
|
/send <filepath or URL> - Send a file or image
|
||||||
|
/profile - Open your profile
|
||||||
|
/servers - Open saved servers
|
||||||
|
/join <channel> - Join a channel
|
||||||
|
/leave - Leave current channel
|
||||||
|
/topic <text> - Set channel topic
|
||||||
|
/users - List online users
|
||||||
|
/quit - Exit the app
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidHex(string s) =>
|
||||||
|
s.All(c => char.IsAsciiHexDigit(c));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace EchoHub.Client.Config;
|
||||||
|
|
||||||
|
public class ClientConfig
|
||||||
|
{
|
||||||
|
public List<SavedServer> SavedServers { get; set; } = [];
|
||||||
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SavedServer
|
||||||
|
{
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Token { get; set; }
|
||||||
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountPreset
|
||||||
|
{
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
public string? NicknameColor { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Config;
|
||||||
|
|
||||||
|
public static class ConfigManager
|
||||||
|
{
|
||||||
|
private static readonly string ConfigDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub");
|
||||||
|
|
||||||
|
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ClientConfig Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
return new ClientConfig();
|
||||||
|
|
||||||
|
var json = File.ReadAllText(ConfigPath);
|
||||||
|
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new ClientConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(ClientConfig config)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ConfigDir);
|
||||||
|
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||||
|
File.WriteAllText(ConfigPath, json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail — config save is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveServer(SavedServer server)
|
||||||
|
{
|
||||||
|
var config = Load();
|
||||||
|
var existing = config.SavedServers.FindIndex(s =>
|
||||||
|
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (existing >= 0)
|
||||||
|
config.SavedServers[existing] = server;
|
||||||
|
else
|
||||||
|
config.SavedServers.Add(server);
|
||||||
|
|
||||||
|
Save(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RemoveServer(string url)
|
||||||
|
{
|
||||||
|
var config = Load();
|
||||||
|
config.SavedServers.RemoveAll(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
Save(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
using EchoHub.Client.Commands;
|
||||||
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
using EchoHub.Client.Themes;
|
||||||
|
using EchoHub.Client.UI;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
private static IApplication _app = null!;
|
||||||
|
private static EchoHubConnection? _connection;
|
||||||
|
private static ApiClient? _apiClient;
|
||||||
|
private static MainWindow? _mainWindow;
|
||||||
|
private static CommandHandler? _commandHandler;
|
||||||
|
private static ClientConfig _config = new();
|
||||||
|
private static UserStatus _currentStatus = UserStatus.Online;
|
||||||
|
private static string? _currentStatusMessage;
|
||||||
|
private static string _currentUsername = string.Empty;
|
||||||
|
|
||||||
|
public static void Main()
|
||||||
|
{
|
||||||
|
// Configure Serilog file logger
|
||||||
|
var logDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "logs");
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Debug()
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logDir, "echohub-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 7,
|
||||||
|
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
Log.Information("EchoHub client starting");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Load configuration
|
||||||
|
_config = ConfigManager.Load();
|
||||||
|
Log.Information("Configuration loaded, active theme: {Theme}", _config.ActiveTheme);
|
||||||
|
|
||||||
|
_app = Application.Create().Init();
|
||||||
|
|
||||||
|
// Apply our custom color scheme
|
||||||
|
var theme = Themes.ThemeManager.GetTheme(_config.ActiveTheme);
|
||||||
|
Themes.ThemeManager.ApplyTheme(theme);
|
||||||
|
|
||||||
|
_mainWindow = new MainWindow(_app);
|
||||||
|
_commandHandler = new CommandHandler();
|
||||||
|
|
||||||
|
// Wire MainWindow events
|
||||||
|
_mainWindow.OnConnectRequested += HandleConnect;
|
||||||
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||||
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||||
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||||
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||||
|
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
||||||
|
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
||||||
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||||
|
|
||||||
|
// Wire CommandHandler events
|
||||||
|
WireCommandHandlerEvents();
|
||||||
|
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
|
||||||
|
_app.Run(_mainWindow);
|
||||||
|
_app.Dispose();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "EchoHub client crashed");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.Information("EchoHub client shutting down");
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void WireCommandHandlerEvents()
|
||||||
|
{
|
||||||
|
_commandHandler!.OnSetStatus += async (status, message) =>
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _connection.UpdateStatusAsync(status, message);
|
||||||
|
_currentStatus = status;
|
||||||
|
_currentStatusMessage = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnSetNick += async (displayName) =>
|
||||||
|
{
|
||||||
|
if (_apiClient is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName));
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.SetCurrentUser(displayName);
|
||||||
|
_mainWindow.UpdateStatusBar("Connected");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnSetColor += async (color) =>
|
||||||
|
{
|
||||||
|
if (_apiClient is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color));
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnSetTheme += (name) =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() => HandleThemeSelected(name));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnSendFile += async (target) =>
|
||||||
|
{
|
||||||
|
if (_apiClient is null || _connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channel = _mainWindow!.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
// Download from URL then upload
|
||||||
|
Log.Information("Downloading file from {Url}", target);
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
using var response = await httpClient.GetAsync(uri);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
{
|
||||||
|
// Try to infer extension from Content-Type
|
||||||
|
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
|
||||||
|
var ext = contentType switch
|
||||||
|
{
|
||||||
|
"image/png" => ".png",
|
||||||
|
"image/jpeg" => ".jpg",
|
||||||
|
"image/gif" => ".gif",
|
||||||
|
"image/webp" => ".webp",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
fileName = $"download{ext}";
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
await _apiClient.UploadFileAsync(channel, stream, fileName);
|
||||||
|
Log.Information("URL file uploaded: {FileName}", fileName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Local file
|
||||||
|
await using var stream = File.OpenRead(target);
|
||||||
|
var fileName = Path.GetFileName(target);
|
||||||
|
await _apiClient.UploadFileAsync(channel, stream, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "File upload failed for {Target}", target);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"File upload failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnOpenProfile += () =>
|
||||||
|
{
|
||||||
|
_app.Invoke(HandleProfileRequested);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnOpenServers += () =>
|
||||||
|
{
|
||||||
|
_app.Invoke(HandleSavedServersRequested);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnJoinChannel += async (channelName) =>
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var history = await _connection.JoinChannelAsync(channelName);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.SwitchToChannel(channelName);
|
||||||
|
if (history.Count > 0)
|
||||||
|
_mainWindow.LoadHistory(channelName, history);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Failed to join channel: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnLeaveChannel += async () =>
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channel = _mainWindow!.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connection.LeaveChannelAsync(channel);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.AddSystemMessage(channel, $"You left #{channel}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Failed to leave channel: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnSetTopic += async (topic) =>
|
||||||
|
{
|
||||||
|
// Topic setting would go through an API endpoint if available.
|
||||||
|
// For now, show as a system message.
|
||||||
|
await Task.CompletedTask;
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
var channel = _mainWindow!.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
_mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnListUsers += async () =>
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channel = _mainWindow!.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = await _connection.GetOnlineUsersAsync(channel);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.AddSystemMessage(channel, $"Online users in #{channel}:");
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var displayName = user.DisplayName ?? user.Username;
|
||||||
|
var statusText = user.Status.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(user.StatusMessage))
|
||||||
|
statusText += $" - {user.StatusMessage}";
|
||||||
|
_mainWindow.AddSystemMessage(channel, $" {displayName} ({statusText})");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Failed to list users: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnQuit += () =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() => _app.RequestStop());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
// OnHelp is handled by CommandHandler returning help text — no additional wiring needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MainWindow Event Handlers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void HandleConnect()
|
||||||
|
{
|
||||||
|
var result = ConnectDialog.Show(_app, _config.SavedServers);
|
||||||
|
if (result is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Log.Information("Connecting to {Url} as {User} (register={IsRegister})",
|
||||||
|
result.ServerUrl, result.Username, result.IsRegister);
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = new ApiClient(result.ServerUrl);
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.UpdateStatusBar("Authenticating..."));
|
||||||
|
|
||||||
|
LoginResponse loginResponse;
|
||||||
|
if (result.IsRegister)
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentUsername = loginResponse.Username;
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
|
||||||
|
_mainWindow.UpdateStatusBar("Authenticated, connecting...");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispose previous connection if any
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_connection = new EchoHubConnection(result.ServerUrl, _apiClient.Token!);
|
||||||
|
WireConnectionEvents(_connection);
|
||||||
|
|
||||||
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
// Load channels
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.SetChannels(channels);
|
||||||
|
_mainWindow.UpdateStatusBar("Connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join the default channel
|
||||||
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.SwitchToChannel(HubConstants.DefaultChannel));
|
||||||
|
|
||||||
|
// Load history for default channel
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.LoadHistory(HubConstants.DefaultChannel, history);
|
||||||
|
_mainWindow.FocusInput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// History might not be available, that is okay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save server to config on successful connection
|
||||||
|
var savedServer = new SavedServer
|
||||||
|
{
|
||||||
|
Name = new Uri(result.ServerUrl).Host,
|
||||||
|
Url = result.ServerUrl,
|
||||||
|
Username = result.Username,
|
||||||
|
Token = _apiClient.Token,
|
||||||
|
LastConnected = DateTimeOffset.Now
|
||||||
|
};
|
||||||
|
ConfigManager.SaveServer(savedServer);
|
||||||
|
_config = ConfigManager.Load();
|
||||||
|
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Connection failed to {Url}", result.ServerUrl);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.ShowError($"Connection failed: {ex.Message}");
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleDisconnect()
|
||||||
|
{
|
||||||
|
Log.Information("Disconnecting from server");
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
await _connection.DisconnectAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.ClearAll();
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Disconnect error: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleMessageSubmitted(string channelName, string content)
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
{
|
||||||
|
_mainWindow!.ShowError("Not connected to a server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a command
|
||||||
|
if (_commandHandler!.IsCommand(content))
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _commandHandler.HandleAsync(content);
|
||||||
|
if (result.Message is not null)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (result.IsError)
|
||||||
|
_mainWindow!.ShowError(result.Message);
|
||||||
|
else
|
||||||
|
_mainWindow!.AddSystemMessage(channelName, result.Message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Command failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connection.SendMessageAsync(channelName, content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Send failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleChannelSelected(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null || !_connection.IsConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connection.JoinChannelAsync(channelName);
|
||||||
|
|
||||||
|
// Load history if the channel has no messages cached yet
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var history = await _connection.GetHistoryAsync(channelName);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.LoadHistory(channelName, history));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// History might not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Failed to join channel: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleProfileRequested()
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
UserProfileDto? profile = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_apiClient is not null && !string.IsNullOrEmpty(_currentUsername))
|
||||||
|
{
|
||||||
|
profile = await _apiClient.GetUserProfileAsync(_currentUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Profile may not be available; continue with null
|
||||||
|
}
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
var action = UserPanelDialog.Show(_app,
|
||||||
|
profile,
|
||||||
|
_config.SavedServers,
|
||||||
|
_currentStatus,
|
||||||
|
_currentStatusMessage);
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case UserPanelAction.EditProfile:
|
||||||
|
HandleEditProfile(profile);
|
||||||
|
break;
|
||||||
|
case UserPanelAction.SetStatus:
|
||||||
|
HandleStatusRequested();
|
||||||
|
break;
|
||||||
|
case UserPanelAction.Close:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleEditProfile(UserProfileDto? currentProfile)
|
||||||
|
{
|
||||||
|
var editResult = ProfileEditDialog.Show(_app,
|
||||||
|
currentProfile?.DisplayName,
|
||||||
|
currentProfile?.Bio,
|
||||||
|
currentProfile?.NicknameColor);
|
||||||
|
|
||||||
|
if (editResult is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_apiClient is not null)
|
||||||
|
{
|
||||||
|
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(
|
||||||
|
editResult.DisplayName,
|
||||||
|
editResult.Bio,
|
||||||
|
editResult.NicknameColor));
|
||||||
|
|
||||||
|
if (editResult.DisplayName is not null)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.SetCurrentUser(editResult.DisplayName);
|
||||||
|
_mainWindow.UpdateStatusBar("Connected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local config preset
|
||||||
|
_config.DefaultPreset = new AccountPreset
|
||||||
|
{
|
||||||
|
DisplayName = editResult.DisplayName,
|
||||||
|
Bio = editResult.Bio,
|
||||||
|
NicknameColor = editResult.NicknameColor
|
||||||
|
};
|
||||||
|
ConfigManager.Save(_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Profile update failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleStatusRequested()
|
||||||
|
{
|
||||||
|
var result = StatusDialog.Show(_app, _currentStatus, _currentStatusMessage);
|
||||||
|
if (result is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_currentStatus = result.Status;
|
||||||
|
_currentStatusMessage = result.StatusMessage;
|
||||||
|
|
||||||
|
if (_connection is not null && _connection.IsConnected)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connection.UpdateStatusAsync(result.Status, result.StatusMessage);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Status update failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleThemeSelected(string themeName)
|
||||||
|
{
|
||||||
|
Log.Information("Theme selected: {Theme}", themeName);
|
||||||
|
|
||||||
|
var theme = Themes.ThemeManager.GetTheme(themeName);
|
||||||
|
Themes.ThemeManager.ApplyTheme(theme);
|
||||||
|
|
||||||
|
_config.ActiveTheme = themeName;
|
||||||
|
ConfigManager.Save(_config);
|
||||||
|
|
||||||
|
// Defer UI refresh so the menu finishes processing its click first
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindow!.ApplyColorSchemes();
|
||||||
|
_mainWindow.SetNeedsDraw();
|
||||||
|
Log.Debug("Theme applied and UI refreshed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleSavedServersRequested()
|
||||||
|
{
|
||||||
|
if (_config.SavedServers.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Query(_app, "Saved Servers", "No saved servers yet.\nConnect to a server to save it automatically.", "OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverLines = _config.SavedServers
|
||||||
|
.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var message = string.Join("\n", serverLines);
|
||||||
|
MessageBox.Query(_app, "Saved Servers", message, "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection Event Wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void WireConnectionEvents(EchoHubConnection connection)
|
||||||
|
{
|
||||||
|
connection.OnMessageReceived += message =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.AddMessage(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserJoined += (channelName, username) =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserLeft += (channelName, username) =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserStatusChanged += presence =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
var displayName = presence.DisplayName ?? presence.Username;
|
||||||
|
var statusText = presence.Status.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(presence.StatusMessage))
|
||||||
|
statusText += $" - {presence.StatusMessage}";
|
||||||
|
|
||||||
|
// Show status change in all active channels
|
||||||
|
foreach (var channelName in _mainWindow!.GetChannelNames())
|
||||||
|
{
|
||||||
|
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnError += errorMessage =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError(errorMessage));
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnConnectionStateChanged += status =>
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.UpdateStatusBar(status));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public sealed class ApiClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private string? _token;
|
||||||
|
|
||||||
|
public string? Token => _token;
|
||||||
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
public ApiClient(string baseUrl)
|
||||||
|
{
|
||||||
|
BaseUrl = baseUrl.TrimEnd('/');
|
||||||
|
_http = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(BaseUrl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
||||||
|
{
|
||||||
|
var request = new RegisterRequest(username, password, displayName);
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
|
?? throw new InvalidOperationException("Registration returned empty response.");
|
||||||
|
|
||||||
|
SetToken(result.Token);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||||
|
{
|
||||||
|
var request = new LoginRequest(username, password);
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
|
?? throw new InvalidOperationException("Login returned empty response.");
|
||||||
|
|
||||||
|
SetToken(result.Token);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var channels = await _http.GetFromJsonAsync<List<ChannelDto>>("/api/channels");
|
||||||
|
return channels ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ServerStatusDto?> GetServerInfoAsync()
|
||||||
|
{
|
||||||
|
var info = await _http.GetFromJsonAsync<ServerStatusDto>("/api/server/info");
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var profile = await _http.GetFromJsonAsync<UserProfileDto>($"/api/users/{Uri.EscapeDataString(username)}/profile");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await _http.PutAsJsonAsync("/api/users/profile", request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> UploadAvatarAsync(Stream imageStream, string fileName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
using var streamContent = new StreamContent(imageStream);
|
||||||
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
|
var response = await _http.PostAsync("/api/users/avatar", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||||
|
return result?.AsciiArt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
using var streamContent = new StreamContent(fileStream);
|
||||||
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
|
var response = await _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetToken(string token)
|
||||||
|
{
|
||||||
|
_token = token;
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Try to extract a meaningful error message from the response body
|
||||||
|
var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
// Try to parse {"error": "..."} format
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (doc.RootElement.TryGetProperty("error", out var errorProp) ||
|
||||||
|
doc.RootElement.TryGetProperty("Error", out errorProp))
|
||||||
|
{
|
||||||
|
errorMessage = errorProp.GetString() ?? errorMessage;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errorMessage = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If we can't parse the body, use the status code message
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpRequestException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAuthenticated()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_token))
|
||||||
|
throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetContentType(string fileName)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".txt" => "text/plain",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal record AvatarUploadResponse(string AsciiArt);
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly HubConnection _connection;
|
||||||
|
|
||||||
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
|
public event Action<string, string>? OnUserJoined;
|
||||||
|
public event Action<string, string>? OnUserLeft;
|
||||||
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
|
public event Action<string>? OnError;
|
||||||
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
|
|
||||||
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
|
public EchoHubConnection(string serverUrl, string jwtToken)
|
||||||
|
{
|
||||||
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
|
_connection = new HubConnectionBuilder()
|
||||||
|
.WithUrl(hubUrl, options =>
|
||||||
|
{
|
||||||
|
options.AccessTokenProvider = () => Task.FromResult<string?>(jwtToken);
|
||||||
|
})
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
RegisterHandlers();
|
||||||
|
|
||||||
|
_connection.Reconnecting += _ =>
|
||||||
|
{
|
||||||
|
OnConnectionStateChanged?.Invoke("Reconnecting...");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_connection.Reconnected += _ =>
|
||||||
|
{
|
||||||
|
OnConnectionStateChanged?.Invoke("Connected");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
_connection.Closed += _ =>
|
||||||
|
{
|
||||||
|
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterHandlers()
|
||||||
|
{
|
||||||
|
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||||
|
{
|
||||||
|
OnMessageReceived?.Invoke(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
||||||
|
{
|
||||||
|
OnUserJoined?.Invoke(channelName, username);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
||||||
|
{
|
||||||
|
OnUserLeft?.Invoke(channelName, username);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<ChannelDto>(nameof(Core.Contracts.IEchoHubClient.ChannelUpdated), channel =>
|
||||||
|
{
|
||||||
|
OnChannelUpdated?.Invoke(channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<UserPresenceDto>(nameof(Core.Contracts.IEchoHubClient.UserStatusChanged), presence =>
|
||||||
|
{
|
||||||
|
OnUserStatusChanged?.Invoke(presence);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
||||||
|
{
|
||||||
|
OnError?.Invoke(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync()
|
||||||
|
{
|
||||||
|
OnConnectionStateChanged?.Invoke("Connecting...");
|
||||||
|
await _connection.StartAsync();
|
||||||
|
OnConnectionStateChanged?.Invoke("Connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
await _connection.StopAsync();
|
||||||
|
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
await _connection.InvokeAsync("LeaveChannel", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessageAsync(string channelName, string content)
|
||||||
|
{
|
||||||
|
await _connection.InvokeAsync("SendMessage", channelName, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
|
{
|
||||||
|
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
||||||
|
{
|
||||||
|
await _connection.InvokeAsync("UpdateStatus", status, statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
||||||
|
{
|
||||||
|
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace EchoHub.Client.Themes;
|
||||||
|
|
||||||
|
public class Theme
|
||||||
|
{
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ThemeColors Base { get; set; } = new();
|
||||||
|
public ThemeColors Menu { get; set; } = new();
|
||||||
|
public ThemeColors Dialog { get; set; } = new();
|
||||||
|
public ThemeColors Status { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ThemeColors
|
||||||
|
{
|
||||||
|
public string Foreground { get; set; } = "White";
|
||||||
|
public string Background { get; set; } = "Black";
|
||||||
|
public string FocusForeground { get; set; } = "White";
|
||||||
|
public string FocusBackground { get; set; } = "Blue";
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Configuration;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Themes;
|
||||||
|
|
||||||
|
public static class ThemeManager
|
||||||
|
{
|
||||||
|
private static readonly string ThemeDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "themes");
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme DefaultTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Default",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Gray",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Gray",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "Gray"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme ClassicTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Classic",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Blue",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "Cyan"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "Cyan"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme LightTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Light",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Black",
|
||||||
|
Background = "White",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "Blue"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Blue",
|
||||||
|
FocusForeground = "BrightYellow",
|
||||||
|
FocusBackground = "Blue"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Black",
|
||||||
|
Background = "White",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "Blue"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Blue",
|
||||||
|
FocusForeground = "White",
|
||||||
|
FocusBackground = "Blue"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme HackerTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Hacker",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightGreen",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "Green"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightGreen",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "BrightGreen"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightGreen",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "Black",
|
||||||
|
FocusBackground = "Green"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightGreen",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "BrightGreen",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme SolarizedTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Solarized",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Cyan",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "BrightYellow",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightCyan",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "BrightYellow",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Cyan",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "BrightYellow",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "BrightCyan",
|
||||||
|
Background = "DarkGray",
|
||||||
|
FocusForeground = "BrightCyan",
|
||||||
|
FocusBackground = "DarkGray"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Theme TransparentTheme = new()
|
||||||
|
{
|
||||||
|
Name = "Transparent",
|
||||||
|
Base = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "BrightCyan",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Menu = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "BrightCyan",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Dialog = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "White",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "BrightCyan",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
},
|
||||||
|
Status = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "Gray",
|
||||||
|
Background = "Black",
|
||||||
|
FocusForeground = "Gray",
|
||||||
|
FocusBackground = "Black"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly List<Theme> BuiltInThemes =
|
||||||
|
[
|
||||||
|
DefaultTheme,
|
||||||
|
TransparentTheme,
|
||||||
|
ClassicTheme,
|
||||||
|
LightTheme,
|
||||||
|
HackerTheme,
|
||||||
|
SolarizedTheme
|
||||||
|
];
|
||||||
|
|
||||||
|
public static List<Theme> GetAvailableThemes()
|
||||||
|
{
|
||||||
|
var themes = new List<Theme>(BuiltInThemes);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(ThemeDir))
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.GetFiles(ThemeDir, "*.json"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(file);
|
||||||
|
var theme = JsonSerializer.Deserialize<Theme>(json, JsonOptions);
|
||||||
|
if (theme is not null && !string.IsNullOrWhiteSpace(theme.Name))
|
||||||
|
{
|
||||||
|
// Skip if a built-in theme already has this name
|
||||||
|
if (!themes.Exists(t => string.Equals(t.Name, theme.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
themes.Add(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip malformed theme files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If we can't read the theme directory, just return built-ins
|
||||||
|
}
|
||||||
|
|
||||||
|
return themes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Theme GetTheme(string name)
|
||||||
|
{
|
||||||
|
var themes = GetAvailableThemes();
|
||||||
|
return themes.Find(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? DefaultTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplyTheme(Theme theme)
|
||||||
|
{
|
||||||
|
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
|
||||||
|
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
||||||
|
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveTheme(Theme theme)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ThemeDir);
|
||||||
|
var fileName = $"{theme.Name}.json";
|
||||||
|
var filePath = Path.Combine(ThemeDir, fileName);
|
||||||
|
var json = JsonSerializer.Serialize(theme, JsonOptions);
|
||||||
|
File.WriteAllText(filePath, json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail — theme save is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Scheme BuildColorScheme(ThemeColors colors)
|
||||||
|
{
|
||||||
|
var normal = new Attribute(ParseColor(colors.Foreground), ParseColor(colors.Background));
|
||||||
|
var focus = new Attribute(ParseColor(colors.FocusForeground), ParseColor(colors.FocusBackground));
|
||||||
|
|
||||||
|
return new Scheme
|
||||||
|
{
|
||||||
|
Normal = normal,
|
||||||
|
Focus = focus,
|
||||||
|
HotNormal = normal,
|
||||||
|
HotFocus = focus,
|
||||||
|
Disabled = normal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ParseColor(string colorName)
|
||||||
|
{
|
||||||
|
if (Color.TryParse(colorName, out var color))
|
||||||
|
return color ?? Color.White;
|
||||||
|
|
||||||
|
return Color.White;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using EchoHub.Client.Config;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result returned from the connect dialog.
|
||||||
|
/// </summary>
|
||||||
|
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
|
/// Includes a saved servers selector when saved servers are available.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConnectDialog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the connect dialog with an optional list of saved servers.
|
||||||
|
/// Returns the result, or null if cancelled.
|
||||||
|
/// </summary>
|
||||||
|
public static ConnectDialogResult? Show(IApplication app, List<SavedServer>? savedServers = null)
|
||||||
|
{
|
||||||
|
ConnectDialogResult? result = null;
|
||||||
|
savedServers ??= [];
|
||||||
|
|
||||||
|
var hasSavedServers = savedServers.Count > 0;
|
||||||
|
var dialogHeight = hasSavedServers ? 20 : 16;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
|
int yOffset = 0;
|
||||||
|
|
||||||
|
// -- Saved Servers section (if any) -----------------------------------
|
||||||
|
ListView? savedServerList = null;
|
||||||
|
if (hasSavedServers)
|
||||||
|
{
|
||||||
|
var savedLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Saved Servers:",
|
||||||
|
X = 1,
|
||||||
|
Y = 1
|
||||||
|
};
|
||||||
|
dialog.Add(savedLabel);
|
||||||
|
|
||||||
|
var serverDisplayNames = savedServers
|
||||||
|
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
savedServerList = new ListView
|
||||||
|
{
|
||||||
|
Source = new ListWrapper<string>(new ObservableCollection<string>(serverDisplayNames)),
|
||||||
|
X = 1,
|
||||||
|
Y = 2,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 3
|
||||||
|
};
|
||||||
|
dialog.Add(savedServerList);
|
||||||
|
|
||||||
|
// Visual separator
|
||||||
|
var separator = new Label
|
||||||
|
{
|
||||||
|
Text = new string('-', 56),
|
||||||
|
X = 1,
|
||||||
|
Y = 5
|
||||||
|
};
|
||||||
|
dialog.Add(separator);
|
||||||
|
|
||||||
|
yOffset = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Manual entry fields ----------------------------------------------
|
||||||
|
var urlLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Server URL:",
|
||||||
|
X = 1,
|
||||||
|
Y = yOffset + 1
|
||||||
|
};
|
||||||
|
var urlField = new TextField
|
||||||
|
{
|
||||||
|
Text = "http://localhost:5000",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var userLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Username:",
|
||||||
|
X = 1,
|
||||||
|
Y = yOffset + 3
|
||||||
|
};
|
||||||
|
var userField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 3,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var passLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Password:",
|
||||||
|
X = 1,
|
||||||
|
Y = yOffset + 5
|
||||||
|
};
|
||||||
|
var passField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 5,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Secret = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var displayLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Display Name:",
|
||||||
|
X = 1,
|
||||||
|
Y = yOffset + 7
|
||||||
|
};
|
||||||
|
var displayField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 7,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var loginButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Login",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 20,
|
||||||
|
Y = yOffset + 9
|
||||||
|
};
|
||||||
|
|
||||||
|
var registerButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Register",
|
||||||
|
X = Pos.Center() - 5,
|
||||||
|
Y = yOffset + 9
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 10,
|
||||||
|
Y = yOffset + 9
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire saved server selection to auto-fill fields
|
||||||
|
if (savedServerList is not null && savedServers.Count > 0)
|
||||||
|
{
|
||||||
|
savedServerList.ValueChanged += (sender, e) =>
|
||||||
|
{
|
||||||
|
var index = e.NewValue;
|
||||||
|
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||||
|
{
|
||||||
|
var server = savedServers[index.Value];
|
||||||
|
urlField.Text = server.Url;
|
||||||
|
userField.Text = server.Username ?? "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-fill with the first saved server
|
||||||
|
urlField.Text = savedServers[0].Url;
|
||||||
|
userField.Text = savedServers[0].Username ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
loginButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||||
|
displayLabel, displayField, loginButton, registerButton, cancelButton);
|
||||||
|
|
||||||
|
if (hasSavedServers && savedServerList is not null)
|
||||||
|
savedServerList.SetFocus();
|
||||||
|
else
|
||||||
|
urlField.SetFocus();
|
||||||
|
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using EchoHub.Client.Themes;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Configuration;
|
||||||
|
using Terminal.Gui.Input;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly IApplication _app;
|
||||||
|
private readonly ListView _channelList;
|
||||||
|
private readonly ListView _messageList;
|
||||||
|
private readonly TextView _inputField;
|
||||||
|
private readonly FrameView _chatFrame;
|
||||||
|
private readonly Label _statusLabel;
|
||||||
|
private MenuBar _menuBar;
|
||||||
|
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, List<string>> _channelMessages = [];
|
||||||
|
private string _currentChannel = string.Empty;
|
||||||
|
private string _currentUser = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user selects a channel. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnChannelSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user presses Enter in the input field. Parameters: channel name, message content.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnMessageSubmitted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to connect via the menu.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnConnectRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to disconnect via the menu.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnDisconnectRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to open their profile panel.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnProfileRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to set their status.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnStatusRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user selects a theme from the menu. Parameter is the theme name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnThemeSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to view saved servers.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnSavedServersRequested;
|
||||||
|
|
||||||
|
public MainWindow(IApplication app)
|
||||||
|
{
|
||||||
|
_app = app;
|
||||||
|
Title = "EchoHub";
|
||||||
|
BorderStyle = LineStyle.None;
|
||||||
|
|
||||||
|
// Menu bar at the top
|
||||||
|
_menuBar = BuildMenuBar();
|
||||||
|
Add(_menuBar);
|
||||||
|
|
||||||
|
// Left panel - channels
|
||||||
|
var channelsFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Channels",
|
||||||
|
X = 0,
|
||||||
|
Y = 1, // below menu bar
|
||||||
|
Width = 25,
|
||||||
|
Height = Dim.Fill(1) // leave room for status bar
|
||||||
|
};
|
||||||
|
|
||||||
|
_channelList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill()
|
||||||
|
};
|
||||||
|
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||||
|
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
||||||
|
channelsFrame.Add(_channelList);
|
||||||
|
Add(channelsFrame);
|
||||||
|
|
||||||
|
// Center panel - messages
|
||||||
|
_chatFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Chat",
|
||||||
|
X = 25,
|
||||||
|
Y = 1, // below menu bar
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||||
|
};
|
||||||
|
|
||||||
|
_messageList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill()
|
||||||
|
};
|
||||||
|
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
||||||
|
_chatFrame.Add(_messageList);
|
||||||
|
Add(_chatFrame);
|
||||||
|
|
||||||
|
// Bottom input area (multiline)
|
||||||
|
var inputFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Message (Enter=send, Shift+Enter=newline)",
|
||||||
|
X = 25,
|
||||||
|
Y = Pos.Bottom(_chatFrame),
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
_inputField = new TextView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill(),
|
||||||
|
Text = "",
|
||||||
|
WordWrap = true
|
||||||
|
};
|
||||||
|
_inputField.KeyDown += OnInputKeyDown;
|
||||||
|
inputFrame.Add(_inputField);
|
||||||
|
Add(inputFrame);
|
||||||
|
|
||||||
|
// Status bar at the very bottom
|
||||||
|
_statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Disconnected",
|
||||||
|
X = 0,
|
||||||
|
Y = Pos.AnchorEnd(1),
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = 1
|
||||||
|
};
|
||||||
|
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
||||||
|
Add(_statusLabel);
|
||||||
|
|
||||||
|
// Apply our custom color schemes to all views
|
||||||
|
ApplyColorSchemes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the currently registered color schemes to all views.
|
||||||
|
/// Call after theme changes to refresh colors.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyColorSchemes()
|
||||||
|
{
|
||||||
|
var baseScheme = SchemeManager.GetScheme("Base");
|
||||||
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||||
|
|
||||||
|
if (baseScheme is not null)
|
||||||
|
{
|
||||||
|
this.SetScheme(baseScheme);
|
||||||
|
|
||||||
|
// Propagate to all child views that should use the base scheme
|
||||||
|
foreach (var sub in SubViews)
|
||||||
|
{
|
||||||
|
if (sub != _menuBar && sub != _statusLabel)
|
||||||
|
sub.SetScheme(baseScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuScheme is not null)
|
||||||
|
{
|
||||||
|
_menuBar.SetScheme(menuScheme);
|
||||||
|
_statusLabel.SetScheme(menuScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the menu bar with File, Server, User menus and a theme submenu.
|
||||||
|
/// </summary>
|
||||||
|
private MenuBar BuildMenuBar()
|
||||||
|
{
|
||||||
|
// Build theme menu items and prepend them with a separator header
|
||||||
|
var themeItems = new List<MenuItem>();
|
||||||
|
foreach (var t in Themes.ThemeManager.GetAvailableThemes())
|
||||||
|
{
|
||||||
|
var name = t.Name;
|
||||||
|
themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine user items with theme items, separated by a line
|
||||||
|
var userMenuChildren = new MenuItem[]
|
||||||
|
{
|
||||||
|
new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty),
|
||||||
|
new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge: user items + separator + theme items
|
||||||
|
var allUserItems = new MenuItem[userMenuChildren.Length + 1 + themeItems.Count];
|
||||||
|
userMenuChildren.CopyTo(allUserItems, 0);
|
||||||
|
allUserItems[userMenuChildren.Length] = null!; // null separator
|
||||||
|
for (int i = 0; i < themeItems.Count; i++)
|
||||||
|
allUserItems[userMenuChildren.Length + 1 + i] = themeItems[i];
|
||||||
|
|
||||||
|
return new MenuBar(
|
||||||
|
[
|
||||||
|
new MenuBarItem("_File",
|
||||||
|
[
|
||||||
|
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
|
||||||
|
]),
|
||||||
|
new MenuBarItem("_Server",
|
||||||
|
[
|
||||||
|
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||||
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||||
|
null!,
|
||||||
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
||||||
|
]),
|
||||||
|
new MenuBarItem("_User", allUserItems)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds and replaces the menu bar (e.g., after theme list changes).
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshMenuBar()
|
||||||
|
{
|
||||||
|
Remove(_menuBar);
|
||||||
|
_menuBar = BuildMenuBar();
|
||||||
|
Add(_menuBar);
|
||||||
|
ApplyColorSchemes();
|
||||||
|
SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs<int?> e)
|
||||||
|
{
|
||||||
|
var index = e.NewValue;
|
||||||
|
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
||||||
|
{
|
||||||
|
var channelName = _channelNames[index.Value];
|
||||||
|
if (channelName != _currentChannel)
|
||||||
|
{
|
||||||
|
SwitchToChannel(channelName);
|
||||||
|
OnChannelSelected?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInputKeyDown(object? sender, Key e)
|
||||||
|
{
|
||||||
|
if (e == Key.Enter.WithShift)
|
||||||
|
{
|
||||||
|
// Shift+Enter: let TextView handle it (inserts newline)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e == Key.Enter)
|
||||||
|
{
|
||||||
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
|
||||||
|
{
|
||||||
|
OnMessageSubmitted?.Invoke(_currentChannel, text);
|
||||||
|
_inputField.Text = string.Empty;
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a message to the specified channel's message list and refresh if it is the current channel.
|
||||||
|
/// </summary>
|
||||||
|
public void AddMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var lines = FormatMessage(message);
|
||||||
|
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[message.ChannelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
messages.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ChannelName == _currentChannel)
|
||||||
|
{
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a system/informational message to a channel.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSystemMessage(string channelName, string text)
|
||||||
|
{
|
||||||
|
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}";
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
messages.Add(formatted);
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
{
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a status change message to a channel.
|
||||||
|
/// </summary>
|
||||||
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
|
{
|
||||||
|
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}";
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
messages.Add(formatted);
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
{
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the list of available channels and refresh the channel list view.
|
||||||
|
/// </summary>
|
||||||
|
public void SetChannels(List<ChannelDto> channels)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
foreach (var ch in channels)
|
||||||
|
{
|
||||||
|
_channelNames.Add(ch.Name);
|
||||||
|
if (!_channelMessages.ContainsKey(ch.Name))
|
||||||
|
_channelMessages[ch.Name] = [];
|
||||||
|
}
|
||||||
|
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show an error message to the user.
|
||||||
|
/// </summary>
|
||||||
|
public void ShowError(string message)
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "Error", message, "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the connection status displayed in the status bar.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateStatusBar(string status)
|
||||||
|
{
|
||||||
|
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
|
||||||
|
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
|
||||||
|
_statusLabel.Text = $" {status}{userPart}{channelPart}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the current user name for display in the status bar.
|
||||||
|
/// </summary>
|
||||||
|
public void SetCurrentUser(string username)
|
||||||
|
{
|
||||||
|
_currentUser = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the current channel name.
|
||||||
|
/// </summary>
|
||||||
|
public string CurrentChannel => _currentChannel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all channel names that have message buffers (for broadcasting status changes).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switch the chat view to the given channel.
|
||||||
|
/// </summary>
|
||||||
|
public void SwitchToChannel(string channelName)
|
||||||
|
{
|
||||||
|
_currentChannel = channelName;
|
||||||
|
_chatFrame.Title = $"#{channelName}";
|
||||||
|
RefreshMessages();
|
||||||
|
|
||||||
|
// Update channel list selection
|
||||||
|
var idx = _channelNames.IndexOf(channelName);
|
||||||
|
if (idx >= 0)
|
||||||
|
_channelList.SelectedItem = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load historical messages into a channel (prepend).
|
||||||
|
/// </summary>
|
||||||
|
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||||
|
{
|
||||||
|
existing = [];
|
||||||
|
_channelMessages[channelName] = existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatted = messages.SelectMany(FormatMessage).ToList();
|
||||||
|
existing.InsertRange(0, formatted);
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
{
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages and channels (used on disconnect).
|
||||||
|
/// </summary>
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelMessages.Clear();
|
||||||
|
_currentChannel = string.Empty;
|
||||||
|
_currentUser = string.Empty;
|
||||||
|
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||||
|
_chatFrame.Title = "Chat";
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Focus the input field for typing.
|
||||||
|
/// </summary>
|
||||||
|
public void FocusInput()
|
||||||
|
{
|
||||||
|
_inputField.SetFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshMessages()
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
||||||
|
{
|
||||||
|
_messageList.SetSource(new ObservableCollection<string>(messages));
|
||||||
|
if (messages.Count > 0)
|
||||||
|
_messageList.SelectedItem = messages.Count - 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> FormatMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
|
var sender = message.SenderNicknameColor is not null
|
||||||
|
? $"<{message.SenderUsername}>"
|
||||||
|
: message.SenderUsername + ":";
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
switch (message.Type)
|
||||||
|
{
|
||||||
|
case MessageType.Image:
|
||||||
|
lines.Add($"[{time}] {sender} [Image]");
|
||||||
|
// Content IS the ASCII art — add each line as a separate list item
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
{
|
||||||
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
|
{
|
||||||
|
lines.Add($" {artLine}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.File:
|
||||||
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
||||||
|
lines.Add($"[{time}] {sender} [File: {fileName}]{fileContent}");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Text:
|
||||||
|
default:
|
||||||
|
var contentLines = message.Content.Split('\n');
|
||||||
|
lines.Add($"[{time}] {sender} {contentLines[0]}");
|
||||||
|
// Continuation lines indented to align with first line's content
|
||||||
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
{
|
||||||
|
lines.Add($" {contentLines[i]}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result returned from the profile edit dialog.
|
||||||
|
/// </summary>
|
||||||
|
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProfileEditDialog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
||||||
|
/// </summary>
|
||||||
|
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
|
||||||
|
{
|
||||||
|
ProfileEditResult? result = null;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
||||||
|
|
||||||
|
// Display Name
|
||||||
|
var nameLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Display Name:",
|
||||||
|
X = 1,
|
||||||
|
Y = 1
|
||||||
|
};
|
||||||
|
var nameField = new TextField
|
||||||
|
{
|
||||||
|
Text = currentDisplayName ?? "",
|
||||||
|
X = 17,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bio
|
||||||
|
var bioLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Bio:",
|
||||||
|
X = 1,
|
||||||
|
Y = 3
|
||||||
|
};
|
||||||
|
var bioField = new TextField
|
||||||
|
{
|
||||||
|
Text = currentBio ?? "",
|
||||||
|
X = 17,
|
||||||
|
Y = 3,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nickname Color
|
||||||
|
var colorLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Nickname Color:",
|
||||||
|
X = 1,
|
||||||
|
Y = 5
|
||||||
|
};
|
||||||
|
var colorField = new TextField
|
||||||
|
{
|
||||||
|
Text = currentColor ?? "",
|
||||||
|
X = 17,
|
||||||
|
Y = 5,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var colorHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(hex e.g. #FF5733)",
|
||||||
|
X = 17,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
colorHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color Preview
|
||||||
|
var previewLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Preview:",
|
||||||
|
X = 1,
|
||||||
|
Y = 8
|
||||||
|
};
|
||||||
|
var colorPreview = new Label
|
||||||
|
{
|
||||||
|
Text = "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||||
|
X = 17,
|
||||||
|
Y = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
|
|
||||||
|
colorField.TextChanged += (sender, e) =>
|
||||||
|
{
|
||||||
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
var saveButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Save",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 10,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 5,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
saveButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||||
|
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||||
|
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||||
|
|
||||||
|
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||||
|
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
||||||
|
|
||||||
|
nameField.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a hex color string and update the preview label color.
|
||||||
|
/// </summary>
|
||||||
|
private static void UpdateColorPreview(Label preview, string? hexColor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hexColor))
|
||||||
|
{
|
||||||
|
preview.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.White, Color.Blue)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = ParseHexToTrueColor(hexColor.Trim());
|
||||||
|
preview.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(color, Color.Blue)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
|
||||||
|
/// V2 supports TrueColor via new Color(r, g, b).
|
||||||
|
/// </summary>
|
||||||
|
private static Color ParseHexToTrueColor(string hex)
|
||||||
|
{
|
||||||
|
if (hex.StartsWith('#'))
|
||||||
|
hex = hex[1..];
|
||||||
|
|
||||||
|
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
|
||||||
|
return Color.White;
|
||||||
|
|
||||||
|
int r = (rgb >> 16) & 0xFF;
|
||||||
|
int g = (rgb >> 8) & 0xFF;
|
||||||
|
int b = rgb & 0xFF;
|
||||||
|
|
||||||
|
return new Color(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result returned from the status dialog.
|
||||||
|
/// </summary>
|
||||||
|
public record StatusDialogResult(UserStatus Status, string? StatusMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Terminal.Gui dialog for setting the user's status and status message.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StatusDialog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the status dialog and returns the result, or null if cancelled.
|
||||||
|
/// </summary>
|
||||||
|
public static StatusDialogResult? Show(IApplication app, UserStatus currentStatus, string? currentMessage)
|
||||||
|
{
|
||||||
|
StatusDialogResult? result = null;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = "Set Status", Width = 50, Height = 12 };
|
||||||
|
|
||||||
|
var statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Status:",
|
||||||
|
X = 1,
|
||||||
|
Y = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var optionSelector = new OptionSelector<UserStatus>
|
||||||
|
{
|
||||||
|
X = 12,
|
||||||
|
Y = 1
|
||||||
|
};
|
||||||
|
optionSelector.Value = currentStatus;
|
||||||
|
|
||||||
|
var messageLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Message:",
|
||||||
|
X = 1,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
var messageField = new TextField
|
||||||
|
{
|
||||||
|
Text = currentMessage ?? "",
|
||||||
|
X = 12,
|
||||||
|
Y = 6,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var saveButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Save",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 10,
|
||||||
|
Y = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 5,
|
||||||
|
Y = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
saveButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
var status = optionSelector.Value ?? UserStatus.Online;
|
||||||
|
var message = messageField.Text?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
message = null;
|
||||||
|
|
||||||
|
result = new StatusDialogResult(status, message);
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(statusLabel, optionSelector, messageLabel, messageField, saveButton, cancelButton);
|
||||||
|
|
||||||
|
optionSelector.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
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), "...");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EchoHub.Core.Constants;
|
||||||
|
|
||||||
|
public static class HubConstants
|
||||||
|
{
|
||||||
|
public const string ChatHubPath = "/hubs/chat";
|
||||||
|
public const string DefaultChannel = "general";
|
||||||
|
public const int DefaultHistoryCount = 50;
|
||||||
|
public const int MaxMessageLength = 2000;
|
||||||
|
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
|
public const int AsciiArtWidth = 80;
|
||||||
|
public const int AsciiArtHeight = 40;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Methods the server can invoke on connected clients.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEchoHubClient
|
||||||
|
{
|
||||||
|
Task ReceiveMessage(MessageDto message);
|
||||||
|
Task UserJoined(string channelName, string username);
|
||||||
|
Task UserLeft(string channelName, string username);
|
||||||
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
Task Error(string message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record RegisterRequest(string Username, string Password, string? DisplayName = null);
|
||||||
|
|
||||||
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public record LoginResponse(string Token, string Username, string? DisplayName, string? NicknameColor);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record MessageDto(
|
||||||
|
Guid Id,
|
||||||
|
string Content,
|
||||||
|
string SenderUsername,
|
||||||
|
string? SenderNicknameColor,
|
||||||
|
string ChannelName,
|
||||||
|
MessageType Type,
|
||||||
|
string? AttachmentUrl,
|
||||||
|
string? AttachmentFileName,
|
||||||
|
DateTimeOffset SentAt);
|
||||||
|
|
||||||
|
public record ChannelDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Topic,
|
||||||
|
int MessageCount,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
public record UserDto(
|
||||||
|
Guid Id,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? NicknameColor,
|
||||||
|
UserStatus Status,
|
||||||
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
|
public record SendMessageRequest(string ChannelName, string Content);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record UserProfileDto(
|
||||||
|
Guid Id,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? NicknameColor,
|
||||||
|
string? AvatarAscii,
|
||||||
|
UserStatus Status,
|
||||||
|
string? StatusMessage,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
|
public record UpdateProfileRequest(
|
||||||
|
string? DisplayName = null,
|
||||||
|
string? Bio = null,
|
||||||
|
string? NicknameColor = null);
|
||||||
|
|
||||||
|
public record UpdateStatusRequest(
|
||||||
|
UserStatus Status,
|
||||||
|
string? StatusMessage = null);
|
||||||
|
|
||||||
|
public record UserPresenceDto(
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? NicknameColor,
|
||||||
|
UserStatus Status,
|
||||||
|
string? StatusMessage);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record ServerInfoDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string Url,
|
||||||
|
int OnlineUsers,
|
||||||
|
int TotalChannels,
|
||||||
|
bool IsOnline,
|
||||||
|
DateTimeOffset LastPingAt);
|
||||||
|
|
||||||
|
public record RegisterServerRequest(string Name, string Url, string? Description = null);
|
||||||
|
|
||||||
|
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class Channel
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? Topic { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
public List<Message> Messages { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class Message
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string Content { get; set; }
|
||||||
|
public MessageType Type { get; set; } = MessageType.Text;
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Guid ChannelId { get; set; }
|
||||||
|
public Channel? Channel { get; set; }
|
||||||
|
|
||||||
|
public Guid SenderUserId { get; set; }
|
||||||
|
public required string SenderUsername { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public enum MessageType
|
||||||
|
{
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
File
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class ServerInfo
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public int OnlineUsers { get; set; }
|
||||||
|
public int TotalChannels { get; set; }
|
||||||
|
public DateTimeOffset RegisteredAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset LastPingAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public bool IsOnline { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string Username { get; set; }
|
||||||
|
public required string PasswordHash { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
public string? NicknameColor { get; set; }
|
||||||
|
public string? AvatarAscii { get; set; }
|
||||||
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
|
public string? StatusMessage { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public enum UserStatus
|
||||||
|
{
|
||||||
|
Online,
|
||||||
|
Away,
|
||||||
|
DoNotDisturb,
|
||||||
|
Invisible
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Auth;
|
||||||
|
|
||||||
|
public class JwtTokenService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly string _secret = configuration["Jwt:Secret"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
|
||||||
|
private readonly string _issuer = configuration["Jwt:Issuer"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||||
|
private readonly string _audience = configuration["Jwt:Audience"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||||
|
|
||||||
|
public string GenerateToken(User user)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
Claim[] claims =
|
||||||
|
[
|
||||||
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
|
new("username", user.Username),
|
||||||
|
new("display_name", user.DisplayName ?? user.Username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
];
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _issuer,
|
||||||
|
audience: _audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddDays(7),
|
||||||
|
signingCredentials: credentials);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data;
|
||||||
|
|
||||||
|
public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
if (!optionsBuilder.IsConfigured)
|
||||||
|
{
|
||||||
|
optionsBuilder.UseSqlite("Data Source=echohub.db");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<User>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(u => u.Id);
|
||||||
|
entity.HasIndex(u => u.Username).IsUnique();
|
||||||
|
entity.Property(u => u.Username).IsRequired().HasMaxLength(50);
|
||||||
|
entity.Property(u => u.PasswordHash).IsRequired();
|
||||||
|
entity.Property(u => u.DisplayName).HasMaxLength(100);
|
||||||
|
entity.Property(u => u.Bio).HasMaxLength(500);
|
||||||
|
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
||||||
|
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
||||||
|
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Channel>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(c => c.Id);
|
||||||
|
entity.HasIndex(c => c.Name);
|
||||||
|
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
||||||
|
entity.Property(c => c.Topic).HasMaxLength(500);
|
||||||
|
|
||||||
|
entity.HasMany(c => c.Messages)
|
||||||
|
.WithOne(m => m.Channel)
|
||||||
|
.HasForeignKey(m => m.ChannelId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Message>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(m => m.Id);
|
||||||
|
entity.HasIndex(m => m.SentAt);
|
||||||
|
entity.Property(m => m.Content).IsRequired().HasMaxLength(2000);
|
||||||
|
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
||||||
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SQLite does not support DateTimeOffset in ORDER BY clauses.
|
||||||
|
// Convert all DateTimeOffset properties to Unix milliseconds (long) for storage.
|
||||||
|
var dateTimeOffsetConverter = new ValueConverter<DateTimeOffset, long>(
|
||||||
|
v => v.ToUnixTimeMilliseconds(),
|
||||||
|
v => DateTimeOffset.FromUnixTimeMilliseconds(v));
|
||||||
|
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
foreach (var property in entityType.GetProperties())
|
||||||
|
{
|
||||||
|
if (property.ClrType == typeof(DateTimeOffset) || property.ClrType == typeof(DateTimeOffset?))
|
||||||
|
{
|
||||||
|
property.SetValueConverter(dateTimeOffsetConverter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Hubs;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTracker presenceTracker) : Hub<IEchoHubClient>
|
||||||
|
{
|
||||||
|
private Guid CurrentUserId =>
|
||||||
|
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? throw new HubException("User ID claim not found."));
|
||||||
|
|
||||||
|
private string CurrentUsername =>
|
||||||
|
Context.User?.FindFirstValue("username")
|
||||||
|
?? throw new HubException("Username claim not found.");
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(CurrentUserId);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
user.Status = UserStatus.Online;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
// Capture channels before disconnecting, since UserDisconnected clears them
|
||||||
|
// when the last connection for a user is removed.
|
||||||
|
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||||
|
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||||
|
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
||||||
|
|
||||||
|
if (username is not null && !presenceTracker.IsOnline(username))
|
||||||
|
{
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
user.Status = UserStatus.Invisible;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var presence = new UserPresenceDto(
|
||||||
|
username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.NicknameColor,
|
||||||
|
UserStatus.Invisible,
|
||||||
|
user.StatusMessage);
|
||||||
|
|
||||||
|
foreach (var channel in channelsBeforeDisconnect)
|
||||||
|
{
|
||||||
|
await Clients.Group(channel).UserStatusChanged(presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
|
||||||
|
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> JoinChannel(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = channelName,
|
||||||
|
CreatedByUserId = CurrentUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("Channel '{Channel}' created by {User}", channelName, CurrentUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||||
|
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
|
||||||
|
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
|
||||||
|
|
||||||
|
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
|
||||||
|
|
||||||
|
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveChannel(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
||||||
|
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||||
|
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
||||||
|
|
||||||
|
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessage(string channelName, string content)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error("Message content cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.Length > HubConstants.MaxMessageLength)
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sender = await db.Users.FindAsync(CurrentUserId);
|
||||||
|
|
||||||
|
var message = new Message
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Content = content,
|
||||||
|
Type = MessageType.Text,
|
||||||
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
|
ChannelId = channel.Id,
|
||||||
|
SenderUserId = CurrentUserId,
|
||||||
|
SenderUsername = CurrentUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Messages.Add(message);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageDto = new MessageDto(
|
||||||
|
message.Id,
|
||||||
|
message.Content,
|
||||||
|
message.SenderUsername,
|
||||||
|
sender?.NicknameColor,
|
||||||
|
channelName,
|
||||||
|
MessageType.Text,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
message.SentAt);
|
||||||
|
|
||||||
|
await Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||||
|
|
||||||
|
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
|
.OrderByDescending(m => m.SentAt)
|
||||||
|
.Take(count)
|
||||||
|
.Join(db.Users,
|
||||||
|
m => m.SenderUserId,
|
||||||
|
u => u.Id,
|
||||||
|
(m, u) => new MessageDto(
|
||||||
|
m.Id,
|
||||||
|
m.Content,
|
||||||
|
m.SenderUsername,
|
||||||
|
u.NicknameColor,
|
||||||
|
channelName,
|
||||||
|
m.Type,
|
||||||
|
m.AttachmentUrl,
|
||||||
|
m.AttachmentFileName,
|
||||||
|
m.SentAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
messages.Reverse();
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateStatus(UserStatus status, string? statusMessage)
|
||||||
|
{
|
||||||
|
var user = await db.Users.FindAsync(CurrentUserId);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error("User not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Status = status;
|
||||||
|
user.StatusMessage = statusMessage;
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var presence = new UserPresenceDto(
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.NicknameColor,
|
||||||
|
status,
|
||||||
|
statusMessage);
|
||||||
|
|
||||||
|
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
|
||||||
|
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await Clients.Group(channel).UserStatusChanged(presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPresenceDto>> GetOnlineUsers(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||||
|
|
||||||
|
var users = await db.Users
|
||||||
|
.Where(u => onlineUsernames.Contains(u.Username))
|
||||||
|
.Select(u => new UserPresenceDto(
|
||||||
|
u.Username,
|
||||||
|
u.DisplayName,
|
||||||
|
u.NicknameColor,
|
||||||
|
u.Status,
|
||||||
|
u.StatusMessage))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Auth;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Hubs;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── SQLite + EF Core ──────────────────────────────────────────────────────────
|
||||||
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? "Data Source=echohub.db";
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<EchoHubDbContext>(options =>
|
||||||
|
options.UseSqlite(connectionString));
|
||||||
|
|
||||||
|
// ── JWT Authentication ────────────────────────────────────────────────────────
|
||||||
|
var jwtSecret = builder.Configuration["Jwt:Secret"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Secret must be configured.");
|
||||||
|
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server";
|
||||||
|
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client";
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidAudience = jwtAudience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Allow SignalR clients to send the JWT via query string
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
var path = context.HttpContext.Request.Path;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// ── SignalR ───────────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<JwtTokenService>();
|
||||||
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
|
|
||||||
|
// ── CORS (allow all for development) ──────────────────────────────────────────
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials()
|
||||||
|
.SetIsOriginAllowed(_ => true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ── Database initialization ───────────────────────────────────────────────────
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
|
||||||
|
{
|
||||||
|
db.Channels.Add(new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = HubConstants.DefaultChannel,
|
||||||
|
Topic = "General discussion",
|
||||||
|
CreatedByUserId = Guid.Empty,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware ─────────────────────────────────────────────────────────────────
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// ── Auth Endpoints ────────────────────────────────────────────────────────────
|
||||||
|
var auth = app.MapGroup("/api/auth");
|
||||||
|
|
||||||
|
auth.MapPost("/register", async (RegisterRequest request, EchoHubDbContext db, JwtTokenService jwt) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Error = "Username and password are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Username.Length < 3 || request.Username.Length > 50)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Error = "Username must be between 3 and 50 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Password.Length < 6)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Error = "Password must be at least 6 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { Error = "Username is already taken." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = normalizedUsername,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
|
DisplayName = request.DisplayName?.Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var token = jwt.GenerateToken(user);
|
||||||
|
|
||||||
|
return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.MapPost("/login", async (LoginRequest request, EchoHubDbContext db, JwtTokenService jwt) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Error = "Username and password are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
|
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var token = jwt.GenerateToken(user);
|
||||||
|
|
||||||
|
return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Channel Endpoints ─────────────────────────────────────────────────────────
|
||||||
|
app.MapGet("/api/channels", async (EchoHubDbContext db) =>
|
||||||
|
{
|
||||||
|
var channels = await db.Channels
|
||||||
|
.Select(c => new ChannelDto(
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Topic,
|
||||||
|
c.Messages.Count,
|
||||||
|
c.CreatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(channels);
|
||||||
|
})
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
// ── Server Info Endpoint ──────────────────────────────────────────────────────
|
||||||
|
app.MapGet("/api/server/info", async (EchoHubDbContext db, IConfiguration config) =>
|
||||||
|
{
|
||||||
|
var userCount = await db.Users.CountAsync();
|
||||||
|
var channelCount = await db.Channels.CountAsync();
|
||||||
|
|
||||||
|
var status = new ServerStatusDto(
|
||||||
|
config["Server:Name"] ?? "EchoHub Server",
|
||||||
|
config["Server:Description"],
|
||||||
|
userCount,
|
||||||
|
channelCount);
|
||||||
|
|
||||||
|
return Results.Ok(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User Profile Endpoints ────────────────────────────────────────────────────
|
||||||
|
app.MapGet("/api/users/{username}/profile", async (string username, EchoHubDbContext db) =>
|
||||||
|
{
|
||||||
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { Error = "User not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = new UserProfileDto(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Bio,
|
||||||
|
user.NicknameColor,
|
||||||
|
user.AvatarAscii,
|
||||||
|
user.Status,
|
||||||
|
user.StatusMessage,
|
||||||
|
user.CreatedAt,
|
||||||
|
user.LastSeenAt);
|
||||||
|
|
||||||
|
return Results.Ok(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPut("/api/users/profile", async (UpdateProfileRequest request, EchoHubDbContext db, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var userId = Guid.Parse(userIdClaim);
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { Error = "User not found." });
|
||||||
|
|
||||||
|
if (request.DisplayName is not null)
|
||||||
|
user.DisplayName = request.DisplayName.Trim();
|
||||||
|
|
||||||
|
if (request.Bio is not null)
|
||||||
|
user.Bio = request.Bio.Trim();
|
||||||
|
|
||||||
|
if (request.NicknameColor is not null)
|
||||||
|
user.NicknameColor = request.NicknameColor.Trim();
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var profile = new UserProfileDto(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Bio,
|
||||||
|
user.NicknameColor,
|
||||||
|
user.AvatarAscii,
|
||||||
|
user.Status,
|
||||||
|
user.StatusMessage,
|
||||||
|
user.CreatedAt,
|
||||||
|
user.LastSeenAt);
|
||||||
|
|
||||||
|
return Results.Ok(profile);
|
||||||
|
})
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/users/avatar", async (HttpRequest req, EchoHubDbContext db, ImageToAsciiService asciiService, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var userId = Guid.Parse(userIdClaim);
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { Error = "User not found." });
|
||||||
|
|
||||||
|
if (!req.HasFormContentType || req.Form.Files.Count == 0)
|
||||||
|
return Results.BadRequest(new { Error = "No file uploaded." });
|
||||||
|
|
||||||
|
var file = req.Form.Files[0];
|
||||||
|
|
||||||
|
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
||||||
|
return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." });
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var asciiArt = asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
|
user.AvatarAscii = asciiArt;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { AvatarAscii = asciiArt });
|
||||||
|
})
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
// ── File Upload Endpoints ────────────────────────────────────────────────────
|
||||||
|
app.MapPost("/api/channels/{channel}/upload", async (string channel, HttpRequest req, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var usernameClaim = ctx.User.FindFirstValue("username");
|
||||||
|
if (userIdClaim is null || usernameClaim is null)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var userId = Guid.Parse(userIdClaim);
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return Results.NotFound(new { Error = $"Channel '{channelName}' does not exist." });
|
||||||
|
|
||||||
|
if (!req.HasFormContentType || req.Form.Files.Count == 0)
|
||||||
|
return Results.BadRequest(new { Error = "No file uploaded." });
|
||||||
|
|
||||||
|
var file = req.Form.Files[0];
|
||||||
|
|
||||||
|
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||||
|
return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." });
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
|
var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
|
||||||
|
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
var isImage = imageExtensions.Contains(extension);
|
||||||
|
|
||||||
|
var messageType = isImage ? MessageType.Image : MessageType.File;
|
||||||
|
var content = isImage ? asciiService.ConvertToAscii(File.OpenRead(filePath)) : file.FileName;
|
||||||
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
|
|
||||||
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
var message = new Message
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Content = content,
|
||||||
|
Type = messageType,
|
||||||
|
AttachmentUrl = attachmentUrl,
|
||||||
|
AttachmentFileName = file.FileName,
|
||||||
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
|
ChannelId = dbChannel.Id,
|
||||||
|
SenderUserId = userId,
|
||||||
|
SenderUsername = usernameClaim,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Messages.Add(message);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageDto = new MessageDto(
|
||||||
|
message.Id,
|
||||||
|
message.Content,
|
||||||
|
message.SenderUsername,
|
||||||
|
sender?.NicknameColor,
|
||||||
|
channelName,
|
||||||
|
messageType,
|
||||||
|
attachmentUrl,
|
||||||
|
file.FileName,
|
||||||
|
message.SentAt);
|
||||||
|
|
||||||
|
return Results.Ok(messageDto);
|
||||||
|
})
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/files/{fileId}", (string fileId, FileStorageService fileStorage) =>
|
||||||
|
{
|
||||||
|
var filePath = fileStorage.GetFilePath(fileId);
|
||||||
|
|
||||||
|
if (filePath is null)
|
||||||
|
return Results.NotFound(new { Error = "File not found." });
|
||||||
|
|
||||||
|
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
".txt" => "text/plain",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
return Results.File(filePath, contentType, fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SignalR Hub ───────────────────────────────────────────────────────────────
|
||||||
|
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7171;http://localhost:5189",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class FileStorageService
|
||||||
|
{
|
||||||
|
private readonly string _storagePath;
|
||||||
|
|
||||||
|
public FileStorageService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_storagePath = configuration["Storage:Path"] ?? "./uploads";
|
||||||
|
|
||||||
|
if (!Directory.Exists(_storagePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string fileId, string filePath)> SaveFileAsync(Stream stream, string fileName)
|
||||||
|
{
|
||||||
|
var fileId = Guid.NewGuid().ToString();
|
||||||
|
var extension = Path.GetExtension(fileName);
|
||||||
|
var storedFileName = $"{fileId}{extension}";
|
||||||
|
var filePath = Path.Combine(_storagePath, storedFileName);
|
||||||
|
|
||||||
|
using var fileStream = File.Create(filePath);
|
||||||
|
await stream.CopyToAsync(fileStream);
|
||||||
|
|
||||||
|
return (fileId, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetFilePath(string fileId)
|
||||||
|
{
|
||||||
|
var files = Directory.GetFiles(_storagePath, $"{fileId}.*");
|
||||||
|
|
||||||
|
return files.Length > 0 ? files[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteFile(string fileId)
|
||||||
|
{
|
||||||
|
var filePath = GetFilePath(fileId);
|
||||||
|
|
||||||
|
if (filePath is not null && File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class ImageToAsciiService
|
||||||
|
{
|
||||||
|
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray();
|
||||||
|
|
||||||
|
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight)
|
||||||
|
{
|
||||||
|
using var image = Image.Load<Rgba32>(imageStream);
|
||||||
|
|
||||||
|
image.Mutate(x => x.Resize(width, height));
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
for (int y = 0; y < image.Height; y++)
|
||||||
|
{
|
||||||
|
for (int x = 0; x < image.Width; x++)
|
||||||
|
{
|
||||||
|
var pixel = image[x, y];
|
||||||
|
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
|
||||||
|
|
||||||
|
// Map brightness (0-255) to ASCII char index (inverted: dark pixels get dense chars)
|
||||||
|
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
|
||||||
|
sb.Append(AsciiChars[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < image.Height - 1)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class PresenceTracker
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, (Guid userId, string username)> _connections = new();
|
||||||
|
private readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
|
||||||
|
private readonly ConcurrentDictionary<string, HashSet<string>> _userChannels = new();
|
||||||
|
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public void UserConnected(string connectionId, Guid userId, string username)
|
||||||
|
{
|
||||||
|
_connections[connectionId] = (userId, username);
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_userConnections.TryGetValue(username, out var connections))
|
||||||
|
{
|
||||||
|
connections = new HashSet<string>();
|
||||||
|
_userConnections[username] = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
connections.Add(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? UserDisconnected(string connectionId)
|
||||||
|
{
|
||||||
|
if (!_connections.TryRemove(connectionId, out var userInfo))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var username = userInfo.username;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_userConnections.TryGetValue(username, out var connections))
|
||||||
|
{
|
||||||
|
connections.Remove(connectionId);
|
||||||
|
|
||||||
|
if (connections.Count == 0)
|
||||||
|
{
|
||||||
|
_userConnections.TryRemove(username, out _);
|
||||||
|
_userChannels.TryRemove(username, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void JoinChannel(string username, string channelName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_userChannels.TryGetValue(username, out var channels))
|
||||||
|
{
|
||||||
|
channels = new HashSet<string>();
|
||||||
|
_userChannels[username] = channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
channels.Add(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LeaveChannel(string username, string channelName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_userChannels.TryGetValue(username, out var channels))
|
||||||
|
{
|
||||||
|
channels.Remove(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetOnlineUsersInChannel(string channelName)
|
||||||
|
{
|
||||||
|
var users = new List<string>();
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var (username, channels) in _userChannels)
|
||||||
|
{
|
||||||
|
if (channels.Contains(channelName))
|
||||||
|
{
|
||||||
|
users.Add(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetChannelsForUser(string username)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_userChannels.TryGetValue(username, out var channels))
|
||||||
|
{
|
||||||
|
return channels.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOnline(string username)
|
||||||
|
{
|
||||||
|
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Api\EchoHub.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using EchoHub.Api.Data;
|
||||||
|
using EchoHub.Api.Extensions;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? "Data Source=serverdirectory.db";
|
||||||
|
|
||||||
|
builder.Services.AddServerDirectory(connectionString);
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ServerDirectoryDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
app.MapGet("/", () => Results.Content("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>EchoHub</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome to EchoHub</h1>
|
||||||
|
<p>EchoHub is a decentralized IRC-like chat network.</p>
|
||||||
|
<p>Use the <a href="/api/servers">Server Directory API</a> to browse available servers.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", "text/html"));
|
||||||
|
|
||||||
|
app.MapServerDirectoryApi();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:6000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7103;http://localhost:5062",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user