mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
|
||||
To install a specific version or to a custom directory:
|
||||
|
||||
```bash
|
||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.8
|
||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.10
|
||||
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.9](v0.2.9.md) - Linux/macOS Single-File Publish Fix
|
||||
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
||||
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
|
||||
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.10
|
||||
href: v0.2.10.md
|
||||
- name: v0.2.9
|
||||
href: v0.2.9.md
|
||||
- name: v0.2.8
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# v0.2.10
|
||||
|
||||
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
|
||||
|
||||
## New Features
|
||||
|
||||
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
|
||||
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
|
||||
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
|
||||
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
|
||||
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
|
||||
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
|
||||
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
|
||||
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
|
||||
@@ -3,3 +3,21 @@
|
||||
## Bug Fixes
|
||||
|
||||
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
|
||||
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
|
||||
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
|
||||
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
|
||||
|
||||
## Documentation
|
||||
|
||||
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
|
||||
- Refresh README badges and reorganize the articles table of contents for better discoverability
|
||||
- Polish Docker, getting-started, and flow docs to match the current configuration surface
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
|
||||
|
||||
## CI
|
||||
|
||||
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
|
||||
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
- that means basically multiple servers linked, so users can chat cross-server in this network
|
||||
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
||||
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
|
||||
- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||
- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
|
||||
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
|
||||
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
|
||||
sed -n '2,8p' "$0" 2>/dev/null || true
|
||||
echo ""
|
||||
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
||||
echo " curl ... | sh -s -- --version 0.2.8"
|
||||
echo " curl ... | sh -s -- --version 0.2.10"
|
||||
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.9</Version>
|
||||
<Version>0.2.10</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private readonly ConnectionManager _conn = new();
|
||||
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _channelUsersLock = new();
|
||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private ClientConfig _config;
|
||||
private readonly UserSession _session = new();
|
||||
@@ -91,6 +92,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||
}
|
||||
|
||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||
@@ -720,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}, "Failed to join channel");
|
||||
}
|
||||
|
||||
private void HandleLoadMoreRequested()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
if (!_channelsLoadingMore.Add(channel)) return;
|
||||
|
||||
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
|
||||
InvokeUI(() => _messageManager.PrependHistory(channel, history));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_channelsLoadingMore.Remove(channel);
|
||||
}
|
||||
}, "Failed to load more messages");
|
||||
}
|
||||
|
||||
private void HandleChannelJoinFromMessage(string channelName)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -733,6 +761,38 @@ public sealed class AppOrchestrator : IDisposable
|
||||
HandleChannelSelected(channelName);
|
||||
}
|
||||
|
||||
|
||||
private void HandleSearchRequested()
|
||||
{
|
||||
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
|
||||
if (result is null) return;
|
||||
|
||||
switch (result.Type)
|
||||
{
|
||||
case SearchResultType.Channel:
|
||||
_mainWindow.SwitchToChannel(result.Key);
|
||||
HandleChannelSelected(result.Key);
|
||||
break;
|
||||
|
||||
case SearchResultType.Action:
|
||||
switch (result.Key)
|
||||
{
|
||||
case "connect": HandleConnect(); break;
|
||||
case "disconnect": HandleDisconnect(); break;
|
||||
case "logout": HandleLogout(); break;
|
||||
case "profile": HandleProfileRequested(); break;
|
||||
case "status": HandleStatusRequested(); break;
|
||||
case "create-channel": HandleCreateChannelRequested(); break;
|
||||
case "delete-channel": HandleDeleteChannelRequested(); break;
|
||||
case "servers": HandleSavedServersRequested(); break;
|
||||
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
|
||||
case "updates": HandleCheckForUpdatesRequested(); break;
|
||||
case "quit": _app.RequestStop(); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProfileRequested()
|
||||
{
|
||||
HandleViewProfile(null);
|
||||
|
||||
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_connection?.SendMessageAsync(channel, content)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
||||
_connection?.GetHistoryAsync(channel)
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||
_connection?.GetHistoryAsync(channel, count, offset)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||
|
||||
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
|
||||
return DecryptMessages(messages);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
|
||||
|
||||
public class NotificationSoundService
|
||||
{
|
||||
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
|
||||
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Player _player = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly NotificationConfig _config;
|
||||
private string? _resolvedSoundPath;
|
||||
|
||||
@@ -41,18 +45,31 @@ public class NotificationSoundService
|
||||
|
||||
private async Task PlayInternal()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
|
||||
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
|
||||
// to hold the lock for the duration of the sound. A one-shot handler + timeout
|
||||
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
|
||||
// (TrySetResult); handler throws → caller's catch still runs finally.
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
|
||||
_player.PlaybackFinished += OnFinished;
|
||||
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
await _player.Stop();
|
||||
|
||||
await _player.SetVolume(_config.Volume);
|
||||
await _player.Play(_resolvedSoundPath!);
|
||||
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to play notification sound");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_player.PlaybackFinished -= OnFinished;
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveSoundPath()
|
||||
|
||||
@@ -40,7 +40,37 @@ public static class UpdateBackupService
|
||||
|
||||
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
|
||||
|
||||
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false);
|
||||
using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(appDir, file);
|
||||
|
||||
// Skip log files to prevent locking errors with Serilog while zipping
|
||||
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for the zip archive format
|
||||
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
|
||||
try
|
||||
{
|
||||
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable
|
||||
// Create backup before the update starts
|
||||
try
|
||||
{
|
||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup..."));
|
||||
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
||||
UpdateBackupService.CreateBackup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable
|
||||
Log.Error(ex, "Failed to create pre-update backup");
|
||||
|
||||
var proceed = false;
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
proceed = MessageBox.Query(
|
||||
_app,
|
||||
"Backup Warning",
|
||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||
"Continue", "Cancel") == 0;
|
||||
});
|
||||
proceed = MessageBox.Query(
|
||||
_app,
|
||||
"Backup Warning",
|
||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||
"Continue", "Cancel") == 0;
|
||||
|
||||
if (!proceed)
|
||||
{
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
_progressDialog?.Close();
|
||||
_progressDialog = null;
|
||||
});
|
||||
_progressDialog?.Close();
|
||||
_progressDialog = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update..."));
|
||||
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
||||
await _updater.UpdateAsync();
|
||||
});
|
||||
|
||||
|
||||
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
|
||||
MessagesChanged?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
||||
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
||||
/// </summary>
|
||||
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
|
||||
{
|
||||
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||
return;
|
||||
|
||||
var existingIds = existing
|
||||
.Where(l => l.MessageId.HasValue)
|
||||
.Select(l => l.MessageId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
var newLines = olderMessages
|
||||
.Where(m => !existingIds.Contains(m.Id))
|
||||
.SelectMany(FormatMessage)
|
||||
.ToList();
|
||||
|
||||
if (newLines.Count == 0)
|
||||
return;
|
||||
|
||||
existing.InsertRange(0, newLines);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
HistoryPrepended?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
|
||||
/// </summary>
|
||||
public event Action<string>? HistoryPrepended;
|
||||
|
||||
/// <summary>
|
||||
/// Reset all message state (used on disconnect).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using EchoHub.Client.UI.ListSources;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Input;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
public enum SearchResultType
|
||||
{
|
||||
Channel,
|
||||
Action
|
||||
}
|
||||
|
||||
public record SearchResult(SearchResultType Type, string Key, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
|
||||
/// </summary>
|
||||
public static class SearchDialog
|
||||
{
|
||||
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
|
||||
new(SearchResultType.Action, "connect", "Connect to Server"),
|
||||
new(SearchResultType.Action, "disconnect", "Disconnect"),
|
||||
new(SearchResultType.Action, "logout", "Logout"),
|
||||
new(SearchResultType.Action, "profile", "My Profile"),
|
||||
new(SearchResultType.Action, "status", "Set Status"),
|
||||
new(SearchResultType.Action, "create-channel", "Create Channel"),
|
||||
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
|
||||
new(SearchResultType.Action, "servers", "Saved Servers"),
|
||||
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
|
||||
new(SearchResultType.Action, "updates", "Check for Updates"),
|
||||
new(SearchResultType.Action, "quit", "Quit"),
|
||||
];
|
||||
|
||||
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
|
||||
{
|
||||
SearchResult? result = null;
|
||||
var source = new SearchListSource(BuildAllItems(channels));
|
||||
|
||||
var dialog = new Dialog
|
||||
{
|
||||
Title = "Search",
|
||||
Width = 59,
|
||||
Height = 22,
|
||||
};
|
||||
|
||||
var hintLabel = new Label
|
||||
{
|
||||
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
|
||||
X = 1,
|
||||
Y = 1,
|
||||
};
|
||||
|
||||
var searchField = new TextField
|
||||
{
|
||||
X = 1,
|
||||
Y = 2,
|
||||
Title = "Search",
|
||||
Width = Dim.Fill(2),
|
||||
};
|
||||
|
||||
var resultList = new ListView
|
||||
{
|
||||
X = 1,
|
||||
Y = 4,
|
||||
Width = Dim.Fill(2),
|
||||
Height = Dim.Fill(3),
|
||||
Source = source
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center(),
|
||||
Y = Pos.AnchorEnd(1),
|
||||
};
|
||||
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
|
||||
searchField.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
searchField.TextChanged += (s, e) =>
|
||||
{
|
||||
source.Filter(searchField.Text ?? string.Empty);
|
||||
resultList.Source = source;
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
};
|
||||
|
||||
searchField.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
|
||||
{
|
||||
app.Invoke(() =>
|
||||
{
|
||||
searchField.SetFocus();
|
||||
});
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
dialog.Add(hintLabel, searchField, resultList, cancelButton);
|
||||
searchField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
|
||||
void TryConfirm(CommandEventArgs e)
|
||||
{
|
||||
var idx = resultList.SelectedItem ?? 0;
|
||||
if (source.Count > 0 && idx >= 0 && idx < source.Count)
|
||||
{
|
||||
result = source.GetItem(idx);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
|
||||
{
|
||||
var items = new List<SearchResult>();
|
||||
foreach (var ch in channels)
|
||||
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
|
||||
items.AddRange(DefaultActions);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using EchoHub.Client.UI.Chat;
|
||||
using EchoHub.Client.UI.Dialogs;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI.ListSources;
|
||||
|
||||
/// <summary>
|
||||
/// List data source for the search dialog with filtering and colored rendering.
|
||||
/// </summary>
|
||||
public class SearchListSource(List<SearchResult> items) : IListDataSource
|
||||
{
|
||||
private readonly List<SearchResult> _allItems = items;
|
||||
private List<SearchResult> _filtered = [.. items];
|
||||
|
||||
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
|
||||
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
public int Count => _filtered.Count;
|
||||
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
|
||||
public bool SuspendCollectionChangedEvent { get; set; }
|
||||
|
||||
public void Filter(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
_filtered = [.. _allItems];
|
||||
}
|
||||
else
|
||||
{
|
||||
_filtered = [.. _allItems.Where(i =>
|
||||
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
|
||||
}
|
||||
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
|
||||
|
||||
public bool IsMarked(int item) => false;
|
||||
public void SetMark(int item, bool value) { }
|
||||
public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
|
||||
|
||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||
{
|
||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||
|
||||
var entry = _filtered[item];
|
||||
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||
|
||||
Attribute itemAttr;
|
||||
if (selected)
|
||||
{
|
||||
itemAttr = fillAttr;
|
||||
}
|
||||
else
|
||||
{
|
||||
var raw = entry.Type switch
|
||||
{
|
||||
SearchResultType.Channel => ChannelAttribute,
|
||||
SearchResultType.Action => ActionAttribute,
|
||||
_ => fillAttr
|
||||
};
|
||||
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
|
||||
}
|
||||
|
||||
listView.SetAttribute(itemAttr);
|
||||
|
||||
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
|
||||
|
||||
listView.SetAttribute(fillAttr);
|
||||
for (var i = drawn; i < width; i++)
|
||||
listView.AddStr(" ");
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Themes;
|
||||
@@ -48,6 +49,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||
private static readonly Key TabKey = Key.Tab;
|
||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
@@ -116,6 +118,11 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action? OnSavedServersRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
|
||||
/// </summary>
|
||||
public event Action? OnLoadMoreRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to create a new channel.
|
||||
/// </summary>
|
||||
@@ -151,11 +158,17 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string>? OnChannelJoinRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
|
||||
/// </summary>
|
||||
public event Action? OnSearchRequested;
|
||||
|
||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||
{
|
||||
_app = app;
|
||||
_messageManager = messageManager;
|
||||
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||
_messageManager.HistoryPrepended += OnHistoryPrepended;
|
||||
Arrangement = ViewArrangement.Fixed;
|
||||
|
||||
// Menu bar at the top
|
||||
@@ -216,13 +229,16 @@ public sealed partial class MainWindow : Runnable
|
||||
};
|
||||
_messageList.Source = new ChatListSource();
|
||||
_messageList.Accepting += OnMessageListAccepting;
|
||||
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||
_messageList.VerticalScrollBar.Visible = true;
|
||||
|
||||
_chatFrame.Add(_messageList);
|
||||
Add(_chatFrame);
|
||||
|
||||
// Bottom input area
|
||||
_inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
|
||||
X = 22,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(UsersPanelWidth),
|
||||
@@ -472,6 +488,12 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||
{
|
||||
if (_messageList.VerticalScrollBar.Value == 0)
|
||||
OnLoadMoreRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
||||
{
|
||||
var index = _usersList.SelectedItem;
|
||||
@@ -513,6 +535,11 @@ public sealed partial class MainWindow : Runnable
|
||||
_app.RequestStop();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool _suppressEmojiReplace;
|
||||
@@ -572,6 +599,9 @@ public sealed partial class MainWindow : Runnable
|
||||
if (prefix.Length > text.Length)
|
||||
_inputField.Text = prefix;
|
||||
}
|
||||
|
||||
// Move cursor to end after autocomplete
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
@@ -597,6 +627,16 @@ public sealed partial class MainWindow : Runnable
|
||||
ToggleUsersPanel();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSearchDialog()
|
||||
{
|
||||
OnSearchRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnMessagesChanged(string channelName)
|
||||
@@ -607,6 +647,26 @@ public sealed partial class MainWindow : Runnable
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
private void OnHistoryPrepended(string channelName)
|
||||
{
|
||||
if (channelName != _messageManager.CurrentChannel)
|
||||
return;
|
||||
|
||||
var messages = _messageManager.GetMessages(channelName);
|
||||
if (messages is null)
|
||||
return;
|
||||
|
||||
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
|
||||
|
||||
RefreshMessages();
|
||||
|
||||
// Scroll to the item that was at the top before the prepend so the user
|
||||
// stays at their previous reading position rather than jumping to the top.
|
||||
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
|
||||
if (prependedCount > 0)
|
||||
_messageList.SelectedItem = prependedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,7 +13,7 @@ public static partial class ValidationConstants
|
||||
public const int MaxBioLength = 500;
|
||||
public const int MaxStatusMessageLength = 100;
|
||||
public const int MaxChannelTopicLength = 500;
|
||||
public const int MaxHistoryCount = 100;
|
||||
public const int MaxHistoryCount = 200;
|
||||
|
||||
[GeneratedRegex(UsernamePattern)]
|
||||
public static partial Regex UsernameRegex();
|
||||
|
||||
@@ -15,7 +15,7 @@ public interface IChatService
|
||||
|
||||
// Messaging
|
||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||
|
||||
// Presence
|
||||
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
|
||||
|
||||
@@ -106,11 +106,11 @@ public class ChatHub : Hub<IEchoHubClient>
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _chatService.GetChannelHistoryAsync(channelName, count);
|
||||
return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -245,15 +245,16 @@ public class ChatService : IChatService
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count)
|
||||
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
offset = Math.Max(offset, 0);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
||||
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||
}
|
||||
|
||||
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||
@@ -366,7 +367,7 @@ public class ChatService : IChatService
|
||||
return string.Join('\n', result);
|
||||
}
|
||||
|
||||
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
|
||||
{
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (channel is null)
|
||||
@@ -375,6 +376,7 @@ public class ChatService : IChatService
|
||||
var raw = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Skip(offset)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
|
||||
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
|
||||
return Task.FromResult(SendMessageError);
|
||||
}
|
||||
|
||||
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
|
||||
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
|
||||
Task.FromResult(HistoryToReturn);
|
||||
|
||||
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||
|
||||
Reference in New Issue
Block a user