mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +02:00
Add chat composer autocomplete for mentions and channels
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Decho.ViewModels;
|
||||||
|
|
||||||
|
public sealed class AutocompleteController : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly List<AutocompleteProvider> _providers;
|
||||||
|
|
||||||
|
private static readonly Regex TriggerPattern = new(@"([@#])(\w*)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public ObservableCollection<string> FilteredItems { get; } = [];
|
||||||
|
|
||||||
|
public bool ShowPopup
|
||||||
|
{
|
||||||
|
get => field;
|
||||||
|
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SelectedIndex
|
||||||
|
{
|
||||||
|
get => field;
|
||||||
|
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FilterText
|
||||||
|
{
|
||||||
|
get => field;
|
||||||
|
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutocompleteProvider? ActiveProvider { get; private set; }
|
||||||
|
|
||||||
|
public int TriggerCharIndex { get; private set; }
|
||||||
|
|
||||||
|
public AutocompleteController(IEnumerable<AutocompleteProvider> providers)
|
||||||
|
{
|
||||||
|
_providers = providers.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text) || _providers.Count == 0)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Match match = TriggerPattern.Match(text);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char trigger = match.Groups[1].Value[0];
|
||||||
|
AutocompleteProvider? provider = _providers.FirstOrDefault(p => p.Trigger == trigger);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string filter = match.Groups[2].Value;
|
||||||
|
ActiveProvider = provider;
|
||||||
|
FilterText = filter;
|
||||||
|
TriggerCharIndex = match.Index;
|
||||||
|
|
||||||
|
List<string> items = provider.ItemsSource().ToList();
|
||||||
|
List<string> filtered = items
|
||||||
|
.Where(item => provider.Filter(item, filter))
|
||||||
|
.Take(provider.MaxResults)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
SelectedIndex = -1;
|
||||||
|
FilteredItems.Clear();
|
||||||
|
foreach (string item in filtered)
|
||||||
|
{
|
||||||
|
FilteredItems.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowPopup = FilteredItems.Count > 0;
|
||||||
|
SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetInsertion(string item)
|
||||||
|
{
|
||||||
|
if (ActiveProvider is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActiveProvider.FormatInsertion(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
ShowPopup = false;
|
||||||
|
ActiveProvider = null;
|
||||||
|
FilterText = string.Empty;
|
||||||
|
FilteredItems.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Decho.ViewModels;
|
||||||
|
|
||||||
|
public sealed class AutocompleteProvider
|
||||||
|
{
|
||||||
|
public char Trigger { get; }
|
||||||
|
public Func<IEnumerable<string>> ItemsSource { get; }
|
||||||
|
public Func<string, string, bool> Filter { get; }
|
||||||
|
public Func<string, string> FormatInsertion { get; }
|
||||||
|
public int MaxResults { get; }
|
||||||
|
|
||||||
|
public AutocompleteProvider(
|
||||||
|
char trigger,
|
||||||
|
Func<IEnumerable<string>> itemsSource,
|
||||||
|
string insertPrefix,
|
||||||
|
int maxResults = 10)
|
||||||
|
{
|
||||||
|
Trigger = trigger;
|
||||||
|
ItemsSource = itemsSource;
|
||||||
|
Filter = (item, filter) => item.StartsWith(filter, StringComparison.OrdinalIgnoreCase);
|
||||||
|
FormatInsertion = item => $"{insertPrefix}{item} ";
|
||||||
|
MaxResults = maxResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ public sealed class ChatViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
OnlineUserCount = $"{users.Count}";
|
OnlineUserCount = $"{users.Count}";
|
||||||
ShowOnlineUsers = true;
|
ShowOnlineUsers = true;
|
||||||
|
Composer.UpdateAvailableUsers(OnlineUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddOnlineUser(UserPresenceDto user)
|
public void AddOnlineUser(UserPresenceDto user)
|
||||||
@@ -120,6 +121,7 @@ public sealed class ChatViewModel : ViewModelBase
|
|||||||
OnlineUserCount = $"{OnlineUsers.Count}";
|
OnlineUserCount = $"{OnlineUsers.Count}";
|
||||||
}
|
}
|
||||||
ShowOnlineUsers = OnlineUsers.Count > 0;
|
ShowOnlineUsers = OnlineUsers.Count > 0;
|
||||||
|
Composer.UpdateAvailableUsers(OnlineUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveOnlineUser(string username)
|
public void RemoveOnlineUser(string username)
|
||||||
@@ -131,6 +133,7 @@ public sealed class ChatViewModel : ViewModelBase
|
|||||||
OnlineUserCount = $"{OnlineUsers.Count}";
|
OnlineUserCount = $"{OnlineUsers.Count}";
|
||||||
}
|
}
|
||||||
ShowOnlineUsers = OnlineUsers.Count > 0;
|
ShowOnlineUsers = OnlineUsers.Count > 0;
|
||||||
|
Composer.UpdateAvailableUsers(OnlineUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearMessages()
|
public void ClearMessages()
|
||||||
|
|||||||
@@ -827,6 +827,12 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
bool isServerConnected = Sidebar.GetServer(serverUrl)?.IsConnected ?? false;
|
bool isServerConnected = Sidebar.GetServer(serverUrl)?.IsConnected ?? false;
|
||||||
Chat.SetChannel(channel, serverUrl, isServerConnected);
|
Chat.SetChannel(channel, serverUrl, isServerConnected);
|
||||||
|
|
||||||
|
ServerViewModel? currentServer = Sidebar.GetServer(serverUrl);
|
||||||
|
if (currentServer is not null)
|
||||||
|
{
|
||||||
|
Chat.Composer.UpdateAvailableChannels(currentServer.Channels.Select(c => c.Name));
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(serverUrl))
|
if (!string.IsNullOrEmpty(serverUrl))
|
||||||
{
|
{
|
||||||
Chat.Composer.SetCommandHandler(new CommandHandler());
|
Chat.Composer.SetCommandHandler(new CommandHandler());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using EchoHub.Client.Commands;
|
using EchoHub.Client.Commands;
|
||||||
|
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Reactive;
|
using System.Reactive;
|
||||||
|
|
||||||
namespace Decho.ViewModels;
|
namespace Decho.ViewModels;
|
||||||
@@ -14,6 +15,9 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
|||||||
|
|
||||||
private CommandHandler? _commandHandler;
|
private CommandHandler? _commandHandler;
|
||||||
|
|
||||||
|
private readonly ObservableCollection<UserViewModel> _onlineUsers = [];
|
||||||
|
private readonly ObservableCollection<string> _channelNames = [];
|
||||||
|
|
||||||
public string Draft
|
public string Draft
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
@@ -32,10 +36,44 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasCommandHandler => _commandHandler is not null;
|
public bool HasCommandHandler => _commandHandler is not null;
|
||||||
|
|
||||||
|
public AutocompleteController Autocomplete { get; }
|
||||||
|
|
||||||
public MessageComposerViewModel()
|
public MessageComposerViewModel()
|
||||||
{
|
{
|
||||||
|
AutocompleteProvider mentionProvider = new(
|
||||||
|
trigger: '@',
|
||||||
|
itemsSource: () => _onlineUsers.Select(u => u.Username),
|
||||||
|
insertPrefix: "@");
|
||||||
|
|
||||||
|
AutocompleteProvider channelProvider = new(
|
||||||
|
trigger: '#',
|
||||||
|
itemsSource: () => _channelNames,
|
||||||
|
insertPrefix: "#");
|
||||||
|
|
||||||
|
Autocomplete = new AutocompleteController([mentionProvider, channelProvider]);
|
||||||
|
|
||||||
IObservable<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
|
IObservable<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
|
||||||
SendCommand = ReactiveCommand.Create(Send, canSend);
|
SendCommand = ReactiveCommand.Create(Send, canSend);
|
||||||
|
|
||||||
|
_ = this.WhenAnyValue(x => x.Draft).Subscribe(OnDraftChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateAvailableUsers(IEnumerable<UserViewModel> users)
|
||||||
|
{
|
||||||
|
_onlineUsers.Clear();
|
||||||
|
foreach (UserViewModel user in users)
|
||||||
|
{
|
||||||
|
_onlineUsers.Add(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateAvailableChannels(IEnumerable<string> channelNames)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
foreach (string name in channelNames)
|
||||||
|
{
|
||||||
|
_channelNames.Add(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RequestFileUpload(string filePath)
|
public void RequestFileUpload(string filePath)
|
||||||
@@ -63,6 +101,24 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
|||||||
return _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
|
return _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void InsertAutocomplete(string item)
|
||||||
|
{
|
||||||
|
string? insertion = Autocomplete.GetInsertion(item);
|
||||||
|
if (insertion is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string before = Draft[..Autocomplete.TriggerCharIndex];
|
||||||
|
Draft = $"{before}{insertion}";
|
||||||
|
Autocomplete.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDraftChanged(string? draft)
|
||||||
|
{
|
||||||
|
Autocomplete.Update(draft ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
private void Send()
|
private void Send()
|
||||||
{
|
{
|
||||||
string text = Draft.Trim();
|
string text = Draft.Trim();
|
||||||
|
|||||||
@@ -4,16 +4,52 @@
|
|||||||
xmlns:i="https://github.com/projektanker/icons.avalonia"
|
xmlns:i="https://github.com/projektanker/icons.avalonia"
|
||||||
x:Class="Decho.Views.MessageComposerView"
|
x:Class="Decho.Views.MessageComposerView"
|
||||||
x:DataType="vm:MessageComposerViewModel">
|
x:DataType="vm:MessageComposerViewModel">
|
||||||
<Grid ColumnDefinitions="*, Auto, Auto" Margin="5">
|
<Grid>
|
||||||
<TextBox Grid.Column="0"
|
<Grid.ColumnDefinitions>
|
||||||
HorizontalAlignment="Stretch"
|
<ColumnDefinition Width="*" />
|
||||||
Margin="0 0 5 0"
|
<ColumnDefinition Width="Auto" />
|
||||||
Text="{Binding Draft, Mode=TwoWay}"
|
<ColumnDefinition Width="Auto" />
|
||||||
IsEnabled="{Binding IsConnected}">
|
</Grid.ColumnDefinitions>
|
||||||
<TextBox.KeyBindings>
|
<Grid>
|
||||||
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
|
<TextBox x:Name="MessageTextBox"
|
||||||
</TextBox.KeyBindings>
|
HorizontalAlignment="Stretch"
|
||||||
</TextBox>
|
Margin="0 0 5 0"
|
||||||
|
Text="{Binding Draft, Mode=TwoWay}"
|
||||||
|
IsEnabled="{Binding IsConnected}"
|
||||||
|
KeyDown="OnTextBoxKeyDown">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Popup x:Name="MentionPopup"
|
||||||
|
Placement="TopEdgeAlignedLeft"
|
||||||
|
PlacementTarget="{Binding #MessageTextBox}"
|
||||||
|
IsOpen="{Binding Autocomplete.ShowPopup}"
|
||||||
|
IsLightDismissEnabled="True"
|
||||||
|
MinWidth="100"
|
||||||
|
MaxHeight="140">
|
||||||
|
<ListBox ItemsSource="{Binding Autocomplete.FilteredItems}"
|
||||||
|
SelectedIndex="{Binding Autocomplete.SelectedIndex}"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="{DynamicResource UiTheme08}"
|
||||||
|
Background="{DynamicResource UiTheme02}">
|
||||||
|
<ListBox.Styles>
|
||||||
|
<Style Selector="ListBoxItem:selected">
|
||||||
|
<Setter Property="Background" Value="#3D3D3D" />
|
||||||
|
</Style>
|
||||||
|
</ListBox.Styles>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<TextBlock Text="{Binding}"
|
||||||
|
FontSize="12"
|
||||||
|
Padding="4 2"
|
||||||
|
Cursor="Hand"
|
||||||
|
PointerPressed="OnMentionPointerPressed" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Height="33"
|
Height="33"
|
||||||
Width="33"
|
Width="33"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace Decho.Views;
|
namespace Decho.Views;
|
||||||
|
|
||||||
public partial class MessageComposerView : UserControl
|
public partial class MessageComposerView : UserControl
|
||||||
@@ -15,6 +18,104 @@ public partial class MessageComposerView : UserControl
|
|||||||
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
||||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||||
DragDrop.SetAllowDrop(this, true);
|
DragDrop.SetAllowDrop(this, true);
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MessageComposerViewModel vm)
|
||||||
|
{
|
||||||
|
vm.Autocomplete.PropertyChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
if (args.PropertyName is nameof(AutocompleteController.ShowPopup) or nameof(AutocompleteController.FilterText))
|
||||||
|
{
|
||||||
|
PositionPopupAtCursor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
|
||||||
|
if (vm is null || !vm.Autocomplete.ShowPopup)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Key == Key.Down)
|
||||||
|
{
|
||||||
|
vm.Autocomplete.SelectedIndex = Math.Min(vm.Autocomplete.SelectedIndex + 1, vm.Autocomplete.FilteredItems.Count - 1);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Up)
|
||||||
|
{
|
||||||
|
vm.Autocomplete.SelectedIndex = Math.Max(vm.Autocomplete.SelectedIndex - 1, 0);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key is Key.Enter or Key.Tab)
|
||||||
|
{
|
||||||
|
if (vm.Autocomplete.SelectedIndex >= 0 && vm.Autocomplete.SelectedIndex < vm.Autocomplete.FilteredItems.Count)
|
||||||
|
{
|
||||||
|
InsertAutocomplete(vm.Autocomplete.FilteredItems[vm.Autocomplete.SelectedIndex]);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
vm.Autocomplete.ShowPopup = false;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is TextBlock textBlock && textBlock.DataContext is string item)
|
||||||
|
{
|
||||||
|
InsertAutocomplete(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InsertAutocomplete(string item)
|
||||||
|
{
|
||||||
|
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
|
||||||
|
if (vm is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.InsertAutocomplete(item);
|
||||||
|
MessageTextBox.CaretIndex = MessageTextBox.Text?.Length ?? 0;
|
||||||
|
_ = MessageTextBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionPopupAtCursor()
|
||||||
|
{
|
||||||
|
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
|
||||||
|
if (vm is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string text = MessageTextBox.Text ?? "";
|
||||||
|
int targetIndex = Math.Min(vm.Autocomplete.TriggerCharIndex, text.Length);
|
||||||
|
string beforeTarget = text[..targetIndex];
|
||||||
|
|
||||||
|
double width = 0;
|
||||||
|
if (beforeTarget.Length > 0)
|
||||||
|
{
|
||||||
|
FormattedText formatted = new(
|
||||||
|
beforeTarget,
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
FlowDirection.LeftToRight,
|
||||||
|
new Typeface(MessageTextBox.FontFamily, MessageTextBox.FontStyle, MessageTextBox.FontWeight, MessageTextBox.FontStretch),
|
||||||
|
MessageTextBox.FontSize,
|
||||||
|
null);
|
||||||
|
width = formatted.Width;
|
||||||
|
}
|
||||||
|
|
||||||
|
double maxOffset = Math.Max(0, MessageTextBox.Bounds.Width - 20);
|
||||||
|
MentionPopup.HorizontalOffset = Math.Min(width + 4, maxOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
|
private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
FontSize="10"
|
FontSize="10"
|
||||||
Margin="5 0 0 0" />
|
Margin="5 0 0 0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock VerticalAlignment="Top"
|
<TextBlock x:Name="MessageContent"
|
||||||
Text="{Binding Content}"
|
VerticalAlignment="Top"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding ShowContent}" />
|
IsVisible="{Binding ShowContent}" />
|
||||||
<Image x:Name="MessageImage"
|
<Image x:Name="MessageImage"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
@@ -7,6 +9,8 @@ using Decho.ViewModels;
|
|||||||
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Decho.Views;
|
namespace Decho.Views;
|
||||||
|
|
||||||
public partial class MessageItemView : UserControl
|
public partial class MessageItemView : UserControl
|
||||||
@@ -21,6 +25,8 @@ public partial class MessageItemView : UserControl
|
|||||||
Loaded += OnLoaded;
|
Loaded += OnLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Regex MentionRegex = new(@"@(\w+)", RegexOptions.Compiled);
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs args)
|
private void OnDataContextChanged(object? sender, EventArgs args)
|
||||||
{
|
{
|
||||||
if (DataContext is MessageViewModel newMsg && newMsg.Model.Id == _loadedMessageId)
|
if (DataContext is MessageViewModel newMsg && newMsg.Model.Id == _loadedMessageId)
|
||||||
@@ -32,6 +38,44 @@ public partial class MessageItemView : UserControl
|
|||||||
_loadCts = new CancellationTokenSource();
|
_loadCts = new CancellationTokenSource();
|
||||||
this.FindControl<Image>("MessageImage")?.ClearValue(Image.SourceProperty);
|
this.FindControl<Image>("MessageImage")?.ClearValue(Image.SourceProperty);
|
||||||
_loadedMessageId = null;
|
_loadedMessageId = null;
|
||||||
|
|
||||||
|
if (DataContext is MessageViewModel msg)
|
||||||
|
{
|
||||||
|
BuildMessageInlines(msg.Content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildMessageInlines(string content)
|
||||||
|
{
|
||||||
|
TextBlock? tb = MessageContent;
|
||||||
|
if (tb is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.Inlines?.Clear();
|
||||||
|
|
||||||
|
int lastIndex = 0;
|
||||||
|
foreach (Match match in MentionRegex.Matches(content))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
{
|
||||||
|
tb.Inlines?.Add(new Run(content[lastIndex..match.Index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.Inlines?.Add(new Run(match.Value)
|
||||||
|
{
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FEE75C")),
|
||||||
|
FontWeight = FontWeight.Bold,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < content.Length)
|
||||||
|
{
|
||||||
|
tb.Inlines?.Add(new Run(content[lastIndex..]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLoaded(object? sender, RoutedEventArgs e)
|
private void OnLoaded(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
+1
-1
Submodule src/EchoHub updated: aae788028e...b62729dc95
Reference in New Issue
Block a user