mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +02:00
Add message reply support and new commands
This commit is contained in:
@@ -2,7 +2,15 @@ using EchoHub.Core.DTOs;
|
||||
|
||||
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;
|
||||
|
||||
@@ -17,4 +25,6 @@ public sealed class MessageModel(string id, UserModel author, DateTimeOffset sen
|
||||
public string? ServerUrl { get; } = serverUrl;
|
||||
|
||||
public List<AttachmentDto> Attachments { get; } = attachments ?? [];
|
||||
|
||||
public ReplyRefDto? ReplyTo { get; } = replyTo;
|
||||
}
|
||||
@@ -52,6 +52,8 @@ public sealed class ConnectionService : IDisposable
|
||||
|
||||
public event Action<string, string>? ErrorOccurred;
|
||||
|
||||
public event Action<string, string>? ChannelDeleted;
|
||||
|
||||
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
|
||||
internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections;
|
||||
|
||||
@@ -85,14 +87,14 @@ public sealed class ConnectionService : IDisposable
|
||||
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))
|
||||
{
|
||||
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)
|
||||
@@ -340,6 +342,58 @@ public sealed class ConnectionService : IDisposable
|
||||
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)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
|
||||
@@ -664,7 +718,7 @@ public sealed class ConnectionService : IDisposable
|
||||
{
|
||||
UserModel author = new UserModel(
|
||||
dto.SenderUsername,
|
||||
dto.SenderUsername,
|
||||
dto.SenderDisplayName ?? dto.SenderUsername,
|
||||
dto.SenderNicknameColor);
|
||||
|
||||
List<AttachmentDto> attachments = dto.Attachments ?? [];
|
||||
@@ -676,7 +730,8 @@ public sealed class ConnectionService : IDisposable
|
||||
dto.Content,
|
||||
dto.ChannelName,
|
||||
entry.Server.ServerUrl,
|
||||
attachments);
|
||||
attachments,
|
||||
dto.ReplyTo);
|
||||
}
|
||||
|
||||
internal ServerConnection? GetConnection(string serverUrl)
|
||||
@@ -875,6 +930,11 @@ public sealed class ConnectionService : IDisposable
|
||||
entry.User.StatusMessage = presence.StatusMessage;
|
||||
};
|
||||
|
||||
conn.ChannelDeleted += channelName =>
|
||||
{
|
||||
ChannelDeleted?.Invoke(entry.Server.ServerUrl, channelName);
|
||||
};
|
||||
|
||||
conn.ChannelUpdated += channel =>
|
||||
{
|
||||
ChannelModel? existing = entry.Server.Channels.FirstOrDefault(c =>
|
||||
|
||||
@@ -11,11 +11,13 @@ using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Core.Services;
|
||||
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Base;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Linq;
|
||||
|
||||
@@ -33,12 +35,6 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public ChatViewModel Chat { get; }
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
} = "Ready";
|
||||
|
||||
public ConnectionService ConnectionService { get; }
|
||||
|
||||
public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
|
||||
@@ -101,8 +97,6 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
private async Task ConnectAndSaveAsync(ConnectDialogResult result)
|
||||
{
|
||||
StatusText = "Connecting...";
|
||||
|
||||
if (result.IsSavedSession && result.SavedRefreshToken is not null)
|
||||
{
|
||||
await ConnectionService.ConnectWithSavedTokenAsync(
|
||||
@@ -195,6 +189,20 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
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()
|
||||
{
|
||||
_commandHandler.OnSetStatus += async (status, message) =>
|
||||
@@ -205,7 +213,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectionService.UpdateStatusAsync(serverUrl, status, message);
|
||||
await ConnectionService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message);
|
||||
};
|
||||
|
||||
_commandHandler.OnSetTheme += themeName =>
|
||||
@@ -313,7 +321,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel);
|
||||
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 =>
|
||||
@@ -428,6 +436,114 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
_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) =>
|
||||
{
|
||||
string serverUrl = GetCurrentServerUrl();
|
||||
@@ -451,7 +567,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
StatusText = "User not found";
|
||||
Debug.WriteLine("User not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -522,10 +638,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = $"Failed to load profile: {ex.Message}";
|
||||
});
|
||||
Debug.WriteLine($"Failed to load profile: {ex.Message}");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -534,7 +647,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
ClientConfig config = ConfigManager.Load();
|
||||
string servers = string.Join("\n", config.SavedServers.Select(s =>
|
||||
$"{s.Name} ({s.Url}) - {s.Username ?? "?"}"));
|
||||
StatusText = servers;
|
||||
ShowSystemMessage(servers);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
@@ -570,7 +683,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
defaultChannel ??= serverVm.Channels.FirstOrDefault();
|
||||
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)
|
||||
{
|
||||
Chat.ClearMessages();
|
||||
StatusText = "Ready";
|
||||
Debug.WriteLine("Ready");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -602,7 +715,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
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) =>
|
||||
{
|
||||
string? username = ConnectionService.GetCurrentUsername(serverUrl);
|
||||
bool isMention = !string.IsNullOrEmpty(username)
|
||||
&& message.Content.Contains($"@{username}", StringComparison.OrdinalIgnoreCase);
|
||||
bool isReplyToMe = !string.IsNullOrEmpty(username)
|
||||
&& 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(() =>
|
||||
{
|
||||
@@ -653,14 +769,31 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
};
|
||||
|
||||
ConnectionService.ErrorOccurred += (serverUrl, error) =>
|
||||
ConnectionService.ChannelDeleted += (serverUrl, channelName) =>
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -695,18 +847,12 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
CommandResult result = await _commandHandler.HandleAsync(commandText);
|
||||
if (result.Message is not null)
|
||||
{
|
||||
Chat.Messages.Add(new MessageViewModel(new MessageModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
new UserModel("system", "System"),
|
||||
DateTimeOffset.Now,
|
||||
result.Message,
|
||||
Chat.CurrentChannelName,
|
||||
Chat.CurrentServerUrl)));
|
||||
ShowSystemMessage(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))
|
||||
{
|
||||
@@ -720,13 +866,10 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths);
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
StatusText = "Message sent");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
StatusText = $"Send failed: {ex.Message}");
|
||||
Debug.WriteLine($"Send failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -742,14 +885,11 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text);
|
||||
await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = $"Send failed: {ex.Message}";
|
||||
});
|
||||
Debug.WriteLine($"Send failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -770,14 +910,12 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
try
|
||||
{
|
||||
StatusText = "Creating channel...";
|
||||
|
||||
ChannelDto? channel = await ConnectionService.CreateChannelAsync(
|
||||
server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
StatusText = "Failed to create channel";
|
||||
ShowSystemMessage("Failed to create channel");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -804,7 +942,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
channelVm.AddMessage(msg);
|
||||
}
|
||||
|
||||
StatusText = $"Created #{channel.Name}";
|
||||
ShowSystemMessage($"Created #{channel.Name}");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -826,7 +964,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
string channelName = Chat.CurrentChannelName;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -864,7 +1002,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
?? server.Channels.FirstOrDefault();
|
||||
server.SelectedChannel = defaultChannel;
|
||||
|
||||
StatusText = $"Deleted #{channelName}";
|
||||
ShowSystemMessage($"Deleted #{channelName}");
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1008,7 +1146,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
channel.IsLocked = false;
|
||||
Chat.Composer.IsConnected = isServerConnected;
|
||||
|
||||
if (channel.Messages.Count == 0)
|
||||
if (!channel.Messages.Any(m => m.AuthorName != "System"))
|
||||
{
|
||||
foreach (MessageModel msg in joinResult.History)
|
||||
{
|
||||
@@ -1109,7 +1247,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
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)
|
||||
{
|
||||
StatusText = $"Disconnect error: {ex.Message}";
|
||||
Debug.WriteLine($"Disconnect error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1159,7 +1297,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
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 event Action<string, string, IReadOnlyList<string>>? SendRequested;
|
||||
public event Action<string, string, IReadOnlyList<string>, Guid?>? SendRequested;
|
||||
|
||||
public event Func<string, Task<string?>>? CommandRequested;
|
||||
|
||||
@@ -48,6 +48,27 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
||||
|
||||
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()
|
||||
{
|
||||
AutocompleteProvider mentionProvider = new(
|
||||
@@ -153,6 +174,11 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
||||
Autocomplete.Reset();
|
||||
}
|
||||
|
||||
public void ClearReplyTarget()
|
||||
{
|
||||
ReplyTarget = null;
|
||||
}
|
||||
|
||||
private void UpdateStagedSummary()
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(HasStagedFiles));
|
||||
@@ -181,6 +207,9 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
||||
StagedFiles.Clear();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public sealed class MessageViewModel(MessageModel model) : ViewModelBase
|
||||
|
||||
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 Dictionary<string, Bitmap> ImageCache { get; } = [];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
xmlns:i="https://github.com/projektanker/icons.avalonia"
|
||||
x:Class="Decho.Views.MessageComposerView"
|
||||
x:DataType="vm:MessageComposerViewModel">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Border IsVisible="{Binding HasStagedFiles}"
|
||||
Background="{DynamicResource UiTheme02}"
|
||||
CornerRadius="4"
|
||||
@@ -48,7 +48,26 @@
|
||||
</ItemsControl>
|
||||
</DockPanel>
|
||||
</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>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
|
||||
@@ -169,6 +169,12 @@ public partial class MessageComposerView : UserControl
|
||||
vm?.ClearStagedFiles();
|
||||
}
|
||||
|
||||
private void OnCancelReplyClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
|
||||
vm?.ClearReplyTarget();
|
||||
}
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
#pragma warning disable CS0618
|
||||
|
||||
@@ -23,7 +23,21 @@
|
||||
Text="{Binding TimeText}"
|
||||
FontSize="10"
|
||||
Margin="5 0 0 0" />
|
||||
<TextBlock VerticalAlignment="Top"
|
||||
Text="↩"
|
||||
FontSize="10"
|
||||
Margin="5 0 0 0"
|
||||
Opacity="0.3"
|
||||
Cursor="Hand"
|
||||
PointerPressed="OnReplyPointerPressed" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="ReplyQuote"
|
||||
VerticalAlignment="Top"
|
||||
TextWrapping="Wrap"
|
||||
FontSize="11"
|
||||
Opacity="0.5"
|
||||
IsVisible="{Binding HasReply}"
|
||||
PointerPressed="OnReplyQuotePointerPressed" />
|
||||
<TextBlock x:Name="MessageContent"
|
||||
VerticalAlignment="Top"
|
||||
TextWrapping="Wrap"
|
||||
|
||||
@@ -59,7 +59,13 @@ public partial class MessageItemView : UserControl
|
||||
if (DataContext is MessageViewModel msg)
|
||||
{
|
||||
_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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule src/EchoHub updated: 584ce45979...ef42c42736
Reference in New Issue
Block a user