From 6d5cf17723d9b1f72c5d3dd25df2d75baa76b2fb Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 03:53:51 +0200 Subject: [PATCH] Posts for later, hashtags followed, and Explore The composer's clock button gives a post a time (scheduled_at, at least five minutes ahead; located and group posts have none), and /scheduled moves or drops what waits. A hashtag's feed tab shows its week and follows it, so its public posts come home. Explore (/explore) shows what PrivaPub says is trending, hashtags, posts and links from public posts it holds, the directory by activity or by joining, and the hashtags the persona follows. DiscoveryService does the calls; the e2e tests check each against PrivaPub's own answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 7 +- Components/Composer.razor | 53 +++++- Components/Feed/FeedTabs.razor | 45 ++++- MastodonJsonContext.cs | 6 + Models/APIs.cs | 10 + Models/ComposeForm.cs | 8 + Models/Mastodon/Discovery.cs | 33 ++++ Models/Mastodon/PrivaPubApi.cs | 24 +++ Pages/Explore.razor | 271 ++++++++++++++++++++++++++++ Pages/Scheduled.razor | 144 +++++++++++++++ Program.cs | 1 + Resources/AllStrings.it.resx | 84 +++++++++ Resources/AllStrings.resx | 84 +++++++++ Services/DiscoveryService.cs | 85 +++++++++ Services/StatusService.cs | 39 +++- Shared/NavMenu.razor | 4 + tests/e2e/Actions/DiscoveryTests.cs | 110 +++++++++++ 17 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 Models/Mastodon/Discovery.cs create mode 100644 Pages/Explore.razor create mode 100644 Pages/Scheduled.razor create mode 100644 Services/DiscoveryService.cs create mode 100644 tests/e2e/Actions/DiscoveryTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6067f6a..5550b41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,8 @@ supplies: located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), - `FilterService` (the persona's v2 filters, their keywords and filtered posts), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. + `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and + following them, trends, the directory), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. @@ -227,6 +228,10 @@ supplies: lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks, conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs its media (blur). +- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a + time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them. +- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts + it holds), the directory, and the hashtags the persona follows. A hashtag's feed tab follows it too. - **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from `PrivaPub.ClientModels`. - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), diff --git a/Components/Composer.razor b/Components/Composer.razor index a14afb8..3a1720c 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -2,7 +2,8 @@ @* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of - this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. *@ + this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. + A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those. *@ @@ -121,6 +122,13 @@ } + + @if (!form.IsLocated && GroupId is null) + { + + + + } @@ -128,10 +136,22 @@ { } - @Localizer[InReplyTo is null ? "Post" : "Reply"] - + @(form.IsScheduled ? Localizer["Schedule"] : Localizer[InReplyTo is null ? "Post" : "Reply"]) + + + @if (form.IsScheduled && !form.IsLocated && GroupId is null) + { +
+ + @Localizer["Scheduled posts"] +
+ }
@code { @@ -198,6 +218,14 @@ form.IsScopeOptionsOpen = false; } + // a scheduled post starts an hour ahead, which the reader moves as it likes + void ToggleScheduled() + { + form.IsScheduled = !form.IsScheduled; + if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt)) + form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm"); + } + async Task ToggleLocated() { form.IsLocated = !form.IsLocated; @@ -275,6 +303,17 @@ return; } + if (form.IsScheduled && form.ScheduledAtUtc is not { } at) + { + error = Localizer["Choose when to publish it."]; + return; + } + if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5)) + { + error = Localizer["A post is scheduled at least five minutes ahead."]; + return; + } + isPublishing = true; var result = await StatusService.Publish(form, CascadingState.PersonaId); isPublishing = false; @@ -284,6 +323,14 @@ return; } + if (result.Data is ScheduledStatus scheduled) + { + Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]); + form = new() { GroupId = GroupId }; + await OnPublished.InvokeAsync(null); + return; + } + Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]); var published = result.Data as FeedPost; form = new() { GroupId = GroupId }; diff --git a/Components/Feed/FeedTabs.razor b/Components/Feed/FeedTabs.razor index 704aec1..7316263 100644 --- a/Components/Feed/FeedTabs.razor +++ b/Components/Feed/FeedTabs.razor @@ -2,7 +2,7 @@ @* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being - followed. The stats icon shows how the current feed was + read, with its week and a button to follow it (its public posts then come home). The stats icon shows how the current feed was read; the buttons on the right open the composer and, on narrow screens, the globe. *@
@@ -74,7 +74,17 @@ } else if (Filter.TimelineType is TimelineType.Tag) { -

#@Filter.Tag

+
+ #@Filter.Tag + @if (tag is not null) + { + @Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts] + + @(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name]) + + } +
}
@@ -100,8 +110,39 @@ ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId); + [Inject] DiscoveryService DiscoveryService { get; set; } + + Tag tag; + string loadedTag; + bool isFollowing; + protected override void OnInitialized() => IsLoading = false; + // the hashtag being read: its week and whether the persona follows it, read again when it changes + protected override async Task OnParametersSetAsync() + { + var wanted = Filter.TimelineType is TimelineType.Tag ? Filter.Tag : null; + if (wanted == loadedTag) + return; + loadedTag = wanted; + tag = wanted is { Length: > 0 } ? await DiscoveryService.Tag(wanted) : null; + } + + async Task ToggleTagFollow() + { + isFollowing = true; + var result = await DiscoveryService.FollowTag(tag.Name, tag.Following != true); + isFollowing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + tag = (Tag)result.Data; + Toast.AddResponseSuccess(tag.Following == true ? Localizer["Following #{0}: its public posts come home.", tag.Name] : Localizer["No longer following #{0}.", tag.Name]); + } + string TabName(TimelineType type) => type switch { TimelineType.Federation => Localizer["Federated"], diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index 5480e4f..2f68cf0 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -42,6 +42,12 @@ namespace decePubClient; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(FilterForm))] [JsonSerializable(typeof(FilterStatus))] +[JsonSerializable(typeof(ScheduledStatus))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ScheduleForm))] +[JsonSerializable(typeof(Tag))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 08b460d..1fe854f 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -70,6 +70,16 @@ public static class APIs public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts"; public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists"; public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}"; + public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; + public const string GET_FollowedTags = "api/v1/followed_tags"; + public const string GET_TrendingTags = "api/v1/trends/tags"; + public const string GET_TrendingStatuses = "api/v1/trends/statuses"; + public const string GET_TrendingLinks = "api/v1/trends/links"; + public const string GET_Directory = "api/v1/directory"; + public static string GET_M_Tag(string name) => $"api/v1/tags/{Uri.EscapeDataString(name)}"; + public static string POST_M_TagAction(string name, string action) => $"api/v1/tags/{Uri.EscapeDataString(name)}/{action}"; + public static string PUT_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}"; + public static string DELETE_M_ScheduledStatus(string id) => $"api/v1/scheduled_statuses/{Uri.EscapeDataString(id)}"; public const string GET_Filters = "api/v2/filters"; public const string POST_Filter = "api/v2/filters"; public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}"; diff --git a/Models/ComposeForm.cs b/Models/ComposeForm.cs index 20695e9..d3414b1 100644 --- a/Models/ComposeForm.cs +++ b/Models/ComposeForm.cs @@ -37,6 +37,14 @@ public class ComposeForm public double? Latitude { get; set; } public double? Longitude { get; set; } + /// Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now. + public bool IsScheduled { get; set; } + public string ScheduledAt { get; set; } + + /// When the post is to be published, in UTC, or null when it goes now or ScheduledAt is not a time. + public DateTime? ScheduledAtUtc => IsScheduled && DateTime.TryParse(ScheduledAt, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, out var local) ? local.ToUniversalTime() : null; + [JsonIgnore, Bindable(false)] public MediaType MediaType { get; set; } = MediaType.Images; [JsonIgnore, Bindable(false)] diff --git a/Models/Mastodon/Discovery.cs b/Models/Mastodon/Discovery.cs new file mode 100644 index 0000000..aa07efc --- /dev/null +++ b/Models/Mastodon/Discovery.cs @@ -0,0 +1,33 @@ +namespace decePubClient.Models.Mastodon; + +/// A hashtag as PrivaPub describes it: its last week of use in public posts it holds, and whether the persona +/// follows it (null when nobody is signed in). +public class Tag +{ + public string Name { get; set; } + public string Url { get; set; } + public List History { get; set; } = []; + public bool? Following { get; set; } + + public int WeekUses => History.Sum(h => h.UsesCount); + public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0; +} + +/// One day of a tag's or a link's use; Mastodon sends the numbers as strings, the day as unix time. +public class TagDay +{ + public string Day { get; set; } + public string Uses { get; set; } + public string Accounts { get; set; } + + public int UsesCount => int.TryParse(Uses, out var n) ? n : 0; + public int AccountsCount => int.TryParse(Accounts, out var n) ? n : 0; +} + +/// A link many authors shared this week: its preview card with its days. +public class TrendLink : PreviewCard +{ + public List History { get; set; } = []; + + public int WeekAccounts => History.Sum(h => h.AccountsCount); +} diff --git a/Models/Mastodon/PrivaPubApi.cs b/Models/Mastodon/PrivaPubApi.cs index 5e31017..9f5dd99 100644 --- a/Models/Mastodon/PrivaPubApi.cs +++ b/Models/Mastodon/PrivaPubApi.cs @@ -176,4 +176,28 @@ public class PostStatusForm public string SpoilerText { get; set; } public string Visibility { get; set; } public string Language { get; set; } + /// When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now. + public string ScheduledAt { get; set; } +} + +/// A post waiting for its time (/api/v1/scheduled_statuses). +public class ScheduledStatus +{ + public string Id { get; set; } + public DateTime ScheduledAt { get; set; } + public ScheduledParams Params { get; set; } = new(); + public List MediaAttachments { get; set; } = []; +} + +public class ScheduledParams +{ + public string Text { get; set; } + public string SpoilerText { get; set; } + public string Visibility { get; set; } + public string InReplyToId { get; set; } +} + +public class ScheduleForm +{ + public string ScheduledAt { get; set; } } diff --git a/Pages/Explore.razor b/Pages/Explore.razor new file mode 100644 index 0000000..2b73b37 --- /dev/null +++ b/Pages/Explore.razor @@ -0,0 +1,271 @@ +@page "/explore" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Explore"] + +@* What there is to find on PrivaPub: the hashtags, posts and links being talked about (from public posts it already + holds; PrivaPub fetches nothing for them), the directory of accounts that let themselves be discovered, and the + hashtags the persona follows. A hashtag opens as a tab of the feed; a link goes out to its site. *@ +
+
+ +
+ +

@Localizer["Explore"]

+
+ +
+ +
@Localizer["Hashtags"]
+
@Localizer["Popular posts"]
+
@Localizer["Shared links"]
+
@Localizer["People"]
+
@Localizer["Followed hashtags"]
+
+
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (tab is ExploreTab.Hashtags or ExploreTab.Followed) + { + @if (tags.Count == 0) + { +

+ @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."]) +

+ } +
+ @foreach (var item in tags) + { +
+ + + + @(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"]) + +
+ } +
+ } + else if (tab is ExploreTab.Posts) + { + @if (posts.Count == 0) + { +

@Localizer["Nothing is trending yet."]

+ } +
+ @foreach (var post in posts) + { + + } +
+ } + else if (tab is ExploreTab.Links) + { + @if (links.Count == 0) + { +

@Localizer["Nothing is trending yet."]

+ } + + } + else + { +
+ + + + + +
+ @if (people.Count == 0) + { +

@Localizer["Empty"]

+ } +
+ @foreach (var account in people) + { + + @* alt="": the name is next to it. *@ + + + @account.Name + @("@" + account.Acct) + + + } + @if (morePeople) + { + @Localizer["Load more"] + } +
+ } +
+
+ +@code { + enum ExploreTab + { + Hashtags, + Posts, + Links, + People, + Followed + } + + [Inject] DiscoveryService DiscoveryService { get; set; } + [Inject] InstanceService InstanceService { get; set; } + [Inject] ILocalStorageService LocalStorage { get; set; } + + ExploreTab tab = ExploreTab.Hashtags; + List tags = []; + List posts = []; + List links = []; + List people = []; + DirectoryOrder order = DirectoryOrder.Active; + bool localOnly; + bool morePeople; + bool isActing; + string loadedPersonaId; + + static int Bar(int uses, Tag tag) => tag.History.Max(h => h.UsesCount) is > 0 and var most ? Math.Max(8, uses * 100 / most) : 8; + + // The persona changed (NavMenu's switcher): what it follows is another persona's. + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId is null) + { + IsLoading = false; + return; + } + if (CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + } + + async Task LoadData() + { + IsLoading = true; + switch (tab) + { + case ExploreTab.Hashtags: + tags = await DiscoveryService.TrendingTags(); + break; + case ExploreTab.Followed: + tags = await DiscoveryService.FollowedTags(); + break; + case ExploreTab.Posts: + posts = [.. (await DiscoveryService.TrendingStatuses()).Select(FeedPost.Of)]; + await InstanceService.Resolve(posts.Select(p => p.Host).Append(InstanceService.SelfHost)); + InstanceService.Locate(posts); + break; + case ExploreTab.Links: + links = await DiscoveryService.TrendingLinks(); + break; + default: + people = await DiscoveryService.Directory(order, localOnly, 0); + morePeople = people.Count == DiscoveryService.DirectoryPage; + break; + } + IsLoading = false; + } + + async Task SelectTab(int index) + { + tab = (ExploreTab)index; + await LoadData(); + } + + async Task ChangeOrder(DirectoryOrder value) + { + order = value; + await LoadData(); + } + + async Task ToggleLocal(ChangeEventArgs e) + { + localOnly = e.Value is true; + await LoadData(); + } + + async Task LoadMorePeople() + { + isActing = true; + var known = people.Select(a => a.Id).ToHashSet(); + var page = await DiscoveryService.Directory(order, localOnly, people.Count); + isActing = false; + people.AddRange(page.Where(a => known.Add(a.Id))); + morePeople = page.Count == DiscoveryService.DirectoryPage; + } + + async Task ToggleFollow(Tag item) + { + isActing = true; + var result = await DiscoveryService.FollowTag(item.Name, item.Following != true); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + item.Following = ((Tag)result.Data).Following; + if (tab is ExploreTab.Followed && item.Following != true) + tags.Remove(item); + Toast.AddResponseSuccess(item.Following == true ? Localizer["Following #{0}: its public posts come home.", item.Name] : Localizer["No longer following #{0}.", item.Name]); + } + + async Task OpenTag(string tagName) + { + var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); + filter.Tag = tagName; + filter.TimelineType = TimelineType.Tag; + await LocalStorage.SetItemAsync(nameof(FeedFilter), filter); + Navigation.NavigateTo(""); + } +} diff --git a/Pages/Scheduled.razor b/Pages/Scheduled.razor new file mode 100644 index 0000000..a1a31d7 --- /dev/null +++ b/Pages/Scheduled.razor @@ -0,0 +1,144 @@ +@page "/scheduled" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Scheduled posts"] + +@* The current persona's posts waiting for their time (/api/v1/scheduled_statuses): moved to another time, or dropped so + they are never published. The composer schedules them. *@ +
+
+ +
+ +

@Localizer["Scheduled posts"]

+
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (scheduled.Count == 0) + { +

@Localizer["Nothing is waiting to be published."]

+ } + +
+ @foreach (var item in scheduled) + { +
+
+ + @item.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) + @VisibilityName(item.Params.Visibility) + @if (item.MediaAttachments.Count > 0) + { + @item.MediaAttachments.Count + } +
+ @if (item.Params.SpoilerText is { Length: > 0 } warning) + { +

@warning

+ } +

@item.Params.Text

+
+ + + @Localizer["Move"] + + + @Localizer["Never publish it"] + +
+
+ } +
+
+
+ +@code { + [Inject] StatusService StatusService { get; set; } + + List scheduled = []; + readonly Dictionary moves = []; + string loadedPersonaId; + bool isSaving; + + string VisibilityName(string visibility) => visibility switch + { + "unlisted" => Localizer["Unlisted"], + "private" => Localizer["Followers only"], + "direct" => Localizer["Direct"], + _ => Localizer["Public"] + }; + + // The persona changed (NavMenu's switcher): its scheduled posts are another persona's. + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId is null) + { + IsLoading = false; + return; + } + if (CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + } + + async Task LoadData() + { + IsLoading = true; + moves.Clear(); + scheduled = await StatusService.Scheduled(); + IsLoading = false; + } + + async Task Move(ScheduledStatus item) + { + if (!DateTime.TryParse(moves[item.Id], System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var local)) + return; + isSaving = true; + var result = await StatusService.Reschedule(item.Id, local.ToUniversalTime()); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var moved = (ScheduledStatus)result.Data; + moves.Remove(item.Id); + item.ScheduledAt = moved.ScheduledAt; + scheduled = [.. scheduled.OrderBy(s => s.ScheduledAt)]; + Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", moved.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]); + } + + async Task Drop(ScheduledStatus item) + { + isSaving = true; + var result = await StatusService.Unschedule(item.Id); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + scheduled.Remove(item); + Toast.AddResponseSuccess(Localizer["It will not be published."]); + } +} diff --git a/Program.cs b/Program.cs index 49881fd..7cb0604 100644 --- a/Program.cs +++ b/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index b6ac6c6..9ff3c80 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1106,4 +1106,88 @@ Gestisci i filtri + + Esplora + + + Post popolari + + + Link condivisi + + + Persone + + + Hashtag seguiti + + + Questa persona non segue ancora hashtag. + + + Ancora niente di cui si parla. + + + {0} post questa settimana, {1} persone oggi + + + {0} persone questa settimana + + + Attivi di recente + + + Nuovi arrivati + + + Solo questo server + + + Segui #{0} + + + Smetti di seguire #{0} + + + Segui #{0}: i suoi post pubblici arrivano nella home. + + + Non segui più #{0}. + + + Pubblica più tardi + + + Programma + + + Pubblicalo il + + + Post programmati + + + Scegli quando pubblicarlo. + + + Un post si programma almeno cinque minuti prima. + + + Programmato per il {0}. + + + Niente aspetta di essere pubblicato. + + + Solo follower + + + Sposta + + + Non pubblicarlo mai + + + Non sarà pubblicato. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 87e2cfe..608bdac 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1106,4 +1106,88 @@ Manage filters + + Explore + + + Popular posts + + + Shared links + + + People + + + Followed hashtags + + + This persona follows no hashtag yet. + + + Nothing is trending yet. + + + {0} posts this week, {1} people today + + + {0} people this week + + + Recently active + + + Newcomers + + + Only this server + + + Follow #{0} + + + Stop following #{0} + + + Following #{0}: its public posts come home. + + + No longer following #{0}. + + + Publish later + + + Schedule + + + Publish it on + + + Scheduled posts + + + Choose when to publish it. + + + A post is scheduled at least five minutes ahead. + + + Scheduled for {0}. + + + Nothing is waiting to be published. + + + Followers only + + + Move + + + Never publish it + + + It will not be published. + diff --git a/Services/DiscoveryService.cs b/Services/DiscoveryService.cs new file mode 100644 index 0000000..2536d87 --- /dev/null +++ b/Services/DiscoveryService.cs @@ -0,0 +1,85 @@ +using System.Net; + +using collAnon.Client.Services; + +using decePubClient.Extensions; +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels; + +namespace decePubClient.Services +{ + /// Which order the directory lists accounts in. + public enum DirectoryOrder + { + /// By their latest public post. + Active, + /// By the day they say they joined. + New + } + + /// + /// What there is to find on PrivaPub: hashtags (their week, following them), what is trending there (hashtags, posts + /// and links, from public posts it already holds) and the directory of accounts that let themselves be discovered. + /// Lists come back empty and values null on failure; nothing throws. + /// + public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + public async Task Tag(string name, CancellationToken cancellationToken = default) + { + using var response = await httpService.GetAnon(APIs.Mastodon.GET_M_Tag(name), cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken); + + await LogFailure(response, nameof(Tag), cancellationToken); + return null; + } + + /// Follows or stops following a hashtag; Data is the Tag. + public async Task FollowTag(string name, bool follow, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_TagAction(name, follow ? "follow" : "unfollow"), cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + public Task> FollowedTags(CancellationToken cancellationToken = default) => + List(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken); + + public Task> TrendingTags(CancellationToken cancellationToken = default) => + List(APIs.Mastodon.GET_TrendingTags, ["limit=20"], nameof(TrendingTags), cancellationToken); + + public Task> TrendingStatuses(CancellationToken cancellationToken = default) => + List(APIs.Mastodon.GET_TrendingStatuses, ["limit=40"], nameof(TrendingStatuses), cancellationToken); + + public Task> TrendingLinks(CancellationToken cancellationToken = default) => + List(APIs.Mastodon.GET_TrendingLinks, ["limit=20"], nameof(TrendingLinks), cancellationToken); + + public const int DirectoryPage = 40; + + public Task> Directory(DirectoryOrder order, bool localOnly, int offset, CancellationToken cancellationToken = default) => + List(APIs.Mastodon.GET_Directory, + [$"order={(order is DirectoryOrder.New ? "new" : "active")}", $"local={(localOnly ? "true" : "false")}", $"limit={DirectoryPage}", $"offset={offset}"], + nameof(Directory), cancellationToken); + + async Task> List(string route, string[] parameters, string caller, CancellationToken cancellationToken) + { + using var response = await httpService.GetAnon(route, parameters, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, caller, cancellationToken); + return []; + } + + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound)) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(DiscoveryService)}.{caller}", cancellationToken); + } + } +} diff --git a/Services/StatusService.cs b/Services/StatusService.cs index 73f8ddc..c2ed72c 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -54,15 +54,52 @@ namespace decePubClient.Services Sensitive = form.HasContentWarning, SpoilerText = form.HasContentWarning ? form.SpoilerText : null, Visibility = form.Visibility, - Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName + Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, + ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (form.ScheduledAtUtc is not null) + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; var status = await response.MastodonReadFromJsonAsync(cancellationToken); return new WebResult { Data = FeedPost.Of(status) }; } + /// The persona's posts waiting for their time, soonest first. + public async Task> Scheduled(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken); + return []; + } + return [.. (await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)]; + } + + /// Moves a scheduled post to another time; Data is the ScheduledStatus. + public async Task Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default) + { + using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId), + new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + /// Drops a scheduled post: it is never published. + public async Task Unschedule(string scheduledId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult(); + } + /// A located or group post: text only, through /clientapi/post/insert. async Task PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken) { diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 09b5fbb..11a777d 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -47,6 +47,10 @@ @Localizer["Search"] + + + @Localizer["Explore"] + @Localizer["Lists"] diff --git a/tests/e2e/Actions/DiscoveryTests.cs b/tests/e2e/Actions/DiscoveryTests.cs new file mode 100644 index 0000000..0490323 --- /dev/null +++ b/tests/e2e/Actions/DiscoveryTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // Posts written for later, hashtags followed and what Explore shows, each checked against PrivaPub's own answer + public sealed class DiscoveryTests(Browser browser) + { + [Fact] + public async Task A_post_scheduled_in_the_composer_waits_is_moved_and_is_dropped() + { + var persona = Pasture.Personas().First(); + var text = $"later {Guid.NewGuid():N}"[..20]; + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.action.schedule", "privapub", persona.UserName, async () => + { + await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')"); + await page.GotoAsync($"{Pasture.Site}/"); + await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync(); + await page.Locator("textarea").First.FillAsync(text); + await page.Locator("button[data-schedule]").ClickAsync(); + await page.Locator("input[data-schedule-at]").FillAsync(DateTime.Now.AddHours(2).ToString("yyyy-MM-ddTHH:mm")); + await page.GetByRole(AriaRole.Button, new() { Name = "Schedule" }).ClickAsync(); + JsonNode waiting = null; + Assert.True(await Pasture.Eventually(async () => (waiting = (await PersonaApi.Call(persona, "/api/v1/scheduled_statuses"))!.AsArray() + .FirstOrDefault(s => s!["params"]!["text"]?.GetValue() == text)) is not null, 15), "PrivaPub never kept the scheduled post"); + var id = waiting!["id"]!.GetValue(); + Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(), + s => s!["content"]!.GetValue().Contains(text)); + + await page.GotoAsync($"{Pasture.Site}/scheduled"); + var item = page.Locator($"article[data-scheduled-id='{id}']"); + await Assertions.Expect(item).ToContainTextAsync(text, new() { Timeout = 15000 }); + var later = DateTime.Now.AddHours(5); + await item.Locator("input[type=datetime-local]").FillAsync(later.ToString("yyyy-MM-ddTHH:mm")); + await item.GetByRole(AriaRole.Button, new() { Name = "Move" }).ClickAsync(); + Assert.True(await Pasture.Eventually(async () => + (await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue().ToUniversalTime() + is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post"); + await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync(); + await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 }); + Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}")); + await session.AssertGuards(); + }, page); + } + + [Fact] + public async Task A_hashtag_is_followed_from_its_feed_tab_and_listed_in_explore() + { + var persona = Pasture.Personas().First(); + var tag = $"e2etag{Guid.NewGuid():N}"[..14]; + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + try + { + await Results.Cell("client.action.follow-tag", "privapub", persona.UserName, async () => + { + // a reader finds the hashtag through search, which opens it as a tab of the feed + await page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("#" + tag)}"); + await page.GetByRole(AriaRole.Button, new() { Name = "#" + tag }).ClickAsync(new() { Timeout = 30000 }); + var follow = page.Locator("button[data-tag-follow]"); + await Assertions.Expect(follow).ToContainTextAsync($"Follow #{tag}", new() { Timeout = 30000 }); + await follow.ClickAsync(); + await Assertions.Expect(follow).ToContainTextAsync($"Stop following #{tag}", new() { Timeout = 15000 }); + Assert.True((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue()); + + await page.GotoAsync($"{Pasture.Site}/explore"); + await page.GetByRole(AriaRole.Tab, new() { Name = "Followed hashtags" }).ClickAsync(); + var row = page.Locator($"[data-tag='{tag}']"); + await row.WaitForAsync(new() { Timeout = 15000 }); + await row.GetByRole(AriaRole.Button, new() { Name = "Unfollow" }).ClickAsync(); + await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 }); + Assert.False((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue()); + await session.AssertGuards(); + }, page); + } + finally + { + await PersonaApi.Call(persona, $"/api/v1/tags/{tag}/unfollow", HttpMethod.Post); + } + } + + [Fact] + public async Task Explore_shows_what_privapub_says_is_trending_and_its_directory() + { + var persona = Pasture.Personas().First(); + var trending = (await PersonaApi.Call(persona, "/api/v1/trends/tags?limit=20"))!.AsArray().Select(t => t!["name"]!.GetValue()).ToList(); + var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue()).ToList(); + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.render.explore", "privapub", persona.UserName, async () => + { + await page.GotoAsync($"{Pasture.Site}/explore"); + await page.Locator("[data-tag], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + var shownTags = await page.Locator("[data-tag]").EvaluateAllAsync("rows => rows.map(r => r.dataset.tag)"); + Assert.Equal(trending, shownTags); + + await page.GetByRole(AriaRole.Tab, new() { Name = "People" }).ClickAsync(); + await page.Locator("[data-account], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + var shownPeople = await page.Locator("[data-account]").EvaluateAllAsync("rows => rows.map(r => r.dataset.account)"); + Assert.Equal(directory, shownPeople); + await session.AssertGuards(); + }, page); + } + } +}