From b1ffb3924a7e87a4845631d60e8cb7494ed30e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 19 Aug 2026 13:04:46 +0200 Subject: [PATCH] added url search for giphy, install wizard, fixed ui and added minor changes --- README.md | 6 +- installer/EbbesMemeClipboard.iss | 2 +- .../Services/GiphyGifProvider.cs | 103 ++++++++++++++--- .../Services/IGifProvider.cs | 16 ++- .../ViewModels/PickerViewModel.cs | 105 +++++++++++++++++- .../Views/PickerWindow.xaml | 34 +++++- .../Views/PickerWindow.xaml.cs | 16 +++ 7 files changed, 251 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 02067b2..a3223a6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ stays out of the way until you need it. - **Global hotkey** — opens the picker over any app. Defaults to Ctrl+Alt+M, fully remappable (the Win key works too, e.g. Win+Y). - **Your own meme library** — add images by clicking **+**, dragging files onto the window, or simply pasting with Ctrl+V. Supports JPG, PNG and GIF. -- **Giphy search** — a second tab searches Giphy directly (needs a free API key, see below). +- **Giphy search** — a second tab searches Giphy directly (needs a free API key, see below). Scroll and it keeps loading more results. You can also paste a Giphy link straight into the search box to jump to that one GIF. - **Favourites** — right-click any meme to pin it to a ★ Favourites row at the top of that tab. Kept separately per source. - **Three insert modes** — copy to clipboard, paste into the active window, or paste *and* send instantly. - **Animated GIFs stay animated** — the clipboard is written in several formats at once so GIFs paste as real animations in Discord, Slack and Teams, rather than as a flattened still frame. @@ -65,6 +65,10 @@ The Giphy tab needs your own free API key: The free tier allows 100 requests per hour, which is plenty for personal use. Giphy's terms require the "Powered by GIPHY" attribution shown in the app whenever their results are displayed. +You can also paste a Giphy link into the search box — share links +(`giphy.com/gifs/funny-cat-`), direct media links (`media.giphy.com/media//giphy.gif`) +and `i.giphy.com` image links all work, and resolve to that single GIF. + ## Where your data lives ``` diff --git a/installer/EbbesMemeClipboard.iss b/installer/EbbesMemeClipboard.iss index dd3d488..077e9cf 100644 --- a/installer/EbbesMemeClipboard.iss +++ b/installer/EbbesMemeClipboard.iss @@ -16,7 +16,7 @@ ; anyone who just wants the portable single exe. #define AppName "Ebbe's Meme Clipboard" -#define AppVersion "1.1.0" +#define AppVersion "1.1.5" #define AppPublisher "Ebbe Baß" #define AppExeName "EbbesMemeClipboard.exe" #define SourceExe "..\publish-installer\EbbesMemeClipboard.exe" diff --git a/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs b/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs index 9e7e1eb..3722aae 100644 --- a/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs +++ b/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs @@ -1,11 +1,13 @@ +using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using EbbesMemeClipboard.Models; namespace EbbesMemeClipboard.Services; -public sealed class GiphyGifProvider : IGifProvider +public sealed partial class GiphyGifProvider : IGifProvider { private const string BaseUrl = "https://api.giphy.com/v1/gifs"; private const string Rating = "pg-13"; @@ -20,45 +22,112 @@ public sealed class GiphyGifProvider : IGifProvider public string NotConfiguredMessage => "No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com."; + public string SearchPlaceholder => "Search Giphy, or paste a GIF link..."; + public GiphyGifProvider(HttpClient http, ISettingsService settings) { _http = http; _settings = settings; } - public async Task> SearchAsync(string query, int limit, CancellationToken ct) + public async Task> SearchAsync(string query, int limit, int offset, CancellationToken ct) { if (!IsConfigured) return Array.Empty(); var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!); var url = string.IsNullOrWhiteSpace(query) - ? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&rating={Rating}" - : $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&rating={Rating}"; + ? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&offset={offset}&rating={Rating}" + : $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}&rating={Rating}"; var response = await _http.GetAsync(url, ct); response.EnsureSuccessStatusCode(); - var payload = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: ct); if (payload?.Data is null) return Array.Empty(); - return payload.Data - .Where(item => item.Images?.FixedWidth?.Url is not null && item.Images.Original?.Url is not null) - .Select(item => new GifSearchResult - { - Id = item.Id ?? Guid.NewGuid().ToString("N"), - Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title, - PreviewUrl = item.Images!.FixedWidth!.Url!, - FullUrl = item.Images.Original!.Url!, - Source = MemeSource.Giphy, - }) - .ToList(); + return payload.Data.Select(ToResult).OfType().ToList(); } - private sealed class GiphyResponse + public bool CanResolveLink(string text) => TryExtractId(text, out _); + + public async Task ResolveLinkAsync(string text, CancellationToken ct) + { + if (!IsConfigured || !TryExtractId(text, out var id)) return null; + + var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!); + var response = await _http.GetAsync($"{BaseUrl}/{Uri.EscapeDataString(id)}?api_key={apiKey}", ct); + + // A link can easily point at something deleted or region-blocked; treat that as "no + // result" rather than surfacing a raw HTTP error to the user. + if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) return null; + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return payload?.Data is null ? null : ToResult(payload.Data); + } + + private static GifSearchResult? ToResult(GiphyItem item) + { + var preview = item.Images?.FixedWidth?.Url ?? item.Images?.Original?.Url; + var full = item.Images?.Original?.Url ?? item.Images?.FixedWidth?.Url; + if (preview is null || full is null || item.Id is null) return null; + + return new GifSearchResult + { + Id = item.Id, + Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title, + PreviewUrl = preview, + FullUrl = full, + Source = MemeSource.Giphy, + }; + } + + /// + /// Pulls the GIF id out of the various shapes a Giphy link can take - the share URL + /// (giphy.com/gifs/some-slug-ID), a direct media URL, or an i.giphy.com image URL. + /// + private static bool TryExtractId(string text, out string id) + { + id = string.Empty; + if (string.IsNullOrWhiteSpace(text) || !text.Contains("giphy.com", StringComparison.OrdinalIgnoreCase)) + return false; + + var trimmed = text.Trim(); + foreach (var regex in new[] { MediaUrlRegex(), DirectImageUrlRegex(), ShareUrlRegex() }) + { + var match = regex.Match(trimmed); + if (match.Success) + { + id = match.Groups["id"].Value; + return true; + } + } + + return false; + } + + // media.giphy.com/media///giphy.gif + [GeneratedRegex(@"media\d*\.giphy\.com/media/(?:.+/)?(?[A-Za-z0-9]{6,})/[^/]*\.(?:gif|webp|mp4)", RegexOptions.IgnoreCase)] + private static partial Regex MediaUrlRegex(); + + // i.giphy.com/.gif + [GeneratedRegex(@"i\.giphy\.com/(?[A-Za-z0-9]{6,})\.", RegexOptions.IgnoreCase)] + private static partial Regex DirectImageUrlRegex(); + + // giphy.com/gifs/funny-cat- (also /clips/, /stickers/, /embed/) + [GeneratedRegex(@"giphy\.com/(?:gifs|clips|stickers|embed)/(?:[^/?#]*-)?(?[A-Za-z0-9]{6,})", RegexOptions.IgnoreCase)] + private static partial Regex ShareUrlRegex(); + + private sealed class GiphyListResponse { [JsonPropertyName("data")] public List? Data { get; set; } } + private sealed class GiphySingleResponse + { + [JsonPropertyName("data")] public GiphyItem? Data { get; set; } + } + private sealed class GiphyItem { [JsonPropertyName("id")] public string? Id { get; set; } diff --git a/src/EbbesMemeClipboard/Services/IGifProvider.cs b/src/EbbesMemeClipboard/Services/IGifProvider.cs index ce4c431..3782e7c 100644 --- a/src/EbbesMemeClipboard/Services/IGifProvider.cs +++ b/src/EbbesMemeClipboard/Services/IGifProvider.cs @@ -12,6 +12,18 @@ public interface IGifProvider /// Shown in the UI when IsConfigured is false, explaining how to fix it. string NotConfiguredMessage { get; } - /// An empty query returns whatever the provider considers trending. - Task> SearchAsync(string query, int limit, CancellationToken ct); + /// Placeholder shown in the search box while it's empty. + string SearchPlaceholder { get; } + + /// + /// An empty query returns whatever the provider considers trending. + /// pages through results for infinite scrolling. + /// + Task> SearchAsync(string query, int limit, int offset, CancellationToken ct); + + /// True when the text looks like a link to a single item on this provider. + bool CanResolveLink(string text); + + /// Resolves a link from to one result, or null if it can't be found. + Task ResolveLinkAsync(string text, CancellationToken ct); } diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs index 9ae7f35..3941ea3 100644 --- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs @@ -9,7 +9,9 @@ namespace EbbesMemeClipboard.ViewModels; public partial class PickerViewModel : ObservableObject { - private const int RemoteResultLimit = 30; + // 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; @@ -23,6 +25,13 @@ public partial class PickerViewModel : ObservableObject /// 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. @@ -49,6 +58,12 @@ public partial class PickerViewModel : ObservableObject 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( @@ -78,6 +93,7 @@ public partial class PickerViewModel : ObservableObject { 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 @@ -264,6 +280,50 @@ public partial class PickerViewModel : ObservableObject 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. @@ -271,13 +331,17 @@ public partial class PickerViewModel : ObservableObject _searchCts?.Dispose(); _searchCts = null; + _remoteOffset = 0; + _remoteHasMore = false; + _showingResolvedLink = false; + if (IsLocalSource) { RefreshLocalItems(); return; } - var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource); + var provider = ActiveProvider; if (provider is null) return; if (!provider.IsConfigured) @@ -297,9 +361,15 @@ public partial class PickerViewModel : ObservableObject await Task.Delay(SearchDebounce, cts.Token); IsBusy = true; - StatusMessage = "Searching..."; - var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token); + 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(); @@ -310,6 +380,9 @@ public partial class PickerViewModel : ObservableObject _ = tile.LoadThumbnailAsync(cts.Token); } + _remoteOffset = results.Count; + _remoteHasMore = results.Count >= RemoteResultLimit; + SyncFavoriteFlags(); StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s)."; } @@ -330,6 +403,30 @@ public partial class PickerViewModel : ObservableObject } } + 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) diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml index dee6adb..b62c715 100644 --- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml +++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml @@ -152,11 +152,32 @@ - + + + + + + + + +