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 _providers; /// Cancels the in-flight remote search when a newer keystroke supersedes it. private CancellationTokenSource? _searchCts; 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 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)); 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(); } 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; } /// 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); } } }