Added Giphy support, Favourites
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user