feat: enhance chat functionality with auto-join, persistent read positions, and theme border customization

This commit is contained in:
HueByte
2026-07-16 08:19:43 +02:00
parent c048b39e42
commit aae788028e
10 changed files with 320 additions and 56 deletions
+93 -12
View File
@@ -67,6 +67,8 @@ public sealed class AppOrchestrator : IDisposable
public void Dispose()
{
// Quit while still connected — capture read positions before tearing down
PersistLastReads();
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
_updateService.Dispose();
}
@@ -384,6 +386,10 @@ public sealed class AppOrchestrator : IDisposable
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
if (history is null) return; // user cancelled the password prompt
// A deliberate join cancels any earlier /leave exclusion
UpdateServerConfig(server =>
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
InvokeUI(() =>
{
_mainWindow.EnsureChannelInList(channelName);
@@ -553,6 +559,14 @@ public sealed class AppOrchestrator : IDisposable
try
{
await _conn.LeaveChannelAsync(channel);
// Remember the leave so the connect-time auto-join doesn't pull us back in
UpdateServerConfig(server =>
{
if (!server.LeftChannels.Contains(channel, StringComparer.OrdinalIgnoreCase))
server.LeftChannels.Add(channel);
});
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
}
catch (Exception ex)
@@ -957,13 +971,28 @@ public sealed class AppOrchestrator : IDisposable
_session.Username = result.Login.Username;
// Persisted last-read markers for this server — used to seed unread counts,
// mention highlights, and "new messages" markers from the fetched histories.
var lastReads = ConfigManager.Load().SavedServers
.FirstOrDefault(s => string.Equals(s.Url, dialogResult.ServerUrl, StringComparison.OrdinalIgnoreCase))
?.LastReadMessages ?? [];
InvokeUI(() =>
{
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
_mainWindow.SetChannels(result.Channels);
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
if (result.DefaultHistory.Count > 0)
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
foreach (var (channel, history) in result.Histories)
{
if (history.Count == 0)
continue;
Guid? lastRead = lastReads.TryGetValue(channel, out var idText)
&& Guid.TryParse(idText, out var id) ? id : null;
_messageManager.LoadHistory(channel, history, lastRead);
}
_mainWindow.FocusInput();
FetchAndUpdateOnlineUsers();
});
@@ -975,6 +1004,7 @@ public sealed class AppOrchestrator : IDisposable
{
Log.Information("Disconnecting from server");
lock (_channelUsersLock) _channelUsers.Clear();
PersistLastReads();
RunAsync(async () =>
{
@@ -990,6 +1020,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleLogout()
{
Log.Information("Logging out from server");
PersistLastReads();
RunAsync(async () =>
{
@@ -1061,6 +1092,9 @@ public sealed class AppOrchestrator : IDisposable
{
if (!_conn.IsConnected) return;
// Checkpoint read positions — the previous channel was just marked read
PersistLastReads();
RunAsync(async () =>
{
if (_conn.TrackChannel(channelName))
@@ -1073,6 +1107,10 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
return;
}
// A deliberate join cancels any earlier /leave exclusion
UpdateServerConfig(server =>
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
}
try
@@ -1667,20 +1705,63 @@ public sealed class AppOrchestrator : IDisposable
private void SaveServerToConfig(ConnectDialogResult result)
{
var savedServer = new SavedServer
// Update the existing entry in place (never replace it) — the per-server entry also
// carries cached room keys, left channels, and last-read markers that must survive.
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, result.ServerUrl, StringComparison.OrdinalIgnoreCase));
if (server is null)
{
Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl,
Username = result.Username,
RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now
};
ConfigManager.SaveServer(savedServer);
_config = ConfigManager.Load();
server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl };
config.SavedServers.Add(server);
}
server.Username = result.Username;
server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null;
server.RememberMe = result.RememberMe;
server.LastConnected = DateTimeOffset.Now;
ConfigManager.Save(config);
_config = config;
Log.Information("Connected successfully to {Url}", result.ServerUrl);
}
/// <summary>
/// Mutates the current server's config entry and persists it. No-op when not
/// authenticated or the server isn't saved.
/// </summary>
private void UpdateServerConfig(Action<SavedServer> mutate)
{
var url = _conn.Api?.BaseUrl;
if (url is null) return;
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
if (server is null) return;
mutate(server);
ConfigManager.Save(config);
_config = config;
}
/// <summary>
/// Persists the in-memory last-read message ids to the current server's config entry,
/// so unread/mention state can be reconstructed on the next connect.
/// </summary>
private void PersistLastReads()
{
var lastReads = _messageManager.LastReadIds;
if (lastReads.Count == 0) return;
UpdateServerConfig(server =>
{
foreach (var (channel, id) in lastReads)
server.LastReadMessages[channel] = id.ToString();
});
}
private void ClearSavedToken(string serverUrl)
{
var config = ConfigManager.Load();
+12
View File
@@ -42,6 +42,18 @@ public class SavedServer
/// user's machine — the server never sees them.
/// </summary>
public Dictionary<string, string> ChannelKeys { get; set; } = [];
/// <summary>
/// Channels the user explicitly left with /leave. Excluded from the automatic
/// join-all-channels pass at connect until the user joins them again.
/// </summary>
public List<string> LeftChannels { get; set; } = [];
/// <summary>
/// Last message the user has read per channel (message id as string), persisted so
/// unread counts, @mention highlights, and the "new messages" marker survive restarts.
/// </summary>
public Dictionary<string, string> LastReadMessages { get; set; } = [];
}
public class AccountPreset
+45 -29
View File
@@ -9,6 +9,10 @@ public static class ConfigManager
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
// Load-mutate-save cycles run from both the UI thread and background tasks
// (token refresh, room keys, last-read checkpoints) — serialize file access.
private static readonly Lock FileLock = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
@@ -17,54 +21,66 @@ public static class ConfigManager
public static ClientConfig Load()
{
try
lock (FileLock)
{
if (!File.Exists(ConfigPath))
return new ClientConfig();
try
{
if (!File.Exists(ConfigPath))
return new ClientConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
}
catch
{
return new ClientConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
}
catch
{
return new ClientConfig();
}
}
}
public static void Save(ClientConfig config)
{
try
lock (FileLock)
{
Directory.CreateDirectory(ConfigDir);
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
catch
{
// Silently fail — config save is best-effort
try
{
Directory.CreateDirectory(ConfigDir);
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
catch
{
// Silently fail — config save is best-effort
}
}
}
public static void SaveServer(SavedServer server)
{
var config = Load();
var existing = config.SavedServers.FindIndex(s =>
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
lock (FileLock)
{
var config = Load();
var existing = config.SavedServers.FindIndex(s =>
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
if (existing >= 0)
config.SavedServers[existing] = server;
else
config.SavedServers.Add(server);
if (existing >= 0)
config.SavedServers[existing] = server;
else
config.SavedServers.Add(server);
Save(config);
Save(config);
}
}
public static void RemoveServer(string url)
{
var config = Load();
config.SavedServers.RemoveAll(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
lock (FileLock)
{
var config = Load();
config.SavedServers.RemoveAll(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
Save(config);
Save(config);
}
}
}
@@ -9,11 +9,13 @@ namespace EchoHub.Client.Services;
/// <summary>
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
/// <paramref name="Histories"/> holds the initial history of every auto-joined channel
/// (keyed by channel name, always including the default channel).
/// </summary>
internal record ConnectResult(
LoginResponse Login,
List<ChannelDto> Channels,
List<MessageDto> DefaultHistory);
Dictionary<string, List<MessageDto>> Histories);
/// <summary>
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
@@ -109,24 +111,54 @@ internal sealed class ConnectionManager : IAsyncDisposable
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
onStatus("Joining channels...");
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
var histories = new Dictionary<string, List<MessageDto>>(StringComparer.OrdinalIgnoreCase);
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
}
catch
{
// History might not be available
}
return new ConnectResult(loginResponse, channels, history);
// Auto-join every other channel the server lists for this user (public +
// prior memberships) so message events — unread counts, @mentions — flow for
// all of them, not just channels opened this session. Channels the user left
// with /leave stay out until rejoined; protected channels we can't enter
// silently (no cached membership) are skipped, never prompted for.
var leftChannels = FindServer(ConfigManager.Load(), info.ServerUrl)?.LeftChannels ?? [];
foreach (var channel in channels)
{
if (channel.Name.Equals(HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase))
continue;
if (leftChannels.Contains(channel.Name, StringComparer.OrdinalIgnoreCase))
continue;
try
{
var outcome = await _connection.JoinChannelAsync(channel.Name);
_joinedChannels.Add(channel.Name);
histories[channel.Name] = outcome.History;
}
catch (ChannelPasswordRequiredException)
{
// First-time protected channel — joining stays a manual, prompted action
}
catch (Exception ex)
{
Log.Warning(ex, "Auto-join failed for #{Channel}", channel.Name);
}
}
onStatus("Connected");
return new ConnectResult(loginResponse, channels, histories);
}
catch
{
@@ -236,11 +268,19 @@ internal sealed class ConnectionManager : IAsyncDisposable
foreach (var channel in channels)
{
_joinedChannels.Add(channel);
await _connection.JoinChannelAsync(channel);
// One channel gone bad (deleted, membership revoked) must not stop the rest
try
{
await _connection.JoinChannelAsync(channel);
_joinedChannels.Add(channel);
}
catch (Exception ex)
{
Log.Warning(ex, "Rejoin failed for #{Channel}", channel);
}
}
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
Log.Information("Rejoined {Count} channel(s) after reconnect", _joinedChannels.Count);
}
// ── SignalR Event Wiring ──────────────────────────────────────────────
@@ -268,8 +308,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
{
if (_apiClient?.RefreshToken is null) return;
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
var server = FindServer(config, _apiClient.BaseUrl);
if (server is not null && server.RememberMe)
{
server.RefreshToken = _apiClient.RefreshToken;
@@ -277,6 +316,10 @@ internal sealed class ConnectionManager : IAsyncDisposable
}
}
private static SavedServer? FindServer(ClientConfig config, string url) =>
config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
// ── Dispose ───────────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
+8
View File
@@ -7,6 +7,14 @@ public class Theme
public ThemeColors Menu { get; set; } = new();
public ThemeColors Dialog { get; set; } = new();
public ThemeColors Status { get; set; } = new();
/// <summary>
/// Colors for the main-window frame borders (and their titles). Null falls back
/// to <see cref="Base"/>. Lets themes tone borders down independently of text —
/// e.g. the transparent themes use a dim gray for a subtler, glassy look.
/// Supports hex values ("#6E6E6E") as well as named colors.
/// </summary>
public ThemeColors? Border { get; set; }
}
public class ThemeColors
+18
View File
@@ -442,6 +442,14 @@ public static class ThemeManager
Background = "None",
FocusForeground = "Gray",
FocusBackground = "None"
},
// Dim, grayish borders — bright white frames fight the glassy transparent look
Border = new ThemeColors
{
Foreground = "#6E6E6E",
Background = "None",
FocusForeground = "#8A8A8A",
FocusBackground = "None"
}
};
@@ -475,6 +483,14 @@ public static class ThemeManager
Background = "None",
FocusForeground = "DarkGray",
FocusBackground = "None"
},
// Softer gray borders against light terminal backgrounds
Border = new ThemeColors
{
Foreground = "#8F8F8F",
Background = "None",
FocusForeground = "#6E6E6E",
FocusBackground = "None"
}
};
@@ -546,6 +562,8 @@ public static class ThemeManager
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
// Frame borders/titles; themes without an explicit Border section keep Base
SchemeManager.AddScheme("Border", BuildColorScheme(theme.Border ?? theme.Base));
}
public static void SaveTheme(Theme theme)
@@ -28,6 +28,8 @@ public sealed class ChatMessageManager
private readonly HashSet<string> _markedChannels = [];
private readonly Dictionary<string, Guid> _markerAnchor = [];
private readonly HashSet<string> _mentionChannels = [];
private readonly Dictionary<string, Guid> _lastRead = [];
private readonly Dictionary<string, Guid> _channelNewestId = [];
private string _currentUser = string.Empty;
private string _currentChannel = string.Empty;
@@ -50,8 +52,10 @@ public sealed class ChatMessageManager
return;
// Leaving a channel consumes its "new messages" marker so the next
// unread burst gets a fresh one (irssi behavior).
// unread burst gets a fresh one (irssi behavior), and everything
// visible up to now counts as read.
RemoveUnreadMarker(_currentChannel);
MarkRead(_currentChannel);
_currentChannel = value;
}
}
@@ -78,6 +82,19 @@ public sealed class ChatMessageManager
{
_channelUnread[channelName] = 0;
_mentionChannels.Remove(channelName);
MarkRead(channelName);
}
/// <summary>
/// Last message the user has read per channel — persisted by the orchestrator so
/// unread/mention state can be seeded from history on the next connect.
/// </summary>
public IReadOnlyDictionary<string, Guid> LastReadIds => _lastRead;
private void MarkRead(string channelName)
{
if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest))
_lastRead[channelName] = newest;
}
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
@@ -108,6 +125,9 @@ public sealed class ChatMessageManager
}
var isCurrent = message.ChannelName == _currentChannel;
_channelNewestId[message.ChannelName] = message.Id;
if (isCurrent)
_lastRead[message.ChannelName] = message.Id;
// First unread message in an inactive channel → "new messages" marker,
// anchored to this message so a history reload can re-place it
@@ -213,11 +233,17 @@ public sealed class ChatMessageManager
/// <summary>
/// Load historical messages into a channel, replacing any existing messages.
/// When <paramref name="lastReadId"/> is given (persisted from a previous session),
/// messages after it seed the unread count, @mention highlight, and the
/// "new messages" marker — so activity that happened while offline still lights up.
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages)
public void LoadHistory(string channelName, List<MessageDto> messages, Guid? lastReadId = null)
{
var formatted = FormatWithDateRules(messages, out var lastDate);
if (messages.Count > 0)
_channelNewestId[channelName] = messages[^1].Id;
// Re-place the "new messages" marker at its anchor — channel selection
// reloads history, which would otherwise wipe the marker right when the
// user switches in to read the unread backlog.
@@ -237,6 +263,10 @@ public sealed class ChatMessageManager
_markerAnchor.Remove(channelName);
}
}
else if (lastReadId is { } lastRead && messages.Count > 0)
{
SeedUnreadFromHistory(channelName, messages, formatted, lastRead);
}
_channelMessages[channelName] = formatted;
@@ -245,8 +275,46 @@ public sealed class ChatMessageManager
else
_channelLastDate.Remove(channelName);
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Reconstructs unread state from a persisted last-read message id: places the
/// "new messages" marker before the first unread message and, for inactive
/// channels, seeds the unread count and @mention highlight. A last-read id that
/// is no longer inside the fetched window treats the whole window as unread.
/// </summary>
private void SeedUnreadFromHistory(string channelName, List<MessageDto> messages,
List<ChatLine> formatted, Guid lastReadId)
{
// FindIndex miss (-1 → 0) means the last-read message is older than the fetched
// window: everything in the window counts as unread.
var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1;
if (firstUnread >= messages.Count)
return; // everything read
var anchor = messages[firstUnread];
var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id);
if (lineIdx < 0)
return;
formatted.Insert(lineIdx, UnreadMarkerRule());
_markedChannels.Add(channelName);
_markerAnchor[channelName] = anchor.Id;
// The active channel shows the marker but is being read right now —
// badges and mention highlights are only for background channels.
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
return;
_channelUnread[channelName] = messages.Count - firstUnread;
if (!string.IsNullOrEmpty(_currentUser))
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)))
_mentionChannels.Add(channelName);
}
}
/// <summary>
@@ -302,6 +370,8 @@ public sealed class ChatMessageManager
_markedChannels.Clear();
_markerAnchor.Clear();
_mentionChannels.Clear();
_lastRead.Clear();
_channelNewestId.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
}
+10
View File
@@ -366,6 +366,7 @@ public sealed partial class MainWindow : Runnable
{
var baseScheme = SchemeManager.GetScheme("Base");
var menuScheme = SchemeManager.GetScheme("Menu");
var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme;
if (baseScheme is not null)
{
@@ -376,6 +377,12 @@ public sealed partial class MainWindow : Runnable
{
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
sub.SetScheme(baseScheme);
// Frame borders (and their titles) take the theme's border colors, so
// themes can tone them down independently of text (e.g. transparent
// themes use dim gray instead of eye-catching white)
if (sub is FrameView frame && borderScheme is not null)
frame.Border?.SetScheme(borderScheme);
}
}
@@ -919,6 +926,9 @@ public sealed partial class MainWindow : Runnable
RefreshMessages();
else
RefreshChannelList();
// Background-channel activity feeds the status bar's Act segment
_statusLabel.SetNeedsDraw();
}
private void OnHistoryPrepended(string channelName)