496 lines
15 KiB
C#
496 lines
15 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
|
|
{
|
|
// 50 is Giphy's maximum per request; more results come from paging via offset as the user
|
|
// scrolls rather than from a single bigger call.
|
|
private const int RemoteResultLimit = 50;
|
|
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;
|
|
|
|
private int _remoteOffset;
|
|
private bool _remoteHasMore;
|
|
private bool _isLoadingMore;
|
|
|
|
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
|
|
private bool _showingResolvedLink;
|
|
|
|
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 string SearchPlaceholder => IsLocalSource
|
|
? "Search your memes..."
|
|
: ActiveProvider?.SearchPlaceholder ?? "Search...";
|
|
|
|
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
|
|
|
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));
|
|
OnPropertyChanged(nameof(SearchPlaceholder));
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Synchronous peek so the Ctrl+V key handler can decide immediately whether to swallow the
|
|
/// keystroke (image on the clipboard) or let it through to the search box (plain text).
|
|
/// </summary>
|
|
public ClipboardImportPayload? ReadClipboardImage()
|
|
{
|
|
try
|
|
{
|
|
return _clipboard.TryReadImage();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Another process holding the clipboard shouldn't break the keystroke entirely.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task ImportClipboardAsync(ClipboardImportPayload payload)
|
|
{
|
|
try
|
|
{
|
|
if (payload.HasFiles)
|
|
{
|
|
await ImportPathsAsync(payload.FilePaths!);
|
|
return;
|
|
}
|
|
|
|
if (!payload.HasBytes) return;
|
|
|
|
var name = $"pasted-{DateTime.Now:yyyy-MM-dd-HHmmss}{payload.Extension}";
|
|
var record = await _library.ImportBytesAsync(payload.Data!, payload.Extension!, name);
|
|
|
|
if (record is null)
|
|
{
|
|
StatusMessage = "Couldn't add that image.";
|
|
return;
|
|
}
|
|
|
|
ActiveSource = MemeSource.Local;
|
|
RefreshLocalItems();
|
|
StatusMessage = $"Pasted {name}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Couldn't paste image: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the next page of remote results. Called as the grid is scrolled near the bottom,
|
|
/// so browsing feels continuous instead of stopping dead at the first batch.
|
|
/// </summary>
|
|
public async Task LoadMoreAsync()
|
|
{
|
|
if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
|
|
|
|
var provider = ActiveProvider;
|
|
if (provider is null || !provider.IsConfigured) return;
|
|
|
|
_isLoadingMore = true;
|
|
var queryAtStart = SearchText;
|
|
try
|
|
{
|
|
var results = await provider.SearchAsync(queryAtStart, RemoteResultLimit, _remoteOffset, CancellationToken.None);
|
|
|
|
// The query may have changed while this page was in flight; if so the newer search
|
|
// owns the grid and these results are stale.
|
|
if (!string.Equals(queryAtStart, SearchText, StringComparison.Ordinal)) return;
|
|
|
|
foreach (var result in results)
|
|
{
|
|
var tile = new MemeTileViewModel(result, _gifCache);
|
|
Items.Add(tile);
|
|
_ = tile.LoadThumbnailAsync();
|
|
}
|
|
|
|
_remoteOffset += results.Count;
|
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
|
SyncFavoriteFlags();
|
|
StatusMessage = $"{Items.Count} result(s).";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Couldn't load more: {ex.Message}";
|
|
_remoteHasMore = false;
|
|
}
|
|
finally
|
|
{
|
|
_isLoadingMore = false;
|
|
}
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
// Any pending remote search is stale the moment the query or tab changes.
|
|
_searchCts?.Cancel();
|
|
_searchCts?.Dispose();
|
|
_searchCts = null;
|
|
|
|
_remoteOffset = 0;
|
|
_remoteHasMore = false;
|
|
_showingResolvedLink = false;
|
|
|
|
if (IsLocalSource)
|
|
{
|
|
RefreshLocalItems();
|
|
return;
|
|
}
|
|
|
|
var provider = ActiveProvider;
|
|
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;
|
|
|
|
if (provider.CanResolveLink(SearchText))
|
|
{
|
|
await ResolveLinkAsync(provider, cts.Token);
|
|
return;
|
|
}
|
|
|
|
StatusMessage = "Searching...";
|
|
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, 0, 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);
|
|
}
|
|
|
|
_remoteOffset = results.Count;
|
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
|
|
|
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 async Task ResolveLinkAsync(IGifProvider provider, CancellationToken ct)
|
|
{
|
|
StatusMessage = "Opening link...";
|
|
var result = await provider.ResolveLinkAsync(SearchText, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
Items.Clear();
|
|
|
|
if (result is null)
|
|
{
|
|
StatusMessage = "Couldn't find that GIF - check the link.";
|
|
return;
|
|
}
|
|
|
|
var tile = new MemeTileViewModel(result, _gifCache);
|
|
Items.Add(tile);
|
|
_ = tile.LoadThumbnailAsync(ct);
|
|
|
|
// A single resolved item has nothing to page through.
|
|
_showingResolvedLink = true;
|
|
SyncFavoriteFlags();
|
|
StatusMessage = "Found 1 GIF from link.";
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|