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);
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);
+2
View File
@@ -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))
+2 -2
View File
@@ -186,10 +186,10 @@ public sealed class ApiClient : IDisposable
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();
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);
+63 -29
View File
@@ -90,13 +90,23 @@ public partial class ChatLine
}
/// <summary>
/// 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).
/// </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 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();
}
/// <summary>
+16 -7
View File
@@ -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);
+17 -3
View File
@@ -582,6 +582,20 @@ public sealed class MainWindow : Runnable
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>
/// Update the topic for a specific channel.
/// </summary>
@@ -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}"));
}
+2 -1
View File
@@ -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);
+1
View File
@@ -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; }
+32 -2
View File
@@ -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;
}
/// <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>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>
@@ -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);
@@ -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")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -21,8 +21,10 @@ public class ImageToAsciiService
/// <summary>
/// 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.
/// </summary>
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)