diff --git a/Blacklight/Blacklight.csproj b/Blacklight/Blacklight.csproj index 44b879eb95e16045143269a8a1797023956b8507..1901658e5cb353a73d3a091eb5eb0bf92393ee3f 100644 --- a/Blacklight/Blacklight.csproj +++ b/Blacklight/Blacklight.csproj @@ -19,12 +19,14 @@ <PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.3" /> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.3" /> + <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.1.0.7" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" /> <PackageReference Include="Dock.Avalonia" Version="11.1.0.1" /> <PackageReference Include="Dock.Model.Mvvm" Version="11.1.0.1" /> <PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageReference Include="ReactiveUI" Version="20.1.63" /> <PackageReference Include="Serilog" Version="4.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> diff --git a/Blacklight/TODO b/Blacklight/TODO new file mode 100644 index 0000000000000000000000000000000000000000..162e95a9bc7580f011fdcdbb7f32dc9617792d0c --- /dev/null +++ b/Blacklight/TODO @@ -0,0 +1,21 @@ +next things to do: + +[done] Scrolling in chat + +[done] Load chat history + +[done] Timestamps + +[done] Timestamps 2: Sorting boogaloo + +[done] Message edit and delete events + +Scroll to top for more messages + +Message edited indicator + +Message editing and deleting (scary for editing?) + +Maybe look into transforming fakeMsg into final message instead of remove and then wait for event to arrive, +or remove it when the event has arrived (how to tell? -> Client attribute with guid?) + diff --git a/Blacklight/Util/ScrollToEndBehavior.cs b/Blacklight/Util/ScrollToEndBehavior.cs new file mode 100644 index 0000000000000000000000000000000000000000..4b6dffae17742045a961621a302ffe77fae25d42 --- /dev/null +++ b/Blacklight/Util/ScrollToEndBehavior.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.Xaml.Interactivity; +using Serilog; + +namespace Blacklight.Util; + +public class ScrollToEndBehavior : Behavior<ScrollViewer> +{ + public static readonly StyledProperty<bool> ShouldScrollToEndProperty = + AvaloniaProperty.Register<ScrollToEndBehavior, bool>(nameof(ShouldScrollToEnd)); + + public bool ShouldScrollToEnd + { + get => GetValue(ShouldScrollToEndProperty); + set => SetValue(ShouldScrollToEndProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + // Optionally listen for property changes + this.GetObservable(ShouldScrollToEndProperty).Subscribe(ScrollNow); + } + + private void ScrollNow(bool shouldScroll) + { + Log.Information("I should scroll maybe? {ShouldScroll}, {Other}", shouldScroll, AssociatedObject != null); + if (shouldScroll && AssociatedObject != null) + { + Dispatcher.UIThread.Post(() => + { + AssociatedObject.ScrollToEnd(); + }); + } + } +} diff --git a/Blacklight/ViewModels/DockFactory.cs b/Blacklight/ViewModels/DockFactory.cs index 3026c107a3ba8738d0eca985fabba254002c9c7f..414f3124cfbe01608e75482eb5f31d93e8115fdd 100644 --- a/Blacklight/ViewModels/DockFactory.cs +++ b/Blacklight/ViewModels/DockFactory.cs @@ -200,10 +200,9 @@ public class DockContext public void AddDocument(Channel channel, bool isEphemeral, bool shouldCloseEphemeral = true) { - var document = new DocumentViewModel + var document = new DocumentViewModel(channel) { Id = channel.Id.ToString(), - Channel = channel, Title = channel.Name, CanPin = false, CanFloat = false diff --git a/Blacklight/ViewModels/Documents/DocumentViewModel.cs b/Blacklight/ViewModels/Documents/DocumentViewModel.cs index d43ac06eeb8fac54ab1311dc9a8503d639313bfe..6a86f6ab02b3a1f28b6096ecf0ab52b4867dd675 100644 --- a/Blacklight/ViewModels/Documents/DocumentViewModel.cs +++ b/Blacklight/ViewModels/Documents/DocumentViewModel.cs @@ -1,10 +1,14 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Media; +using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; using Dock.Model.Mvvm.Controls; +using DynamicData; +using DynamicData.Binding; using Lightquark.NET; using Lightquark.NET.Objects; using Microsoft.Extensions.DependencyInjection; @@ -16,9 +20,48 @@ public class DocumentViewModel : Document { private bool _isEphemeral; private string _messageInput = ""; - public required Channel Channel { get; set; } + private bool _scrollToEndTrigger; + private bool _scrollLocked = true; + public Channel Channel { get; set; } + private ReadOnlyObservableCollection<Message> _messages; - public ObservableCollection<Message> Messages + public bool ScrollToEndTrigger + { + get => _scrollToEndTrigger; + set => SetProperty(ref _scrollToEndTrigger, value); + } + + public DocumentViewModel(Channel channel) + { + Channel = channel; + MessagesCollection + .ToObservableChangeSet() + .Sort(SortExpressionComparer<Message>.Ascending(m => m.Timestamp)) + .Bind(out _messages) + .Subscribe(); + ((INotifyCollectionChanged)Messages).CollectionChanged += (_, _) => + { + if (!_scrollLocked) return; + Dispatcher.UIThread.Post(() => + { + Log.Information("New message! Scroll status {Status}", ScrollToEndTrigger); + ScrollToEndTrigger = false; + ScrollToEndTrigger = true; + }); + }; + } + + public void ScrollChanged(double deltaY, double currentOffset, double maxOffset) + { + if (deltaY < 0) _scrollLocked = false; + // If roughly at the bottom lock the scroll again + if (Math.Abs(currentOffset - maxOffset) < 10) _scrollLocked = true; + // Log.Information("Scroll changed {Delta} => {Current}/{Max}", deltaY, currentOffset, maxOffset); + } + + public ReadOnlyObservableCollection<Message> Messages => _messages; + + private ObservableCollection<Message> MessagesCollection { get { @@ -28,6 +71,24 @@ public class DocumentViewModel : Document Log.Information("Channel {ChannelId} has no messages, creating collection", Channel.Id); messageCollection = new ObservableCollection<Message>(); client.Messages[Channel.Id] = messageCollection; + Task.Run(async () => + { + try + { + var messages = await client.GetMessages(Channel.Id); + Dispatcher.UIThread.Post(() => + { + foreach (var message in messages) + { + messageCollection.Add(message); + } + }); + } + catch (Exception) + { + // Unfortunate + } + }); } return messageCollection; } diff --git a/Blacklight/Views/Documents/DocumentView.axaml b/Blacklight/Views/Documents/DocumentView.axaml index 6f3a5b98fe87e5ab464eb89c890508513b07c9ec..4de1e4ba7e55803f9e74b51e833978cdf3de950e 100644 --- a/Blacklight/Views/Documents/DocumentView.axaml +++ b/Blacklight/Views/Documents/DocumentView.axaml @@ -1,55 +1,66 @@ <UserControl x:Class="Blacklight.Views.Documents.DocumentView" - xmlns="https://github.com/avaloniaui" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:vm="using:Blacklight.ViewModels.Documents" - xmlns:util="clr-namespace:Blacklight.Util" - xmlns:objects="clr-namespace:Lightquark.NET.Objects;assembly=Lightquark.NET" - xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections" - mc:Ignorable="d" - d:DesignWidth="300" d:DesignHeight="400" - x:DataType="vm:DocumentViewModel" x:CompileBindings="True"> - <!-- <Grid Focusable="True" Background="{DynamicResource DocumentColor}"> --> - <Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Background="{DynamicResource DocumentColor}"> - <Grid.RowDefinitions> - <RowDefinition Height="*"/> - <RowDefinition Height="Auto"/> - </Grid.RowDefinitions> - <StackPanel Grid.Row="0"> - <!-- <TextBlock Text="{Binding Id}" Padding="2" /> --> - <!-- <TextBlock Text="{Binding Title}" Padding="2" /> --> - <!-- <TextBlock Text="{Binding Context}" Padding="2" /> --> - <!-- <TextBlock Text="{Binding IsEphemeral}" Padding="2" /> --> - <!-- <Button Command="{Binding TestCommand}">Meow</Button> --> - <ItemsControl ItemsSource="{Binding Messages}" > - <ItemsControl.ItemTemplate> - <DataTemplate> - <StackPanel Margin="8 3" Orientation="Vertical"> - <StackPanel Orientation="Horizontal"> - <TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" /> - <Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="5 0" IsVisible="{Binding VisualAuthor.IsBot}"> - <TextBlock FontWeight="Bold" Text="{Binding VisualAuthor.BotTag}" /> - </Border> - </StackPanel> - <TextBlock Foreground="{Binding Visuals.TextColor, Converter={StaticResource MessageStatusEnumToColor}}" Text="{Binding Content}" /> - </StackPanel> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </StackPanel> - <Grid Grid.Row="1" HorizontalAlignment="Stretch"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <TextBox MaxHeight="200" Grid.Column="0" AcceptsReturn="True" TextWrapping="Wrap" Text="{Binding MessageInput}"> - <TextBox.KeyBindings> - <KeyBinding Gesture="Enter" Command="{Binding SendCommand}"/> - </TextBox.KeyBindings> - </TextBox> - <Button Grid.Column="1" Command="{Binding SendCommand}">Send</Button> - </Grid> - </Grid> - <!-- </Grid> --> + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:vm="using:Blacklight.ViewModels.Documents" + xmlns:util="clr-namespace:Blacklight.Util" + xmlns:objects="clr-namespace:Lightquark.NET.Objects;assembly=Lightquark.NET" + xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections" + mc:Ignorable="d" + d:DesignWidth="300" d:DesignHeight="400" + x:DataType="vm:DocumentViewModel" x:CompileBindings="True"> + <!-- <Grid Focusable="True" Background="{DynamicResource DocumentColor}"> --> + <Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Background="{DynamicResource DocumentColor}"> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <!-- <TextBlock Text="{Binding Id}" Padding="2" /> --> + <!-- <TextBlock Text="{Binding Title}" Padding="2" /> --> + <!-- <TextBlock Text="{Binding Context}" Padding="2" /> --> + <!-- <TextBlock Text="{Binding IsEphemeral}" Padding="2" /> --> + <!-- <Button Command="{Binding TestCommand}">Meow</Button> --> + <ScrollViewer Grid.Row="0" Name="MessageView" ScrollChanged="MessageView_OnScrollChanged"> + <Interaction.Behaviors> + <util:ScrollToEndBehavior ShouldScrollToEnd="{Binding ScrollToEndTrigger}" /> + </Interaction.Behaviors> + <ItemsControl ItemsSource="{Binding Messages}" x:Name="MessageItems"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <StackPanel Margin="8 3" Orientation="Vertical"> + <StackPanel Orientation="Horizontal"> + <TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" /> + <Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="5 0" + IsVisible="{Binding VisualAuthor.IsBot}"> + <TextBlock FontWeight="Bold" Text="{Binding VisualAuthor.BotTag}" /> + </Border> + <TextBlock Foreground="Gray" Text="{Binding PrettyTime}"> + <ToolTip.Tip> + <TextBlock Text="{Binding PrettyTimePrecise}" /> + </ToolTip.Tip> + </TextBlock> + </StackPanel> + <TextBlock Foreground="{Binding Visuals.TextColor, Converter={StaticResource MessageStatusEnumToColor}}" + TextWrapping="Wrap" + Text="{Binding Content}" /> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + <Grid Grid.Row="1" HorizontalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBox MaxHeight="200" Grid.Column="0" AcceptsReturn="True" TextWrapping="Wrap" Text="{Binding MessageInput}"> + <TextBox.KeyBindings> + <KeyBinding Gesture="Enter" Command="{Binding SendCommand}" /> + </TextBox.KeyBindings> + </TextBox> + <Button Grid.Column="1" Command="{Binding SendCommand}">Send</Button> + </Grid> + </Grid> + <!-- </Grid> --> </UserControl> \ No newline at end of file diff --git a/Blacklight/Views/Documents/DocumentView.axaml.cs b/Blacklight/Views/Documents/DocumentView.axaml.cs index 9eda30951ebda2caa797acd1bcd985983fa03b75..3f268f81fb3fabbfc50d55cb93048f324bf83521 100644 --- a/Blacklight/Views/Documents/DocumentView.axaml.cs +++ b/Blacklight/Views/Documents/DocumentView.axaml.cs @@ -1,6 +1,9 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Blacklight.ViewModels.Documents; +using Serilog; namespace Blacklight.Views.Documents; @@ -10,9 +13,15 @@ public partial class DocumentView : UserControl { InitializeComponent(); } - + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); } + + private void MessageView_OnScrollChanged(object? sender, ScrollChangedEventArgs e) + { + var sv = (ScrollViewer)sender!; + ((DocumentViewModel)DataContext!).ScrollChanged(e.OffsetDelta.Y, sv.Offset.Y, sv.ScrollBarMaximum.Y); + } } \ No newline at end of file diff --git a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs index 2cbaf966f508feeb002cba5856b11f26334f4917..9e657ea8e0a1ee8a8b82789b0e80cd05bc0009a6 100644 --- a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs +++ b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs @@ -36,7 +36,6 @@ public partial class ResourceExplorerView : UserControl _doubleTimer?.Dispose(); _selectedItem = null; App.DockContext.AddDocument(quarkListItem.Channel!, false); - // TODO: Open persistent tab } else { @@ -53,7 +52,6 @@ public partial class ResourceExplorerView : UserControl }; _doubleTimer.Start(); - // TODO: Open or update ephemeral tab App.DockContext.AddDocument(quarkListItem.Channel!, true); } break; diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs index 5bbf9697d4b9955f4eacc13fc7aa45200eb3dfd0..86990912885fee07f71c046bf69ba47495fb51e5 100644 --- a/Lightquark.NET/Client.cs +++ b/Lightquark.NET/Client.cs @@ -64,6 +64,7 @@ public partial class Client : ObservableObject private HttpClient _httpClient = new(); private User? _currentUser; private IMapper _mapper; + private Timer _stupidTimer; public Client(ILogger? logger = null) { @@ -82,6 +83,21 @@ public partial class Client : ObservableObject cfg.CreateMap<Channel, Channel>().ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null)); }); _mapper = mapperConfig.CreateMapper(); + var today = DateTimeOffset.Now; + var tomorrow = DateTimeOffset.Now.AddDays(1).Date; + var timeUntilMidnight = (tomorrow - today).Add(TimeSpan.FromMilliseconds(100)); + _stupidTimer = new Timer(_ => + { + foreach (var keyValuePair in Messages) + { + foreach (var message in keyValuePair.Value) + { + message.UpdateTime(); + } + } + // party time its midnight + }, null, timeUntilMidnight, TimeSpan.FromDays(1)); + } diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs index ae5c87b8a1a168809c95f8dff5f1bf1d67e088c6..d326035753c42b74f6e2032047f8c352a24d7f6d 100644 --- a/Lightquark.NET/ClientMethods/Gateway.cs +++ b/Lightquark.NET/ClientMethods/Gateway.cs @@ -104,9 +104,14 @@ public partial class Client } break; case "messageCreate": + case "messageUpdate": var messageCreateMessage = ParseGateway<MessageCreateGatewayMessage>(msg.Text!); MessageFromObject(messageCreateMessage.Message, messageCreateMessage.ChannelId); break; + case "messageDelete": + var messageDeleteMessage = ParseGateway<MessageCreateGatewayMessage>(msg.Text!); + RemoveMessage(messageDeleteMessage.Message.Id, messageDeleteMessage.ChannelId); + break; case "gatekeeperMeasure": var measureMessage = ParseGateway<MeasureGatewayMessage>(msg.Text!); GatekeeperMeasure(measureMessage); diff --git a/Lightquark.NET/ClientMethods/Message.cs b/Lightquark.NET/ClientMethods/Message.cs index 72a532a74d7f225109942d07b75fecf3fead6a38..ea2572aa6a340bfecac07794309732fbdda125d1 100644 --- a/Lightquark.NET/ClientMethods/Message.cs +++ b/Lightquark.NET/ClientMethods/Message.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using Lightquark.NET.Objects; using Lightquark.NET.Objects.Reply; +using Lightquark.NET.Util; using MongoDB.Bson; using Newtonsoft.Json; using Serilog; @@ -45,6 +46,22 @@ public partial class Client } } + 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); + if (!res.Request.Success) throw new ApiException($"Failed to retrieve messages for {channelId}: {res.Request.StatusCode} {res.Response.Message}"); + + return res.Response.Messages; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to retrieve messages in channel {ChannelId}: {ExMsg}", channelId, ex.Message); + throw; + } + } + private void MessageFromObject(Message message, ObjectId? channelId = null) { channelId ??= message.ChannelId; @@ -54,17 +71,52 @@ public partial class Client Log.Information("Channel {ChannelId} has no prior messages, creating collection", channelId); messageCollection = []; Messages[channelId.Value] = messageCollection; + Task.Run(async () => + { + try + { + var messages = await GetMessages(channelId.Value); + foreach (var msg in messages) + { + messageCollection.Add(msg); + } + } + catch (Exception) + { + // Unfortunate + } + }); } // If no message with same id in collection add message - if (messageCollection.All(m => m.Id != message.Id)) + var existingMessage = messageCollection.FirstOrDefault(m => m.Id == message.Id); + if (existingMessage == null) { Log.Information("New message {MessageId} in channel {ChannelId}", message.Id, channelId.Value); messageCollection.Add(message); } + else + { + Log.Information("Edited message {MessageId} in channel {ChannelId}", message.Id, channelId.Value); + var index = messageCollection.IndexOf(existingMessage); + messageCollection[index] = message; + } + } + + private void RemoveMessage(ObjectId messageId, ObjectId channelId) + { + if (!Messages.TryGetValue(channelId, out var messageCollection)) return; // nothing to delete + var message = messageCollection.FirstOrDefault(m => m.Id == messageId); + if (message == null) return; // welp we dont have it + messageCollection.Remove(message); } } public record MessageSendResponse : BaseReplyResponse { - +// lol apparently we dont need anything from here? +} + +public record GetMessagesResponse : BaseReplyResponse +{ + public List<Message> Messages { get; set; } } \ No newline at end of file diff --git a/Lightquark.NET/Objects/Message.cs b/Lightquark.NET/Objects/Message.cs index 11f14f5fabf6ac685ff511d607400afa1d3451c2..1b1365a994cc55703f0c2524a962ef06a4971397 100644 --- a/Lightquark.NET/Objects/Message.cs +++ b/Lightquark.NET/Objects/Message.cs @@ -155,6 +155,37 @@ public class Message() : ObservableObject } } + public void UpdateTime() + { + OnPropertyChanged(nameof(PrettyTime)); + OnPropertyChanged(nameof(PrettyTimePrecise)); + } + + [JsonIgnore] + public string PrettyTime + { + get + { + var date = DateTimeOffset.FromUnixTimeMilliseconds(Timestamp).ToLocalTime(); + if (date.Date == DateTime.Today) + { + return $"Today at {date:H:mm}"; + } + + return $"{date:dd.MM.yyyy H:mm}"; + } + } + + [JsonIgnore] + public string PrettyTimePrecise + { + get + { + var date = DateTimeOffset.FromUnixTimeMilliseconds(Timestamp).ToLocalTime(); + return $"{date:dd.MM.yyyy H:mm:ss.fff}"; + } + } + private ObjectId _id; private ObjectId _authorId; private string? _content; diff --git a/Lightquark.NET/Util/ApiException.cs b/Lightquark.NET/Util/ApiException.cs new file mode 100644 index 0000000000000000000000000000000000000000..77d2e0f8f0405d7ba0b22f620705558529ca1906 --- /dev/null +++ b/Lightquark.NET/Util/ApiException.cs @@ -0,0 +1,6 @@ +namespace Lightquark.NET.Util; + +public class ApiException(string message) : Exception(message) +{ + +} \ No newline at end of file