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.