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)
+ {
+
+ }
@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. *@
+
+