mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 08:56:14 +02:00
Add jump-to-message feature when clicking on reply
This commit is contained in:
@@ -21,3 +21,16 @@ public static class StringConverters
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class BoolToOpacityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
return value is true ? 0.3 : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public sealed class ChatViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
public Action? LoadMoreRequested;
|
public Action? LoadMoreRequested;
|
||||||
|
|
||||||
|
public Action<string>? ScrollToMessageRequested;
|
||||||
|
|
||||||
public ObservableCollection<MessageViewModel> Messages
|
public ObservableCollection<MessageViewModel> Messages
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public sealed class MessageViewModel(MessageModel model) : ViewModelBase
|
|||||||
|
|
||||||
public bool ShowContent => !string.IsNullOrEmpty(Content);
|
public bool ShowContent => !string.IsNullOrEmpty(Content);
|
||||||
|
|
||||||
|
public bool IsHighlighted
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||||
|
}
|
||||||
|
|
||||||
public string TimeText
|
public string TimeText
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
Margin="0 0 5 0"
|
Margin="0 0 5 0"
|
||||||
Text="{Binding Draft, Mode=TwoWay}"
|
Text="{Binding Draft, Mode=TwoWay}"
|
||||||
IsEnabled="{Binding IsEditable}"
|
IsEnabled="{Binding IsEditable}"
|
||||||
AcceptsReturn="False"
|
AcceptsReturn="True"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
KeyDown="OnTextBoxKeyDown" />
|
KeyDown="OnTextBoxKeyDown" />
|
||||||
<Popup x:Name="MentionPopup"
|
<Popup x:Name="MentionPopup"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Avalonia.Platform.Storage;
|
|||||||
using Decho.Models;
|
using Decho.Models;
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reactive;
|
using System.Reactive;
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ public partial class MessageComposerView : UserControl
|
|||||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||||
DragDrop.SetAllowDrop(this, true);
|
DragDrop.SetAllowDrop(this, true);
|
||||||
DataContextChanged += OnDataContextChanged;
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
|
||||||
|
// Intercept Enter before the TextBox processes it (AcceptsReturn=True would insert a newline)
|
||||||
|
MessageTextBox.AddHandler(InputElement.KeyDownEvent, OnTextBoxTunnelingKeyDown, RoutingStrategies.Tunnel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
@@ -81,23 +85,35 @@ public partial class MessageComposerView : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.Key == Key.Enter)
|
if (e.Key == Key.Escape)
|
||||||
{
|
{
|
||||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
bool hasReply = vm.HasReplyTarget;
|
||||||
|
bool hasFiles = vm.HasStagedFiles;
|
||||||
|
if (hasReply || hasFiles)
|
||||||
{
|
{
|
||||||
int caret = MessageTextBox.CaretIndex;
|
if (hasReply) vm.ClearReplyTarget();
|
||||||
string text = vm.Draft ?? "";
|
if (hasFiles) vm.ClearStagedFiles();
|
||||||
vm.Draft = text[..caret] + "\n" + text[caret..];
|
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => MessageTextBox.CaretIndex = caret + 1);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextBoxTunnelingKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter) return;
|
||||||
|
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) return;
|
||||||
|
|
||||||
|
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
|
||||||
|
if (vm is null) return;
|
||||||
|
|
||||||
|
// Don't intercept Enter when autocomplete is open (bubbling handler handles it)
|
||||||
|
if (vm.Autocomplete.ShowPopup) return;
|
||||||
|
|
||||||
vm.Draft = MessageTextBox.Text ?? "";
|
vm.Draft = MessageTextBox.Text ?? "";
|
||||||
vm.Send();
|
vm.Send();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,18 @@
|
|||||||
xmlns:conv="using:Decho.Converters"
|
xmlns:conv="using:Decho.Converters"
|
||||||
x:Class="Decho.Views.MessageItemView"
|
x:Class="Decho.Views.MessageItemView"
|
||||||
x:DataType="vm:MessageViewModel">
|
x:DataType="vm:MessageViewModel">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<conv:BoolToOpacityConverter x:Key="BoolOpacityConverter" />
|
||||||
|
</UserControl.Resources>
|
||||||
<Grid Margin="5" ColumnDefinitions="Auto, *">
|
<Grid Margin="5" ColumnDefinitions="Auto, *">
|
||||||
|
<Border Grid.ColumnSpan="2" Background="#2645a1" IsHitTestVisible="False" CornerRadius="4"
|
||||||
|
Opacity="{Binding IsHighlighted, Converter={StaticResource BoolOpacityConverter}}">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.3" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
</Border>
|
||||||
<Ellipse VerticalAlignment="Top"
|
<Ellipse VerticalAlignment="Top"
|
||||||
Width="40"
|
Width="40"
|
||||||
Height="40"
|
Height="40"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
using Decho.Models;
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -64,7 +65,7 @@ public partial class MessageItemView : UserControl
|
|||||||
TextBlock? replyQuote = ReplyQuote;
|
TextBlock? replyQuote = ReplyQuote;
|
||||||
if (replyQuote is not null && msg.ReplyTo is { } reply)
|
if (replyQuote is not null && msg.ReplyTo is { } reply)
|
||||||
{
|
{
|
||||||
replyQuote.Text = $"\u2514 {reply.SenderUsername}: {reply.Content}";
|
replyQuote.Text = $"\u2514 {reply.SenderUsername}: {reply.Content.Replace('\n', ' ')[..Math.Min(100, reply.Content.Length)]}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,8 +422,66 @@ public partial class MessageItemView : UserControl
|
|||||||
if (channel is null)
|
if (channel is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Switch to the channel containing the original message
|
string targetId = msg.ReplyTo.MessageId.ToString("N");
|
||||||
if (!string.Equals(channel.Name, mainVm.Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase))
|
bool needsSwitch = !string.Equals(channel.Name, mainVm.Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (needsSwitch)
|
||||||
|
{
|
||||||
mainVm.Sidebar.SelectedChannel = channel;
|
mainVm.Sidebar.SelectedChannel = channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JumpToReply(targetId, needsSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void JumpToReply(string targetId, bool needsSwitch)
|
||||||
|
{
|
||||||
|
if (needsSwitch)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
|
||||||
|
if (mainVm is null) return;
|
||||||
|
|
||||||
|
bool found = mainVm.Chat.Messages.Any(m => m.Model.Id == targetId);
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
string serverUrl = ResolveServerUrl();
|
||||||
|
string channelName = mainVm.Chat.CurrentChannelName;
|
||||||
|
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channelName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<MessageModel> allMessages = await mainVm.ChannelService.GetHistoryAsync(
|
||||||
|
serverUrl, channelName, 200, 0);
|
||||||
|
|
||||||
|
if (allMessages.Any(m => m.Id == targetId))
|
||||||
|
{
|
||||||
|
ChannelViewModel? channelVm = mainVm.Sidebar.GetServer(serverUrl)?.Channels
|
||||||
|
.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (channelVm is not null)
|
||||||
|
{
|
||||||
|
HashSet<string> existing = channelVm.Messages.Select(m => m.Model.Id).ToHashSet();
|
||||||
|
foreach (MessageModel m in allMessages)
|
||||||
|
{
|
||||||
|
if (!existing.Contains(m.Id))
|
||||||
|
channelVm.AddMessage(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Failed to fetch reply target: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainVm.Chat.ScrollToMessageRequested?.Invoke(targetId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ public partial class MessageListView : UserControl
|
|||||||
_chat.Messages.CollectionChanged -= OnMessagesChanged;
|
_chat.Messages.CollectionChanged -= OnMessagesChanged;
|
||||||
_chat.PropertyChanged -= OnViewModelPropertyChanged;
|
_chat.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
_chat.LoadMoreRequested -= OnLoadMoreRequested;
|
_chat.LoadMoreRequested -= OnLoadMoreRequested;
|
||||||
|
_chat.ScrollToMessageRequested -= ScrollToMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||||
@@ -40,6 +42,7 @@ public partial class MessageListView : UserControl
|
|||||||
_chat.Messages.CollectionChanged += OnMessagesChanged;
|
_chat.Messages.CollectionChanged += OnMessagesChanged;
|
||||||
_chat.PropertyChanged += OnViewModelPropertyChanged;
|
_chat.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
_chat.LoadMoreRequested += OnLoadMoreRequested;
|
_chat.LoadMoreRequested += OnLoadMoreRequested;
|
||||||
|
_chat.ScrollToMessageRequested += ScrollToMessage;
|
||||||
scroll?.ScrollChanged += OnScrollChanged;
|
scroll?.ScrollChanged += OnScrollChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,4 +141,74 @@ public partial class MessageListView : UserControl
|
|||||||
_wasAtBottom = true;
|
_wasAtBottom = true;
|
||||||
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
|
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ScrollToMessage(string messageId)
|
||||||
|
{
|
||||||
|
if (_chat is null) return;
|
||||||
|
|
||||||
|
MessageViewModel? target = _chat.Messages.FirstOrDefault(m => m.Model.Id == messageId);
|
||||||
|
if (target is null) return;
|
||||||
|
|
||||||
|
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||||
|
if (scroll is null) return;
|
||||||
|
|
||||||
|
ClearHighlight();
|
||||||
|
target.IsHighlighted = true;
|
||||||
|
_highlightedMessage = target;
|
||||||
|
_highlightCts = new CancellationTokenSource();
|
||||||
|
CancellationToken ct = _highlightCts.Token;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
double? targetY = FindItemY(scroll, target);
|
||||||
|
if (targetY is not null)
|
||||||
|
scroll.Offset = new Vector(scroll.Offset.X, targetY.Value);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, ct);
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
target.IsHighlighted = false;
|
||||||
|
if (_highlightedMessage == target)
|
||||||
|
_highlightedMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? FindItemY(Visual parent, MessageViewModel target)
|
||||||
|
{
|
||||||
|
foreach (Visual child in parent.GetVisualChildren())
|
||||||
|
{
|
||||||
|
if (child.DataContext == target)
|
||||||
|
return child.Bounds.Y;
|
||||||
|
|
||||||
|
double? result = FindItemY(child, target);
|
||||||
|
if (result.HasValue)
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageViewModel? _highlightedMessage;
|
||||||
|
private CancellationTokenSource? _highlightCts;
|
||||||
|
|
||||||
|
private void ClearHighlight()
|
||||||
|
{
|
||||||
|
_highlightCts?.Cancel();
|
||||||
|
_highlightCts = null;
|
||||||
|
if (_highlightedMessage is not null)
|
||||||
|
{
|
||||||
|
_highlightedMessage.IsHighlighted = false;
|
||||||
|
_highlightedMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user