feat: enhance profile editing with avatar support and UI adjustments

- Added AvatarPath to ProfileEditResult for user profile updates.
- Updated ProfileEditDialog to include avatar selection with a browse button.
- Increased dialog height to accommodate new avatar input fields.
- Implemented avatar file path handling in the profile edit dialog.

feat: introduce moderation features and user roles

- Added ServerRole enum to define user roles (Member, Mod, Admin, Owner).
- Extended User model to include role, mute, and ban status.
- Created ModerationController for user role assignment, kicking, banning, and muting.
- Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions.
- Updated database schema with new columns for user roles and moderation states.

fix: ensure muted users cannot send messages

- Added mute status checks in ChatService to prevent message sending for muted users.
- Updated user presence and status handling to reflect role changes and moderation actions.

chore: update constants for ASCII art rendering

- Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
This commit is contained in:
HueByte
2026-02-19 17:47:10 +01:00
parent 257ac34224
commit b4c3ebd254
28 changed files with 1457 additions and 73 deletions
@@ -37,6 +37,7 @@ public class JwtTokenService
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new("username", user.Username),
new("display_name", user.DisplayName ?? user.Username),
new("role", user.Role.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
@@ -42,12 +42,16 @@ public class AuthController : ControllerBase
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken."));
// First registered user on the server becomes the Owner
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
_db.Users.Add(user);
@@ -80,6 +84,9 @@ public class AuthController : ControllerBase
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password."));
if (user.IsBanned)
return Unauthorized(new ErrorResponse("Your account has been banned."));
user.LastSeenAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
@@ -141,8 +141,10 @@ public class ChannelsController : ControllerBase
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
var userId = Guid.Parse(userIdClaim);
var caller = await _db.Users.FindAsync(userId);
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
_db.Channels.Remove(dbChannel);
await _db.SaveChangesAsync();
@@ -0,0 +1,228 @@
using System.Security.Claims;
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.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/moderation")]
[Authorize]
[EnableRateLimiting("general")]
public class ModerationController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly IChatService _chatService;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
public ModerationController(
EchoHubDbContext db,
IChatService chatService,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters)
{
_db = db;
_chatService = chatService;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
}
[HttpPost("role")]
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
if (request.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
if (target.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
if (request.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
target.Role = request.Role;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
}
[HttpPost("kick/{username}")]
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
// Broadcast kick to all channels the user is in
var channels = _presenceTracker.GetChannelsForUser(target.Username);
foreach (var channel in channels)
{
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
}
return Ok(new { Message = $"{target.Username} has been kicked." });
}
[HttpPost("ban/{username}")]
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
target.IsBanned = true;
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
return Ok(new { Message = $"{target.Username} has been banned." });
}
[HttpPost("unban/{username}")]
public async Task<IActionResult> UnbanUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsBanned = false;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unbanned." });
}
[HttpPost("mute/{username}")]
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
target.IsMuted = true;
target.MutedUntil = request?.DurationMinutes is > 0
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
: null;
await _db.SaveChangesAsync();
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
}
[HttpPost("unmute/{username}")]
public async Task<IActionResult> UnmuteUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsMuted = false;
target.MutedUntil = null;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unmuted." });
}
[HttpDelete("messages/{messageId:guid}")]
public async Task<IActionResult> DeleteMessage(Guid messageId)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var message = await _db.Messages
.Include(m => m.Channel)
.FirstOrDefaultAsync(m => m.Id == messageId);
if (message is null)
return NotFound(new ErrorResponse("Message not found."));
var channelName = message.Channel!.Name;
_db.Messages.Remove(message);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
return Ok(new { Message = "Message deleted." });
}
[HttpDelete("channels/{channel}/nuke")]
public async Task<IActionResult> NukeChannel(string channel)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
_db.Messages.RemoveRange(messages);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
}
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return (null, Unauthorized(new ErrorResponse("User not found.")));
if (caller.Role < minimumRole)
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
return (caller, null);
}
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in _broadcasters)
{
try { await action(broadcaster); }
catch { /* logged by broadcaster */ }
}
}
}
@@ -120,6 +120,7 @@ public class UsersController : ControllerBase
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.Role,
user.CreatedAt,
user.LastSeenAt);
}
@@ -34,6 +34,7 @@ public class EchoHubDbContext : DbContext
entity.Property(u => u.NicknameColor).HasMaxLength(7);
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
entity.Property(u => u.StatusMessage).HasMaxLength(100);
entity.Property(u => u.Role).HasConversion<int>();
});
modelBuilder.Entity<Channel>(entity =>
@@ -0,0 +1,222 @@
// <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("20260219162414_AddModerationRoles")]
partial class AddModerationRoles
{
/// <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<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,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddModerationRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsMuted",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<long>(
name: "MutedUntil",
table: "Users",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Role",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBanned",
table: "Users");
migrationBuilder.DropColumn(
name: "IsMuted",
table: "Users");
migrationBuilder.DropColumn(
name: "MutedUntil",
table: "Users");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}
@@ -144,9 +144,18 @@ namespace EchoHub.Server.Data.Migrations
.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");
@@ -155,6 +164,9 @@ namespace EchoHub.Server.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
+22 -4
View File
@@ -72,7 +72,8 @@ public class ChatService : IChatService
user.DisplayName,
user.NicknameColor,
UserStatus.Invisible,
user.StatusMessage);
user.StatusMessage,
user.Role);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
}
@@ -139,6 +140,21 @@ public class ChatService : IChatService
var sender = await db.Users.FindAsync(userId);
// Check mute status
if (sender is not null && sender.IsMuted)
{
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
{
sender.IsMuted = false;
sender.MutedUntil = null;
await db.SaveChangesAsync();
}
else
{
return "You are muted and cannot send messages.";
}
}
var message = new Message
{
Id = Guid.NewGuid(),
@@ -203,7 +219,8 @@ public class ChatService : IChatService
user.DisplayName,
user.NicknameColor,
status,
statusMessage);
statusMessage,
user.Role);
var channels = _presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
@@ -226,7 +243,8 @@ public class ChatService : IChatService
u.DisplayName,
u.NicknameColor,
u.Status,
u.StatusMessage))
u.StatusMessage,
u.Role))
.ToListAsync();
}
@@ -264,7 +282,7 @@ public class ChatService : IChatService
return new UserProfileDto(
user.Id, user.Username, user.DisplayName, user.Bio,
user.NicknameColor, user.AvatarAscii, user.Status,
user.StatusMessage, user.CreatedAt, user.LastSeenAt);
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
@@ -8,47 +8,72 @@ 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)
/// <summary>
/// Converts an image to ASCII art using half-block characters (▀▄█) with
/// 24-bit ANSI foreground and background colors for 2x vertical resolution.
/// Each character cell represents two vertical pixels.
/// </summary>
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock)
{
using var image = Image.Load<Rgba32>(imageStream);
// Ensure height is even for pair processing
if (height % 2 != 0) height++;
image.Mutate(x => x.Resize(width, height));
var sb = new StringBuilder();
byte lastR = 0, lastG = 0, lastB = 0;
bool hasLastColor = false;
for (int y = 0; y < image.Height; y++)
for (int y = 0; y < image.Height; y += 2)
{
byte lastFgR = 0, lastFgG = 0, lastFgB = 0;
byte lastBgR = 0, lastBgG = 0, lastBgB = 0;
bool hasLastColor = false;
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;
var topPixel = image[x, y];
var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel;
// Map brightness (0-255) to ASCII char index
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
byte fgR, fgG, fgB, bgR, bgG, bgB;
char blockChar;
// Emit ANSI 24-bit color only when it changes
if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB)
if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B)
{
sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m");
lastR = pixel.R;
lastG = pixel.G;
lastB = pixel.B;
hasLastColor = true;
// 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'; // ▀
}
sb.Append(AsciiChars[index]);
// 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");
if (bgChanged)
sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m");
sb.Append(blockChar);
lastFgR = fgR; lastFgG = fgG; lastFgB = fgB;
lastBgR = bgR; lastBgG = bgG; lastBgB = bgB;
hasLastColor = true;
}
// Reset color at end of line
sb.Append("\x1b[0m");
hasLastColor = false;
if (y < image.Height - 1)
if (y + 2 < image.Height)
{
sb.AppendLine();
}
@@ -54,6 +54,18 @@ public class SignalRBroadcaster : IChatBroadcaster
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
}
public Task SendUserKickedAsync(string channelName, string username, string? reason)
=> HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason);
public Task SendUserBannedAsync(string username, string? reason)
=> HubContext.Clients.All.UserBanned(username, reason);
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
public Task SendChannelNukedAsync(string channelName)
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
public Task SendErrorAsync(string connectionId, string message)
{
if (connectionId.StartsWith("irc-"))