From b2ad9556af3f05e756852afd21a5fc01d4a64bab Mon Sep 17 00:00:00 2001 From: Emilia <emilia@jumpsca.re> Date: Sat, 1 Mar 2025 18:41:59 +0200 Subject: [PATCH] Replies and deletions --- .idea/.idea.Blacklight/.idea/avalonia.xml | 2 +- .../ViewModels/Documents/DocumentViewModel.cs | 67 ++++++++- Blacklight/Views/Documents/DocumentView.axaml | 25 +++- Lightquark.NET/ClientMethods/Gateway.cs | 2 +- Lightquark.NET/ClientMethods/Message.cs | 129 +++++++++++++++++- Lightquark.NET/Objects/Message.cs | 12 ++ 6 files changed, 229 insertions(+), 8 deletions(-) diff --git a/.idea/.idea.Blacklight/.idea/avalonia.xml b/.idea/.idea.Blacklight/.idea/avalonia.xml index 9fd4a89..e6b1c31 100644 --- a/.idea/.idea.Blacklight/.idea/avalonia.xml +++ b/.idea/.idea.Blacklight/.idea/avalonia.xml @@ -19,7 +19,7 @@ <entry key="Blacklight/Views/LoginView.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/MainView.axaml" value="Blacklight/Blacklight.csproj" /> <entry key="Blacklight/Views/MainWindow.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> - <entry key="Blacklight/Views/Tools/BeltRight.axaml" value="Blacklight/Blacklight.csproj" /> + <entry key="Blacklight/Views/Tools/BeltRight.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Tools/ResourceExplorerView.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Utility/MarkdownText.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Views/ClientView.axaml" value="Blacklight/Blacklight.csproj" /> diff --git a/Blacklight/ViewModels/Documents/DocumentViewModel.cs b/Blacklight/ViewModels/Documents/DocumentViewModel.cs index 6a86f6a..c3c933a 100644 --- a/Blacklight/ViewModels/Documents/DocumentViewModel.cs +++ b/Blacklight/ViewModels/Documents/DocumentViewModel.cs @@ -1,6 +1,8 @@ using System; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Linq; +using System.Reactive; using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Media; @@ -12,6 +14,8 @@ using DynamicData.Binding; using Lightquark.NET; using Lightquark.NET.Objects; using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using ReactiveUI; using Serilog; namespace Blacklight.ViewModels.Documents; @@ -24,6 +28,7 @@ public class DocumentViewModel : Document private bool _scrollLocked = true; public Channel Channel { get; set; } private ReadOnlyObservableCollection<Message> _messages; + private ObjectId? _replyToId; public bool ScrollToEndTrigger { @@ -103,9 +108,69 @@ public class DocumentViewModel : Document } } + public ObjectId? ReplyToId + { + get => _replyToId; + set + { + if (SetProperty(ref _replyToId, value)) OnPropertyChanged(nameof(IsReplying)); + } + } + + public bool IsReplying => ReplyToId != null; + public FontStyle FontStyle => IsEphemeral ? FontStyle.Italic : FontStyle.Normal; public ICommand SendCommand => new RelayCommand(SendMessage); + public void ReplyMessage(object obj) + { + if (obj is not ObjectId msgId) return; + ReplyToId = msgId; + Log.Information("Reply to {MsgId}", msgId); + } + + + // ReSharper disable once UnusedMember.Global + public bool CanReplyMessage(object obj) + { + return obj is ObjectId; + } + + public ICommand EditCommand => new RelayCommand(EditMessage); + + private void EditMessage() + { + throw new NotImplementedException(); + } + + public void DeleteMessage(object obj) + { + try + { + var msgId = (ObjectId)obj; + Log.Information("Deleting message {MessageInput}", msgId); + var client = App.Services!.GetRequiredService<Client>(); + client.DeleteMessage(Channel.Id, msgId); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to delete message"); + } + } + public bool CanDeleteMessage(object obj) + { + try + { + var client = App.Services!.GetRequiredService<Client>(); + return obj is ObjectId msgId && Messages.FirstOrDefault(m => m.Id == msgId)?.Author?.Id == client.CurrentUser?.Id; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to check message deletability"); + return false; + } + } + public string MessageInput { get => _messageInput; @@ -125,7 +190,7 @@ public class DocumentViewModel : Document { Log.Information("Sending message {MessageInput}", MessageInput); var client = App.Services!.GetRequiredService<Client>(); - client.SendMessage(MessageInput, Channel.Id); + client.SendMessage(MessageInput, Channel.Id, ReplyToId); } catch (Exception ex) { diff --git a/Blacklight/Views/Documents/DocumentView.axaml b/Blacklight/Views/Documents/DocumentView.axaml index b80a4b6..59ebe2a 100644 --- a/Blacklight/Views/Documents/DocumentView.axaml +++ b/Blacklight/Views/Documents/DocumentView.axaml @@ -10,6 +10,9 @@ mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="400" x:DataType="vm:DocumentViewModel" x:CompileBindings="True"> + <Control.Resources> + <StreamGeometry x:Key="ReplyIcon">M16.15 13H5q-.425 0-.712-.288T4 12t.288-.712T5 11h11.15L13.3 8.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L19.3 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-4.575 4.575q-.3.3-.712.288t-.713-.313q-.275-.3-.288-.7t.288-.7z</StreamGeometry> + </Control.Resources> <!-- <Grid Focusable="True" Background="{DynamicResource DocumentColor}"> --> <Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Background="{DynamicResource DocumentColor}"> <Grid.RowDefinitions> @@ -29,9 +32,27 @@ <ItemsControl.ItemTemplate> <DataTemplate> <StackPanel Margin="8 3" Orientation="Vertical"> + <StackPanel.ContextMenu> + <ContextMenu> + <MenuItem Header="Edit" + Command="{Binding $parent[ScrollViewer].((vm:DocumentViewModel)DataContext).EditCommand}" + CommandParameter="{Binding Id}"/> + <MenuItem Header="Reply" + Command="{Binding $parent[ScrollViewer].((vm:DocumentViewModel)DataContext).ReplyMessage}" + CommandParameter="{Binding Id}"/> + <MenuItem Header="Delete" + Command="{Binding $parent[ScrollViewer].((vm:DocumentViewModel)DataContext).DeleteMessage}" + CommandParameter="{Binding Id}"/> + </ContextMenu> + </StackPanel.ContextMenu> + <StackPanel IsVisible="{Binding IsReply}" Orientation="Horizontal"> + <PathIcon Foreground="Gray" Margin="0 0 1 0" VerticalAlignment="Center" Height="10" Width="10" Data="{StaticResource ReplyIcon}"></PathIcon> + <TextBlock VerticalAlignment="Center" FontWeight="Bold" Foreground="Gray" Text="{Binding ReplyToMessage.VisualAuthor.Username}" Margin="0 0 2 0" /> + <TextBlock VerticalAlignment="Center" Foreground="Gray" Text="{Binding ReplyToMessage.Content}"></TextBlock> + </StackPanel> <StackPanel Orientation="Horizontal"> - <TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" /> - <Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="5 0" + <TextBlock VerticalAlignment="Center" Margin="0 0 2 0" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" /> + <Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="3 0 5 0" IsVisible="{Binding VisualAuthor.IsBot}"> <TextBlock FontWeight="Bold" Text="{Binding VisualAuthor.BotTag}" /> </Border> diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs index d326035..a3e95de 100644 --- a/Lightquark.NET/ClientMethods/Gateway.cs +++ b/Lightquark.NET/ClientMethods/Gateway.cs @@ -136,7 +136,7 @@ public partial class Client SendGatewayMessage(new {@event = "heartbeat"}); } - public async Task<Reply<T>> CallRpc<T>(string method, string route, object? body) where T : BaseReplyResponse + public async Task<Reply<T>> CallRpc<T>(string method, string route, object? body = null) where T : BaseReplyResponse { Log.Information("Starting RPC to {Method} {Route}", method, route); await GatewayConnectionEvent.WaitAsync(); // Wait until connected just in case diff --git a/Lightquark.NET/ClientMethods/Message.cs b/Lightquark.NET/ClientMethods/Message.cs index ea2572a..ef3fd7c 100644 --- a/Lightquark.NET/ClientMethods/Message.cs +++ b/Lightquark.NET/ClientMethods/Message.cs @@ -10,14 +10,24 @@ namespace Lightquark.NET; public partial class Client { - public async void SendMessage(string message, ObjectId channelId) + public async void SendMessage(string message, ObjectId channelId, ObjectId? replyToId = null) { try { var body = new MultipartFormDataContent(); + var attributes = new List<object>(); + if (replyToId != null) + { + attributes.Add(new + { + type = "reply", + replyTo = replyToId + }); + } body.Add(new StringContent(JsonConvert.SerializeObject(new { - content = message + content = message, + specialAttributes = attributes })), "payload"); var resTask = Call<MessageSendResponse>("POST", $"/{Version}/channel/{channelId}/messages", body); var fakeId = ObjectId.GenerateNewId(); @@ -27,6 +37,7 @@ public partial class Client Author = CurrentUser, Content = message, Status = MessageStatus.Sending + }; Messages[channelId].Add(fakeMsg); var res = await resTask; @@ -46,13 +57,71 @@ public partial class Client } } + public async void DeleteMessage(ObjectId channelId, ObjectId msgId) + { + try + { + var res = await CallRpc<MessageDeleteResponse>("DELETE", $"{Version}/channel/{channelId}/messages/{msgId}"); + if (!res.Request.Success) throw new Exception($"Failed message delete: {res.Response.Message}"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to delete message"); + } + } + public async Task<List<Message>> GetMessages(ObjectId channelId, int limit = 50) { try { - var res = await CallRpc<GetMessagesResponse>("GET", $"{Version}/channel/{channelId}/messages?limit={limit}", null); + var res = await CallRpc<GetMessagesResponse>("GET", $"{Version}/channel/{channelId}/messages?limit={limit}"); if (!res.Request.Success) throw new ApiException($"Failed to retrieve messages for {channelId}: {res.Request.StatusCode} {res.Response.Message}"); + foreach (var msg in res.Response.Messages) + { + var replyAttribute = msg.SpecialAttributes.FirstOrDefault(a => a["type"]?.ToString() == "reply"); + if (replyAttribute != null) + { + if (ObjectId.TryParse(replyAttribute["replyTo"]?.ToString(), out var replyToId)) + { + var replyToMsg = res.Response.Messages.FirstOrDefault(m => m.Id == replyToId); + if (replyToMsg == null) + { + replyToMsg = Messages[channelId].FirstOrDefault(m => m.Id == replyToId); + if (replyToMsg == null) + { + var replyRes = await CallRpc<GetMessageResponse>("GET", $"{Version}/channel/{channelId}/messages/{replyToId}"); + if (replyRes.Request.Success) + { + Log.Information("Reply message found via API"); + replyToMsg = replyRes.Response.Data; + } + else + { + Log.Error("Couldn't fetch reply message {ReplyMsgId} due to {Reason}", replyToId, replyRes.Response.Message); + replyToMsg = new Message + { + Id = replyToId, + Author = CurrentUser, + Content = "Message couldn't be loaded", + Status = MessageStatus.Fail + }; + } + } + else + { + Log.Information("Reply message found in cache"); + } + } + else + { + Log.Information("Reply message found in fetched messages"); + } + msg.ReplyToMessage = replyToMsg; + } + } + } + return res.Response.Messages; } catch (Exception ex) @@ -66,6 +135,49 @@ public partial class Client { channelId ??= message.ChannelId; message.ChannelId = channelId.Value; + var replyAttribute = message.SpecialAttributes.FirstOrDefault(a => a["type"]?.ToString() == "reply"); + if (replyAttribute != null) + { + if (ObjectId.TryParse(replyAttribute["replyTo"]?.ToString(), out var replyToId)) + { + var replyToMsg = Messages[channelId.Value].FirstOrDefault(m => m.Id == replyToId); + if (replyToMsg == null) + { + replyToMsg = new Message + { + Id = replyToId, + Author = CurrentUser, + Content = "Message loading", + Status = MessageStatus.Sending + }; + Task.Run(async () => + { + var replyRes = await CallRpc<GetMessageResponse>("GET", $"{Version}/channel/{channelId}/messages/{replyToId}"); + if (replyRes.Request.Success) + { + Log.Information("Reply message found via API"); + message.ReplyToMessage = replyRes.Response.Data; + } + else + { + Log.Error("Couldn't fetch reply message {ReplyMsgId} due to {Reason}", replyToId, replyRes.Response.Message); + message.ReplyToMessage = new Message + { + Id = replyToId, + Author = CurrentUser, + Content = "Message couldn't be loaded", + Status = MessageStatus.Fail + }; + } + }); + } + else + { + Log.Information("Reply message found in cache"); + } + message.ReplyToMessage = replyToMsg; + } + } if (!Messages.TryGetValue(channelId.Value, out var messageCollection)) { Log.Information("Channel {ChannelId} has no prior messages, creating collection", channelId); @@ -116,7 +228,18 @@ public record MessageSendResponse : BaseReplyResponse // lol apparently we dont need anything from here? } +public record MessageDeleteResponse : BaseReplyResponse +{ +// lol apparently we dont need anything from here? +} + public record GetMessagesResponse : BaseReplyResponse { public List<Message> Messages { get; set; } +} + +public record GetMessageResponse : BaseReplyResponse +{ + [JsonProperty("data")] + public Message Data { get; set; } } \ No newline at end of file diff --git a/Lightquark.NET/Objects/Message.cs b/Lightquark.NET/Objects/Message.cs index 1b1365a..b2b4d8f 100644 --- a/Lightquark.NET/Objects/Message.cs +++ b/Lightquark.NET/Objects/Message.cs @@ -198,6 +198,7 @@ public class Message() : ObservableObject private JArray _specialAttributes = []; private User[]? _virtualAuthors; private MessageStatus _status = MessageStatus.Complete; + private Message? _replyToMessage; [JsonIgnore] public MessageStatus Status @@ -219,6 +220,17 @@ public class Message() : ObservableObject _ => throw new ArgumentOutOfRangeException() } }; + + public Message? ReplyToMessage + { + get => _replyToMessage; + set + { + if (SetProperty(ref _replyToMessage, value)) OnPropertyChanged(nameof(IsReply)); + } + } + + public bool IsReply => ReplyToMessage != null; } public record MessageVisuals -- GitLab