Added Giphy support, Favourites

This commit is contained in:
Ebbe Baß
2026-08-14 13:55:34 +02:00
parent 747cd2a8aa
commit 4c755919d4
24 changed files with 1087 additions and 160 deletions
@@ -9,43 +9,84 @@ 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;
/// <summary>
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
/// </summary>
[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)
IAutoPasteService autoPaste,
IGifCacheService gifCache,
IFavoritesService favorites,
IEnumerable<IGifProvider> providers)
{
_library = library;
_clipboard = clipboard;
_settings = settings;
_autoPaste = autoPaste;
RefreshItems();
_gifCache = gifCache;
_favorites = favorites;
_providers = providers.ToList();
RefreshLocalItems();
RefreshFavorites();
}
partial void OnSearchTextChanged(string value) => RefreshItems();
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)
@@ -54,7 +95,12 @@ public partial class PickerViewModel : ObservableObject
try
{
await _clipboard.CopyLocalFileAsync(tile.FullPath);
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);
@@ -66,20 +112,55 @@ public partial class PickerViewModel : ObservableObject
}
catch (Exception ex)
{
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
// exception here silently (no crash, no message, the click just appears to do
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
// 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 is null) return;
if (tile?.Record is null) return;
await _library.RemoveAsync(tile.Record);
RefreshItems();
// 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}";
}
@@ -120,17 +201,88 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
RefreshItems();
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
}
public void OnShown()
{
SearchText = string.Empty;
RefreshItems();
if (IsLocalSource)
{
RefreshLocalItems();
}
RefreshFavorites();
}
private void RefreshItems()
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()
@@ -143,5 +295,56 @@ public partial class PickerViewModel : ObservableObject
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);
}
}
}