Add message reply support and new commands

This commit is contained in:
Stone_Red
2026-07-18 02:00:22 +02:00
parent 58cb09cfb4
commit 80759e612a
10 changed files with 390 additions and 62 deletions
+11 -1
View File
@@ -2,7 +2,15 @@ using EchoHub.Core.DTOs;
namespace Decho.Models; namespace Decho.Models;
public sealed class MessageModel(string id, UserModel author, DateTimeOffset sentAt, string content, string channelName, string? serverUrl = null, List<AttachmentDto>? attachments = null) public sealed class MessageModel(
string id,
UserModel author,
DateTimeOffset sentAt,
string content,
string channelName,
string? serverUrl = null,
List<AttachmentDto>? attachments = null,
ReplyRefDto? replyTo = null)
{ {
public string Id { get; } = id; public string Id { get; } = id;
@@ -17,4 +25,6 @@ public sealed class MessageModel(string id, UserModel author, DateTimeOffset sen
public string? ServerUrl { get; } = serverUrl; public string? ServerUrl { get; } = serverUrl;
public List<AttachmentDto> Attachments { get; } = attachments ?? []; public List<AttachmentDto> Attachments { get; } = attachments ?? [];
public ReplyRefDto? ReplyTo { get; } = replyTo;
} }
+64 -4
View File
@@ -52,6 +52,8 @@ public sealed class ConnectionService : IDisposable
public event Action<string, string>? ErrorOccurred; public event Action<string, string>? ErrorOccurred;
public event Action<string, string>? ChannelDeleted;
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections; internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections;
@@ -85,14 +87,14 @@ public sealed class ConnectionService : IDisposable
ServerRemoved?.Invoke(serverUrl); ServerRemoved?.Invoke(serverUrl);
} }
public async Task SendMessageAsync(string serverUrl, string channelName, string content) public async Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null)
{ {
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{ {
throw new InvalidOperationException("Not connected to server"); throw new InvalidOperationException("Not connected to server");
} }
await entry.Manager.SendMessageAsync(channelName, content); await entry.Manager.SendMessageAsync(channelName, content, replyToMessageId);
} }
public async Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList<string> filePaths, string? size = null) public async Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList<string> filePaths, string? size = null)
@@ -340,6 +342,58 @@ public sealed class ConnectionService : IDisposable
return await entry.ApiClient.GetChannelCryptoAsync(channelName); return await entry.ApiClient.GetChannelCryptoAsync(channelName);
} }
// ── Invites / Account ────────────────────────────────────────────────
public async Task<InviteDto?> CreateInviteAsync(string serverUrl, int? maxUses, int? expiresInHours)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return null;
}
return await entry.ApiClient.CreateInviteAsync(maxUses, expiresInHours);
}
public async Task<List<InviteDto>> GetInvitesAsync(string serverUrl)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return [];
}
return await entry.ApiClient.GetInvitesAsync();
}
public async Task RevokeInviteAsync(string serverUrl, string code)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.RevokeInviteAsync(code);
}
public async Task<string> ExportMyDataAsync(string serverUrl)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
throw new InvalidOperationException("Not connected");
}
return await entry.ApiClient.ExportMyDataAsync();
}
public async Task DeleteMyAccountAsync(string serverUrl, string password)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
throw new InvalidOperationException("Not connected");
}
await entry.ApiClient.DeleteMyAccountAsync(password);
}
public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted) public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted)
{ {
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
@@ -664,7 +718,7 @@ public sealed class ConnectionService : IDisposable
{ {
UserModel author = new UserModel( UserModel author = new UserModel(
dto.SenderUsername, dto.SenderUsername,
dto.SenderUsername, dto.SenderDisplayName ?? dto.SenderUsername,
dto.SenderNicknameColor); dto.SenderNicknameColor);
List<AttachmentDto> attachments = dto.Attachments ?? []; List<AttachmentDto> attachments = dto.Attachments ?? [];
@@ -676,7 +730,8 @@ public sealed class ConnectionService : IDisposable
dto.Content, dto.Content,
dto.ChannelName, dto.ChannelName,
entry.Server.ServerUrl, entry.Server.ServerUrl,
attachments); attachments,
dto.ReplyTo);
} }
internal ServerConnection? GetConnection(string serverUrl) internal ServerConnection? GetConnection(string serverUrl)
@@ -875,6 +930,11 @@ public sealed class ConnectionService : IDisposable
entry.User.StatusMessage = presence.StatusMessage; entry.User.StatusMessage = presence.StatusMessage;
}; };
conn.ChannelDeleted += channelName =>
{
ChannelDeleted?.Invoke(entry.Server.ServerUrl, channelName);
};
conn.ChannelUpdated += channel => conn.ChannelUpdated += channel =>
{ {
ChannelModel? existing = entry.Server.Channels.FirstOrDefault(c => ChannelModel? existing = entry.Server.Channels.FirstOrDefault(c =>
+189 -51
View File
@@ -11,11 +11,13 @@ using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Core.Security; using EchoHub.Core.Security;
using EchoHub.Core.Services;
using MsBox.Avalonia; using MsBox.Avalonia;
using MsBox.Avalonia.Base; using MsBox.Avalonia.Base;
using MsBox.Avalonia.Enums; using MsBox.Avalonia.Enums;
using System.Diagnostics;
using System.Reactive; using System.Reactive;
using System.Reactive.Linq; using System.Reactive.Linq;
@@ -33,12 +35,6 @@ public sealed class MainWindowViewModel : ViewModelBase
public ChatViewModel Chat { get; } public ChatViewModel Chat { get; }
public string StatusText
{
get;
set => this.RaiseAndSetIfChanged(ref field, value);
} = "Ready";
public ConnectionService ConnectionService { get; } public ConnectionService ConnectionService { get; }
public ReactiveCommand<Unit, Unit> AddServerCommand { get; } public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
@@ -101,8 +97,6 @@ public sealed class MainWindowViewModel : ViewModelBase
private async Task ConnectAndSaveAsync(ConnectDialogResult result) private async Task ConnectAndSaveAsync(ConnectDialogResult result)
{ {
StatusText = "Connecting...";
if (result.IsSavedSession && result.SavedRefreshToken is not null) if (result.IsSavedSession && result.SavedRefreshToken is not null)
{ {
await ConnectionService.ConnectWithSavedTokenAsync( await ConnectionService.ConnectWithSavedTokenAsync(
@@ -195,6 +189,20 @@ public sealed class MainWindowViewModel : ViewModelBase
return result; return result;
} }
private void ShowSystemMessage(string text)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Chat.Messages.Add(new MessageViewModel(new MessageModel(
Guid.NewGuid().ToString("N"),
new UserModel("system", "System"),
DateTimeOffset.Now,
text,
Chat.CurrentChannelName,
Chat.CurrentServerUrl)));
});
}
private void WireCommandHandlerEvents() private void WireCommandHandlerEvents()
{ {
_commandHandler.OnSetStatus += async (status, message) => _commandHandler.OnSetStatus += async (status, message) =>
@@ -205,7 +213,7 @@ public sealed class MainWindowViewModel : ViewModelBase
return; return;
} }
await ConnectionService.UpdateStatusAsync(serverUrl, status, message); await ConnectionService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message);
}; };
_commandHandler.OnSetTheme += themeName => _commandHandler.OnSetTheme += themeName =>
@@ -313,7 +321,7 @@ public sealed class MainWindowViewModel : ViewModelBase
List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel); List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel);
string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username)); string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username));
StatusText = $"Online in #{channel}: {userList}"; ShowSystemMessage($"Online in #{channel}: {userList}");
}; };
_commandHandler.OnSetTopic += async topic => _commandHandler.OnSetTopic += async topic =>
@@ -428,6 +436,114 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnHelp += () => Task.CompletedTask; _commandHandler.OnHelp += () => Task.CompletedTask;
_commandHandler.OnSendAction += async text =>
{
await HandleSendTextAsync(MessageConventions.FormatAction(text));
};
_commandHandler.OnSendBanner += async text =>
{
string? banner = AsciiBannerService.Render(text);
if (banner is null) return;
await HandleSendTextAsync(banner);
};
_commandHandler.OnCreateInvite += async (maxUses, expiresInHours) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return;
try
{
InviteDto? invite = await ConnectionService.CreateInviteAsync(serverUrl, maxUses, expiresInHours);
if (invite is not null)
ShowSystemMessage($"Invite code: {invite.Code}");
}
catch (Exception ex)
{
ShowSystemMessage($"Failed to create invite: {ex.Message}");
}
};
_commandHandler.OnListInvites += async () =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return;
try
{
List<InviteDto> invites = await ConnectionService.GetInvitesAsync(serverUrl);
if (invites.Count == 0)
ShowSystemMessage("No invite codes.");
else
ShowSystemMessage(string.Join(" | ", invites.Select(i => $"{i.Code} ({i.UseCount}/{i.MaxUses})")));
}
catch (Exception ex)
{
ShowSystemMessage($"Failed to list invites: {ex.Message}");
}
};
_commandHandler.OnRevokeInvite += async code =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return;
try
{
await ConnectionService.RevokeInviteAsync(serverUrl, code);
ShowSystemMessage($"Invite {code} revoked.");
}
catch (Exception ex)
{
ShowSystemMessage($"Failed to revoke invite: {ex.Message}");
}
};
_commandHandler.OnExportData += async () =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return;
try
{
string data = await ConnectionService.ExportMyDataAsync(serverUrl);
string fileName = $"echohub-export-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json";
string downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string filePath = Path.Combine(downloadsPath, "Downloads", fileName);
await File.WriteAllTextAsync(filePath, data);
ShowSystemMessage($"Data exported to {filePath}");
}
catch (Exception ex)
{
Debug.WriteLine($"Export failed: {ex.Message}");
}
};
_commandHandler.OnDeleteAccount += async () =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return;
IMsBox<ButtonResult> confirmBox = MessageBoxManager.GetMessageBoxStandard(
"Delete Account", "Are you sure you want to permanently delete your account? This cannot be undone.", ButtonEnum.YesNo);
ButtonResult confirm = await confirmBox.ShowWindowDialogAsync(_mainWindow);
if (confirm != ButtonResult.Yes) return;
string? pwd = await ShowPromptWindowAsync("Confirm Password", "Enter your password to confirm account deletion:", "Delete");
if (string.IsNullOrEmpty(pwd)) return;
try
{
await ConnectionService.DeleteMyAccountAsync(serverUrl, pwd);
ShowSystemMessage("Account deleted.");
}
catch (Exception ex)
{
Debug.WriteLine($"Delete failed: {ex.Message}");
}
};
_commandHandler.OnSendFile += async (target, size) => _commandHandler.OnSendFile += async (target, size) =>
{ {
string serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
@@ -451,7 +567,7 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusText = $"Send failed: {ex.Message}"; ShowSystemMessage($"Send failed: {ex.Message}");
} }
}; };
@@ -509,7 +625,7 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
if (profile is null) if (profile is null)
{ {
StatusText = "User not found"; Debug.WriteLine("User not found");
return; return;
} }
@@ -522,10 +638,7 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Debug.WriteLine($"Failed to load profile: {ex.Message}");
{
StatusText = $"Failed to load profile: {ex.Message}";
});
} }
}; };
@@ -534,7 +647,7 @@ public sealed class MainWindowViewModel : ViewModelBase
ClientConfig config = ConfigManager.Load(); ClientConfig config = ConfigManager.Load();
string servers = string.Join("\n", config.SavedServers.Select(s => string servers = string.Join("\n", config.SavedServers.Select(s =>
$"{s.Name} ({s.Url}) - {s.Username ?? "?"}")); $"{s.Name} ({s.Url}) - {s.Username ?? "?"}"));
StatusText = servers; ShowSystemMessage(servers);
return Task.CompletedTask; return Task.CompletedTask;
}; };
} }
@@ -570,7 +683,7 @@ public sealed class MainWindowViewModel : ViewModelBase
defaultChannel ??= serverVm.Channels.FirstOrDefault(); defaultChannel ??= serverVm.Channels.FirstOrDefault();
serverVm.SelectedChannel = defaultChannel; serverVm.SelectedChannel = defaultChannel;
StatusText = $"Connected to {server.Name}"; ShowSystemMessage($"Connected to {server.Name}");
}); });
}; };
@@ -582,7 +695,7 @@ public sealed class MainWindowViewModel : ViewModelBase
if (Sidebar.Servers.Count == 0) if (Sidebar.Servers.Count == 0)
{ {
Chat.ClearMessages(); Chat.ClearMessages();
StatusText = "Ready"; Debug.WriteLine("Ready");
} }
}); });
}; };
@@ -602,7 +715,7 @@ public sealed class MainWindowViewModel : ViewModelBase
if (!server.IsConnected) if (!server.IsConnected)
{ {
StatusText = $"Disconnected from {server.Name}"; ShowSystemMessage($"Disconnected from {server.Name}");
} }
} }
}); });
@@ -611,8 +724,11 @@ public sealed class MainWindowViewModel : ViewModelBase
ConnectionService.MessageReceived += (serverUrl, message) => ConnectionService.MessageReceived += (serverUrl, message) =>
{ {
string? username = ConnectionService.GetCurrentUsername(serverUrl); string? username = ConnectionService.GetCurrentUsername(serverUrl);
bool isMention = !string.IsNullOrEmpty(username) bool isReplyToMe = !string.IsNullOrEmpty(username)
&& message.Content.Contains($"@{username}", StringComparison.OrdinalIgnoreCase); && string.Equals(message.ReplyTo?.SenderUsername, username, StringComparison.OrdinalIgnoreCase);
bool isMention = isReplyToMe
|| (!string.IsNullOrEmpty(username)
&& message.Content.Contains($"@{username}", StringComparison.OrdinalIgnoreCase));
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
@@ -653,14 +769,31 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
}; };
ConnectionService.ErrorOccurred += (serverUrl, error) => ConnectionService.ChannelDeleted += (serverUrl, channelName) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
StatusText = $"Error: {error}"; ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
if (serverVm is null) return;
ChannelViewModel? channelVm = serverVm.Channels
.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
if (channelVm is not null)
serverVm.Channels.Remove(channelVm);
if (string.Equals(channelName, Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(serverUrl, Chat.CurrentServerUrl, StringComparison.OrdinalIgnoreCase))
{
Chat.ClearMessages();
}
}); });
}; };
ConnectionService.ErrorOccurred += (serverUrl, error) =>
{
Debug.WriteLine($"Error: {error}");
};
ConnectionService.ChannelAdded += (serverUrl, channel) => ConnectionService.ChannelAdded += (serverUrl, channel) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
@@ -674,6 +807,25 @@ public sealed class MainWindowViewModel : ViewModelBase
}; };
} }
private async Task HandleSendTextAsync(string text)
{
string serverUrl = GetCurrentServerUrl();
string channelName = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channelName))
{
return;
}
try
{
await ConnectionService.SendMessageAsync(serverUrl, channelName, text);
}
catch (Exception ex)
{
Debug.WriteLine($"Send failed: {ex.Message}");
}
}
private async Task RefreshOnlineUsersAsync(string serverUrl, string channelName) private async Task RefreshOnlineUsersAsync(string serverUrl, string channelName)
{ {
try try
@@ -695,18 +847,12 @@ public sealed class MainWindowViewModel : ViewModelBase
CommandResult result = await _commandHandler.HandleAsync(commandText); CommandResult result = await _commandHandler.HandleAsync(commandText);
if (result.Message is not null) if (result.Message is not null)
{ {
Chat.Messages.Add(new MessageViewModel(new MessageModel( ShowSystemMessage(result.Message);
Guid.NewGuid().ToString("N"),
new UserModel("system", "System"),
DateTimeOffset.Now,
result.Message,
Chat.CurrentChannelName,
Chat.CurrentServerUrl)));
} }
return result.Message; return result.Message;
} }
private void HandleSendRequested(string serverUrl, string text, IReadOnlyList<string> filePaths) private void HandleSendRequested(string serverUrl, string text, IReadOnlyList<string> filePaths, Guid? replyToMessageId)
{ {
if (string.IsNullOrEmpty(Chat.CurrentChannelName)) if (string.IsNullOrEmpty(Chat.CurrentChannelName))
{ {
@@ -720,13 +866,10 @@ public sealed class MainWindowViewModel : ViewModelBase
try try
{ {
await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths); await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths);
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
StatusText = "Message sent");
} }
catch (Exception ex) catch (Exception ex)
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Debug.WriteLine($"Send failed: {ex.Message}");
StatusText = $"Send failed: {ex.Message}");
} }
}); });
return; return;
@@ -742,14 +885,11 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
try try
{ {
await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text); await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId);
} }
catch (Exception ex) catch (Exception ex)
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Debug.WriteLine($"Send failed: {ex.Message}");
{
StatusText = $"Send failed: {ex.Message}";
});
} }
}); });
} }
@@ -770,14 +910,12 @@ public sealed class MainWindowViewModel : ViewModelBase
try try
{ {
StatusText = "Creating channel...";
ChannelDto? channel = await ConnectionService.CreateChannelAsync( ChannelDto? channel = await ConnectionService.CreateChannelAsync(
server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword); server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword);
if (channel is null) if (channel is null)
{ {
StatusText = "Failed to create channel"; ShowSystemMessage("Failed to create channel");
return; return;
} }
@@ -804,7 +942,7 @@ public sealed class MainWindowViewModel : ViewModelBase
channelVm.AddMessage(msg); channelVm.AddMessage(msg);
} }
StatusText = $"Created #{channel.Name}"; ShowSystemMessage($"Created #{channel.Name}");
}); });
} }
} }
@@ -826,7 +964,7 @@ public sealed class MainWindowViewModel : ViewModelBase
string channelName = Chat.CurrentChannelName; string channelName = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(channelName) || !string.Equals(Chat.CurrentServerUrl, server.ServerUrl, StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrEmpty(channelName) || !string.Equals(Chat.CurrentServerUrl, server.ServerUrl, StringComparison.OrdinalIgnoreCase))
{ {
StatusText = "No channel selected on this server"; Debug.WriteLine("No channel selected on this server");
return; return;
} }
@@ -864,7 +1002,7 @@ public sealed class MainWindowViewModel : ViewModelBase
?? server.Channels.FirstOrDefault(); ?? server.Channels.FirstOrDefault();
server.SelectedChannel = defaultChannel; server.SelectedChannel = defaultChannel;
StatusText = $"Deleted #{channelName}"; ShowSystemMessage($"Deleted #{channelName}");
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -1008,7 +1146,7 @@ public sealed class MainWindowViewModel : ViewModelBase
channel.IsLocked = false; channel.IsLocked = false;
Chat.Composer.IsConnected = isServerConnected; Chat.Composer.IsConnected = isServerConnected;
if (channel.Messages.Count == 0) if (!channel.Messages.Any(m => m.AuthorName != "System"))
{ {
foreach (MessageModel msg in joinResult.History) foreach (MessageModel msg in joinResult.History)
{ {
@@ -1109,7 +1247,7 @@ public sealed class MainWindowViewModel : ViewModelBase
catch (Exception ex) catch (Exception ex)
{ {
serverVm.IsConnecting = false; serverVm.IsConnecting = false;
StatusText = $"Auto-connect failed for {saved.Name}: {ex.Message}"; Debug.WriteLine($"Auto-connect failed for {saved.Name}: {ex.Message}");
} }
} }
@@ -1147,7 +1285,7 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusText = $"Disconnect error: {ex.Message}"; Debug.WriteLine($"Disconnect error: {ex.Message}");
} }
} }
@@ -1159,7 +1297,7 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusText = $"Remove error: {ex.Message}"; Debug.WriteLine($"Remove error: {ex.Message}");
} }
} }
@@ -10,7 +10,7 @@ namespace Decho.ViewModels;
public sealed class MessageComposerViewModel : ViewModelBase public sealed class MessageComposerViewModel : ViewModelBase
{ {
public event Action<string, string, IReadOnlyList<string>>? SendRequested; public event Action<string, string, IReadOnlyList<string>, Guid?>? SendRequested;
public event Func<string, Task<string?>>? CommandRequested; public event Func<string, Task<string?>>? CommandRequested;
@@ -48,6 +48,27 @@ public sealed class MessageComposerViewModel : ViewModelBase
public AutocompleteController Autocomplete { get; } public AutocompleteController Autocomplete { get; }
public MessageViewModel? ReplyTarget
{
get => field;
set
{
this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(HasReplyTarget));
ReplySummary = value is not null
? $"\u21A9 Replying to {value.AuthorName}: {value.Content[..Math.Min(value.Content.Length, 80)]}"
: string.Empty;
}
}
public bool HasReplyTarget => ReplyTarget is not null;
public string ReplySummary
{
get;
private set => this.RaiseAndSetIfChanged(ref field, value);
} = string.Empty;
public MessageComposerViewModel() public MessageComposerViewModel()
{ {
AutocompleteProvider mentionProvider = new( AutocompleteProvider mentionProvider = new(
@@ -153,6 +174,11 @@ public sealed class MessageComposerViewModel : ViewModelBase
Autocomplete.Reset(); Autocomplete.Reset();
} }
public void ClearReplyTarget()
{
ReplyTarget = null;
}
private void UpdateStagedSummary() private void UpdateStagedSummary()
{ {
this.RaisePropertyChanged(nameof(HasStagedFiles)); this.RaisePropertyChanged(nameof(HasStagedFiles));
@@ -181,6 +207,9 @@ public sealed class MessageComposerViewModel : ViewModelBase
StagedFiles.Clear(); StagedFiles.Clear();
UpdateStagedSummary(); UpdateStagedSummary();
SendRequested?.Invoke(ServerUrl, text, filePaths); Guid? replyToId = ReplyTarget is not null && Guid.TryParse(ReplyTarget.Model.Id, out Guid rid) ? rid : null;
ClearReplyTarget();
SendRequested?.Invoke(ServerUrl, text, filePaths, replyToId);
} }
} }
+10
View File
@@ -36,6 +36,16 @@ public sealed class MessageViewModel(MessageModel model) : ViewModelBase
public string Content => Model.Content; public string Content => Model.Content;
public bool IsAction => Model.Content.StartsWith("/me ", StringComparison.Ordinal);
public string ActionText => IsAction ? Model.Content[4..] : Model.Content;
public string DisplayContent => IsAction ? $"* {AuthorName} {ActionText}" : Model.Content;
public ReplyRefDto? ReplyTo => Model.ReplyTo;
public bool HasReply => ReplyTo is not null;
public string? ServerUrl => Model.ServerUrl; public string? ServerUrl => Model.ServerUrl;
public Dictionary<string, Bitmap> ImageCache { get; } = []; public Dictionary<string, Bitmap> ImageCache { get; } = [];
+21 -2
View File
@@ -5,7 +5,7 @@
xmlns:i="https://github.com/projektanker/icons.avalonia" xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.MessageComposerView" x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel"> x:DataType="vm:MessageComposerViewModel">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,*">
<Border IsVisible="{Binding HasStagedFiles}" <Border IsVisible="{Binding HasStagedFiles}"
Background="{DynamicResource UiTheme02}" Background="{DynamicResource UiTheme02}"
CornerRadius="4" CornerRadius="4"
@@ -48,7 +48,26 @@
</ItemsControl> </ItemsControl>
</DockPanel> </DockPanel>
</Border> </Border>
<Grid Grid.Row="1"> <Border Grid.Row="1"
IsVisible="{Binding HasReplyTarget}"
Background="{DynamicResource UiTheme02}"
CornerRadius="4"
Padding="6"
Margin="0 0 0 4">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="x"
FontSize="11"
Padding="4 0"
Cursor="Hand"
Click="OnCancelReplyClick" />
<TextBlock VerticalAlignment="Center"
FontSize="12"
Opacity="0.6"
Text="{Binding ReplySummary}" />
</DockPanel>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -169,6 +169,12 @@ public partial class MessageComposerView : UserControl
vm?.ClearStagedFiles(); vm?.ClearStagedFiles();
} }
private void OnCancelReplyClick(object? sender, RoutedEventArgs e)
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
vm?.ClearReplyTarget();
}
private void OnDragOver(object? sender, DragEventArgs e) private void OnDragOver(object? sender, DragEventArgs e)
{ {
#pragma warning disable CS0618 #pragma warning disable CS0618
+14
View File
@@ -23,7 +23,21 @@
Text="{Binding TimeText}" Text="{Binding TimeText}"
FontSize="10" FontSize="10"
Margin="5 0 0 0" /> Margin="5 0 0 0" />
<TextBlock VerticalAlignment="Top"
Text="↩"
FontSize="10"
Margin="5 0 0 0"
Opacity="0.3"
Cursor="Hand"
PointerPressed="OnReplyPointerPressed" />
</StackPanel> </StackPanel>
<TextBlock x:Name="ReplyQuote"
VerticalAlignment="Top"
TextWrapping="Wrap"
FontSize="11"
Opacity="0.5"
IsVisible="{Binding HasReply}"
PointerPressed="OnReplyQuotePointerPressed" />
<TextBlock x:Name="MessageContent" <TextBlock x:Name="MessageContent"
VerticalAlignment="Top" VerticalAlignment="Top"
TextWrapping="Wrap" TextWrapping="Wrap"
+43 -1
View File
@@ -59,7 +59,13 @@ public partial class MessageItemView : UserControl
if (DataContext is MessageViewModel msg) if (DataContext is MessageViewModel msg)
{ {
_loadedMessageId = msg.Model.Id; _loadedMessageId = msg.Model.Id;
BuildMessageInlines(msg.Content); BuildMessageInlines(msg.DisplayContent);
TextBlock? replyQuote = ReplyQuote;
if (replyQuote is not null && msg.ReplyTo is { } reply)
{
replyQuote.Text = $"\u2514 {reply.SenderUsername}: {reply.Content}";
}
} }
} }
@@ -385,4 +391,40 @@ public partial class MessageItemView : UserControl
ImageViewerWindow viewer = new ImageViewerWindow(bitmap, att.FileName, downloadAsync); ImageViewerWindow viewer = new ImageViewerWindow(bitmap, att.FileName, downloadAsync);
await viewer.ShowDialog(parent); await viewer.ShowDialog(parent);
} }
private void OnReplyPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (DataContext is not MessageViewModel msg)
return;
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
if (mainVm is null)
return;
mainVm.Chat.Composer.ReplyTarget = msg;
}
private void OnReplyQuotePointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (DataContext is not MessageViewModel msg || msg.ReplyTo is null)
return;
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
if (mainVm is null)
return;
string serverUrl = ResolveServerUrl();
ServerViewModel? serverVm = mainVm.Sidebar.GetServer(serverUrl);
if (serverVm is null)
return;
ChannelViewModel? channel = serverVm.Channels.FirstOrDefault(c =>
string.Equals(c.Name, msg.Model.ChannelName, StringComparison.OrdinalIgnoreCase));
if (channel is null)
return;
// Switch to the channel containing the original message
if (!string.Equals(channel.Name, mainVm.Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase))
mainVm.Sidebar.SelectedChannel = channel;
}
} }