351 lines
11 KiB
C#
351 lines
11 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using EbbesMemeClipboard.Models;
|
|
using EbbesMemeClipboard.Services;
|
|
|
|
namespace EbbesMemeClipboard.ViewModels;
|
|
|
|
public partial class PickerViewModel : ObservableObject
|
|
{
|
|
private const int RemoteResultLimit = 30;
|
|
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
|
|
|
|
private readonly ILocalMemeLibraryService _library;
|
|
private readonly IClipboardService _clipboard;
|
|
private readonly ISettingsService _settings;
|
|
private readonly IAutoPasteService _autoPaste;
|
|
private readonly IGifCacheService _gifCache;
|
|
private readonly IFavoritesService _favorites;
|
|
private readonly IReadOnlyList<IGifProvider> _providers;
|
|
|
|
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
|
|
private CancellationTokenSource? _searchCts;
|
|
|
|
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
|
|
|
/// <summary>Favourites for the currently active source only.</summary>
|
|
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
|
|
|
|
[ObservableProperty]
|
|
private string _searchText = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string _statusMessage = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool _isBusy;
|
|
|
|
[ObservableProperty]
|
|
private bool _isDialogOpen;
|
|
|
|
[ObservableProperty]
|
|
private bool _hasFavorites;
|
|
|
|
[ObservableProperty]
|
|
private MemeSource _activeSource = MemeSource.Local;
|
|
|
|
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
|
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
|
|
|
public event EventHandler? RequestClose;
|
|
|
|
public PickerViewModel(
|
|
ILocalMemeLibraryService library,
|
|
IClipboardService clipboard,
|
|
ISettingsService settings,
|
|
IAutoPasteService autoPaste,
|
|
IGifCacheService gifCache,
|
|
IFavoritesService favorites,
|
|
IEnumerable<IGifProvider> providers)
|
|
{
|
|
_library = library;
|
|
_clipboard = clipboard;
|
|
_settings = settings;
|
|
_autoPaste = autoPaste;
|
|
_gifCache = gifCache;
|
|
_favorites = favorites;
|
|
_providers = providers.ToList();
|
|
|
|
RefreshLocalItems();
|
|
RefreshFavorites();
|
|
}
|
|
|
|
partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
|
|
|
|
partial void OnActiveSourceChanged(MemeSource value)
|
|
{
|
|
OnPropertyChanged(nameof(IsLocalSource));
|
|
OnPropertyChanged(nameof(IsGiphySource));
|
|
SearchText = string.Empty;
|
|
RefreshFavorites();
|
|
// Setting SearchText above only triggers a refresh if the value actually changed, so
|
|
// refresh explicitly here to cover switching tabs with an already-empty box.
|
|
_ = RefreshAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectSource(MemeSource source) => ActiveSource = source;
|
|
|
|
[RelayCommand]
|
|
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
|
{
|
|
if (tile is null) return;
|
|
|
|
try
|
|
{
|
|
IsBusy = true;
|
|
// For remote results this downloads and caches the full-quality GIF first; the
|
|
// clipboard's file-drop format needs a real file on disk.
|
|
var path = await tile.EnsureLocalPathAsync();
|
|
|
|
await _clipboard.CopyLocalFileAsync(path);
|
|
StatusMessage = $"Copied {tile.DisplayName}";
|
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
|
|
|
var mode = _settings.Current.InsertMode;
|
|
if (mode is InsertMode.PasteIntoActiveWindow or InsertMode.PasteAndSend)
|
|
{
|
|
await _autoPaste.PasteAsync(alsoSend: mode == InsertMode.PasteAndSend);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
|
|
// a clipboard/download failure would look like the click simply did nothing.
|
|
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ToggleFavoriteAsync(MemeTileViewModel? tile)
|
|
{
|
|
if (tile is null) return;
|
|
|
|
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
|
|
{
|
|
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
|
|
StatusMessage = $"Removed {tile.DisplayName} from favourites";
|
|
}
|
|
else
|
|
{
|
|
await _favorites.AddAsync(new FavoriteRecord
|
|
{
|
|
Source = tile.Source,
|
|
Key = tile.FavoriteKey,
|
|
Title = tile.DisplayName,
|
|
PreviewUrl = tile.RemoteResult?.PreviewUrl,
|
|
FullUrl = tile.RemoteResult?.FullUrl,
|
|
DateAdded = DateTimeOffset.Now,
|
|
});
|
|
StatusMessage = $"Added {tile.DisplayName} to favourites";
|
|
}
|
|
|
|
RefreshFavorites();
|
|
SyncFavoriteFlags();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RemoveItemAsync(MemeTileViewModel? tile)
|
|
{
|
|
if (tile?.Record is null) return;
|
|
|
|
await _library.RemoveAsync(tile.Record);
|
|
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
|
|
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
|
|
|
|
RefreshLocalItems();
|
|
RefreshFavorites();
|
|
StatusMessage = $"Removed {tile.DisplayName}";
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ImportFilesAsync()
|
|
{
|
|
var dialog = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Multiselect = true,
|
|
Filter = "Images and GIFs|*.jpg;*.jpeg;*.png;*.gif",
|
|
Title = "Add memes",
|
|
};
|
|
|
|
IsDialogOpen = true;
|
|
try
|
|
{
|
|
if (dialog.ShowDialog() == true)
|
|
{
|
|
await ImportPathsAsync(dialog.FileNames);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
IsDialogOpen = false;
|
|
}
|
|
}
|
|
|
|
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
|
{
|
|
var candidates = paths
|
|
.Where(p => Path.GetExtension(p).ToLowerInvariant() is ".jpg" or ".jpeg" or ".png" or ".gif")
|
|
.ToList();
|
|
|
|
if (candidates.Count == 0)
|
|
{
|
|
StatusMessage = "No supported image/GIF files in that selection.";
|
|
return;
|
|
}
|
|
|
|
var imported = await _library.ImportAsync(candidates);
|
|
ActiveSource = MemeSource.Local;
|
|
RefreshLocalItems();
|
|
StatusMessage = $"Added {imported.Count} meme(s).";
|
|
}
|
|
|
|
public void OnShown()
|
|
{
|
|
SearchText = string.Empty;
|
|
if (IsLocalSource)
|
|
{
|
|
RefreshLocalItems();
|
|
}
|
|
RefreshFavorites();
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
// Any pending remote search is stale the moment the query or tab changes.
|
|
_searchCts?.Cancel();
|
|
_searchCts?.Dispose();
|
|
_searchCts = null;
|
|
|
|
if (IsLocalSource)
|
|
{
|
|
RefreshLocalItems();
|
|
return;
|
|
}
|
|
|
|
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
|
if (provider is null) return;
|
|
|
|
if (!provider.IsConfigured)
|
|
{
|
|
Items.Clear();
|
|
StatusMessage = provider.NotConfiguredMessage;
|
|
return;
|
|
}
|
|
|
|
var cts = new CancellationTokenSource();
|
|
_searchCts = cts;
|
|
|
|
try
|
|
{
|
|
// Debounce: without this every keystroke fires an API call, which burns through a
|
|
// free-tier rate limit almost immediately.
|
|
await Task.Delay(SearchDebounce, cts.Token);
|
|
|
|
IsBusy = true;
|
|
StatusMessage = "Searching...";
|
|
|
|
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token);
|
|
cts.Token.ThrowIfCancellationRequested();
|
|
|
|
Items.Clear();
|
|
foreach (var result in results)
|
|
{
|
|
var tile = new MemeTileViewModel(result, _gifCache);
|
|
Items.Add(tile);
|
|
_ = tile.LoadThumbnailAsync(cts.Token);
|
|
}
|
|
|
|
SyncFavoriteFlags();
|
|
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Superseded by a newer query - the newer one owns the UI state now.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Search failed: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
if (_searchCts == cts)
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshLocalItems()
|
|
{
|
|
var records = string.IsNullOrWhiteSpace(SearchText)
|
|
? _library.GetAll()
|
|
: _library.Search(SearchText);
|
|
|
|
Items.Clear();
|
|
foreach (var record in records)
|
|
{
|
|
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
|
Items.Add(tile);
|
|
_ = tile.LoadThumbnailAsync();
|
|
}
|
|
|
|
SyncFavoriteFlags();
|
|
StatusMessage = string.Empty;
|
|
}
|
|
|
|
private void RefreshFavorites()
|
|
{
|
|
Favorites.Clear();
|
|
|
|
foreach (var fav in _favorites.GetForSource(ActiveSource))
|
|
{
|
|
MemeTileViewModel tile;
|
|
|
|
if (fav.Source == MemeSource.Local)
|
|
{
|
|
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
|
|
// Favourite pointing at a meme that's since been deleted - skip it rather than
|
|
// rendering a broken tile.
|
|
if (record is null) continue;
|
|
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
|
}
|
|
else
|
|
{
|
|
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
|
|
tile = new MemeTileViewModel(
|
|
new GifSearchResult
|
|
{
|
|
Id = fav.Key,
|
|
Title = fav.Title ?? "Favourite",
|
|
PreviewUrl = fav.PreviewUrl,
|
|
FullUrl = fav.FullUrl,
|
|
Source = fav.Source,
|
|
},
|
|
_gifCache);
|
|
}
|
|
|
|
tile.IsFavorite = true;
|
|
Favorites.Add(tile);
|
|
_ = tile.LoadThumbnailAsync();
|
|
}
|
|
|
|
HasFavorites = Favorites.Count > 0;
|
|
}
|
|
|
|
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
|
|
private void SyncFavoriteFlags()
|
|
{
|
|
foreach (var tile in Items)
|
|
{
|
|
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
|
|
}
|
|
}
|
|
}
|