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 _providers; /// Cancels the in-flight remote search when a newer keystroke supersedes it. private CancellationTokenSource? _searchCts; private int _remoteOffset; private bool _remoteHasMore; private bool _isLoadingMore; /// True when the grid is showing a single item resolved from a pasted link. private bool _showingResolvedLink; public ObservableCollection Items { get; } = new(); /// Favourites for the currently active source only. public ObservableCollection 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 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; } } /// /// 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). /// 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 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(); } /// /// 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. /// 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; } /// Keeps the star state on the main grid in step with the favourites store. private void SyncFavoriteFlags() { foreach (var tile in Items) { tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey); } } }