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:
@@ -4,7 +4,7 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul: Aligned Nick Column, Nick Colors, Date Rules & Unread Marker
|
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||||
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||||
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||||
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# v0.2.13
|
# v0.2.13
|
||||||
|
|
||||||
A visual overhaul of the chat client: messages now line up in a WeeChat-style column layout with a timestamp gutter, right-aligned nicks, and a vertical rail; every user gets a stable nickname color; day boundaries and unread positions are marked with horizontal rules; the status bar gains an irssi-style activity segment and an animated connecting spinner; and an ASCII-art welcome banner greets you before you join a channel. Modern polish, old IRC soul.
|
A visual overhaul of the chat client — WeeChat-style column layout, per-user nick colors, date rules, unread markers, an activity status segment, and an ASCII welcome banner — plus reliable cross-channel notifications: the client now joins all your channels at connect so unread counts and @mentions light up everywhere, and read positions persist across restarts so activity that happened while you were offline still shows. Modern polish, old IRC soul.
|
||||||
|
|
||||||
## New Features
|
## New Features
|
||||||
|
|
||||||
@@ -13,8 +13,14 @@ A visual overhaul of the chat client: messages now line up in a WeeChat-style co
|
|||||||
- **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel.
|
- **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel.
|
||||||
- **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant.
|
- **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant.
|
||||||
- **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners.
|
- **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners.
|
||||||
|
- **Auto-join all channels at connect** — the client now joins every channel it lists for you (public channels and prior memberships) as part of connecting, so message events flow for all of them: unread badges, @mention highlights, and the status-bar activity segment work without having to open each channel first. Protected channels you've never entered are skipped silently (joining them stays a prompted, manual action), and channels you `/leave` are remembered and excluded until you join them again.
|
||||||
|
- **Read positions survive restarts** — the client persists the last message you read per channel (locally, per server). On the next connect it compares that against fetched history, so messages that arrived while you were offline still produce unread counts, mention highlights, and a correctly placed "new messages" marker.
|
||||||
|
- **Theme-tinted frame borders** — themes can now color the window frame borders independently of text via a new optional `border` section (hex values supported). The Transparent and TransparentLight themes use it to draw dim gray borders instead of stark white ones, for a subtler, glassier look. Custom theme JSON files without a `border` section keep their base colors.
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
||||||
- The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight.
|
- The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight.
|
||||||
- The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it.
|
- The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it.
|
||||||
|
- Cached room keys for end-to-end encrypted channels no longer disappear from the config on reconnect. Saving the server entry after a successful connect replaced it wholesale, wiping the cached keys (and forcing a passphrase re-entry on the next launch); the entry is now updated in place.
|
||||||
|
- A failed channel rejoin after a reconnect (e.g. a channel deleted while you were away) no longer aborts rejoining the remaining channels.
|
||||||
|
- Client config file access is now serialized across threads (token refresh, room-key cache, and read-position checkpoints all write it), preventing rare config corruption.
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
// Quit while still connected — capture read positions before tearing down
|
||||||
|
PersistLastReads();
|
||||||
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
_updateService.Dispose();
|
_updateService.Dispose();
|
||||||
}
|
}
|
||||||
@@ -384,6 +386,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||||
if (history is null) return; // user cancelled the password prompt
|
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(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.EnsureChannelInList(channelName);
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
@@ -553,6 +559,14 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _conn.LeaveChannelAsync(channel);
|
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}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -957,13 +971,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_session.Username = result.Login.Username;
|
_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(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
||||||
_mainWindow.SetChannels(result.Channels);
|
_mainWindow.SetChannels(result.Channels);
|
||||||
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
_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();
|
_mainWindow.FocusInput();
|
||||||
FetchAndUpdateOnlineUsers();
|
FetchAndUpdateOnlineUsers();
|
||||||
});
|
});
|
||||||
@@ -975,6 +1004,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -990,6 +1020,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private void HandleLogout()
|
private void HandleLogout()
|
||||||
{
|
{
|
||||||
Log.Information("Logging out from server");
|
Log.Information("Logging out from server");
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1061,6 +1092,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
|
// Checkpoint read positions — the previous channel was just marked read
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_conn.TrackChannel(channelName))
|
if (_conn.TrackChannel(channelName))
|
||||||
@@ -1073,6 +1107,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -1667,20 +1705,63 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
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,
|
server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl };
|
||||||
Url = result.ServerUrl,
|
config.SavedServers.Add(server);
|
||||||
Username = result.Username,
|
}
|
||||||
RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
|
|
||||||
RememberMe = result.RememberMe,
|
server.Username = result.Username;
|
||||||
LastConnected = DateTimeOffset.Now
|
server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null;
|
||||||
};
|
server.RememberMe = result.RememberMe;
|
||||||
ConfigManager.SaveServer(savedServer);
|
server.LastConnected = DateTimeOffset.Now;
|
||||||
_config = ConfigManager.Load();
|
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
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)
|
private void ClearSavedToken(string serverUrl)
|
||||||
{
|
{
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ public class SavedServer
|
|||||||
/// user's machine — the server never sees them.
|
/// user's machine — the server never sees them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
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
|
public class AccountPreset
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public static class ConfigManager
|
|||||||
|
|
||||||
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -17,54 +21,66 @@ public static class ConfigManager
|
|||||||
|
|
||||||
public static ClientConfig Load()
|
public static ClientConfig Load()
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
if (!File.Exists(ConfigPath))
|
try
|
||||||
return new ClientConfig();
|
{
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
return new ClientConfig();
|
||||||
|
|
||||||
var json = File.ReadAllText(ConfigPath);
|
var json = File.ReadAllText(ConfigPath);
|
||||||
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return new ClientConfig();
|
return new ClientConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Save(ClientConfig config)
|
public static void Save(ClientConfig config)
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(ConfigDir);
|
try
|
||||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
{
|
||||||
File.WriteAllText(ConfigPath, json);
|
Directory.CreateDirectory(ConfigDir);
|
||||||
}
|
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||||
catch
|
File.WriteAllText(ConfigPath, json);
|
||||||
{
|
}
|
||||||
// Silently fail — config save is best-effort
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail — config save is best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SaveServer(SavedServer server)
|
public static void SaveServer(SavedServer server)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
var existing = config.SavedServers.FindIndex(s =>
|
{
|
||||||
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
var config = Load();
|
||||||
|
var existing = config.SavedServers.FindIndex(s =>
|
||||||
|
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (existing >= 0)
|
if (existing >= 0)
|
||||||
config.SavedServers[existing] = server;
|
config.SavedServers[existing] = server;
|
||||||
else
|
else
|
||||||
config.SavedServers.Add(server);
|
config.SavedServers.Add(server);
|
||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RemoveServer(string url)
|
public static void RemoveServer(string url)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
config.SavedServers.RemoveAll(s =>
|
{
|
||||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
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>
|
/// <summary>
|
||||||
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
/// 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>
|
/// </summary>
|
||||||
internal record ConnectResult(
|
internal record ConnectResult(
|
||||||
LoginResponse Login,
|
LoginResponse Login,
|
||||||
List<ChannelDto> Channels,
|
List<ChannelDto> Channels,
|
||||||
List<MessageDto> DefaultHistory);
|
Dictionary<string, List<MessageDto>> Histories);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
@@ -109,24 +111,54 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
onStatus("Connected");
|
|
||||||
|
|
||||||
// Join default channel + fetch history
|
// Join default channel + fetch history
|
||||||
|
onStatus("Joining channels...");
|
||||||
_joinedChannels.Clear();
|
_joinedChannels.Clear();
|
||||||
_joinedChannels.Add(HubConstants.DefaultChannel);
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
List<MessageDto> history = [];
|
var histories = new Dictionary<string, List<MessageDto>>(StringComparer.OrdinalIgnoreCase);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// History might not be available
|
// 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
|
catch
|
||||||
{
|
{
|
||||||
@@ -236,11 +268,19 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
_joinedChannels.Add(channel);
|
// One channel gone bad (deleted, membership revoked) must not stop the rest
|
||||||
await _connection.JoinChannelAsync(channel);
|
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 ──────────────────────────────────────────────
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
@@ -268,8 +308,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (_apiClient?.RefreshToken is null) return;
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
var server = config.SavedServers.FirstOrDefault(s =>
|
var server = FindServer(config, _apiClient.BaseUrl);
|
||||||
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (server is not null && server.RememberMe)
|
if (server is not null && server.RememberMe)
|
||||||
{
|
{
|
||||||
server.RefreshToken = _apiClient.RefreshToken;
|
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 ───────────────────────────────────────────────────────────
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ public class Theme
|
|||||||
public ThemeColors Menu { get; set; } = new();
|
public ThemeColors Menu { get; set; } = new();
|
||||||
public ThemeColors Dialog { get; set; } = new();
|
public ThemeColors Dialog { get; set; } = new();
|
||||||
public ThemeColors Status { 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
|
public class ThemeColors
|
||||||
|
|||||||
@@ -442,6 +442,14 @@ public static class ThemeManager
|
|||||||
Background = "None",
|
Background = "None",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "None"
|
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",
|
Background = "None",
|
||||||
FocusForeground = "DarkGray",
|
FocusForeground = "DarkGray",
|
||||||
FocusBackground = "None"
|
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("Base", BuildColorScheme(theme.Base));
|
||||||
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
||||||
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
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)
|
public static void SaveTheme(Theme theme)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class ChatMessageManager
|
|||||||
private readonly HashSet<string> _markedChannels = [];
|
private readonly HashSet<string> _markedChannels = [];
|
||||||
private readonly Dictionary<string, Guid> _markerAnchor = [];
|
private readonly Dictionary<string, Guid> _markerAnchor = [];
|
||||||
private readonly HashSet<string> _mentionChannels = [];
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _lastRead = [];
|
||||||
|
private readonly Dictionary<string, Guid> _channelNewestId = [];
|
||||||
|
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
@@ -50,8 +52,10 @@ public sealed class ChatMessageManager
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// Leaving a channel consumes its "new messages" marker so the next
|
// 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);
|
RemoveUnreadMarker(_currentChannel);
|
||||||
|
MarkRead(_currentChannel);
|
||||||
_currentChannel = value;
|
_currentChannel = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,19 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
_channelUnread[channelName] = 0;
|
_channelUnread[channelName] = 0;
|
||||||
_mentionChannels.Remove(channelName);
|
_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;
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
@@ -108,6 +125,9 @@ public sealed class ChatMessageManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isCurrent = message.ChannelName == _currentChannel;
|
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,
|
// First unread message in an inactive channel → "new messages" marker,
|
||||||
// anchored to this message so a history reload can re-place it
|
// anchored to this message so a history reload can re-place it
|
||||||
@@ -213,11 +233,17 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load historical messages into a channel, replacing any existing messages.
|
/// 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>
|
/// </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);
|
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
|
// Re-place the "new messages" marker at its anchor — channel selection
|
||||||
// reloads history, which would otherwise wipe the marker right when the
|
// reloads history, which would otherwise wipe the marker right when the
|
||||||
// user switches in to read the unread backlog.
|
// user switches in to read the unread backlog.
|
||||||
@@ -237,6 +263,10 @@ public sealed class ChatMessageManager
|
|||||||
_markerAnchor.Remove(channelName);
|
_markerAnchor.Remove(channelName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (lastReadId is { } lastRead && messages.Count > 0)
|
||||||
|
{
|
||||||
|
SeedUnreadFromHistory(channelName, messages, formatted, lastRead);
|
||||||
|
}
|
||||||
|
|
||||||
_channelMessages[channelName] = formatted;
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
@@ -245,8 +275,46 @@ public sealed class ChatMessageManager
|
|||||||
else
|
else
|
||||||
_channelLastDate.Remove(channelName);
|
_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)
|
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>
|
/// <summary>
|
||||||
@@ -302,6 +370,8 @@ public sealed class ChatMessageManager
|
|||||||
_markedChannels.Clear();
|
_markedChannels.Clear();
|
||||||
_markerAnchor.Clear();
|
_markerAnchor.Clear();
|
||||||
_mentionChannels.Clear();
|
_mentionChannels.Clear();
|
||||||
|
_lastRead.Clear();
|
||||||
|
_channelNewestId.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
var baseScheme = SchemeManager.GetScheme("Base");
|
var baseScheme = SchemeManager.GetScheme("Base");
|
||||||
var menuScheme = SchemeManager.GetScheme("Menu");
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||||
|
var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme;
|
||||||
|
|
||||||
if (baseScheme is not null)
|
if (baseScheme is not null)
|
||||||
{
|
{
|
||||||
@@ -376,6 +377,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
||||||
sub.SetScheme(baseScheme);
|
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();
|
RefreshMessages();
|
||||||
else
|
else
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
|
|
||||||
|
// Background-channel activity feeds the status bar's Act segment
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnHistoryPrepended(string channelName)
|
private void OnHistoryPrepended(string channelName)
|
||||||
|
|||||||
Reference in New Issue
Block a user