diff --git a/InterlinedList/Models/NewMessage.cs b/InterlinedList/Models/NewMessage.cs index ba90ec8..76159ce 100644 --- a/InterlinedList/Models/NewMessage.cs +++ b/InterlinedList/Models/NewMessage.cs @@ -44,4 +44,12 @@ public sealed record NewMessage public IReadOnlyList? ImageUrls { get; init; } public IReadOnlyList? VideoUrls { get; init; } + + /// + /// Freeform tags, posted as tags: string[]. Tags may contain spaces + /// and punctuation — live examples include orbit culture and + /// life is short, o brave girl — so they are not hashtag-shaped + /// single words and must not be split on whitespace. + /// + public IReadOnlyList? Tags { get; init; } } diff --git a/InterlinedList/Models/TrendingTag.cs b/InterlinedList/Models/TrendingTag.cs new file mode 100644 index 0000000..8f810d4 --- /dev/null +++ b/InterlinedList/Models/TrendingTag.cs @@ -0,0 +1,33 @@ +namespace InterlinedList.Models; + +/// +/// One row from GET /api/tags/trending or GET /api/tags/autocomplete. +/// Both wrap their rows under { "tags": [ … ] }; autocomplete omits +/// . Verified live 2026-09-16. +/// +/// +/// The two endpoints' values are not the same number: +/// trending counts uses within its trailing window, autocomplete counts all-time +/// (live example: thoughts was 1 in trending and 5 in autocomplete). Don't +/// treat one as a cache of the other. +/// +public sealed class TrendingTag +{ + public required string Tag { get; init; } + + /// How many messages carry the tag — scoped differently per endpoint (see remarks). + public int Count { get; init; } + + /// Trending only; null from autocomplete. + public DateTimeOffset? LastUsedAt { get; init; } + + /// Display form for a chip. + public string Label => "#" + Tag; + + /// + /// Tooltip text. Built here rather than via a binding StringFormat + /// because the target (ToolTip) is object-typed, where StringFormat + /// isn't reliably applied. + /// + public string CountLabel => Count == 1 ? "1 message" : $"{Count} messages"; +} diff --git a/InterlinedList/Services/InterlinedApiClient.Messages.cs b/InterlinedList/Services/InterlinedApiClient.Messages.cs index d3dd8a9..7c21a39 100644 --- a/InterlinedList/Services/InterlinedApiClient.Messages.cs +++ b/InterlinedList/Services/InterlinedApiClient.Messages.cs @@ -36,6 +36,27 @@ public async Task> GetRepliesAsync(string id, CancellationToken ct public Task PostReplyAsync(string parentId, string content, bool publiclyVisible, CancellationToken ct = default) => PostMessageAsync(content, publiclyVisible, parentId: parentId, ct: ct); + /// + /// The feed, optionally filtered to one tag via GET /api/messages?tag=. + /// A distinct name rather than an overload of GetMessagesAsync in + /// InterlinedApiClient.cs, which can't be edited (open PR #130) and whose + /// optional parameters would make an added tag overload ambiguous. + /// + /// + /// Live-verified 2026-09-16: the filter is case-insensitive, pages with + /// offset/limit like the unfiltered feed, and handles tags + /// containing spaces once URL-encoded. + /// + public Task GetFeedPageAsync( + int limit = 20, int offset = 0, string? tag = null, CancellationToken ct = default) + { + var path = $"api/messages?limit={limit}&offset={offset}"; + if (tag is { Length: > 0 }) + path += $"&tag={Uri.EscapeDataString(tag)}"; + + return GetJsonAsync(path, ct); + } + // ── Push (repost) / Quote ─────────────────────────────────────────────────── /// @@ -60,6 +81,7 @@ public Task PostMessageAsync(NewMessage draft, CancellationToken ct = default) if (draft.ScheduledAt is { } at) body["scheduledAt"] = at.UtcDateTime; if (draft.ImageUrls is { Count: > 0 } images) body["imageUrls"] = images; if (draft.VideoUrls is { Count: > 0 } videos) body["videoUrls"] = videos; + if (draft.Tags is { Count: > 0 } tags) body["tags"] = tags; return SendVoidAsync(HttpMethod.Post, "api/messages", body, ct); } diff --git a/InterlinedList/Services/InterlinedApiClient.Tags.cs b/InterlinedList/Services/InterlinedApiClient.Tags.cs new file mode 100644 index 0000000..129035a --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.Tags.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// Tags. Two read endpoints, both answering the bearer sync-token and both +/// wrapping their payload under { "tags": [ … ] } — verified live +/// 2026-09-16 against the test account. +/// +/// +/// Writing tags is not here: they ride along on POST /api/messages as +/// tags: string[], so that lives on . Filtering +/// the feed by tag is GET /api/messages?tag= — see +/// GetFeedPageAsync in InterlinedApiClient.Messages.cs. +/// +public sealed partial class InterlinedApiClient +{ + /// + /// Most-used tags on public messages in a trailing window. Honors + /// ; returns 20 by default. + /// + public Task> GetTrendingTagsAsync(int limit = 20, CancellationToken ct = default) + => ReadTagsAsync($"api/tags/trending?limit={limit}", ct); + + /// + /// Case-insensitive literal prefix match — not a substring search. + /// Live: q=le → Lego, learning; q=ego → nothing, + /// even though Lego contains it. + /// + /// + /// An empty q is a 400 {"error":"Query parameter 'q' is required"}, + /// so a blank query short-circuits to an empty list rather than round-tripping + /// into an error the caller would have to swallow. + /// + public Task> AutocompleteTagsAsync(string query, int limit = 10, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + return Task.FromResult(new List()); + + return ReadTagsAsync($"api/tags/autocomplete?q={Uri.EscapeDataString(query.Trim())}&limit={limit}", ct); + } + + private async Task> ReadTagsAsync(string path, CancellationToken ct) + { + var json = await GetElementAsync(path, ct); + return json.TryGetProperty("tags", out var arr) && arr.ValueKind == JsonValueKind.Array + ? arr.Deserialize>(JsonOptions) ?? new() + : new(); + } +} diff --git a/InterlinedList/ViewModels/FeedViewModel.cs b/InterlinedList/ViewModels/FeedViewModel.cs index 21d9f13..44405e9 100644 --- a/InterlinedList/ViewModels/FeedViewModel.cs +++ b/InterlinedList/ViewModels/FeedViewModel.cs @@ -2,6 +2,7 @@ using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; using InterlinedList.Services; using Microsoft.Win32; @@ -83,11 +84,45 @@ public partial class FeedViewModel : ObservableObject public string ScheduledToggleLabel => ShowScheduled ? "← Back to feed" : "Scheduled"; + // ── Tags ──────────────────────────────────────────────────────────────────── + + /// Tags attached to the next post, sent as tags: string[]. + public ObservableCollection ComposeTags { get; } = new(); + + /// Prefix-match suggestions for whatever is in . + public ObservableCollection TagSuggestions { get; } = new(); + + /// Most-used tags in the server's trailing window. + public ObservableCollection TrendingTags { get; } = new(); + + [ObservableProperty] + private string tagInput = ""; + + public bool HasTrendingTags => TrendingTags.Count > 0; + + /// + /// The tag the feed is currently filtered to (GET /api/messages?tag=), + /// or null for the unfiltered feed. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasActiveTag))] + [NotifyPropertyChangedFor(nameof(ActiveTagLabel))] + private string? activeTag; + + public bool HasActiveTag => !string.IsNullOrEmpty(ActiveTag); + public string ActiveTagLabel => $"Showing #{ActiveTag}"; + + // Debounces the autocomplete call so typing doesn't fire one request per + // keystroke. Cancelled and replaced on each change; never awaited on the + // dispatcher. + private CancellationTokenSource? _autocompleteCts; + public FeedViewModel(SessionService session) { _session = session; AttachedImageUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged(); AttachedVideoUrls.CollectionChanged += (_, _) => PostCommand.NotifyCanExecuteChanged(); + TrendingTags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTrendingTags)); } /// @@ -104,6 +139,129 @@ private MessageItemViewModel Wrap(Models.Message message) private void OnItemPosted(object? sender, EventArgs e) => RefreshCommand.Execute(null); + // ── Tag commands ──────────────────────────────────────────────────────────── + + /// + /// Add whatever is typed to the compose tag list. Splits on commas only — + /// live tags contain spaces (orbit culture, + /// life is short, o brave girl), so whitespace can't be a delimiter. + /// Duplicates are ignored, case-insensitively. + /// + private bool CanAddTag() => !string.IsNullOrWhiteSpace(TagInput); + + [RelayCommand(CanExecute = nameof(CanAddTag))] + private void AddTag() + { + foreach (var part in TagInput.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + AddTagValue(part); + + TagInput = ""; + TagSuggestions.Clear(); + } + + /// Accept an autocomplete or trending suggestion into the composer. + [RelayCommand] + private void UseTagSuggestion(string? tag) + { + AddTagValue(tag); + TagInput = ""; + TagSuggestions.Clear(); + } + + private void AddTagValue(string? tag) + { + var value = tag?.Trim(); + if (string.IsNullOrEmpty(value)) return; + if (ComposeTags.Any(t => string.Equals(t, value, StringComparison.OrdinalIgnoreCase))) return; + ComposeTags.Add(value); + } + + [RelayCommand] + private void RemoveTag(string tag) => ComposeTags.Remove(tag); + + /// Filter the feed to one tag. Called from a chip on a card or from the trending panel. + [RelayCommand] + private async Task FilterByTagAsync(string? tag) + { + if (string.IsNullOrWhiteSpace(tag)) return; + ActiveTag = tag.Trim(); + ShowScheduled = false; + await RefreshAsync(); + } + + [RelayCommand] + private async Task ClearTagFilterAsync() + { + if (ActiveTag is null) return; + ActiveTag = null; + await RefreshAsync(); + } + + [RelayCommand] + private async Task LoadTrendingTagsAsync() + { + try + { + var trending = await _session.Api.GetTrendingTagsAsync(); + TrendingTags.Clear(); + foreach (var t in trending) + TrendingTags.Add(t); + } + catch (InterlinedApiException) + { + // Decorative panel — a failure here shouldn't put an error banner + // over the feed the user actually came for. + } + } + + partial void OnTagInputChanged(string value) + { + AddTagCommand.NotifyCanExecuteChanged(); + _ = SuggestTagsAsync(value); + } + + /// + /// Debounced prefix autocomplete. Fire-and-forget on purpose: it must never + /// block the dispatcher, and a superseded keystroke's request is cancelled + /// rather than awaited. + /// + private async Task SuggestTagsAsync(string prefix) + { + _autocompleteCts?.Cancel(); + _autocompleteCts?.Dispose(); + + if (string.IsNullOrWhiteSpace(prefix)) + { + _autocompleteCts = null; + TagSuggestions.Clear(); + return; + } + + var cts = new CancellationTokenSource(); + _autocompleteCts = cts; + try + { + await Task.Delay(250, cts.Token); + var matches = await _session.Api.AutocompleteTagsAsync(prefix, ct: cts.Token); + if (cts.Token.IsCancellationRequested) return; + + TagSuggestions.Clear(); + foreach (var m in matches) + { + if (ComposeTags.Any(t => string.Equals(t, m.Tag, StringComparison.OrdinalIgnoreCase))) continue; + TagSuggestions.Add(m); + } + } + catch (OperationCanceledException) + { + // Superseded by a later keystroke. + } + catch (InterlinedApiException) + { + TagSuggestions.Clear(); + } + } + [RelayCommand] private async Task AttachImageAsync() { @@ -235,7 +393,7 @@ private async Task RefreshAsync() try { _offset = 0; - var page = await _session.Api.GetMessagesAsync(limit: PageSize, offset: 0); + var page = await _session.Api.GetFeedPageAsync(limit: PageSize, offset: 0, tag: ActiveTag); Messages.Clear(); foreach (var message in page.Messages) @@ -263,7 +421,7 @@ private async Task LoadMoreAsync() IsLoadingMore = true; try { - var page = await _session.Api.GetMessagesAsync(limit: PageSize, offset: _offset); + var page = await _session.Api.GetFeedPageAsync(limit: PageSize, offset: _offset, tag: ActiveTag); foreach (var message in page.Messages) Messages.Add(Wrap(message)); @@ -291,16 +449,22 @@ private async Task PostAsync() IsPosting = true; try { - await _session.Api.PostMessageAsync( - ComposeText.Trim(), - _session.CurrentUser?.DefaultPubliclyVisible ?? true, - crossPostToBluesky: CrossPostToBluesky, - crossPostToTwitter: CrossPostToTwitter, - mastodonProviderIds: CrossPostToMastodon ? MastodonProvider : null, - scheduledAt: ResolveScheduledAt(), - imageUrls: AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null, - videoUrls: AttachedVideoUrls.Count > 0 ? AttachedVideoUrls.ToList() : null); + await _session.Api.PostMessageAsync(new NewMessage + { + Content = ComposeText.Trim(), + PubliclyVisible = _session.CurrentUser?.DefaultPubliclyVisible ?? true, + CrossPostToBluesky = CrossPostToBluesky, + CrossPostToTwitter = CrossPostToTwitter, + MastodonProviderIds = CrossPostToMastodon ? MastodonProvider : null, + ScheduledAt = ResolveScheduledAt(), + ImageUrls = AttachedImageUrls.Count > 0 ? AttachedImageUrls.ToList() : null, + VideoUrls = AttachedVideoUrls.Count > 0 ? AttachedVideoUrls.ToList() : null, + Tags = ComposeTags.Count > 0 ? ComposeTags.ToList() : null, + }); ComposeText = ""; + ComposeTags.Clear(); + TagInput = ""; + TagSuggestions.Clear(); CrossPostToBluesky = false; CrossPostToTwitter = false; CrossPostToMastodon = false; diff --git a/InterlinedList/ViewModels/MessageItemViewModel.cs b/InterlinedList/ViewModels/MessageItemViewModel.cs index c5aa2ca..fc63242 100644 --- a/InterlinedList/ViewModels/MessageItemViewModel.cs +++ b/InterlinedList/ViewModels/MessageItemViewModel.cs @@ -35,6 +35,14 @@ public partial class MessageItemViewModel : ObservableObject public IReadOnlyList VideoUrls { get; } public bool HasVideos => VideoUrls.Count > 0; + /// + /// Freeform tags on this message. Rendered as clickable chips; the click is + /// routed to the feed's tag filter, not handled here (the card has no view of + /// the feed's paging state). + /// + public IReadOnlyList Tags { get; } + public bool HasTags => Tags.Count > 0; + public ObservableCollection Replies { get; } = new(); // ── Push / Quote ──────────────────────────────────────────────────────────── @@ -140,6 +148,7 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu IsMine = message.UserId == currentUserId; ImageUrls = message.ImageUrls ?? new List(); VideoUrls = message.VideoUrls ?? new List(); + Tags = message.Tags ?? new List(); digCount = message.DigCount; pushCount = message.PushCount; diff --git a/InterlinedList/Views/FeedView.xaml b/InterlinedList/Views/FeedView.xaml index 31ae56e..36b1bbc 100644 --- a/InterlinedList/Views/FeedView.xaml +++ b/InterlinedList/Views/FeedView.xaml @@ -102,6 +102,35 @@ + + + + + + + + + + + + +