diff --git a/src/Decho/Converters/StringConverters.cs b/src/Decho/Converters/StringConverters.cs index b358c1c..6500292 100644 --- a/src/Decho/Converters/StringConverters.cs +++ b/src/Decho/Converters/StringConverters.cs @@ -20,4 +20,17 @@ public static class StringConverters throw new NotSupportedException(); } } -} \ No newline at end of file +} + +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(); + } +} diff --git a/src/Decho/ViewModels/ChatViewModel.cs b/src/Decho/ViewModels/ChatViewModel.cs index 4696a32..201a7c4 100644 --- a/src/Decho/ViewModels/ChatViewModel.cs +++ b/src/Decho/ViewModels/ChatViewModel.cs @@ -9,6 +9,8 @@ public sealed class ChatViewModel : ViewModelBase { public Action? LoadMoreRequested; + public Action? ScrollToMessageRequested; + public ObservableCollection Messages { get; diff --git a/src/Decho/ViewModels/MessageViewModel.cs b/src/Decho/ViewModels/MessageViewModel.cs index fed3b66..988d47b 100644 --- a/src/Decho/ViewModels/MessageViewModel.cs +++ b/src/Decho/ViewModels/MessageViewModel.cs @@ -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 diff --git a/src/Decho/Views/MessageComposerView.axaml b/src/Decho/Views/MessageComposerView.axaml index b7907ea..adfb077 100644 --- a/src/Decho/Views/MessageComposerView.axaml +++ b/src/Decho/Views/MessageComposerView.axaml @@ -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" /> MessageTextBox.CaretIndex = caret + 1); + if (hasReply) vm.ClearReplyTarget(); + if (hasFiles) vm.ClearStagedFiles(); e.Handled = true; 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(); + 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) { if (sender is TextBlock textBlock && textBlock.DataContext is string item) diff --git a/src/Decho/Views/MessageItemView.axaml b/src/Decho/Views/MessageItemView.axaml index 5cea08a..068fa3d 100644 --- a/src/Decho/Views/MessageItemView.axaml +++ b/src/Decho/Views/MessageItemView.axaml @@ -5,7 +5,18 @@ xmlns:conv="using:Decho.Converters" x:Class="Decho.Views.MessageItemView" x:DataType="vm:MessageViewModel"> + + + + + + + + + + m.Model.Id == targetId); + + if (!found) + { + string serverUrl = ResolveServerUrl(); + string channelName = mainVm.Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channelName)) + return; + + try + { + List 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 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); } } \ No newline at end of file diff --git a/src/Decho/Views/MessageListView.axaml.cs b/src/Decho/Views/MessageListView.axaml.cs index d93e5eb..5de4ec7 100644 --- a/src/Decho/Views/MessageListView.axaml.cs +++ b/src/Decho/Views/MessageListView.axaml.cs @@ -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("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("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; + } + } } \ No newline at end of file