feat: add IsPublic property to channels and enhance channel creation with visibility options

This commit is contained in:
HueByte
2026-02-19 18:54:50 +01:00
parent 3a0ea0d321
commit 8dfb1a4fb8
14 changed files with 412 additions and 59 deletions
+5 -2
View File
@@ -212,6 +212,8 @@ public sealed class AppOrchestrator : IDisposable
var history = await _connection!.JoinChannelAsync(channelName); var history = await _connection!.JoinChannelAsync(channelName);
InvokeUI(() => InvokeUI(() =>
{ {
// Add to channel list if not already there (e.g. private channels)
_mainWindow.EnsureChannelInList(channelName);
_mainWindow.SwitchToChannel(channelName); _mainWindow.SwitchToChannel(channelName);
if (history.Count > 0) if (history.Count > 0)
_mainWindow.LoadHistory(channelName, history); _mainWindow.LoadHistory(channelName, history);
@@ -704,17 +706,18 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () => 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; if (channel is null) return;
_joinedChannels.Add(channel.Name); _joinedChannels.Add(channel.Name);
var history = await _connection!.JoinChannelAsync(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(); var channels = await _apiClient.GetChannelsAsync();
InvokeUI(() => InvokeUI(() =>
{ {
_mainWindow.SetChannels(channels); _mainWindow.SetChannels(channels);
_mainWindow.EnsureChannelInList(channel.Name);
_mainWindow.SwitchToChannel(channel.Name); _mainWindow.SwitchToChannel(channel.Name);
if (history.Count > 0) if (history.Count > 0)
_mainWindow.LoadHistory(channel.Name, history); _mainWindow.LoadHistory(channel.Name, history);
+2
View File
@@ -4,6 +4,8 @@ using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Drawing;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath)) if (!File.Exists(appSettingsPath))
+2 -2
View File
@@ -186,10 +186,10 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null) public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic); var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request)); _http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
+63 -29
View File
@@ -90,13 +90,23 @@ public partial class ChatLine
} }
/// <summary> /// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments. /// Returns true if a line contains color tags (new format or legacy ANSI).
/// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m).
/// </summary> /// </summary>
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');
/// <summary>
/// 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).
/// </summary>
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<ChatSegment>(); var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0; int lastIndex = 0;
Color? currentFg = null; Color? currentFg = null;
Color? currentBg = null; Color? currentBg = null;
@@ -111,52 +121,76 @@ public partial class ChatLine
return new Attribute(fg, bg); 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) if (match.Index > lastIndex)
{ {
var text = ansiText[lastIndex..match.Index]; var t = text[lastIndex..match.Index];
if (text.Length > 0) if (t.Length > 0)
segments.Add(new ChatSegment(text, BuildAttr())); segments.Add(new ChatSegment(t, BuildAttr()));
} }
// Parse the escape sequence if (isAnsi)
if (match.Groups[1].Value == "0")
{ {
// Reset // Legacy ANSI format
currentFg = null; if (match.Groups[1].Value == "0")
currentBg = null; {
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); // New printable tag format: {F:RRGGBB}, {B:RRGGBB}, {X}
var g = int.Parse(match.Groups[4].Value); if (match.Groups[6].Success)
var b = int.Parse(match.Groups[5].Value); {
// Reset {X}
if (match.Groups[2].Value == "38;2") currentFg = null;
currentFg = new Color(r, g, b); currentBg = null;
else // 48;2 }
currentBg = new Color(r, g, b); 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; lastIndex = match.Index + match.Length;
} }
// Add remaining text if (lastIndex < text.Length)
if (lastIndex < ansiText.Length)
{ {
var text = ansiText[lastIndex..]; var t = text[lastIndex..];
if (text.Length > 0) if (t.Length > 0)
segments.Add(new ChatSegment(text, BuildAttr())); segments.Add(new ChatSegment(t, BuildAttr()));
} }
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); 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")] [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex(); 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();
} }
/// <summary> /// <summary>
+16 -7
View File
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic); public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
public sealed class CreateChannelDialog public sealed class CreateChannelDialog
{ {
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
{ {
CreateChannelResult? result = null; 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 nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; 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 topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; 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 var hintLabel = new Label
{ {
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1, X = 1,
Y = 5, Y = 7,
}; };
var createButton = new Button var createButton = new Button
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
Text = "Create", Text = "Create",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 10, X = Pos.Center() - 10,
Y = 7 Y = 9
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 5, X = Pos.Center() + 5,
Y = 7 Y = 9
}; };
createButton.Accepting += (s, e) => createButton.Accepting += (s, e) =>
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
if (string.IsNullOrWhiteSpace(topic)) if (string.IsNullOrWhiteSpace(topic))
topic = null; topic = null;
result = new CreateChannelResult(name, topic); var isPublic = publicCheckbox.Value == CheckState.Checked;
result = new CreateChannelResult(name, topic, isPublic);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
app.RequestStop(); app.RequestStop();
}; };
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
nameField.SetFocus(); nameField.SetFocus();
app.Run(dialog); app.Run(dialog);
+17 -3
View File
@@ -582,6 +582,20 @@ public sealed class MainWindow : Runnable
RefreshChannelList(); RefreshChannelList();
} }
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
public void EnsureChannelInList(string channelName)
{
if (_channelNames.Contains(channelName))
return;
_channelNames.Add(channelName);
if (!_channelMessages.ContainsKey(channelName))
_channelMessages[channelName] = [];
RefreshChannelList();
}
/// <summary> /// <summary>
/// Update the topic for a specific channel. /// Update the topic for a specific channel.
/// </summary> /// </summary>
@@ -838,10 +852,10 @@ public sealed class MainWindow : Runnable
{ {
foreach (var artLine in message.Content.Split('\n')) 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'); var trimmed = artLine.TrimEnd('\r');
if (trimmed.Contains('\x1b')) if (ChatLine.HasColorTags(trimmed))
lines.Add(ChatLine.FromAnsi(" " + trimmed)); lines.Add(ChatLine.FromColoredText(" " + trimmed));
else else
lines.Add(new ChatLine($" {trimmed}")); lines.Add(new ChatLine($" {trimmed}"));
} }
+2 -1
View File
@@ -17,6 +17,7 @@ public record ChannelDto(
Guid Id, Guid Id,
string Name, string Name,
string? Topic, string? Topic,
bool IsPublic,
int MessageCount, int MessageCount,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
@@ -30,7 +31,7 @@ public record UserDto(
public record SendMessageRequest(string ChannelName, string Content); 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); public record UpdateTopicRequest(string? Topic);
+1
View File
@@ -5,6 +5,7 @@ public class Channel
public Guid Id { get; set; } public Guid Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public string? Topic { get; set; } public string? Topic { get; set; }
public bool IsPublic { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid CreatedByUserId { get; set; } public Guid CreatedByUserId { get; set; }
+32 -2
View File
@@ -1,10 +1,11 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
public static class IrcMessageFormatter public static partial class IrcMessageFormatter
{ {
private const int MaxIrcLineContentBytes = 400; private const int MaxIrcLineContentBytes = 400;
@@ -33,7 +34,7 @@ public static class IrcMessageFormatter
{ {
var trimmed = line.TrimEnd('\r'); var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0) if (trimmed.Length > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
} }
break; break;
@@ -45,6 +46,35 @@ public static class IrcMessageFormatter
return lines; return lines;
} }
/// <summary>
/// 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.
/// </summary>
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();
/// <summary> /// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary> /// </summary>
@@ -43,9 +43,10 @@ public class ChannelsController : ControllerBase
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); 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) .OrderBy(c => c.Name)
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
@@ -53,6 +54,7 @@ public class ChannelsController : ControllerBase
c.Id, c.Id,
c.Name, c.Name,
c.Topic, c.Topic,
c.IsPublic,
c.Messages.Count, c.Messages.Count,
c.CreatedAt)) c.CreatedAt))
.ToListAsync(); .ToListAsync();
@@ -83,14 +85,16 @@ public class ChannelsController : ControllerBase
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = channelName, Name = channelName,
Topic = request.Topic?.Trim(), Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim), CreatedByUserId = Guid.Parse(userIdClaim),
}; };
_db.Channels.Add(channel); _db.Channels.Add(channel);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto); if (channel.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto); return Created($"/api/channels/{channelName}", dto);
} }
@@ -118,7 +122,7 @@ public class ChannelsController : ControllerBase
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); 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); await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto); return Ok(dto);
@@ -0,0 +1,225 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelIsPublic : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "Channels",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPublic",
table: "Channels");
}
}
}
@@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -21,8 +21,10 @@ public class ImageToAsciiService
/// <summary> /// <summary>
/// Converts an image to ASCII art using half-block characters (▀▄█) with /// 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. /// Each character cell represents two vertical pixels.
/// Format: {F:RRGGBB} foreground, {B:RRGGBB} background, {X} reset.
/// Uses only printable ASCII — no terminal escape bytes.
/// </summary> /// </summary>
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock) 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) 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; fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B; bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B;
blockChar = '\u2588'; // █ blockChar = '\u2588'; // █
} }
else else
{ {
// Top pixel = foreground, bottom pixel = background, upper half block
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B; bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B;
blockChar = '\u2580'; // ▀ blockChar = '\u2580'; // ▀
} }
// Emit color codes only when they change
bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB; bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB;
bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB; bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB;
if (fgChanged) if (fgChanged)
sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m"); sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}");
if (bgChanged) if (bgChanged)
sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m"); sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}");
sb.Append(blockChar); sb.Append(blockChar);
@@ -80,8 +79,7 @@ public class ImageToAsciiService
hasLastColor = true; hasLastColor = true;
} }
// Reset color at end of line sb.Append("{X}");
sb.Append("\x1b[0m");
hasLastColor = false; hasLastColor = false;
if (y + 2 < image.Height) if (y + 2 < image.Height)