Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions InterlinedList/Models/NewMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,12 @@ public sealed record NewMessage

public IReadOnlyList<string>? ImageUrls { get; init; }
public IReadOnlyList<string>? VideoUrls { get; init; }

/// <summary>
/// Freeform tags, posted as <c>tags: string[]</c>. Tags may contain spaces
/// and punctuation — live examples include <c>orbit culture</c> and
/// <c>life is short, o brave girl</c> — so they are not hashtag-shaped
/// single words and must not be split on whitespace.
/// </summary>
public IReadOnlyList<string>? Tags { get; init; }
}
33 changes: 33 additions & 0 deletions InterlinedList/Models/TrendingTag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace InterlinedList.Models;

/// <summary>
/// One row from <c>GET /api/tags/trending</c> or <c>GET /api/tags/autocomplete</c>.
/// Both wrap their rows under <c>{ "tags": [ … ] }</c>; autocomplete omits
/// <see cref="LastUsedAt"/>. Verified live 2026-09-16.
/// </summary>
/// <remarks>
/// The two endpoints' <see cref="Count"/> values are <b>not</b> the same number:
/// trending counts uses within its trailing window, autocomplete counts all-time
/// (live example: <c>thoughts</c> was 1 in trending and 5 in autocomplete). Don't
/// treat one as a cache of the other.
/// </remarks>
public sealed class TrendingTag
{
public required string Tag { get; init; }

/// <summary>How many messages carry the tag — scoped differently per endpoint (see remarks).</summary>
public int Count { get; init; }

/// <summary>Trending only; null from autocomplete.</summary>
public DateTimeOffset? LastUsedAt { get; init; }

/// <summary>Display form for a chip.</summary>
public string Label => "#" + Tag;

/// <summary>
/// Tooltip text. Built here rather than via a binding <c>StringFormat</c>
/// because the target (<c>ToolTip</c>) is object-typed, where StringFormat
/// isn't reliably applied.
/// </summary>
public string CountLabel => Count == 1 ? "1 message" : $"{Count} messages";
}
22 changes: 22 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Messages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ public async Task<List<Message>> GetRepliesAsync(string id, CancellationToken ct
public Task PostReplyAsync(string parentId, string content, bool publiclyVisible, CancellationToken ct = default)
=> PostMessageAsync(content, publiclyVisible, parentId: parentId, ct: ct);

/// <summary>
/// The feed, optionally filtered to one tag via <c>GET /api/messages?tag=</c>.
/// A distinct name rather than an overload of <c>GetMessagesAsync</c> in
/// InterlinedApiClient.cs, which can't be edited (open PR #130) and whose
/// optional parameters would make an added <c>tag</c> overload ambiguous.
/// </summary>
/// <remarks>
/// Live-verified 2026-09-16: the filter is case-insensitive, pages with
/// <c>offset</c>/<c>limit</c> like the unfiltered feed, and handles tags
/// containing spaces once URL-encoded.
/// </remarks>
public Task<MessagesPage> 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<MessagesPage>(path, ct);
}

// ── Push (repost) / Quote ───────────────────────────────────────────────────

