From 8dfb1a4fb8f3823e49f2c245951c3c239adad841 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:54:50 +0100 Subject: [PATCH] feat: add IsPublic property to channels and enhance channel creation with visibility options --- src/EchoHub.Client/AppOrchestrator.cs | 7 +- src/EchoHub.Client/Program.cs | 2 + src/EchoHub.Client/Services/ApiClient.cs | 4 +- src/EchoHub.Client/UI/ChatRenderer.cs | 92 ++++--- src/EchoHub.Client/UI/CreateChannelDialog.cs | 23 +- src/EchoHub.Client/UI/MainWindow.cs | 20 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Core/Models/Channel.cs | 1 + src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 34 ++- .../Controllers/ChannelsController.cs | 14 +- ...60219172834_AddChannelIsPublic.Designer.cs | 225 ++++++++++++++++++ .../20260219172834_AddChannelIsPublic.cs | 29 +++ .../EchoHubDbContextModelSnapshot.cs | 3 + .../Services/ImageToAsciiService.cs | 14 +- 14 files changed, 412 insertions(+), 59 deletions(-) create mode 100644 src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 4e3474a..0e4268d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -212,6 +212,8 @@ public sealed class AppOrchestrator : IDisposable var history = await _connection!.JoinChannelAsync(channelName); InvokeUI(() => { + // Add to channel list if not already there (e.g. private channels) + _mainWindow.EnsureChannelInList(channelName); _mainWindow.SwitchToChannel(channelName); if (history.Count > 0) _mainWindow.LoadHistory(channelName, history); @@ -704,17 +706,18 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { - var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic); + var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic); if (channel is null) return; _joinedChannels.Add(channel.Name); var history = await _connection!.JoinChannelAsync(channel.Name); - // Refresh the channel list + // Refresh the channel list and ensure private channels show up var channels = await _apiClient.GetChannelsAsync(); InvokeUI(() => { _mainWindow.SetChannels(channels); + _mainWindow.EnsureChannelInList(channel.Name); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) _mainWindow.LoadHistory(channel.Name, history); diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 23b0598..2955339 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -4,6 +4,8 @@ using EchoHub.Client.Themes; using Microsoft.Extensions.Configuration; using Serilog; using Terminal.Gui.App; +using Terminal.Gui.Drawing; + var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); if (!File.Exists(appSettingsPath)) diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 06f8249..8c6acd9 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -186,10 +186,10 @@ public sealed class ApiClient : IDisposable return await response.Content.ReadFromJsonAsync(); } - public async Task CreateChannelAsync(string name, string? topic = null) + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) { EnsureAuthenticated(); - var request = new CreateChannelRequest(name, topic); + var request = new CreateChannelRequest(name, topic, isPublic); var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 66681b8..29b4284 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -90,13 +90,23 @@ public partial class ChatLine } /// - /// Parse a string containing ANSI 24-bit color escape codes into colored segments. - /// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m). + /// Returns true if a line contains color tags (new format or legacy ANSI). /// - public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) + public static bool HasColorTags(string text) => + text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}") || text.Contains('\x1b'); + + /// + /// Parse a string containing color tags into colored segments. + /// Supports the new printable format ({F:RRGGBB}, {B:RRGGBB}, {X}) + /// and legacy ANSI format (\x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm, \x1b[0m). + /// + public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null) { + // Detect which format is used and pick the right regex + var regex = text.Contains('\x1b') ? AnsiColorRegex() : ColorTagRegex(); + bool isAnsi = text.Contains('\x1b'); + var segments = new List(); - var regex = AnsiColorRegex(); int lastIndex = 0; Color? currentFg = null; Color? currentBg = null; @@ -111,52 +121,76 @@ public partial class ChatLine return new Attribute(fg, bg); } - foreach (Match match in regex.Matches(ansiText)) + foreach (Match match in regex.Matches(text)) { - // Add any text before this escape sequence if (match.Index > lastIndex) { - var text = ansiText[lastIndex..match.Index]; - if (text.Length > 0) - segments.Add(new ChatSegment(text, BuildAttr())); + var t = text[lastIndex..match.Index]; + if (t.Length > 0) + segments.Add(new ChatSegment(t, BuildAttr())); } - // Parse the escape sequence - if (match.Groups[1].Value == "0") + if (isAnsi) { - // Reset - currentFg = null; - currentBg = null; + // Legacy ANSI format + if (match.Groups[1].Value == "0") + { + currentFg = null; + currentBg = null; + } + else if (match.Groups[2].Success) + { + var r = int.Parse(match.Groups[3].Value); + var g = int.Parse(match.Groups[4].Value); + var b = int.Parse(match.Groups[5].Value); + if (match.Groups[2].Value == "38;2") + currentFg = new Color(r, g, b); + else + currentBg = new Color(r, g, b); + } } - else if (match.Groups[2].Success) + else { - var r = int.Parse(match.Groups[3].Value); - var g = int.Parse(match.Groups[4].Value); - var b = int.Parse(match.Groups[5].Value); - - if (match.Groups[2].Value == "38;2") - currentFg = new Color(r, g, b); - else // 48;2 - currentBg = new Color(r, g, b); + // New printable tag format: {F:RRGGBB}, {B:RRGGBB}, {X} + if (match.Groups[6].Success) + { + // Reset {X} + currentFg = null; + currentBg = null; + } + else if (match.Groups[7].Success) + { + var hex = match.Groups[8].Value; + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + if (match.Groups[7].Value == "F") + currentFg = new Color(r, g, b); + else + currentBg = new Color(r, g, b); + } } lastIndex = match.Index + match.Length; } - // Add remaining text - if (lastIndex < ansiText.Length) + if (lastIndex < text.Length) { - var text = ansiText[lastIndex..]; - if (text.Length > 0) - segments.Add(new ChatSegment(text, BuildAttr())); + var t = text[lastIndex..]; + if (t.Length > 0) + segments.Add(new ChatSegment(t, BuildAttr())); } return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } - // Matches: \x1b[0m (reset), \x1b[38;2;R;G;Bm (fg), or \x1b[48;2;R;G;Bm (bg) + // Legacy: \x1b[0m, \x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + + // New: {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background) + [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] + private static partial Regex ColorTagRegex(); } /// diff --git a/src/EchoHub.Client/UI/CreateChannelDialog.cs b/src/EchoHub.Client/UI/CreateChannelDialog.cs index 573afe3..8c407b6 100644 --- a/src/EchoHub.Client/UI/CreateChannelDialog.cs +++ b/src/EchoHub.Client/UI/CreateChannelDialog.cs @@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase; namespace EchoHub.Client.UI; -public record CreateChannelResult(string Name, string? Topic); +public record CreateChannelResult(string Name, string? Topic, bool IsPublic); public sealed class CreateChannelDialog { @@ -12,7 +12,7 @@ public sealed class CreateChannelDialog { CreateChannelResult? result = null; - var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 }; + var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; @@ -20,11 +20,19 @@ public sealed class CreateChannelDialog var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; + var publicCheckbox = new CheckBox + { + Text = "Public (visible to all users)", + X = 1, + Y = 5, + Value = CheckState.Checked + }; + var hintLabel = new Label { Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", X = 1, - Y = 5, + Y = 7, }; var createButton = new Button @@ -32,14 +40,14 @@ public sealed class CreateChannelDialog Text = "Create", IsDefault = true, X = Pos.Center() - 10, - Y = 7 + Y = 9 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 7 + Y = 9 }; createButton.Accepting += (s, e) => @@ -55,7 +63,8 @@ public sealed class CreateChannelDialog if (string.IsNullOrWhiteSpace(topic)) topic = null; - result = new CreateChannelResult(name, topic); + var isPublic = publicCheckbox.Value == CheckState.Checked; + result = new CreateChannelResult(name, topic, isPublic); e.Handled = true; app.RequestStop(); }; @@ -67,7 +76,7 @@ public sealed class CreateChannelDialog app.RequestStop(); }; - dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); + dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton); nameField.SetFocus(); app.Run(dialog); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 98d85c2..5f94915 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -582,6 +582,20 @@ public sealed class MainWindow : Runnable RefreshChannelList(); } + /// + /// Ensure a channel exists in the left panel list (used for private channels joined via /join). + /// + public void EnsureChannelInList(string channelName) + { + if (_channelNames.Contains(channelName)) + return; + + _channelNames.Add(channelName); + if (!_channelMessages.ContainsKey(channelName)) + _channelMessages[channelName] = []; + RefreshChannelList(); + } + /// /// Update the topic for a specific channel. /// @@ -838,10 +852,10 @@ public sealed class MainWindow : Runnable { foreach (var artLine in message.Content.Split('\n')) { - // Parse ANSI color codes from colored ASCII art + // Parse color tags from colored ASCII art var trimmed = artLine.TrimEnd('\r'); - if (trimmed.Contains('\x1b')) - lines.Add(ChatLine.FromAnsi(" " + trimmed)); + if (ChatLine.HasColorTags(trimmed)) + lines.Add(ChatLine.FromColoredText(" " + trimmed)); else lines.Add(new ChatLine($" {trimmed}")); } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 25a84e3..d24b1f1 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -17,6 +17,7 @@ public record ChannelDto( Guid Id, string Name, string? Topic, + bool IsPublic, int MessageCount, DateTimeOffset CreatedAt); @@ -30,7 +31,7 @@ public record UserDto( public record SendMessageRequest(string ChannelName, string Content); -public record CreateChannelRequest(string Name, string? Topic = null); +public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true); public record UpdateTopicRequest(string? Topic); diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index dca533c..b936e6e 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -5,6 +5,7 @@ public class Channel public Guid Id { get; set; } public required string Name { get; set; } public string? Topic { get; set; } + public bool IsPublic { get; set; } = true; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public Guid CreatedByUserId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index a161313..e0963d1 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -1,10 +1,11 @@ using System.Text; +using System.Text.RegularExpressions; using EchoHub.Core.DTOs; using EchoHub.Core.Models; namespace EchoHub.Server.Irc; -public static class IrcMessageFormatter +public static partial class IrcMessageFormatter { private const int MaxIrcLineContentBytes = 400; @@ -33,7 +34,7 @@ public static class IrcMessageFormatter { var trimmed = line.TrimEnd('\r'); if (trimmed.Length > 0) - lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}"); + lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}"); } break; @@ -45,6 +46,35 @@ public static class IrcMessageFormatter return lines; } + /// + /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. + /// Also passes through content that already uses ANSI codes unchanged. + /// + public static string ColorTagsToAnsi(string text) + { + if (!text.Contains('{')) + return text; + + return ColorTagRegex().Replace(text, match => + { + if (match.Groups[1].Success) // {X} reset + return "\x1b[0m"; + if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB} + { + var hex = match.Groups[3].Value; + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + var code = match.Groups[2].Value == "F" ? "38" : "48"; + return $"\x1b[{code};2;{r};{g};{b}m"; + } + return match.Value; + }); + } + + [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] + private static partial Regex ColorTagRegex(); + /// /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index eabef78..9051fd9 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -43,9 +43,10 @@ public class ChannelsController : ControllerBase offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - var total = await _db.Channels.CountAsync(); + var query = _db.Channels.Where(c => c.IsPublic); + var total = await query.CountAsync(); - var channels = await _db.Channels + var channels = await query .OrderBy(c => c.Name) .Skip(offset) .Take(limit) @@ -53,6 +54,7 @@ public class ChannelsController : ControllerBase c.Id, c.Name, c.Topic, + c.IsPublic, c.Messages.Count, c.CreatedAt)) .ToListAsync(); @@ -83,14 +85,16 @@ public class ChannelsController : ControllerBase Id = Guid.NewGuid(), Name = channelName, Topic = request.Topic?.Trim(), + IsPublic = request.IsPublic, CreatedByUserId = Guid.Parse(userIdClaim), }; _db.Channels.Add(channel); await _db.SaveChangesAsync(); - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); - await _chatService.BroadcastChannelUpdatedAsync(dto); + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + if (channel.IsPublic) + await _chatService.BroadcastChannelUpdatedAsync(dto); return Created($"/api/channels/{channelName}", dto); } @@ -118,7 +122,7 @@ public class ChannelsController : ControllerBase await _db.SaveChangesAsync(); var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); return Ok(dto); diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs new file mode 100644 index 0000000..9c49ce0 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs @@ -0,0 +1,225 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219172834_AddChannelIsPublic")] + partial class AddChannelIsPublic + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs new file mode 100644 index 0000000..8cb5a71 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelIsPublic : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPublic", + table: "Channels", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPublic", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 8b4ec60..84a9178 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations b.Property("CreatedByUserId") .HasColumnType("TEXT"); + b.Property("IsPublic") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index a354a29..a1520de 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -21,8 +21,10 @@ public class ImageToAsciiService /// /// Converts an image to ASCII art using half-block characters (▀▄█) with - /// 24-bit ANSI foreground and background colors for 2x vertical resolution. + /// printable color tags for 2x vertical resolution. /// Each character cell represents two vertical pixels. + /// Format: {F:RRGGBB} foreground, {B:RRGGBB} background, {X} reset. + /// Uses only printable ASCII — no terminal escape bytes. /// public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock) { @@ -51,27 +53,24 @@ public class ImageToAsciiService if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B) { - // Both pixels same color — full block fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B; blockChar = '\u2588'; // █ } else { - // Top pixel = foreground, bottom pixel = background, upper half block fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B; blockChar = '\u2580'; // ▀ } - // Emit color codes only when they change bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB; bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB; if (fgChanged) - sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m"); + sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}"); if (bgChanged) - sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m"); + sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}"); sb.Append(blockChar); @@ -80,8 +79,7 @@ public class ImageToAsciiService hasLastColor = true; } - // Reset color at end of line - sb.Append("\x1b[0m"); + sb.Append("{X}"); hasLastColor = false; if (y + 2 < image.Height)