From 80759e612adfd274d0a5ffea6d6b56841d66e3d4 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:00:22 +0200 Subject: [PATCH] Add message reply support and new commands --- src/Decho/Models/MessageModel.cs | 12 +- src/Decho/Services/ConnectionService.cs | 68 ++++- src/Decho/ViewModels/MainWindowViewModel.cs | 240 ++++++++++++++---- .../ViewModels/MessageComposerViewModel.cs | 33 ++- src/Decho/ViewModels/MessageViewModel.cs | 10 + src/Decho/Views/MessageComposerView.axaml | 23 +- src/Decho/Views/MessageComposerView.axaml.cs | 6 + src/Decho/Views/MessageItemView.axaml | 14 + src/Decho/Views/MessageItemView.axaml.cs | 44 +++- src/EchoHub | 2 +- 10 files changed, 390 insertions(+), 62 deletions(-) diff --git a/src/Decho/Models/MessageModel.cs b/src/Decho/Models/MessageModel.cs index b86f9dc..f25ffb1 100644 --- a/src/Decho/Models/MessageModel.cs +++ b/src/Decho/Models/MessageModel.cs @@ -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? attachments = null) +public sealed class MessageModel( + string id, + UserModel author, + DateTimeOffset sentAt, + string content, + string channelName, + string? serverUrl = null, + List? 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 Attachments { get; } = attachments ?? []; + + public ReplyRefDto? ReplyTo { get; } = replyTo; } \ No newline at end of file diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index 16fed39..ad4ea20 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -52,6 +52,8 @@ public sealed class ConnectionService : IDisposable public event Action? ErrorOccurred; + public event Action? ChannelDeleted; + private readonly Dictionary _connections = new(StringComparer.OrdinalIgnoreCase); internal IReadOnlyDictionary 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 filePaths, string? size = null) @@ -340,6 +342,58 @@ public sealed class ConnectionService : IDisposable return await entry.ApiClient.GetChannelCryptoAsync(channelName); } + // ── Invites / Account ──────────────────────────────────────────────── + + public async Task 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> 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 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 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 => diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index b53926f..bbb4133 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -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 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 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 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 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 filePaths) + private void HandleSendRequested(string serverUrl, string text, IReadOnlyList 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}"); } } diff --git a/src/Decho/ViewModels/MessageComposerViewModel.cs b/src/Decho/ViewModels/MessageComposerViewModel.cs index 71997d8..828b1c6 100644 --- a/src/Decho/ViewModels/MessageComposerViewModel.cs +++ b/src/Decho/ViewModels/MessageComposerViewModel.cs @@ -10,7 +10,7 @@ namespace Decho.ViewModels; public sealed class MessageComposerViewModel : ViewModelBase { - public event Action>? SendRequested; + public event Action, Guid?>? SendRequested; public event Func>? 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); } } \ No newline at end of file diff --git a/src/Decho/ViewModels/MessageViewModel.cs b/src/Decho/ViewModels/MessageViewModel.cs index 41b20ae..36ddd64 100644 --- a/src/Decho/ViewModels/MessageViewModel.cs +++ b/src/Decho/ViewModels/MessageViewModel.cs @@ -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 ImageCache { get; } = []; diff --git a/src/Decho/Views/MessageComposerView.axaml b/src/Decho/Views/MessageComposerView.axaml index b8c74f3..18378f6 100644 --- a/src/Decho/Views/MessageComposerView.axaml +++ b/src/Decho/Views/MessageComposerView.axaml @@ -5,7 +5,7 @@ xmlns:i="https://github.com/projektanker/icons.avalonia" x:Class="Decho.Views.MessageComposerView" x:DataType="vm:MessageComposerViewModel"> - + - + + +