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