mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-06 07:36:01 +02:00
feat: Add Create Channel dialog and related events
- Introduced CreateChannelDialog for user to create new channels with name and topic. - Added OnCreateChannelRequested event in MainWindow for channel creation. - Enhanced EchoHubConnection with OnReconnected event for connection state handling. - Updated EchoHubDbContext to use application base directory for SQLite database path. - Integrated Serilog for logging in EchoHub.Server. - Refactored database initialization and migration logic into DatabaseSetup class. - Implemented FirstRunSetup to ensure appsettings.json and generate JWT secret if needed. - Updated appsettings files to configure Serilog logging. - Enhanced error handling and logging in ChatHub methods for better traceability.
This commit is contained in:
+252
-185
@@ -24,246 +24,313 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
if (user is not null)
|
||||
try
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Online;
|
||||
await db.SaveChangesAsync();
|
||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
if (user is not null)
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Online;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
|
||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||
throw;
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
|
||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||
: [];
|
||||
|
||||
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
||||
|
||||
if (username is not null && !presenceTracker.IsOnline(username))
|
||||
try
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user is not null)
|
||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||
: [];
|
||||
|
||||
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
||||
|
||||
if (username is not null && !presenceTracker.IsOnline(username))
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Invisible;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
UserStatus.Invisible,
|
||||
user.StatusMessage);
|
||||
|
||||
foreach (var channel in channelsBeforeDisconnect)
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user is not null)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Invisible;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
UserStatus.Invisible,
|
||||
user.StatusMessage);
|
||||
|
||||
foreach (var channel in channelsBeforeDisconnect)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
|
||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||
throw;
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
|
||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannel(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||
return [];
|
||||
}
|
||||
|
||||
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
|
||||
|
||||
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
|
||||
return history;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||
return [];
|
||||
}
|
||||
|
||||
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
|
||||
|
||||
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
|
||||
return history;
|
||||
}
|
||||
|
||||
public async Task LeaveChannel(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
||||
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
|
||||
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessage(string channelName, string content)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name.");
|
||||
return;
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
await Clients.Caller.Error("Message content cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.Length > HubConstants.MaxMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Text,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channel.Id,
|
||||
SenderUserId = CurrentUserId,
|
||||
SenderUsername = CurrentUsername,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
message.Content,
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
MessageType.Text,
|
||||
null,
|
||||
null,
|
||||
message.SentAt);
|
||||
|
||||
await Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Clients.Caller.Error("Message content cannot be empty.");
|
||||
return;
|
||||
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
||||
}
|
||||
|
||||
if (content.Length > HubConstants.MaxMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Text,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channel.Id,
|
||||
SenderUserId = CurrentUserId,
|
||||
SenderUsername = CurrentUsername,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
message.Content,
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
MessageType.Text,
|
||||
null,
|
||||
null,
|
||||
message.SentAt);
|
||||
|
||||
await Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
if (channel is null)
|
||||
return [];
|
||||
|
||||
var messages = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
u => u.Id,
|
||||
(m, u) => new MessageDto(
|
||||
m.Id,
|
||||
m.Content,
|
||||
m.SenderUsername,
|
||||
u.NicknameColor,
|
||||
channelName,
|
||||
m.Type,
|
||||
m.AttachmentUrl,
|
||||
m.AttachmentFileName,
|
||||
m.SentAt))
|
||||
.ToListAsync();
|
||||
|
||||
messages.Reverse();
|
||||
return messages;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
||||
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
||||
return [];
|
||||
|
||||
var messages = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
u => u.Id,
|
||||
(m, u) => new MessageDto(
|
||||
m.Id,
|
||||
m.Content,
|
||||
m.SenderUsername,
|
||||
u.NicknameColor,
|
||||
channelName,
|
||||
m.Type,
|
||||
m.AttachmentUrl,
|
||||
m.AttachmentFileName,
|
||||
m.SentAt))
|
||||
.ToListAsync();
|
||||
|
||||
messages.Reverse();
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateStatus(UserStatus status, string? statusMessage)
|
||||
{
|
||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
||||
return;
|
||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await Clients.Caller.Error("User not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
user.Status = status;
|
||||
user.StatusMessage = statusMessage?.Trim();
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
status,
|
||||
statusMessage);
|
||||
|
||||
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
}
|
||||
}
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
if (user is null)
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Clients.Caller.Error("User not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
user.Status = status;
|
||||
user.StatusMessage = statusMessage?.Trim();
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
status,
|
||||
statusMessage);
|
||||
|
||||
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserPresenceDto>> GetOnlineUsers(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||
|
||||
var users = await db.Users
|
||||
.Where(u => onlineUsernames.Contains(u.Username))
|
||||
.Select(u => new UserPresenceDto(
|
||||
u.Username,
|
||||
u.DisplayName,
|
||||
u.NicknameColor,
|
||||
u.Status,
|
||||
u.StatusMessage))
|
||||
.ToListAsync();
|
||||
var users = await db.Users
|
||||
.Where(u => onlineUsernames.Contains(u.Username))
|
||||
.Select(u => new UserPresenceDto(
|
||||
u.Username,
|
||||
u.DisplayName,
|
||||
u.NicknameColor,
|
||||
u.Status,
|
||||
u.StatusMessage))
|
||||
.ToListAsync();
|
||||
|
||||
return users;
|
||||
return users;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
||||
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user