mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +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<string>? ScrollToMessageRequested;
|
||||
|
||||
public ObservableCollection<MessageViewModel> Messages
|
||||
{
|
||||
get;
|
||||
|
||||
@@ -60,6 +60,12 @@ public sealed class MessageViewModel(MessageModel model) : ViewModelBase
|
||||
|
||||
public bool ShowContent => !string.IsNullOrEmpty(Content);
|
||||
|
||||
public bool IsHighlighted
|
||||
{
|
||||
get;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
|
||||
public string TimeText
|
||||
{
|
||||
get
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
Margin="0 0 5 0"
|
||||
Text="{Binding Draft, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsEditable}"
|
||||
AcceptsReturn="False"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
KeyDown="OnTextBoxKeyDown" />
|
||||
<Popup x:Name="MentionPopup"
|
||||
|
||||
@@ -7,6 +7,7 @@ using Avalonia.Platform.Storage;
|
||||
using Decho.Models;
|
||||
using Decho.ViewModels;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Reactive;
|
||||
|
||||
@@ -21,6 +22,9 @@ public partial class MessageComposerView : UserControl
|
||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||
DragDrop.SetAllowDrop(this, true);
|
||||
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)
|
||||
@@ -81,23 +85,35 @@ public partial class MessageComposerView : UserControl
|
||||
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;
|
||||
string text = vm.Draft ?? "";
|
||||
vm.Draft = text[..caret] + "\n" + text[caret..];
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => MessageTextBox.CaretIndex = caret + 1);
|
||||
if (hasReply) vm.ClearReplyTarget();
|
||||
if (hasFiles) vm.ClearStagedFiles();
|
||||
e.Handled = true;
|
||||
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.Send();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
xmlns:conv="using:Decho.Converters"
|
||||
x:Class="Decho.Views.MessageItemView"
|
||||
x:DataType="vm:MessageViewModel">
|
||||
<UserControl.Resources>
|
||||
<conv:BoolToOpacityConverter x:Key="BoolOpacityConverter" />
|
||||
</UserControl.Resources>
|
||||
<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"
|
||||
Width="40"
|
||||
Height="40"
|
||||
|
||||
@@ -7,6 +7,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
using Decho.Models;
|
||||
using Decho.ViewModels;
|
||||
|
||||
using EchoHub.Core.DTOs;
|
||||
@@ -64,7 +65,7 @@ public partial class MessageItemView : UserControl
|
||||
TextBlock? replyQuote = ReplyQuote;
|
||||
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)
|
||||
return;
|
||||
|
||||
// Switch to the channel containing the original message
|
||||
if (!string.Equals(channel.Name, mainVm.Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase))
|
||||
string targetId = msg.ReplyTo.MessageId.ToString("N");
|
||||
bool needsSwitch = !string.Equals(channel.Name, mainVm.Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (needsSwitch)
|
||||
{
|
||||
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.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using Decho.ViewModels;
|
||||
|
||||
@@ -29,6 +30,7 @@ public partial class MessageListView : UserControl
|
||||
_chat.Messages.CollectionChanged -= OnMessagesChanged;
|
||||
_chat.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
_chat.LoadMoreRequested -= OnLoadMoreRequested;
|
||||
_chat.ScrollToMessageRequested -= ScrollToMessage;
|
||||
}
|
||||
|
||||
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||
@@ -40,6 +42,7 @@ public partial class MessageListView : UserControl
|
||||
_chat.Messages.CollectionChanged += OnMessagesChanged;
|
||||
_chat.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_chat.LoadMoreRequested += OnLoadMoreRequested;
|
||||
_chat.ScrollToMessageRequested += ScrollToMessage;
|
||||
scroll?.ScrollChanged += OnScrollChanged;
|
||||
}
|
||||
}
|
||||
@@ -138,4 +141,74 @@ public partial class MessageListView : UserControl
|
||||
_wasAtBottom = true;
|
||||
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