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); + } + } +}