Add jump-to-message feature when clicking on reply

This commit is contained in:
Stone_Red
2026-07-26 14:39:52 +02:00
parent a62ff1b9af
commit ee3beaca2f
8 changed files with 195 additions and 15 deletions
+14 -1
View File
@@ -20,4 +20,17 @@ public static class StringConverters
throw new NotSupportedException(); throw new NotSupportedException();
} }
} }
} }
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();
}
}
+2
View File
@@ -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;
+6
View File
@@ -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
+1 -1
View File
@@ -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"
+26 -10
View File
@@ -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,24 +85,36 @@ 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;
} }
vm.Draft = MessageTextBox.Text ?? "";
vm.Send();
e.Handled = true;
} }
} }
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.Send();
e.Handled = true;
}
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e) private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
{ {
if (sender is TextBlock textBlock && textBlock.DataContext is string item) if (sender is TextBlock textBlock && textBlock.DataContext is string item)
+11
View File
@@ -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"
+62 -3
View File
@@ -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);
} }
} }
+73
View File
@@ -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;
}
}
} }