/// <summary>
Expand All @@ -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);
}
Expand Down
51 changes: 51 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.Tags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Text.Json;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// Tags. Two read endpoints, both answering the bearer sync-token and both
/// wrapping their payload under <c>{ "tags": [ … ] }</c> — verified live
/// 2026-09-16 against the test account.
/// </summary>
/// <remarks>
/// Writing tags is not here: they ride along on <c>POST /api/messages</c> as
/// <c>tags: string[]</c>, so that lives on <see cref="NewMessage"/>. Filtering
/// the feed by tag is <c>GET /api/messages?tag=</c> — see
/// <c>GetFeedPageAsync</c> in InterlinedApiClient.Messages.cs.
/// </remarks>
public sealed partial class InterlinedApiClient
{
/// <summary>
/// Most-used tags on public messages in a trailing window. Honors
/// <paramref name="limit"/>; returns 20 by default.
/// </summary>
public Task<List<TrendingTag>> GetTrendingTagsAsync(int limit = 20, CancellationToken ct = default)
=> ReadTagsAsync($"api/tags/trending?limit={limit}", ct);

/// <summary>
/// Case-insensitive <b>literal prefix</b> match — not a substring search.
/// Live: <c>q=le</c> → <c>Lego</c>, <c>learning</c>; <c>q=ego</c> → nothing,
/// even though <c>Lego</c> contains it.
/// </summary>
/// <remarks>
/// An empty <c>q</c> is a <c>400 {"error":"Query parameter 'q' is required"}</c>,
/// so a blank query short-circuits to an empty list rather than round-tripping
/// into an error the caller would have to swallow.
/// </remarks>
public Task<List<TrendingTag>> AutocompleteTagsAsync(string query, int limit = 10, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return Task.FromResult(new List<TrendingTag>());

return ReadTagsAsync($"api/tags/autocomplete?q={Uri.EscapeDataString(query.Trim())}&limit={limit}", ct);
}

private async Task<List<TrendingTag>> ReadTagsAsync(string path, CancellationToken ct)
{
var json = await GetElementAsync(path, ct);
return json.TryGetProperty("tags", out var arr) && arr.ValueKind == JsonValueKind.Array
? arr.Deserialize<List<TrendingTag>>(JsonOptions) ?? new()
: new();
}
}
186 changes: 175 additions & 11 deletions InterlinedList/ViewModels/FeedViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Models;
using InterlinedList.Services;
using Microsoft.Win32;

Expand Down Expand Up @@ -83,11 +84,45 @@ public partial class FeedViewModel : ObservableObject

public string ScheduledToggleLabel => ShowScheduled ? "← Back to feed" : "Scheduled";

// ── Tags ────────────────────────────────────────────────────────────────────

/// <summary>Tags attached to the next post, sent as <c>tags: string[]</c>.</summary>
public ObservableCollection<string> ComposeTags { get; } = new();

/// <summary>Prefix-match suggestions for whatever is in <see cref="TagInput"/>.</summary>
public ObservableCollection<TrendingTag> TagSuggestions { get; } = new();

/// <summary>Most-used tags in the server's trailing window.</summary>
public ObservableCollection<TrendingTag> TrendingTags { get; } = new();

[ObservableProperty]
private string tagInput = "";

public bool HasTrendingTags => TrendingTags.Count > 0;

/// <summary>
/// The tag the feed is currently filtered to (<c>GET /api/messages?tag=</c>),
/// or null for the unfiltered feed.
/// </summary>
[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));
}

/// <summary>
Expand All @@ -104,6 +139,129 @@ private MessageItemViewModel Wrap(Models.Message message)

private void OnItemPosted(object? sender, EventArgs e) => RefreshCommand.Execute(null);

// ── Tag commands ────────────────────────────────────────────────────────────

/// <summary>
/// Add whatever is typed to the compose tag list. Splits on commas only —
/// live tags contain spaces (<c>orbit culture</c>,
/// <c>life is short, o brave girl</c>), so whitespace can't be a delimiter.
/// Duplicates are ignored, case-insensitively.
/// </summary>
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();
}

/// <summary>Accept an autocomplete or trending suggestion into the composer.</summary>
[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);

/// <summary>Filter the feed to one tag. Called from a chip on a card or from the trending panel.</summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
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()
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions InterlinedList/ViewModels/MessageItemViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public partial class MessageItemViewModel : ObservableObject
public IReadOnlyList<string> VideoUrls { get; }
public bool HasVideos => VideoUrls.Count > 0;

/// <summary>
/// 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).
/// </summary>
public IReadOnlyList<string> Tags { get; }
public bool HasTags => Tags.Count > 0;

public ObservableCollection<MessageItemViewModel> Replies { get; } = new();

// ── Push / Quote ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -140,6 +148,7 @@ public MessageItemViewModel(Message message, InterlinedApiClient api, string? cu
IsMine = message.UserId == currentUserId;
ImageUrls = message.ImageUrls ?? new List<string>();
VideoUrls = message.VideoUrls ?? new List<string>();
Tags = message.Tags ?? new List<string>();

digCount = message.DigCount;
pushCount = message.PushCount;
Expand Down
Loading
Loading