mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: enhance chat functionality with auto-join, persistent read positions, and theme border customization
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user