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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
9a752d9288
commit
6d5cf17723
17 files changed
+1001
-7
No files matched your search
@@ -212,7 +212,8 @@ supplies:
|
|||||||
located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
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
|
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`),
|
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
|
- **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
|
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.
|
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,
|
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
|
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
|
||||||
its media (blur).
|
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
|
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
|
|||||||
@@ -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
|
@* 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
|
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. *@
|
||||||
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@@ -121,6 +122,13 @@
|
|||||||
<i class="ion-md-locate text-base"></i>
|
<i class="ion-md-locate text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!form.IsLocated && GroupId is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
|
||||||
|
<i class="ion-md-time text-base"></i>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
|
||||||
@@ -128,10 +136,22 @@
|
|||||||
{
|
{
|
||||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
}
|
}
|
||||||
<span>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
<span>@(form.IsScheduled ? Localizer["Schedule"] : Localizer[InReplyTo is null ? "Post" : "Reply"])</span>
|
||||||
<i class="ion-md-send"></i>
|
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="flex flex-wrap items-center gap-2">
|
||||||
|
<span>@Localizer["Publish it on"]</span>
|
||||||
|
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
|
||||||
|
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
|
||||||
|
</label>
|
||||||
|
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -198,6 +218,14 @@
|
|||||||
form.IsScopeOptionsOpen = false;
|
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()
|
async Task ToggleLocated()
|
||||||
{
|
{
|
||||||
form.IsLocated = !form.IsLocated;
|
form.IsLocated = !form.IsLocated;
|
||||||
@@ -275,6 +303,17 @@
|
|||||||
return;
|
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;
|
isPublishing = true;
|
||||||
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||||||
isPublishing = false;
|
isPublishing = false;
|
||||||
@@ -284,6 +323,14 @@
|
|||||||
return;
|
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."]);
|
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||||
var published = result.Data as FeedPost;
|
var published = result.Data as FeedPost;
|
||||||
form = new() { GroupId = GroupId };
|
form = new() { GroupId = GroupId };
|
||||||
|
|||||||
@@ -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
|
@* 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
|
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. *@
|
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
@@ -74,7 +74,17 @@
|
|||||||
}
|
}
|
||||||
else if (Filter.TimelineType is TimelineType.Tag)
|
else if (Filter.TimelineType is TimelineType.Tag)
|
||||||
{
|
{
|
||||||
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<i class="ion-md-pricetag"></i><b>#@Filter.Tag</b>
|
||||||
|
@if (tag is not null)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", tag.WeekUses, tag.TodayAccounts]</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(tag.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isFollowing)"
|
||||||
|
OnClick="ToggleTagFollow" data-tag-follow>
|
||||||
|
<span>@(tag.Following == true ? Localizer["Stop following #{0}", tag.Name] : Localizer["Follow #{0}", tag.Name])</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,8 +110,39 @@
|
|||||||
|
|
||||||
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
|
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;
|
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
|
string TabName(TimelineType type) => type switch
|
||||||
{
|
{
|
||||||
TimelineType.Federation => Localizer["Federated"],
|
TimelineType.Federation => Localizer["Federated"],
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(List<Filter>))]
|
[JsonSerializable(typeof(List<Filter>))]
|
||||||
[JsonSerializable(typeof(FilterForm))]
|
[JsonSerializable(typeof(FilterForm))]
|
||||||
[JsonSerializable(typeof(FilterStatus))]
|
[JsonSerializable(typeof(FilterStatus))]
|
||||||
|
[JsonSerializable(typeof(ScheduledStatus))]
|
||||||
|
[JsonSerializable(typeof(List<ScheduledStatus>))]
|
||||||
|
[JsonSerializable(typeof(ScheduleForm))]
|
||||||
|
[JsonSerializable(typeof(Tag))]
|
||||||
|
[JsonSerializable(typeof(List<Tag>))]
|
||||||
|
[JsonSerializable(typeof(List<TrendLink>))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -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 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_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 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 GET_Filters = "api/v2/filters";
|
||||||
public const string POST_Filter = "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)}";
|
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ public class ComposeForm
|
|||||||
public double? Latitude { get; set; }
|
public double? Latitude { get; set; }
|
||||||
public double? Longitude { get; set; }
|
public double? Longitude { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now.</summary>
|
||||||
|
public bool IsScheduled { get; set; }
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the post is to be published, in UTC, or null when it goes now or ScheduledAt is not a time.</summary>
|
||||||
|
public DateTime? ScheduledAtUtc => IsScheduled && DateTime.TryParse(ScheduledAt, System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.AssumeLocal, out var local) ? local.ToUniversalTime() : null;
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public MediaType MediaType { get; set; } = MediaType.Images;
|
public MediaType MediaType { get; set; } = MediaType.Images;
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
public bool? Following { get; set; }
|
||||||
|
|
||||||
|
public int WeekUses => History.Sum(h => h.UsesCount);
|
||||||
|
public int TodayAccounts => History.FirstOrDefault()?.AccountsCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One day of a tag's or a link's use; Mastodon sends the numbers as strings, the day as unix time.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A link many authors shared this week: its preview card with its days.</summary>
|
||||||
|
public class TrendLink : PreviewCard
|
||||||
|
{
|
||||||
|
public List<TagDay> History { get; set; } = [];
|
||||||
|
|
||||||
|
public int WeekAccounts => History.Sum(h => h.AccountsCount);
|
||||||
|
}
|
||||||
@@ -176,4 +176,28 @@ public class PostStatusForm
|
|||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
public string Visibility { get; set; }
|
public string Visibility { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
/// <summary>When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now.</summary>
|
||||||
|
public string ScheduledAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
|
||||||
|
public class ScheduledStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime ScheduledAt { get; set; }
|
||||||
|
public ScheduledParams Params { get; set; } = new();
|
||||||
|
public List<MediaAttachment> 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; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
@page "/explore"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Explore"]</Title>
|
||||||
|
|
||||||
|
@* 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. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-compass text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Explore"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max">
|
||||||
|
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-people"></i> @Localizer["People"]</Header></DTab>
|
||||||
|
<DTab><Header><i class="ion-md-bookmarks"></i> @Localizer["Followed hashtags"]</Header></DTab>
|
||||||
|
</DTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Hashtags or ExploreTab.Followed)
|
||||||
|
{
|
||||||
|
@if (tags.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty>
|
||||||
|
<i class="ion-ios-remove-circle-outline"></i> @(tab is ExploreTab.Followed ? Localizer["This persona follows no hashtag yet."] : Localizer["Nothing is trending yet."])
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var item in tags)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3 p-3 rounded-xl neo-raised neo-2xs" data-tag="@item.Name">
|
||||||
|
<button type="button" class="flex flex-col items-start min-w-0 flex-1 text-left" @onclick="() => OpenTag(item.Name)">
|
||||||
|
<b class="truncate">#@item.Name</b>
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} posts this week, {1} people today", item.WeekUses, item.TodayAccounts]</span>
|
||||||
|
</button>
|
||||||
|
<span class="flex items-end gap-px h-6" aria-hidden="true">
|
||||||
|
@foreach (var day in Enumerable.Reverse(item.History))
|
||||||
|
{
|
||||||
|
<span class="w-1.5 rounded-sm bg-primary/60" style="@($"height:{Bar(day.UsesCount, item)}%")"></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="@(item.Following == true ? DColor.Neutral : DColor.Primary)" Disabled="@(IsDefaultDisabled || isActing)"
|
||||||
|
OnClick="@(() => ToggleFollow(item))">
|
||||||
|
<span>@(item.Following == true ? Localizer["Unfollow"] : Localizer["Follow"])</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Posts)
|
||||||
|
{
|
||||||
|
@if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@foreach (var post in posts)
|
||||||
|
{
|
||||||
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Public" OnTag="OpenTag" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (tab is ExploreTab.Links)
|
||||||
|
{
|
||||||
|
@if (links.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is trending yet."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var link in links)
|
||||||
|
{
|
||||||
|
<a class="flex gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@link.Url" target="_blank" rel="noopener noreferrer" data-link="@link.Url">
|
||||||
|
@if (link.Image is { Length: > 0 } image)
|
||||||
|
{
|
||||||
|
@* alt="": the title is next to it. *@
|
||||||
|
<img alt="" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-lg" src="@image" />
|
||||||
|
}
|
||||||
|
<span class="flex flex-col min-w-0 gap-1">
|
||||||
|
<b class="text-sm break-words">@link.Title</b>
|
||||||
|
@if (link.Description is { Length: > 0 } description)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-80 line-clamp-2">@description</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-60">@link.ProviderName · @Localizer["{0} people this week", link.WeekAccounts]</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<DSelect TValue="DirectoryOrder" Size="DSize.Sm" class="w-auto" Value="order" ValueChanged="ChangeOrder" ValueExpression="() => order">
|
||||||
|
<option value="@DirectoryOrder.Active">@Localizer["Recently active"]</option>
|
||||||
|
<option value="@DirectoryOrder.New">@Localizer["Newcomers"]</option>
|
||||||
|
</DSelect>
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@localOnly" @onchange="ToggleLocal" />
|
||||||
|
<span>@Localizer["Only this server"]</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (people.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach (var account in people)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-3 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-10 w-10 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (morePeople)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isActing)" OnClick="LoadMorePeople">@Localizer["Load more"]</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@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<Tag> tags = [];
|
||||||
|
List<FeedPost> posts = [];
|
||||||
|
List<TrendLink> links = [];
|
||||||
|
List<Account> 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<FeedFilter>(nameof(FeedFilter)) ?? new();
|
||||||
|
filter.Tag = tagName;
|
||||||
|
filter.TimelineType = TimelineType.Tag;
|
||||||
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), filter);
|
||||||
|
Navigation.NavigateTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@page "/scheduled"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Scheduled posts"]</Title>
|
||||||
|
|
||||||
|
@* 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. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-time text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Scheduled posts"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (scheduled.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["Nothing is waiting to be published."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var item in scheduled)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-2 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-scheduled-id="@item.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<i class="ion-md-time"></i>
|
||||||
|
<b>@item.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
|
||||||
|
<span class="opacity-70">@VisibilityName(item.Params.Visibility)</span>
|
||||||
|
@if (item.MediaAttachments.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="opacity-70"><i class="ion-md-images"></i> @item.MediaAttachments.Count</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (item.Params.SpoilerText is { Length: > 0 } warning)
|
||||||
|
{
|
||||||
|
<p class="text-sm"><i class="ion-md-warning text-warning"></i> @warning</p>
|
||||||
|
}
|
||||||
|
<p class="text-sm whitespace-pre-wrap break-words">@item.Params.Text</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="datetime-local" class="input input-sm w-auto" autocomplete="off" aria-label="@Localizer["Publish it on"]"
|
||||||
|
value="@(moves.GetValueOrDefault(item.Id) ?? item.ScheduledAt.ToLocalTime().ToString("yyyy-MM-ddTHH:mm"))"
|
||||||
|
@onchange="@((ChangeEventArgs e) => moves[item.Id] = e.Value?.ToString())" />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving || !moves.ContainsKey(item.Id))" OnClick="@(() => Move(item))">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Move"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Drop(item))">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Never publish it"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
List<ScheduledStatus> scheduled = [];
|
||||||
|
readonly Dictionary<string, string> 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."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
.AddScoped<ListService>()
|
.AddScoped<ListService>()
|
||||||
.AddScoped<FilterService>()
|
.AddScoped<FilterService>()
|
||||||
|
.AddScoped<DiscoveryService>()
|
||||||
.AddScoped<PublicNoticeService>()
|
.AddScoped<PublicNoticeService>()
|
||||||
.AddScoped<MediaService>()
|
.AddScoped<MediaService>()
|
||||||
.AddScoped<GroupService>()
|
.AddScoped<GroupService>()
|
||||||
|
|||||||
@@ -1106,4 +1106,88 @@
|
|||||||
<data name="Manage filters" xml:space="preserve">
|
<data name="Manage filters" xml:space="preserve">
|
||||||
<value>Gestisci i filtri</value>
|
<value>Gestisci i filtri</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Explore" xml:space="preserve">
|
||||||
|
<value>Esplora</value>
|
||||||
|
</data>
|
||||||
|
<data name="Popular posts" xml:space="preserve">
|
||||||
|
<value>Post popolari</value>
|
||||||
|
</data>
|
||||||
|
<data name="Shared links" xml:space="preserve">
|
||||||
|
<value>Link condivisi</value>
|
||||||
|
</data>
|
||||||
|
<data name="People" xml:space="preserve">
|
||||||
|
<value>Persone</value>
|
||||||
|
</data>
|
||||||
|
<data name="Followed hashtags" xml:space="preserve">
|
||||||
|
<value>Hashtag seguiti</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona follows no hashtag yet." xml:space="preserve">
|
||||||
|
<value>Questa persona non segue ancora hashtag.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing is trending yet." xml:space="preserve">
|
||||||
|
<value>Ancora niente di cui si parla.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} posts this week, {1} people today" xml:space="preserve">
|
||||||
|
<value>{0} post questa settimana, {1} persone oggi</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} people this week" xml:space="preserve">
|
||||||
|
<value>{0} persone questa settimana</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recently active" xml:space="preserve">
|
||||||
|
<value>Attivi di recente</value>
|
||||||
|
</data>
|
||||||
|
<data name="Newcomers" xml:space="preserve">
|
||||||
|
<value>Nuovi arrivati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only this server" xml:space="preserve">
|
||||||
|
<value>Solo questo server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follow #{0}" xml:space="preserve">
|
||||||
|
<value>Segui #{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stop following #{0}" xml:space="preserve">
|
||||||
|
<value>Smetti di seguire #{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Following #{0}: its public posts come home." xml:space="preserve">
|
||||||
|
<value>Segui #{0}: i suoi post pubblici arrivano nella home.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No longer following #{0}." xml:space="preserve">
|
||||||
|
<value>Non segui più #{0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish later" xml:space="preserve">
|
||||||
|
<value>Pubblica più tardi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Schedule" xml:space="preserve">
|
||||||
|
<value>Programma</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish it on" xml:space="preserve">
|
||||||
|
<value>Pubblicalo il</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scheduled posts" xml:space="preserve">
|
||||||
|
<value>Post programmati</value>
|
||||||
|
</data>
|
||||||
|
<data name="Choose when to publish it." xml:space="preserve">
|
||||||
|
<value>Scegli quando pubblicarlo.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A post is scheduled at least five minutes ahead." xml:space="preserve">
|
||||||
|
<value>Un post si programma almeno cinque minuti prima.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scheduled for {0}." xml:space="preserve">
|
||||||
|
<value>Programmato per il {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing is waiting to be published." xml:space="preserve">
|
||||||
|
<value>Niente aspetta di essere pubblicato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Followers only" xml:space="preserve">
|
||||||
|
<value>Solo follower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move" xml:space="preserve">
|
||||||
|
<value>Sposta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Never publish it" xml:space="preserve">
|
||||||
|
<value>Non pubblicarlo mai</value>
|
||||||
|
</data>
|
||||||
|
<data name="It will not be published." xml:space="preserve">
|
||||||
|
<value>Non sarà pubblicato.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -1106,4 +1106,88 @@
|
|||||||
<data name="Manage filters" xml:space="preserve">
|
<data name="Manage filters" xml:space="preserve">
|
||||||
<value>Manage filters</value>
|
<value>Manage filters</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Explore" xml:space="preserve">
|
||||||
|
<value>Explore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Popular posts" xml:space="preserve">
|
||||||
|
<value>Popular posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Shared links" xml:space="preserve">
|
||||||
|
<value>Shared links</value>
|
||||||
|
</data>
|
||||||
|
<data name="People" xml:space="preserve">
|
||||||
|
<value>People</value>
|
||||||
|
</data>
|
||||||
|
<data name="Followed hashtags" xml:space="preserve">
|
||||||
|
<value>Followed hashtags</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona follows no hashtag yet." xml:space="preserve">
|
||||||
|
<value>This persona follows no hashtag yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing is trending yet." xml:space="preserve">
|
||||||
|
<value>Nothing is trending yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} posts this week, {1} people today" xml:space="preserve">
|
||||||
|
<value>{0} posts this week, {1} people today</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} people this week" xml:space="preserve">
|
||||||
|
<value>{0} people this week</value>
|
||||||
|
</data>
|
||||||
|
<data name="Recently active" xml:space="preserve">
|
||||||
|
<value>Recently active</value>
|
||||||
|
</data>
|
||||||
|
<data name="Newcomers" xml:space="preserve">
|
||||||
|
<value>Newcomers</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only this server" xml:space="preserve">
|
||||||
|
<value>Only this server</value>
|
||||||
|
</data>
|
||||||
|
<data name="Follow #{0}" xml:space="preserve">
|
||||||
|
<value>Follow #{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Stop following #{0}" xml:space="preserve">
|
||||||
|
<value>Stop following #{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Following #{0}: its public posts come home." xml:space="preserve">
|
||||||
|
<value>Following #{0}: its public posts come home.</value>
|
||||||
|
</data>
|
||||||
|
<data name="No longer following #{0}." xml:space="preserve">
|
||||||
|
<value>No longer following #{0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish later" xml:space="preserve">
|
||||||
|
<value>Publish later</value>
|
||||||
|
</data>
|
||||||
|
<data name="Schedule" xml:space="preserve">
|
||||||
|
<value>Schedule</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish it on" xml:space="preserve">
|
||||||
|
<value>Publish it on</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scheduled posts" xml:space="preserve">
|
||||||
|
<value>Scheduled posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Choose when to publish it." xml:space="preserve">
|
||||||
|
<value>Choose when to publish it.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A post is scheduled at least five minutes ahead." xml:space="preserve">
|
||||||
|
<value>A post is scheduled at least five minutes ahead.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Scheduled for {0}." xml:space="preserve">
|
||||||
|
<value>Scheduled for {0}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nothing is waiting to be published." xml:space="preserve">
|
||||||
|
<value>Nothing is waiting to be published.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Followers only" xml:space="preserve">
|
||||||
|
<value>Followers only</value>
|
||||||
|
</data>
|
||||||
|
<data name="Move" xml:space="preserve">
|
||||||
|
<value>Move</value>
|
||||||
|
</data>
|
||||||
|
<data name="Never publish it" xml:space="preserve">
|
||||||
|
<value>Never publish it</value>
|
||||||
|
</data>
|
||||||
|
<data name="It will not be published." xml:space="preserve">
|
||||||
|
<value>It will not be published.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Which order the directory lists accounts in.</summary>
|
||||||
|
public enum DirectoryOrder
|
||||||
|
{
|
||||||
|
/// <summary>By their latest public post.</summary>
|
||||||
|
Active,
|
||||||
|
/// <summary>By the day they say they joined.</summary>
|
||||||
|
New
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<Tag> 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<Tag>(cancellationToken);
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Tag), cancellationToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows or stops following a hashtag; Data is the Tag.</summary>
|
||||||
|
public async Task<WebResult> 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<Tag>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Tag>> FollowedTags(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Tag>(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<Tag>> TrendingTags(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Tag>(APIs.Mastodon.GET_TrendingTags, ["limit=20"], nameof(TrendingTags), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<Status>> TrendingStatuses(CancellationToken cancellationToken = default) =>
|
||||||
|
List<Status>(APIs.Mastodon.GET_TrendingStatuses, ["limit=40"], nameof(TrendingStatuses), cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<TrendLink>> TrendingLinks(CancellationToken cancellationToken = default) =>
|
||||||
|
List<TrendLink>(APIs.Mastodon.GET_TrendingLinks, ["limit=20"], nameof(TrendingLinks), cancellationToken);
|
||||||
|
|
||||||
|
public const int DirectoryPage = 40;
|
||||||
|
|
||||||
|
public Task<List<Account>> Directory(DirectoryOrder order, bool localOnly, int offset, CancellationToken cancellationToken = default) =>
|
||||||
|
List<Account>(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<T>> List<T>(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<List<T>>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,15 +54,52 @@ namespace decePubClient.Services
|
|||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.Visibility,
|
||||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture)
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
if (form.ScheduledAtUtc is not null)
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
||||||
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
||||||
return new WebResult { Data = FeedPost.Of(status) };
|
return new WebResult { Data = FeedPost.Of(status) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
|
||||||
|
public async Task<List<ScheduledStatus>> 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<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
|
||||||
|
public async Task<WebResult> 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<ScheduledStatus>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops a scheduled post: it is never published.</summary>
|
||||||
|
public async Task<WebResult> 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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
||||||
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
|
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
<i class="ion-md-search"></i>
|
<i class="ion-md-search"></i>
|
||||||
<span>@Localizer["Search"]</span>
|
<span>@Localizer["Search"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="explore">
|
||||||
|
<i class="ion-md-compass"></i>
|
||||||
|
<span>@Localizer["Explore"]</span>
|
||||||
|
</NavLink>
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="lists">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="lists">
|
||||||
<i class="ion-md-list"></i>
|
<i class="ion-md-list"></i>
|
||||||
<span>@Localizer["Lists"]</span>
|
<span>@Localizer["Lists"]</span>
|
||||||
|
|||||||
@@ -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<string>() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
|
||||||
|
var id = waiting!["id"]!.GetValue<string>();
|
||||||
|
Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
|
||||||
|
s => s!["content"]!.GetValue<string>().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<DateTime>().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<bool>());
|
||||||
|
|
||||||
|
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<bool>());
|
||||||
|
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<string>()).ToList();
|
||||||
|
var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()).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<string[]>("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<string[]>("rows => rows.map(r => r.dataset.account)");
|
||||||
|
Assert.Equal(directory, shownPeople);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user