diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 0e4268d..ba641b4 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -712,12 +712,10 @@ public sealed class AppOrchestrator : IDisposable
_joinedChannels.Add(channel.Name);
var history = await _connection!.JoinChannelAsync(channel.Name);
- // Refresh the channel list and ensure private channels show up
- var channels = await _apiClient.GetChannelsAsync();
InvokeUI(() =>
{
- _mainWindow.SetChannels(channels);
_mainWindow.EnsureChannelInList(channel.Name);
+ _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
_mainWindow.SwitchToChannel(channel.Name);
if (history.Count > 0)
_mainWindow.LoadHistory(channel.Name, history);
@@ -756,10 +754,9 @@ public sealed class AppOrchestrator : IDisposable
await _apiClient!.DeleteChannelAsync(channel);
_joinedChannels.Remove(channel);
- var channels = await _apiClient.GetChannelsAsync();
InvokeUI(() =>
{
- _mainWindow.SetChannels(channels);
+ _mainWindow.RemoveChannel(channel);
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
_mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
});
@@ -844,6 +841,16 @@ public sealed class AppOrchestrator : IDisposable
});
};
+ connection.OnChannelUpdated += channel =>
+ {
+ InvokeUI(() =>
+ {
+ if (channel.IsPublic)
+ _mainWindow.EnsureChannelInList(channel.Name);
+ _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
+ });
+ };
+
connection.OnError += errorMessage =>
InvokeUI(() => _mainWindow.ShowError(errorMessage));
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index 7bbf3bd..ed7c9f5 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -596,6 +596,16 @@ public sealed class MainWindow : Runnable
RefreshChannelList();
}
+ ///
+ /// Remove a channel from the left panel list.
+ ///
+ public void RemoveChannel(string channelName)
+ {
+ _channelNames.Remove(channelName);
+ _channelTopics.Remove(channelName);
+ RefreshChannelList();
+ }
+
///
/// Update the topic for a specific channel.
///
diff --git a/src/EchoHub.Core/Models/ChannelMembership.cs b/src/EchoHub.Core/Models/ChannelMembership.cs
new file mode 100644
index 0000000..23c4770
--- /dev/null
+++ b/src/EchoHub.Core/Models/ChannelMembership.cs
@@ -0,0 +1,8 @@
+namespace EchoHub.Core.Models;
+
+public class ChannelMembership
+{
+ public Guid UserId { get; set; }
+ public Guid ChannelId { get; set; }
+ public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs
index 9051fd9..6631e50 100644
--- a/src/EchoHub.Server/Controllers/ChannelsController.cs
+++ b/src/EchoHub.Server/Controllers/ChannelsController.cs
@@ -40,10 +40,17 @@ public class ChannelsController : ControllerBase
[HttpGet]
public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{
+ var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (userIdClaim is null)
+ return Unauthorized(new ErrorResponse("Authentication required."));
+
+ var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100);
- var query = _db.Channels.Where(c => c.IsPublic);
+ // Public channels + private channels the user has joined
+ var query = _db.Channels.Where(c =>
+ c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
@@ -90,6 +97,14 @@ public class ChannelsController : ControllerBase
};
_db.Channels.Add(channel);
+
+ // Creator automatically becomes a member
+ _db.ChannelMemberships.Add(new ChannelMembership
+ {
+ UserId = Guid.Parse(userIdClaim),
+ ChannelId = channel.Id,
+ });
+
await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs
index 43396e6..f4a32ae 100644
--- a/src/EchoHub.Server/Data/EchoHubDbContext.cs
+++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs
@@ -11,6 +11,7 @@ public class EchoHubDbContext : DbContext
public DbSet Channels => Set();
public DbSet Messages => Set();
public DbSet RefreshTokens => Set();
+ public DbSet ChannelMemberships => Set();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -60,6 +61,23 @@ public class EchoHubDbContext : DbContext
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
});
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(cm => new { cm.UserId, cm.ChannelId });
+ entity.HasIndex(cm => cm.UserId);
+ entity.HasIndex(cm => cm.ChannelId);
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(cm => cm.ChannelId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(cm => cm.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
modelBuilder.Entity(entity =>
{
entity.HasKey(r => r.Id);
diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs
index 8cb5a71..a105dd8 100644
--- a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs
+++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs
@@ -15,7 +15,7 @@ namespace EchoHub.Server.Data.Migrations
table: "Channels",
type: "INTEGER",
nullable: false,
- defaultValue: false);
+ defaultValue: true);
}
///
diff --git a/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs
new file mode 100644
index 0000000..2485bbe
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs
@@ -0,0 +1,260 @@
+//
+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("20260219181720_AddChannelMembership")]
+ partial class AddChannelMembership
+ {
+ ///
+ 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.ChannelMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("JoinedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "ChannelId");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ChannelMemberships");
+ });
+
+ 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.ChannelMembership", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", null)
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EchoHub.Core.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ 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/20260219181720_AddChannelMembership.cs b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs
new file mode 100644
index 0000000..d8f0c8a
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs
@@ -0,0 +1,57 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace EchoHub.Server.Data.Migrations
+{
+ ///
+ public partial class AddChannelMembership : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ChannelMemberships",
+ columns: table => new
+ {
+ UserId = table.Column(type: "TEXT", nullable: false),
+ ChannelId = table.Column(type: "TEXT", nullable: false),
+ JoinedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ChannelMemberships", x => new { x.UserId, x.ChannelId });
+ table.ForeignKey(
+ name: "FK_ChannelMemberships_Channels_ChannelId",
+ column: x => x.ChannelId,
+ principalTable: "Channels",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_ChannelMemberships_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ChannelMemberships_ChannelId",
+ table: "ChannelMemberships",
+ column: "ChannelId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ChannelMemberships_UserId",
+ table: "ChannelMemberships",
+ column: "UserId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ChannelMemberships");
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
index 84a9178..adbf800 100644
--- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
+++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
@@ -49,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("Channels");
});
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("JoinedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "ChannelId");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ChannelMemberships");
+ });
+
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property("Id")
@@ -190,6 +210,21 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("Users");
});
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", null)
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EchoHub.Core.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs
index 443ed12..8d60c35 100644
--- a/src/EchoHub.Server/Services/ChatService.cs
+++ b/src/EchoHub.Server/Services/ChatService.cs
@@ -98,6 +98,19 @@ public class ChatService : IChatService
if (channel is null)
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
+ // Persist membership so the channel shows in the user's channel list
+ var hasMembership = await db.ChannelMemberships
+ .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
+ if (!hasMembership)
+ {
+ db.ChannelMemberships.Add(new ChannelMembership
+ {
+ UserId = userId,
+ ChannelId = channel.Id,
+ });
+ await db.SaveChangesAsync();
+ }
+
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
if (isNewJoin)
diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs
index 55c2095..8a85280 100644
--- a/src/EchoHub.Server/Setup/DataMigrationService.cs
+++ b/src/EchoHub.Server/Setup/DataMigrationService.cs
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
+using EchoHub.Core.Constants;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
@@ -13,9 +14,24 @@ public static partial class DataMigrationService
var logger = scope.ServiceProvider.GetRequiredService()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
+ await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
}
+ ///
+ /// Ensure the #general channel (and any pre-existing channels from before the IsPublic column) are public.
+ ///
+ private static async Task EnsureDefaultChannelsPublicAsync(EchoHubDbContext db, ILogger logger)
+ {
+ var general = await db.Channels.FirstOrDefaultAsync(c => c.Name == HubConstants.DefaultChannel);
+ if (general is not null && !general.IsPublic)
+ {
+ general.IsPublic = true;
+ await db.SaveChangesAsync();
+ logger.LogInformation("Marked #{Channel} as public.", HubConstants.DefaultChannel);
+ }
+ }
+
private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger)
{
// Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes.